diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9186e12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. +# +# If you find yourself ignoring temporary files generated by your text editor +# or operating system, you probably want to add a global ignore instead: +# git config --global core.excludesfile ~/.gitignore_global + +# Ignore bundler config +/.bundle + +# Ignore the default SQLite database. +/db/*.sqlite3 + +# Ignore all logfiles and tempfiles. +/log/*.log.* +/log/*.log +/tmp diff --git a/Capfile b/Capfile new file mode 100644 index 0000000..855584e --- /dev/null +++ b/Capfile @@ -0,0 +1,25 @@ +# Load DSL and Setup Up Stages +require 'capistrano/setup' + +# Includes default deployment tasks +require 'capistrano/deploy' + +# Includes tasks from other gems included in your Gemfile +# +# For documentation on these, see for example: +# +# https://github.com/capistrano/rvm +# https://github.com/capistrano/rbenv +# https://github.com/capistrano/chruby +# https://github.com/capistrano/bundler +# https://github.com/capistrano/rails +# +# require 'capistrano/rvm' +# require 'capistrano/rbenv' +# require 'capistrano/chruby' +require 'capistrano/bundler' +require 'capistrano/rails/assets' +require 'capistrano/rails/migrations' + +# Loads custom tasks from `lib/capistrano/tasks' if you have any defined. +Dir.glob('lib/capistrano/tasks/*.cap').each { |r| import r } diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..fead82a --- /dev/null +++ b/Gemfile @@ -0,0 +1,83 @@ +source 'https://rubygems.org' + +gem 'rails', '~> 3.2' +gem 'bootstrap-sass', '~> 2.3' + +# Bundle edge Rails instead: +# gem 'rails', :git => 'git://github.com/rails/rails.git' + +gem 'mysql2', '~> 0.3.20' + +gem 'rollbar' + +gem 'json' +gem 'foursquare2' +gem 'yaml_db' +# gem 'system_timer' +gem 'oauth2' +gem 'settingslogic' +# gem 'chosen-rails' +# gem 'devise' +# gem 'omniauth' +# gem 'omniauth-foursquare' +# gem 'font-awesome-rails' +gem 'compass-rails' +gem 'kaminari' +gem 'bootstrap-kaminari-views' +# gem 'rmagick' +# gem 'aws-s3' +gem 'momentjs-rails' +gem 'delayed_job_active_record' +gem 'daemons' +gem 'bootstrap-datepicker-rails' +# gem 'newrelic_rpm' +gem 'pnotify-rails', '~> 1' +gem 'aws-sdk', '~> 1' +gem 'turnout' +gem 'select2-rails', '~> 3.5' +gem 'underscore-rails' +gem 'delayed_job_recurring' +gem 'net-ssh', '~>2.9.2' + +# Gems used only for assets and not required +# in production environments by default. +group :assets do + gem 'jquery-cookie-rails' + gem 'sass', '3.2.14' + gem 'sass-rails', '~> 3.2.3' + gem 'coffee-rails', '~> 3.2.1' + + gem 'pegjs', :path => 'vendor/ruby-pegjs' + gem 'fontello_rails_converter', '0.3.3' + gem 'handlebars_assets', '~> 0.18.0' + # See https://github.com/sstephenson/execjs#readme for more supported runtimes + # gem 'therubyracer', :platforms => :ruby + + gem 'uglifier' +end + +gem 'jquery-rails' +gem 'jquery-ui-rails' + +# To use ActiveModel has_secure_password +# gem 'bcrypt-ruby', '~> 3.0.0' + + +gem 'execjs' +gem "libv8", "= 3.16.14.19" +gem 'therubyracer' +# To use Jbuilder templates for JSON +# gem 'jbuilder' + +# Use unicorn as the app server +# gem 'unicorn' + +# Deploy with Capistrano +group :development do + gem 'capistrano-rails', '~> 1.1', require: false + gem 'capistrano-bundler', '~> 1.1', require: false + # gem 'coffee-rails-source-maps' +end + +# To use debugger +# gem 'debugger' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..2f45e3c --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,257 @@ +PATH + remote: vendor/ruby-pegjs + specs: + pegjs (0.0.1) + +GEM + remote: https://rubygems.org/ + specs: + actionmailer (3.2.18) + actionpack (= 3.2.18) + mail (~> 2.5.4) + actionpack (3.2.18) + activemodel (= 3.2.18) + activesupport (= 3.2.18) + builder (~> 3.0.0) + erubis (~> 2.7.0) + journey (~> 1.0.4) + rack (~> 1.4.5) + rack-cache (~> 1.2) + rack-test (~> 0.6.1) + sprockets (~> 2.2.1) + activemodel (3.2.18) + activesupport (= 3.2.18) + builder (~> 3.0.0) + activerecord (3.2.18) + activemodel (= 3.2.18) + activesupport (= 3.2.18) + arel (~> 3.0.2) + tzinfo (~> 0.3.29) + activeresource (3.2.18) + activemodel (= 3.2.18) + activesupport (= 3.2.18) + activesupport (3.2.18) + i18n (~> 0.6, >= 0.6.4) + multi_json (~> 1.0) + addressable (2.3.6) + arel (3.0.3) + aws-sdk (1.59.0) + aws-sdk-v1 (= 1.59.0) + aws-sdk-v1 (1.59.0) + json (~> 1.4) + nokogiri (>= 1.4.4) + bootstrap-datepicker-rails (1.3.0.2) + railties (>= 3.0) + bootstrap-kaminari-views (0.0.5) + kaminari (>= 0.13) + rails (>= 3.1) + bootstrap-sass (2.3.2.2) + sass (~> 3.2) + builder (3.0.4) + capistrano (3.2.1) + i18n + rake (>= 10.0.0) + sshkit (~> 1.3) + capistrano-bundler (1.1.3) + capistrano (~> 3.1) + sshkit (~> 1.2) + capistrano-rails (1.1.2) + capistrano (~> 3.1) + capistrano-bundler (~> 1.1) + chunky_png (1.3.3) + coffee-rails (3.2.2) + coffee-script (>= 2.2.0) + railties (~> 3.2.0) + coffee-script (2.3.0) + coffee-script-source + execjs + coffee-script-source (1.8.0) + colorize (0.7.3) + compass (0.12.3) + chunky_png (~> 1.2) + fssm (>= 0.2.7) + sass (= 3.2.14) + compass-rails (2.0.0) + compass (>= 0.12.2) + daemons (1.1.9) + delayed_job (4.0.4) + activesupport (>= 3.0, < 4.2) + delayed_job_active_record (4.0.2) + activerecord (>= 3.0, < 4.2) + delayed_job (>= 3.0, < 4.1) + erubis (2.7.0) + execjs (2.2.2) + faraday (0.9.0) + multipart-post (>= 1.2, < 3) + faraday_middleware (0.9.1) + faraday (>= 0.7.4, < 0.10) + fontello_rails_converter (0.3.2) + launchy + rest-client + rubyzip (~> 1.0) + foursquare2 (2.0.1) + faraday (~> 0.8) + faraday_middleware (>= 0.8) + hashie (>= 1.0, < 3.0.0) + fssm (0.2.10) + handlebars_assets (0.18) + execjs (>= 1.2.9) + multi_json + sprockets (>= 2.0.3) + tilt + hashie (2.1.2) + hike (1.2.3) + i18n (0.6.11) + journey (1.0.4) + jquery-cookie-rails (1.3.1.1) + railties (>= 3.2.0, < 5.0) + jquery-rails (3.1.2) + railties (>= 3.0, < 5.0) + thor (>= 0.14, < 2.0) + jquery-ui-rails (5.0.2) + railties (>= 3.2.16) + json (1.8.1) + jwt (1.0.0) + kaminari (0.16.1) + actionpack (>= 3.0.0) + activesupport (>= 3.0.0) + launchy (2.4.3) + addressable (~> 2.3) + libv8 (3.16.14.7) + mail (2.5.4) + mime-types (~> 1.16) + treetop (~> 1.4.8) + mime-types (1.25.1) + mini_portile (0.6.1) + momentjs-rails (2.8.3) + railties (>= 3.1) + multi_json (1.10.1) + multi_xml (0.5.5) + multipart-post (2.0.0) + mysql2 (0.3.17) + net-scp (1.2.1) + net-ssh (>= 2.6.5) + net-ssh (2.9.1) + netrc (0.8.0) + nokogiri (1.6.4.1) + mini_portile (~> 0.6.0) + oauth2 (1.0.0) + faraday (>= 0.8, < 0.10) + jwt (~> 1.0) + multi_json (~> 1.3) + multi_xml (~> 0.5) + rack (~> 1.2) + pnotify-rails (1.2.2) + polyglot (0.3.5) + rack (1.4.5) + rack-accept (0.4.5) + rack (>= 0.4) + rack-cache (1.2) + rack (>= 0.4) + rack-ssl (1.3.4) + rack + rack-test (0.6.2) + rack (>= 1.0) + rails (3.2.18) + actionmailer (= 3.2.18) + actionpack (= 3.2.18) + activerecord (= 3.2.18) + activeresource (= 3.2.18) + activesupport (= 3.2.18) + bundler (~> 1.0) + railties (= 3.2.18) + railties (3.2.18) + actionpack (= 3.2.18) + activesupport (= 3.2.18) + rack-ssl (~> 1.3.2) + rake (>= 0.8.7) + rdoc (~> 3.4) + thor (>= 0.14.6, < 2.0) + rake (10.3.2) + rdoc (3.12.2) + json (~> 1.4) + ref (1.0.5) + rest-client (1.7.2) + mime-types (>= 1.16, < 3.0) + netrc (~> 0.7) + rmagick (2.13.3) + rollbar (1.2.8) + multi_json (~> 1.3) + rubyzip (1.1.6) + sass (3.2.14) + sass-rails (3.2.6) + railties (~> 3.2.0) + sass (>= 3.1.10) + tilt (~> 1.3) + select2-rails (3.5.9.1) + thor (~> 0.14) + settingslogic (2.0.9) + sprockets (2.2.3) + hike (~> 1.2) + multi_json (~> 1.0) + rack (~> 1.0) + tilt (~> 1.1, != 1.3.0) + sshkit (1.5.1) + colorize + net-scp (>= 1.1.2) + net-ssh (>= 2.8.0) + therubyracer (0.12.1) + libv8 (~> 3.16.14.0) + ref + thor (0.19.1) + tilt (1.4.1) + treetop (1.4.15) + polyglot + polyglot (>= 0.3.1) + turnout (2.0.1) + rack (~> 1.3) + rack-accept (~> 0.4) + tzinfo (0.3.42) + uglifier (2.5.3) + execjs (>= 0.3.0) + json (>= 1.8.0) + underscore-rails (1.7.0) + yaml_db (0.3.0) + rails (>= 3.0, < 4.3) + rake (>= 0.8.7) + +PLATFORMS + ruby + +DEPENDENCIES + aws-sdk + bootstrap-datepicker-rails + bootstrap-kaminari-views + bootstrap-sass (~> 2.3) + capistrano-bundler (~> 1.1) + capistrano-rails (~> 1.1) + coffee-rails (~> 3.2.1) + compass-rails + daemons + delayed_job_active_record + execjs + fontello_rails_converter + foursquare2 + handlebars_assets + jquery-cookie-rails + jquery-rails + jquery-ui-rails + json + kaminari + momentjs-rails + mysql2 + oauth2 + pegjs! + pnotify-rails (~> 1) + rails (= 3.2.18) + rmagick + rollbar + sass (= 3.2.14) + sass-rails (~> 3.2.3) + select2-rails + settingslogic + therubyracer + turnout + uglifier + underscore-rails + yaml_db \ No newline at end of file diff --git a/MIT-LICENSE b/MIT-LICENSE new file mode 100644 index 0000000..bad5dc1 --- /dev/null +++ b/MIT-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2012-2014, http://www.4sweep.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d15d0b3 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +4sweep +====== + +4sweep is a Rails 3.2 web application for mass editing of Foursquare venues. It +is built on a concept of "flags" that can be submitted to the Foursquare API +v2. 4sweep supports the following flag types, which all operate on venues: + + * Edit Venue Details (name, address, contacts, etc) + * Close Flag (event over, closed) and Re-Open + * Delete Flag (inappropriate, does not exist) and Undelete + * Make Private Flag + * Change Categories (add, remove, replace all, with special home category flag) + * Photo Flags + * Tip Flags + +Additionally, there is a rich Javascript based UI that makes generating hundreds +of flags feasible. The UI uses the Google Maps API v3. + +Flags are submitted against the Foursquare API using a queue managed +by DelayedJob. + +Current Status +-------------- + +4sweep is unmaintained as of March 2015. + + +Explorer Features +----------------- + + * Generate flags of any type quickly + * Search based on: + * Search term + * Categories + * Center point + radius + * Bounding box + * Mayorships of user + * Recently created venues + * Split large areas into smaller subareas + * Filter venues using an advanced search BNF grammar + +Flag Features +------------- + * Flags can have comments + * Can be checked to see if they were applied + * Can be scheduled for a future date (through Delayed Job) + +Configuration and setup +----------------------- + +4sweep is currently built for Rails 3.2 and uses Bootstrap 2.0. You will need +to install all required gems. It relies on a database supported by ActiveRecord, +and has only been tested with MySQL 5.5. + +Additionally, you will need to install PEG.js, a JavaScript parser generator +library. The easiest way to do this is via npm: + +```shell +$ npm install pegjs +``` +After installing PEG.js, make sure that it is executable on your command line: + +```shell +$ pegjs -v +PEG.js 0.8.0 +``` + +You only need PEG.js in your development environment. It is used as part of the +Rails asset pipeline to generate a javascript parser. + +API credentials +---- + +4sweep needs you to specify a database in ``config/database.yml``. + +You will need to search globally for all instances of "REPLACE_ME". + +4sweep depends on several external services. IN ``config/application.yml``, +you will need to specify the following: + +```yaml +development: + # Your Foursquare API keys: + app_id: "" + app_secret: "" + callback_url: "" + + # Optional, to support the Rake task of generating and publishing map icons: + aws_key: "" + aws_secret: "" + s3_bucket: "" + + # Optional, for Cloudwatch monitoring of 4sweep in production + cloudwatch_key: "" + cloudwatch_secret: "" +``` diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..8631194 --- /dev/null +++ b/Rakefile @@ -0,0 +1,7 @@ +#!/usr/bin/env rake +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require File.expand_path('../config/application', __FILE__) + +Foursweep::Application.load_tasks diff --git a/app/assets/images/poweredByFoursquare_gray.png b/app/assets/images/poweredByFoursquare_gray.png new file mode 100644 index 0000000..316ad91 Binary files /dev/null and b/app/assets/images/poweredByFoursquare_gray.png differ diff --git a/app/assets/images/rails.png b/app/assets/images/rails.png new file mode 100644 index 0000000..d5edc04 Binary files /dev/null and b/app/assets/images/rails.png differ diff --git a/app/assets/javascripts/advancedsearch.js.pegjs b/app/assets/javascripts/advancedsearch.js.pegjs new file mode 100644 index 0000000..8e9ba3a --- /dev/null +++ b/app/assets/javascripts/advancedsearch.js.pegjs @@ -0,0 +1,541 @@ +// allowedStartRules = multipleexpressions,textval,integer,duration,catchall,stringlist,locationlist +{ + operators = { + // TEXT + 'matches': { + operator: "matches", + opposite: "notmatches", + comparer: function(srcs, targets) { + for (i = 0; i < srcs.length; i++) { + src = srcs[i].toLowerCase(); + for (j = 0; j < targets.length; j++) { + if (src.match(targets[j].toLowerCase())) { + return true; + } + } + } + return false; + } + }, + 'contains': { + operator: "contains", + opposite: "notcontains", + comparer: function(srcs, targets) { + for (i = 0; i < srcs.length; i++) { + src = srcs[i].toLowerCase(); + for (j = 0; j < targets.length; j++) { + if (src.indexOf(targets[j].toLowerCase()) != -1) { + return true; + } + } + } + return false; + } + }, + 'blank': { + operator: "blank", + opposite: "notblank", + comparer: function(vals) { + for (i = 0; i < vals.length; i++) { + if (vals[i].length == 0) { + return true + } + } + return false; + } + }, + 'exact': { + operator: "exact", + opposite: "notequal", + comparer: function(srcs, targets) { + for (i = 0; i < srcs.length; i++) { + src = srcs[i].toLowerCase(); + for (j = 0; j < targets.length; j++) { + if (src.toLowerCase() == targets[j].toLowerCase()) { + return true; + } + } + } + return false; + }, + }, + 'mixedcase': { + operator: "mixedcase", + opposite: "notmixedcase", + comparer: function(vals) { + for (i = 0; i < vals.length; i++) { + if (vals[i].match("[a-z][A-Z]")) { + return true; + } + } + return false; + }, + }, + + 'uppercase': { + operator: "uppercase", + opposite: "notuppercase", + comparer: function(vals) { + for (i = 0; i < vals.length; i++) { + if (vals[i] == vals[i].toUpperCase()) { + return true; + } + } + return false; + }, + }, + + 'lowercase': { + operator: "lowercase", + opposite: "notlowercase", + comparer: function(vals) { + for (i = 0; i < vals.length; i++) { + if (vals[i] == vals[i].toLowerCase()) { + return true; + } + } + return false; + }, + }, + + // NUMBERS: + 'equals': {operator: "equals", comparer: function(a, b) {return a === b;}, opposite: 'notequals'}, + 'greater': {operator: "greater", comparer: function(a, b) {return a > b;}, opposite: 'lessequals'}, + 'greaterequals': {operator: "greaterequals", comparer: function(a, b) {return a >= b;}, opposite: 'less'}, + 'less': {operator: "less", comparer: function(a, b) {return a < b;}, opposite: 'greaterequals'}, + 'lessequals': {operator: "lessequals", comparer: function(a, b) {return a <= b;}, opposite: 'greater'}, + + 'true': { + operator: "true", + opposite: "false", + comparer: function(a) {return a} + } + }; + + fields = { + // DURATION: + age: { + getter: function(venue) { + created = parseInt(venue.id.slice(0,8), 16); + now = Date.now() / 1000 |0 + return now-created + }, + field: "age" + }, + + // TEXT: + name: { + getter: function(venue) {return [venue.name]}, + field: "name", + }, + phone: { + getter: function(venue) { + if (venue.contact) + return [venue.contact.phone || "", (venue.contact.formattedPhone || "").replace(/[^0-9]/g,"")] + else + return [""] + }, + field: "phone", + normalizer: function(string) { + return string.replace(/[^0-9]/g,"") + } + }, + url: { + getter: function(venue) { + return [venue.url || ""] + }, + field: "url", + }, + twitter: { + getter: function(venue) { + if (venue.contact) + return [venue.contact.twitter || ""] + else + return [""] + }, + normalizer: function(string) { + return string.replace("@","") + }, + field: "twitter", + }, + facebook: { + getter: function(venue) { + if (venue.contact) + return [venue.contact.facebook || "", venue.contact.facebookName || "", venue.contact.facebookUsername || ""] + else + return [""] + }, + field: "facebook", + }, + address: { + getter: function(venue) { + return [venue.location.address || ""] + }, + field: "address", + }, + city: { + getter: function(venue) { + return [venue.location.city || ""] + }, + field: "city", + }, + state: { + getter: function(venue) { + return [venue.location.state || ""] + }, + field: "state", + }, + country: { + getter: function(venue) { + return [(venue.location.country || ""), (venue.location.cc || "")] + }, + field: "country" + }, + crossStreet: { + getter: function(venue) {return [venue.location.crossStreet || ""]}, + field: "crossStreet", + }, + category: { + getter: function(venue) { + if (venue.categories.length > 0) + return [venue.categories[0].name] + else + return [""] + }, + field: "category", + }, + postalCode: { + getter: function(venue) { + return [venue.location.postalCode || ""] + }, + field: "postalCode", + }, + + // BOOL FIELDS + 'private': { + getter: (function(venue) {return venue.private;}), + field: "private" + }, + verified: { + getter: (function(venue) {return venue.verified;}), + field: "verified" + }, + home: { + getter: function(venue) { + if (venue.categories && venue.categories.length > 0) { + return venue.categories[0].id === "4bf58dd8d48988d103941735" + } else { + return false + } + }, + field: "home" + }, + // flagged: { + // getter: (function(venue) {return venue.alreadyflagged;}), + // field: 'flagged' + // }, + locked: { + getter: (function(venue) {return venue.locked;}), + field: "locked" + }, + closed: { + getter: (function(venue) {return venue.closed;}), + field: "closed" + }, + + // NUMBER: + tips: { + getter: function(venue) {return venue.stats.tipCount}, + field: "tips", + }, + herenow: { + getter: function(venue) {return venue.hereNow.count}, + field: "herenow", + }, + users: { + getter: function(venue) {return venue.stats.usersCount}, + field: "users", + }, + checkins: { + getter: function(venue) {return venue.stats.checkinsCount}, + field: "checkins", + } + }; + fields.any = { + getter: function(venue) { + allfields = [ + fields['name'].getter(venue), + fields.phone.getter(venue), + fields.twitter.getter(venue), + fields.facebook.getter(venue), + fields.address.getter(venue), + fields.crossStreet.getter(venue), + fields.category.getter(venue), + fields.city.getter(venue), + fields.state.getter(venue), + fields.country.getter(venue), + fields.postalCode.getter(venue) + ] + return [].concat.apply([], allfields) + }, + field: "any" + } + units = { + 'minute': 60, + 'hour': 60*60, + 'day': 60*60*24, + 'week': 60*60*24*7, + 'month': 60*60*24*30, + 'year': 60*60*24*365 + } +} + +/** + * This file describes the parse syntax and semantics for the advanced search field. + */ + +multipleexpressions = + first:expression rest:(expression_separator e:expression {return e;})* + {return [first].concat(rest).filter(function(e) {return e})} + +expressionwithspace = + expressions:expression " "+ {return expressions} + +expression = + negatedexpression / positiveexpression / "" + +positiveunparenthesized = + +positiveexpression = + boolvalued / numbervalued / durationvalued / textvalued / anyfield + +expression_separator = + " AND "i / " "+ + +anyfield = + operands:stringlist {return { + field: "any", + type: "text", + arity: 2, + values: operands, + operator: operators['contains'], + predicate: function(venue) { + return operators['contains'].comparer(fields.any.getter(venue), operands) + } + }} + +// TEXT VALUED: + +textvalued = + text_unary_valued / text_binary_valued + +text_binary_valued = + field:textfield " "* operator:binary_text_operator " "* operands:stringlist {return { + field: field.field, + predicate: function(venue) { + if (field.hasOwnProperty('normalizer')) { + targets = operands.map(field.normalizer) + } else { + targets = operands + } + return operator.comparer(field.getter(venue), targets); + }, + values: operands, + operator: operator, + type: 'text', + arity: 2 + }} + +text_unary_valued = + field:textfield " "* operator:unary_text_operator {return { + field: field.field, + predicate: function(venue) { + return operator.comparer(field.getter(venue)) + }, + type: "text", + operator: operator, + arity: 1 + }} + +unary_text_operator = + blank / mixedcase / uppercase / lowercase + +blank = + (":" / "IS"i / "=") " "* ("empty"i / "missing"i / "blank"i) {return operators.blank } +mixedcase = + (":" / "IS"i / "=") " "* ("mixedcase"i) {return operators.mixedcase } +lowercase = + (":" / "IS"i / "=") " "* ("lowercase"i) {return operators.lowercase } +uppercase = + (":" / "IS"i / "=") " "* ("uppercase"i) {return operators.uppercase } +//punctuation = +// (":" / "IS"i / "=") " "* ("punctuation"i) {return operators.punctuation } + +textfield = + name / address / category / crossStreet / phone / postalCode / twitter / facebook / url / category / city / state / country / any + +name = "name"i {return fields.name} +phone = "phone"i {return fields.phone} +postalCode = ("zip"i / "postalCode"i) {return fields.postalCode} +url = ("url"i / "website"i) {return fields.url} +twitter = ("twitter"i) {return fields.twitter} +facebook = ("facebook"i) {return fields.facebook} +address = "address"i {return fields.address} +category = ("category"i / "cat"i) {return fields.category} +crossStreet = ("crossStreet"i / "cross"i) {return fields.crossStreet} +city = "city"i {return fields.city} +state = "state"i {return fields.state} +country = "country"i {return fields.country} +any = "any"i {return fields.any} + +binary_text_operator = contains_any / matches_any / exact_any + +contains_any = (":" / "IN "i) {return operators['contains']} +matches_any = ("REGEXP "i / "MATCH "i / "REGEX "i) {return operators['matches']} +exact_any = "=" {return operators['exact']} + +// BOOL VALUED: +boolvalued = + field:boolfield {return { + field: field.field, + predicate: function(venue) {return field.getter(venue);}, + type: 'bool', + arity: 1, + operator: operators.true + } +} + +boolfield = (private / verified / home / locked / closed) /* FIXME: 'homexxx' parses as a boolfield instead of text; add flagged here */ + +private = "private"i {return fields['private'] } +verified = "verified"i {return fields.verified} +home = "home"i {return fields.home} +// flagged = ("flagged"i / "alreadyflagged"i) {return fields.flagged} + +locked = "locked"i {return fields.locked} +closed = "closed"i {return fields.closed} + +// DURATION VALUED +durationvalued = + field:agefield " "* operator:numberoperator " "* value:duration {return { + field: field.field, + predicate: function(venue) { + return operator.comparer(field.getter(venue), value.value); + }, + operator: operator, + arity: 2, + type: "duration", + value: value + }} + +agefield = "age"i {return fields.age } +duration = + val:integer " "+ unit:("minute"/"hour"/"day"/"week"/"month"/"year") "s"? {return { + 'value': (val * units[unit]), + 'text': text(), + 'type': 'duration', + 'count': val, + 'unit': unit + }} + +// NUMBER VALUED: + +numbervalued = + field:numberfield " "* operator:numberoperator " "* value:integer {return { + field: field.field, + predicate: function(venue) { + return operator.comparer(field.getter(venue), value); + }, + value: value, + operator: operator, + type: "numeric", + arity: 2 + };} + +numberfield = + users / checkins / herenow / tips + +users = "users"i {return fields.users} +checkins = "checkins"i {return fields.checkins} +herenow = "herenow"i {return fields.herenow} +tips = "tips"i {return fields.tips} + +numberoperator = equals / greaterequals / greater / lessequals / less + +equals = "=" {return operators.equals} +greater = ">" {return operators.greater} +greaterequals = ">=" {return operators.greaterequals} +less = "<" {return operators.less} +lessequals = "<=" {return operators.lessequals} + +negatedexpression = + ("-" / "NOT"i) " "* val:positiveexpression {return { + 'predicate': function(venue) {return !val.predicate(venue)}, + "type": "negated", + "target": val + } + } + +textval = stringlist / doublequotedstring / singlequotedstring / catchall + +catchall = + chars:.+ {return [chars.join("")]} + +stringlist = + strings:stringwithcomma* last:string {strings.push(last); return strings} + +stringwithcomma = + text:string "," " "? {return text} + +string = + singlequotedstring + / doublequotedstring + / text:[^ ,:><=~"'&]+ {return text.join("")} + +singlequotedstring = + "'" text:[^\']* "'" {return text.join("")} + + +doublequotedstring = + "\"" chars:char* "\"" {return chars.join("")} + +char + = unescaped + / escape + sequence:( + '"' + / "\\" + / "/" + / "b" { return "\b"; } + / "f" { return "\f"; } + / "n" { return "\n"; } + / "r" { return "\r"; } + / "t" { return "\t"; } + / "u" digits:$(HEXDIG HEXDIG HEXDIG HEXDIG) { + return String.fromCharCode(parseInt(digits, 16)); + } + ) + { return sequence; } + +escape = "\\" +quotation_mark = '"' +unescaped = [\x20-\x21\x23-\x5B\x5D-\uFFFFF] +HEXDIG = [0-9a-f]i + +integer = + number:[0-9]+ {return parseInt(number.join(""), 10);} + + +/* Locationlist related */ +locationlist = (loc:location)* + +location = lat:lat separator? "," separator? lng:lat locationseparator? {return lat + "," + lng} + +separator = [\n \t\r]* + +locationseparator = [\n \t\r;]* + +lat = num:("-"? int "."? int?) {return parseFloat(num.join(""))} + +int = num:[0-9]+ {return num.join("")} + diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js new file mode 100644 index 0000000..6e70b99 --- /dev/null +++ b/app/assets/javascripts/application.js @@ -0,0 +1,36 @@ +// This is a manifest file that'll be compiled into application.js, which will include all the files +// listed below. +// +// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, +// or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path. +// +// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the +// the compiled file. +// +// WARNING: THE FIRST BLANK LINE MARKS THE END OF WHAT'S TO BE PROCESSED, ANY BLANK LINE SHOULD +// GO AFTER THE REQUIRES BELOW. +// +//= require jquery +//= require jquery_ujs +//= require jquery.cookie +//= require jquery-ui/effect.all +//= require select2 +//= require bootstrap-dropdown +//= require bootstrap-tooltip +//= require bootstrap-popover +//= require bootstrap-tab +//= require bootstrap-button +//= require bootstrap-modal +//= require moment +//= require jquery.hoverIntent.minified +//= require query-string +//= require bootstrap-datepicker +//= require pnotify +//= require handlebars.runtime +//= require handlebars_helpers +//= require_tree ./templates +//= require_tree ./items +//= require handlebars_customhelpers +//= require imagesloaded.pkgd +//= require underscore +//= require HoursParser diff --git a/app/assets/javascripts/explorer.js.coffee b/app/assets/javascripts/explorer.js.coffee new file mode 100644 index 0000000..6a010ee --- /dev/null +++ b/app/assets/javascripts/explorer.js.coffee @@ -0,0 +1,16 @@ +#= require ./search/Listeners +#= require ./search/Explorer +#= require ./search/Searches/Search +#= require ./search/Searches/VenueSearch +#= require ./search/Searches/UserSearch +#= require ./search/SearchLocation/SearchLocation +#= require_tree ./search/ + +@API_VERSION = "20140810" + +window.STACK_BOTTOMRIGHT = {"dir1": "up", "dir2": "left", "firstpos1": 25, "firstpos2": 25, "spacing1": 0, "spacing2": 0}; +window.STACK_BOTTOMLEFT = {"dir1": "up", "dir2": "right"}; +$.pnotify.defaults.history = false + +$().ready -> + explorer = new Explorer($("body")) diff --git a/app/assets/javascripts/flags.js.coffee b/app/assets/javascripts/flags.js.coffee new file mode 100644 index 0000000..ed36835 --- /dev/null +++ b/app/assets/javascripts/flags.js.coffee @@ -0,0 +1,283 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/ + +$().ready -> + setupListeners() + updateQueueButtons(true) + setupTooltips() + setupPagesize() + setupPhotoPopover() + setupFilterButton() + $(".statuschange").click( (e) -> + e.preventDefault() + updateUrlParams({'status': $(this).data('status'), 'page': 1}) + ) + +queues = { + check: { + items: [], + running: false, + processingtext: "Checking", + processedtext: "Checked", + runtext: "Check", + action: "check", + allbuttonclass: ".checkall", + btnclass: ".flagcheck", + process_size: 8 + }, + submit: { + items: [], + running: false, + processingtext: "Submitting", + processedtext: "Submitted", + runtext: "Run", + action: "run", + allbuttonclass: ".runall", + btnclass: ".flagrun", + process_size: 50 + }, + cancel: { + items: [], + running: false, + processingtext: "Canceling", + processedtext: "Canceled", + runtext: "Cancel", + action: "cancel", + allbuttonclass: ".cancelall", + btnclass: ".flagcancel", + process_size: 10 + }, + resubmit: { + items: [], + running: false, + processingtext: "Resubmitting", + processedtext: "Resubmitted", + runtext: "Resubmit", + action: "resubmit", + allbuttonclass: ".resubmitall", + btnclass: ".flagresubmit", + process_size: 2 + } + hide: { + items: [], + running: false, + processingtext: "Hiding", + processedtext: "Hidden", + runtext: "Resubmit", + action: "hide", + allbuttonclass: ".hideall", + btnclass: ".flaghide", + process_size: 5 + } +} + + +createAndRunFlag = (flag) -> + $.ajax( + type: "POST" + url: '/flags' + dataType: 'json' + data: + flags: [flag], + runimmediately: true + success: (data) -> + result = data.flags[0] + ) + +friendlyStatus = (status, details) -> + friendly = switch status + when 'not_authorized' then "Not authorized" + when 'new' then "Not yet submitted" + when 'resolved' then 'Accepted' + when 'cancelled' then "Canceled" + when 'canceled' then "Canceled" + when 'submitted' then "Submitted" + when 'hidden' then "Hidden" + when 'queued' then "Queued" + when 'scheduled' then "Scheduled" + when 'failed' then 'Failed' + else status + + if details + return "#{friendly} #{details}" + else + return friendly + +setupPagesize = -> + $(".pagesize").change (e) -> + e.preventDefault() + updateUrlParams({'pagesize': $(".pagesize").val(), 'page': 1}) + +updateActions = (flag) -> + flagid = flag.id + status = flag.status + + $("#flagrow_#{flagid} .flaghide").tooltip('hide') + if status == 'resolved' + $("#flagrow_#{flagid} .flagcheck").remove() + $("#flagrow_#{flagid} .flagrun").remove() + $("#flagrow_#{flagid} .flagresubmit").remove() + $("#flagrow_#{flagid} .flaghide").remove() + + if status != 'new' and status != 'queued' and status != 'scheduled' + $("#flagrow_#{flagid} .flagcancel").remove() + $("#flagrow_#{flagid} .flagrun").remove() + + if status == 'cancelled' or status == 'canceled' + $("#flagrow_#{flagid} .flagcheck").remove() + $("#flagrow_#{flagid} .flagcancel").remove() + $("#flagrow_#{flagid} .flagrun").remove() + $("#flagrow_#{flagid} .flaghide").remove() + + if status == 'hidden' + $("#flagrow_#{flagid} .flagcheck").remove() + $("#flagrow_#{flagid} .flagrun").remove() + $("#flagrow_#{flagid} .flagresubmit").remove() + $("#flagrow_#{flagid} .flaghide").remove() + + +updateQueueButtons = (firstrun) -> + $("#queuebuttons").removeClass('hidden') + + for i in ['submit', 'check', 'cancel'] + queue = queues[i] + left = $(queue.btnclass).length + + $(queue.allbuttonclass).text("#{queue.runtext} all flags on this page (" + left + ")") + + if left == 0 + if !firstrun + # $(queue.allbuttonclass).text("Complete!").removeClass("btn-primary").addClass("btn-success") + $(queue.allbuttonclass).delay(1500).fadeOut() + else + $(queue.allbuttonclass).remove() + + +@friendlyDate = (timestamp) -> + if timestamp + moment(new Date(timestamp)).calendar() + else + "-" + +@futureFriendlyDate = (timestamp) -> + if timestamp + return moment(timestamp).calendar() + else + "-" + +runQueue = (name) -> + queue = queues[name] + return if queue.running # i think this is a good enough mutex in JS + + queue.running = true + processsize = queue.process_size + flagids = [] + while ((processsize-- > 0) && (queueitem = queue.items.shift())) + flagids.push($(queueitem).data('flagid')) + $(queueitem).text(queue.processingtext + "...") + + startTime = new Date().getTime() + $.ajax + type: "POST" + url: "/flags/#{queue.action}/" + data: {ids: flagids} + success: (data) -> + queue.running = false + + $(data.flags).each (i, flagresponse) -> + flag = flagresponse.flag + flagid = flag.id + target = $("#flagrow_#{flagid} #{queue.btnclass}") + if flagresponse.message + $("#flagrow_#{flagid} .status").text(flagresponse.message) + $(target).text(queue.runtext) + $(target).removeClass("disabled") + else + $("#flagrow_#{flagid} .status").text(friendlyStatus(flag.status, flag?.resolved_details)) + + $("#flagrow_#{flagid} .last_checked").html(friendlyDate(flag?.last_checked)) + + $("#flagrow_#{flagid} .last_checked").effect('highlight') + $(target).text(queue.processedtext) + + $("#flagrow_#{flagid} .status").effect('highlight') + if (name == 'submit' and flag.status == 'submitted') + setTimeout( () -> + $("#flagrow_#{flagid} .flagcheck").click() + , 8000) + + updateActions(flag) + + if data.newcount == 0 + $("#flagcount").remove() + else + $("#flagcount").text(data.newcount) + + updateQueueButtons(false) + + if queue.items.length > 0 + runQueue(name) + error: (jqXHR, textStatus, errorThrown) -> + if jqXHR.readyState >= 4 # Request not yet sent, likely user navigated away + # Rollbar.error("Error on executing #{queue.action}: ", textStatus, errorThrown, flagids, jqXHR.responseText) + alert("Stopped due to error: " + errorThrown) + queue.running = false + +setupQueueListeners = (name) -> + queue = queues[name] + $(queue.btnclass).click (e) -> + e.preventDefault() + $(e.target).addClass("disabled") + queue.items.push($(e.target)) + setTimeout( (() -> runQueue(name)), 100) + +setupListeners = -> + for i in ['submit', 'cancel', 'check', 'resubmit', 'hide'] + setupQueueListeners(i) + + $(".runall").click (e) -> + e.preventDefault() + $(".flagrun").click() + $(e.target).addClass('disabled').text("Running...") + + $(".checkall").click (e) -> + e.preventDefault() + $(".flagcheck").click() + $(e.target).addClass('disabled').text("Running...") + + $(".cancelall").click (e) -> + e.preventDefault() + $(".flagcancel").click() + $(e.target).addClass('disabled').text("Running...") + +setupTooltips = -> + $(".flaghide").tooltip() + $("i[rel=tooltip]").tooltip() + +setupPhotoPopover = -> + $("a.photopopover").popover( + trigger: "hover" + content: () -> "" + html: true + placement: (context, source) -> + if (($(source).position().top - $(window).scrollTop())/$(window).height() > 0.5) + 'top' + else + 'bottom' + ) + +setupFilterButton = -> + $(".include_types").on("change", (e) -> + e.preventDefault() + types = [] + $(".include_flag_type:checked").each( (i,e) -> types.push(e.value)) + + updateUrlParams({'include_types': types.join(",")}) + ) + +@updateUrlParams = (newparams) -> + clearTimeout(@timeoutId) + q = queryString.parse(location.search); + $.extend(q, newparams) + @timeoutId = setTimeout((-> location.search = queryString.stringify(q)), 600) diff --git a/app/assets/javascripts/items/ItemModal.js.coffee b/app/assets/javascripts/items/ItemModal.js.coffee new file mode 100644 index 0000000..c25b090 --- /dev/null +++ b/app/assets/javascripts/items/ItemModal.js.coffee @@ -0,0 +1,310 @@ +#= require search/SubmitListener +class ItemModal + limit: 50 + HOME_CAT_ID: '4bf58dd8d48988d103941735' + itemStats: {total: 0, deleted: 0, home: 0, private: 0, closed: 0, no_longer_relevant: 0} + extraOptions: () -> {} + + loading: false + hasMore: true + nextOffset: 0 + alreadyflagged: null + + requestParams: () -> + v: API_VERSION + oauth_token: token + m: "swarm" + limit: @limit + offset: @nextOffset + + constructor: (@source, @type) -> + + statusVisibility: () -> + no_longer_relevant: @checkCookie("no_longer_relevant_visible") + deleted: @checkCookie("deleted_visible") + home: @checkCookie("home_visible") + private: @checkCookie("private_visible") + closed: @checkCookie("closed_visible") + + checkCookie: (cookiename) -> + ($.cookie(cookiename) || "hidden") == "shown" + + clearItems: () -> + @modal.find(".modal-body").html(HandlebarsTemplates["#{@type}s/grid"]()) + @nextOffset = 0 + @loading = false + @hasMore = true + @itemStats = {total: 0, deleted: 0, home: 0, private: 0, closed: 0, no_longer_relevant: 0} + + toggleMultiSelection: (startItem, endItem) -> + range = [$(startItem).parents(".itemcontainer").index()..$(endItem).parents(".itemcontainer").index()] + onOff = $(endItem).hasClass('selected') + for i in @modal.find(".items .item:visible") when $(i).parents(".itemcontainer").index() in range + $(i).toggleClass('selected', onOff) + + show: () -> + $(".attach-modal").html(HandlebarsTemplates["#{@type}s/modal"]()) + @modal = $(".attach-modal ##{@type}modal") + @modal.data('ItemModal', this) + @modal.find(".modal-header").html(HandlebarsTemplates["#{@type}s/modal_header"]({source: @source, sourceType: @sourceType, statusVisibility: @statusVisibility()})) + + @clearItems() + @lastClicked = undefined + @modal.modal('show') + @loadMore() + @updateSelectedCount() + self = this + + # Attach click to select event + @modal.on "click", ".items .item", (e) -> + return if $(e.target).is("a") or $(e.target).parent().is("a") or $(e.target).is(".zoomicon") + e.preventDefault() + $(this).toggleClass("selected") + if e.shiftKey && self.lastClicked && sulevel >= 2 + self.toggleMultiSelection(self.lastClicked, this) + + self.updateSelectedCount() + self.lastClicked = this + + # Attach load more on scroll event + @modal.find(".modal-body").on "scroll", (e) -> + if ($(this).scrollTop() + $(this).innerHeight() >= this.scrollHeight - 300 && + ($(this).scrollTop() > 50)) + self.loadMore() + + # Attach destroy event + @modal.on "hidden", (e) -> + return unless $(e.target).is(self.modal) + self.destroy() + + # Attach action popovers + @modal.find(".itemactions .item-popover-trigger").each (i, flag) -> + context = + problem: $(flag).children('a').data('problem'), + problem_description: $(flag).children(".description").html() + problem_text: $(flag).children('a').text() + + $(flag).popover({ + html: true, + placement: 'bottom', + title: () -> "Remove #{context['problem_text']} #{if self.type == 'photo' then "Photos" else "Tips"} " + + " ", + content: () -> HandlebarsTemplates[self.type + "s/actions"](context), + trigger: 'click', + }).click (event) -> + event.preventDefault() + if $(this).children("a").hasClass("disabled") + $(this).popover('hide') + return + $(".open-popover").not(flag).popover('hide') + $(flag).addClass("open-popover") + + # Attach hide events to popover close buttons + @modal.on "click", ".popover-close", (e) -> + e.preventDefault() + self.closePopovers() + + # Attach flag creation events: + @modal.on "click", "button.itemflag", (e) -> + e.preventDefault() + self.createFlags($(this).data("flagtype"), $(this).data('problem')) + + # Attach filter + @modal.on "keyup", ".itemfilter", (e) -> + self.filterItems($(this).val()) + + # Attach retry button + @modal.on "click", "button.retry", (e) -> + e.preventDefault() + self.loading = false + self.loadMore() + self.modal.find(".retrytext .progress").removeClass("hide") + + # Attach listeners for changes on itemvisibles: + @modal.find(".toggles .item_visible_toggle").click (e) -> + e.preventDefault() + newVis = if $(this).text() == "shown" then "hidden" else "shown" + $(this).text(newVis) + $.cookie($(this).data("visibility-type") + "_visible", newVis) + self.toggleItemVisibility() + + filterItems: (filter = "") -> + filter = filter.toLowerCase() + self = this + @modal.find(".item").each (i, e) -> + $(e).toggleClass("hide", self.searchText($(e).data('item')).toLowerCase().indexOf(filter) == -1) + + if (@modal.find('.hasmore').length > 0 && !(@modal.find(".placeholder").hasClass("loading")) && + @modal.find(".hasmore").position().top < @modal.find(".modal-body").height()) + @modal.find(".placeholder").addClass("loading") + self.loadMore() + + closePopovers: () -> + @modal.find(".open-popover").popover('hide').removeClass("open-popover") + + hide: () -> + @modal.modal('hide') + + destroy: () -> + @clearItems() + @modal.remove() + + updateSelectedCount: () -> + count = @modal.find(".items .selected").length + @modal.find(".selectedcount").text(count) + @modal.find(".itemactions a").toggleClass('disabled', count == 0) + if count == 0 + @modal.find(".actions button").attr('disabled', 'disabled') + else + @modal.find(".actions button").removeAttr('disabled') + + processItems: (items) -> + @hasMore = items.items.length > 2 + @loading = false + + context = {source: @source, items: items} + context['hasmoreclass'] = if @hasMore then 'hasmore' else 'nomore' + context = $.extend(context, @extraOptions()) + + @modal.find(".modal-body .placeholder").replaceWith(HandlebarsTemplates[@template](context)) + @nextOffset += @limit + + for item in items.items + @modal.find(".item_#{item.id}").data('item', item) + + @markAlreadyFlagged() + if @modal.find(".itemfilter").length > 0 + @filterItems(@modal.find(".itemfilter").val()) + + if @sourceType == 'user' + @itemVenueStatuses(items.items) + @modal.find(".total_count").text(@itemStats.total) + @modal.find(".deleted_count").text(@itemStats.deleted) + @modal.find(".home_count").text(@itemStats.home) + @modal.find(".private_count").text(@itemStats.private) + @modal.find(".closed_count").text(@itemStats.closed) + @modal.find(".no_longer_relevant_count").text(@itemStats.no_longer_relevant) + + @toggleItemVisibility() + + imagesLoaded(this.modal).on "always", (e) => + if (@modal.find(".placeholder").position().top - 50) < @modal.find(".modal-body").height() + @loadMore() + + toggleItemVisibility: () -> + statusVisibility = @statusVisibility() + @modal.find(".item_home").parent(".itemcontainer").toggleClass("hide", not statusVisibility.home) + @modal.find(".item_deleted").parent(".itemcontainer").toggleClass("hide", not statusVisibility.deleted) + @modal.find(".item_closed").parent(".itemcontainer").toggleClass("hide", not statusVisibility.closed) + @modal.find(".item_private").parent(".itemcontainer").toggleClass("hide", not statusVisibility.private) + @modal.find(".item_no_longer_relevant").parent(".itemcontainer").toggleClass("hide", not statusVisibility.no_longer_relevant) + + itemVenueStatuses: (items) -> + for item in items + elem = @modal.find(".item_#{item.id}") + @itemStats.total++ + unless item.venue + @itemStats.deleted++ + elem.addClass("item_deleted") + if item.venue?.private + @itemStats.private++ + elem.addClass("item_private") + if item.venue?.categories?[0]?.id == @HOME_CAT_ID + @itemStats.home++ + elem.addClass("item_home") + if item.venue?.closed + @itemStats.closed++ + elem.addClass("item_closed") + if "no_longer_relevant" in (item.flags || []) + @itemStats.no_longer_relevant++ + elem.addClass("item_no_longer_relevant") + + loadMore: -> + return if @loading or @hasMore == false + @loading = true + @modal.find(".placeholder").addClass("loading") + + $.ajax + dataType: 'json' + url: @loadUrl() + success: (data) => + @processItems(@getItems(data)) + error: (xhr, textStatus, errorThrown) => + errorText = switch + when xhr.status == 0 then "Could not connect to server, please check your network and try again." + when xhr.status >= 500 and xhr.status then "A server error occurred, please try again later." + when textStatus == 'timeout' then "The request timed out. Please try again." + else + # Rollbar.error("AJAX Items Modal Error: ", {xhr: xhr, textStatus: textStatus, errorThrown: errorThrown}) + "An unknown error occurred. Try again, and if the problem continues, please email 4sweep@4sweep.com" + @modal.find(".placeholder").html(HandlebarsTemplates['items/retry_placeholder']({errorText: errorText})) + + data: @requestParams() + + postSubmitProcess: (flag) -> + i = @modal.find(".item_#{flag.itemId}") + i.addClass('alreadyflagged', 500) + i.removeClass("selected") + if @alreadyflagged + @alreadyflagged.push(flag.itemId) + @updateSelectedCount() + + markAlreadyFlagged: () -> + if (@alreadyflagged == null) + # We have to pull these from the server + sourceObj = switch @sourceType + when "user" then {creator_ids: [@source.id]} + when "venue" then {venue_ids: [@source.id]} + + FlagSubmissionService.get().getAlreadyFlaggedStatuses @source.id, + success: (data) => + @alreadyflagged = data.map (e) -> e.itemId + @markAlreadyFlagged() + error: () -> # NOOP; fail silently on this + type: @flagType + fetchBy: switch @sourceType + when 'venue' then 'venue_ids' + when 'user' then 'creator_ids' + else throw "unknown type" + else + for id in @alreadyflagged + @modal.find(".item_#{id}").addClass("alreadyflagged") + + createFlags: (flagType, problem) -> + flags = [] + for item in @modal.find(".selected") + data = $(item).data('item') + + if @sourceType == 'user' + user = @source.user + venue = new VenueResult(data.venue, 0) + else if @sourceType == 'venue' + user = data.user + venue = @source + + continue unless venue + + flags.push venue.createFlag flagType, + problem: problem + itemId: data.id + itemName: @itemName(data) + comment: if $(".popover .comment") then $(".popover .comment").val() else "" + creatorId: user.id + creatorName: ((user.firstName || "") + " " + (user.lastName || "")).trim() + + FlagSubmissionService.get().submitFlags flags, new ItemsSubmitListener(this) + + @closePopovers() + + class ItemsSubmitListener extends SubmitListener + constructor: (@itemModal) -> + objectType: () -> + switch @itemModal.flagType + when 'PhotoFlag' then 'photos' + when 'TipFlag' then 'tips' + processSubmit: (flag) -> + @itemModal.postSubmitProcess flag + processUndo: (flag) -> + # FIXME: should remove alreadyflagged status from element unless there are other pending flags + +window.ItemModal = ItemModal diff --git a/app/assets/javascripts/items/PhotoModal.js.coffee b/app/assets/javascripts/items/PhotoModal.js.coffee new file mode 100644 index 0000000..a84e2ef --- /dev/null +++ b/app/assets/javascripts/items/PhotoModal.js.coffee @@ -0,0 +1,99 @@ +class PhotoModal extends ItemModal + template: "photos/photos" + flagType: "PhotoFlag" + DEFAULT_SIZE: 'medium' + photoTips: {} + + fetchTips: (offset = 0, retries = 3) -> + tipModal = @tipModal() + + $.ajax + dataType: 'json' + url: tipModal.loadUrl() + success: (data) => + tips = tipModal.getItems(data) + @fetchTips(offset + tipModal.limit) if (tips.items.length > 2) # Kick off next page + for tip in tips.items when tip.photo + @photoTips[tip.photo.id] = tip + @attachTip(tip) + error: () => + if retries > 0 + @fetchTips(offset, --retries) + else + $.pnotify + title: "Problem loading tips" + width: '450px' + text: "\nCould not load tips that may be associated with these photos." + type: 'error' + icon: false + + data: + $.extend(tipModal.requestParams(), {offset: offset}) + + attachTip: (tip) -> + return unless tip.photo + photoElem = @modal.find(".item_#{tip.photo.id}") + return if photoElem.hasClass('hasTip') + photoElem.addClass("hasTip").removeClass("noknowntip") + photoElem.find(".tipholder").text(" [Has Associated Tip]").tooltip + title: tip.text + photoElem.find(".tipicon").tooltip + title: tip.text + + processItems: (items) -> + super(items) + for item in items.items when @photoTips[item.id] + @attachTip(@photoTips[item.id]) + + photoOptions: (size) -> + switch size + when 'tiny' + {size: size, span: "span1", text: "Tiny", request: "100x100", height: "100px", width: "100px"} + when 'small' + {size: size, span: "span2", text: "Small", request: "300x300", height: "200px", width: "200px"} + when 'medium' + {size: size, span: "span3", text: "Medium", request: "300x300", height: "300px", width: "300px"} + when 'large' + {size: size, span: 'span4', text: "Large", request: "500x500", height: "400px", width: "400px"} + + photoSize: () -> + if $.cookie("photosize") && $.cookie("photosize") in ["tiny", "small", "medium", "large"] + $.cookie("photosize") + else + @DEFAULT_SIZE + + extraOptions: () -> + $.extend(super(), size: @photoOptions(@photoSize())) + + getItems: (data) -> + data.response.photos + + constructor: (@source) -> + super(@source, "photo") + + show: () -> + super() + @fetchTips() + + # Set up zoom icon for photos + @modal.on("click", ".zoomicon", (e) => + e.preventDefault() + photo = $(e.target).parents(".venuephoto").data("item") + $("#photozoommodal").modal('show') + $("#photozoommodal .modal-body").html(HandlebarsTemplates['photos/zoommodal']({photo: photo, tip: @photoTips[photo.id]})) + ) + + # Set up radio checkbox + self = this + @modal.find("input:radio[name=photosize][value='#{@photoSize()}']").prop("checked", true) + + @modal.find("input:radio[name=photosize]").change (e) -> + $.cookie("photosize", $(this).val()) + self.clearItems() + self.loadMore() + self.markAlreadyFlagged() + + itemName: (photo) -> + photo.prefix + "SIZE" + photo.suffix + +window.PhotoModal = PhotoModal diff --git a/app/assets/javascripts/items/TipModal.js.coffee b/app/assets/javascripts/items/TipModal.js.coffee new file mode 100644 index 0000000..0f5a4ae --- /dev/null +++ b/app/assets/javascripts/items/TipModal.js.coffee @@ -0,0 +1,10 @@ +class TipModal extends ItemModal + limit: 200 + template: "tips/tips" + flagType: "TipFlag" + constructor: (@source) -> + super(@source, "tip") + itemName: (tip) -> + tip.text + +window.TipModal = TipModal diff --git a/app/assets/javascripts/items/UserPhotoModal.js.coffee b/app/assets/javascripts/items/UserPhotoModal.js.coffee new file mode 100644 index 0000000..fd66680 --- /dev/null +++ b/app/assets/javascripts/items/UserPhotoModal.js.coffee @@ -0,0 +1,7 @@ +class UserPhotoModal extends PhotoModal + sourceType: "user" + loadUrl: () -> "https://api.foursquare.com/v2/users/#{@source.id}/photos" + tipModal: () -> + @tipModalCache ||= new UserTipModal(@source) + +window.UserPhotoModal = UserPhotoModal diff --git a/app/assets/javascripts/items/UserTipModal.js.coffee b/app/assets/javascripts/items/UserTipModal.js.coffee new file mode 100644 index 0000000..900157f --- /dev/null +++ b/app/assets/javascripts/items/UserTipModal.js.coffee @@ -0,0 +1,16 @@ +class UserTipModal extends TipModal + sourceType: "user" + loadUrl: () -> "https://api.foursquare.com/v2/lists/#{@source.id}/tips" + + searchText: (tipdata) -> + tipdata.text + " " + (tipdata.venue.name) + + getItems: (data) -> + items: + data.response.list.listItems.items.map (e) -> + result = $.extend(e.tip, {venue: e.venue}) + if e.photo + result = $.extend(result, {photo: e.photo}) + result + +window.UserTipModal = UserTipModal diff --git a/app/assets/javascripts/items/VenuePhotoModal.js.coffee b/app/assets/javascripts/items/VenuePhotoModal.js.coffee new file mode 100644 index 0000000..3d0b5eb --- /dev/null +++ b/app/assets/javascripts/items/VenuePhotoModal.js.coffee @@ -0,0 +1,38 @@ +class VenuePhotoModal extends PhotoModal + sourceType: "venue" + + DEFAULT_GROUP: "" + group: () -> + if $.cookie("photosort") && $.cookie("photosort") in ["", "venue"] + $.cookie("photosort") + else + @DEFAULT_GROUP + + photoSort: () -> + @modal.find(".photosort").val() || @DEFAULT_GROUP + + constructor: (@source) -> + super(@source) + + tipModal: () -> + @tipModalCache ||= new VenueTipModal(@source) + + show: () -> + super() + + self = this + @modal.find(".sort-holder").html(HandlebarsTemplates['photos/sort']()) + + @modal.find(".photosort").val(@group()) + + @modal.find(".photosort").change (e) -> + $.cookie("photosort", $(this).val()) + self.clearItems() + self.loadMore() + + loadUrl: () -> "https://api.foursquare.com/v2/venues/#{@source.id}/photos" + + requestParams: () -> + $.extend(super(), {group: @group()}) + +window.VenuePhotoModal = VenuePhotoModal diff --git a/app/assets/javascripts/items/VenueTipModal.js.coffee b/app/assets/javascripts/items/VenueTipModal.js.coffee new file mode 100644 index 0000000..3a15fa8 --- /dev/null +++ b/app/assets/javascripts/items/VenueTipModal.js.coffee @@ -0,0 +1,38 @@ +class VenueTipModal extends TipModal + sourceType: "venue" + + DEFAULT_SORT: "popular" + + order: () -> + if $.cookie("tipsort") && $.cookie("tipsort") in ["popular", "recent"] + $.cookie("tipsort") + else + @DEFAULT_SORT + + loadUrl: () -> "https://api.foursquare.com/v2/venues/#{@source.id}/tips" + + getItems: (data) -> data.response.tips + + searchText: (tipdata) -> + tipdata.text + " " + (tipdata.user.firstName || " ") + " " + (tipdata.user.lastName || "") + + constructor: (@source) -> + super(@source) + + show: () -> + super() + + self = this + @modal.find(".sortholder").html(HandlebarsTemplates['tips/sort']()) + + @modal.find(".tipsort").val(@order()) + + @modal.find(".tipsort").change (e) -> + $.cookie("tipsort", $(this).val()) + self.clearItems() + self.loadMore() + + requestParams: () -> + $.extend(super(), {sort: @order()}) + +window.VenueTipModal = VenueTipModal diff --git a/app/assets/javascripts/search/BootstrapUtils.js.coffee b/app/assets/javascripts/search/BootstrapUtils.js.coffee new file mode 100644 index 0000000..3d88543 --- /dev/null +++ b/app/assets/javascripts/search/BootstrapUtils.js.coffee @@ -0,0 +1,35 @@ +class BootstrapUtils + + @repositionPopover: (popover) -> + placement = popover.options.placement + pos = popover.getPosition() + + actualWidth = popover.$tip[0].offsetWidth + actualHeight = popover.$tip[0].offsetHeight + + tp = switch (placement) + when 'bottom' + {top: pos.top + pos.height, left: pos.left + pos.width / 2 - actualWidth / 2} + when 'top' + {top: pos.top - actualHeight, left: pos.left + pos.width / 2 - actualWidth / 2} + when 'left' + {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth - 7} + when 'right' + {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width + 7} + originaltop = tp.top + + popoverheight = popover.tip().height() + if ((popoverheight + tp.top) > $(window).height()) + if placement != 'bottom' + tp.top = $(window).height() - popoverheight - 10 + else + popover.options.placement = 'top' + return @repositionPopover(popover) + if tp.top < 0 and placement != 'top' + tp.top = 0 + + if tp.top != originaltop + popover.tip().find(".arrow").hide() + popover.applyPlacement(tp, placement) + +window.BootstrapUtils = BootstrapUtils diff --git a/app/assets/javascripts/search/CategorySelector.js.coffee b/app/assets/javascripts/search/CategorySelector.js.coffee new file mode 100644 index 0000000..e9e689a --- /dev/null +++ b/app/assets/javascripts/search/CategorySelector.js.coffee @@ -0,0 +1,76 @@ +class CategorySelector + + subCategoriesData: (cats, level = 1, upId) -> + result = [] + prevId = upId + for cat in cats + result.push + id: cat.id + text: cat.name + level: level + hasChildren: cat.categories?.length > 0 + prevId: prevId + upId: upId + if cat.categories + result = result.concat(@subCategoriesData(cat.categories, level + 1, cat.id)) + prevId = cat.id + result + + setupCategoryData: () -> + result = @subCategoriesData(categories) + for cat, i in result + continue if i == 0 + result[i-1].nextId = cat.id + result + + # options: + # 'allowMultiple' + # 'recentChoicesSelector' + # 'rotateButtonsSpanSelector' + setupCategories: (selector, options = {}) -> + CategorySelector.categoryData ||= @setupCategoryData() + selector.select2 + data: CategorySelector.categoryData + multiple: options.allowMultiple == true + formatResultCssClass: (object) -> + 'category-indent-' + object.level + " " + if object.hasChildren then " optionbold" else "" + + @setupRecentChoices(selector, options.recentChoicesSelector) if options.recentChoicesSelector? + @setupRotateButtons(selector, options.rotateButtonsSpanSelector) if options.rotateButtonsSpanSelector? + + setupRecentChoices: (selector, recentChoicesSelector) -> + recentlyused = JSON.parse($.cookie("recentlyused")) || [] + if recentlyused.length > 0 + $(selector).select2('val', recentlyused[0]['cat_id']) + @showRecentlyUsed(recentlyused, recentChoicesSelector) + + $(selector).change (e) => + cat_id = $(e.target).select2('val') + cat_name = $(e.target).select2('data').text + + recentlyused = JSON.parse($.cookie("recentlyused")) || [] + return if (recentlyused.length > 0 && recentlyused[0]['cat_id'] == cat_id) + + recentlyused = $.grep recentlyused, ((e, i) -> e['cat_id'] == cat_id), true + recentlyused.unshift({cat_id: cat_id, name: cat_name}) + recentlyused = recentlyused[0..6] + $.cookie('recentlyused', JSON.stringify(recentlyused)) + @showRecentlyUsed(recentlyused, recentChoicesSelector) + + $(recentChoicesSelector).on "click", ".chooserecentcat", (e) => + e.preventDefault() + $(selector).select2('val', $(e.target).data('catid')).trigger('change') + + setupRotateButtons: (selector, rotateButtonsSpanSelector) -> + rotateButtonsSpanSelector.find(".catrotate").click (e) -> + e.preventDefault() + field = $(this).data('rotate') + "Id" + data = selector.select2('data')[0] + if data?[field] + selector.select2('val', [data[field]]) + true + + showRecentlyUsed: (recentlyused, recentChoicesSelector) -> + $(recentChoicesSelector).html(HandlebarsTemplates['explore/recently_used_categories'](recent: recentlyused[1..5])) + +window.CategorySelector = CategorySelector diff --git a/app/assets/javascripts/search/DetailsEditor.js.coffee b/app/assets/javascripts/search/DetailsEditor.js.coffee new file mode 100644 index 0000000..ce59a80 --- /dev/null +++ b/app/assets/javascripts/search/DetailsEditor.js.coffee @@ -0,0 +1,253 @@ +class DetailsEditor + constructor: (@venueResultElement, attach) -> + @venueresult = @venueResultElement.venueresult # For convenience + @setupEditPopover(attach) + + editChanged: (popover, element, changed) -> + changed = changed && !$(element).hasClass("error") + $(element).toggleClass("changed", changed).parents(".control-group").toggleClass("success", changed) + + disablesubmit = popover.find(".submittable.changed").not(".venuedetails_comment").not(".error").length == 0 + popover.find(".submitbtn").toggleClass('disabled', disablesubmit) + + # enable/disable venuelinks, set hrefs + popover.find(".twitter-link").toggleClass('disabled', popover.find(".venuedetails_twitter").val().trim().length == 0) + .attr("href", "http://twitter.com/#{encodeURIComponent(popover.find(".venuedetails_twitter").val().trim())}") + popover.find(".facebook-link").toggleClass('disabled', popover.find(".venuedetails_facebook").val().trim().length == 0) + .attr("href", popover.find(".venuedetails_facebook").val().trim()) + popover.find(".facebook-link").toggleClass('disabled', popover.find(".venuedetails_facebook").val().trim().length == 0) + .attr("href", popover.find(".venuedetails_facebook").val().trim()) + popover.find(".googlesearch") + .attr("href", "https://www.google.com/search?q=#{encodeURIComponent(popover.find(".venuedetails_name").val())}" + + "+#{encodeURIComponent(popover.find(".venuedetails_address").val())}" + + "+#{encodeURIComponent(popover.find(".venuedetails_city").val())}" + + "+#{encodeURIComponent(popover.find(".venuedetails_state").val())}") + + urlval = popover.find(".venuedetails_url")?.val()?.trim() + if urlval?.length > 0 + urlval = "http://" + urlval unless (urlval.match(/https?:\/\//)) + popover.find(".webpage-url-link").removeClass("disabled").attr("href", "#{urlval}") + else + popover.find(".webpage-url-link").addClass("disabled") + + menuurl = popover.find(".venuedetails_menuUrl")?.val()?.trim() + if urlval?.length > 0 + urlval = "http://" + urlval unless (menuurl.match(/https?:\/\//)) + popover.find(".webpage-menuurl-link").removeClass("disabled").attr("href", "#{menuurl}") + else + popover.find(".webpage-menuurl-link").addClass("disabled") + + setupParentEditor: (popover) -> + popover.find(".venuedetails_parentId").select2 + placeholder: "Search for parent venue" + minimumInputLength: 3 + allowClear: true + initSelection: (element, callback) => + parent = @venueresult.venuedata.parent + if parent + callback + id: parent.id + text: parent.name + object: parent + formatResult: (object, container, query) => + HandlebarsTemplates['venues/edit_venue_details/parentcandidate']({candidate: object.object, venue: @venueresult.venuedata}) + formatSelection: (object, container) => + HandlebarsTemplates['venues/edit_venue_details/parentcandidate']({candidate: object.object, venue: @venueresult.venuedata}) + sortResults: (results, container, query) => + results.sort (a, b) => + a.object?.location?.distance - b.object?.location?.distance + formatResultCssClass: (object) -> + if object.object?.location?.distance > 1000 + "distance-warning" + ajax: + url: (term, page) -> + if term.match(/^ *([0-9a-f]{24}) *$/) + venueid = term.match(/^ *([0-9a-f]{24}) *$/)[1] + "https://api.foursquare.com/v2/venues/#{venueid}" + else + "https://api.foursquare.com/v2/venues/suggestcompletion" + dataType: "json" + data: (term, page) => + if term.match(/^ *([0-9a-f]{24}) *$/) + oauth_token: token + v: API_VERSION + m: 'swarm' + else + ll: @venueresult.venuedata.location.lat + "," + @venueresult.venuedata.location.lng + query: term + oauth_token: token + v: API_VERSION + m: 'swarm' + results: (data, page) => + # FIXME: replace with custom display + results: if data.response.minivenues + data.response.minivenues.map (e) => + id: e.id + text: e.name + object: e + .filter (e) => e.id != @venueresult.id + else + [{id: data.response.venue.id, text: data.response.venue.name, object: data.response.venue}] + more: false + + setupMapEditor: (popover) -> + # Only load Google Maps if actually tabbed to, and then only do it once + mapsInitialized = false + venuedata = @venueresult.venuedata + self = this + popover.find("a[href=#relocate]").on('shown', (e) -> + return if mapsInitialized + relocateMap = new google.maps.Map document.getElementById("relocateMap"), + zoom: 17 + center: new google.maps.LatLng(venuedata.location.lat, venuedata.location.lng) + mapTypeId: google.maps.MapTypeId.ROADMAP + mapTypeControl: true + zoomControl: true + zoomControlOptions: + position: google.maps.ControlPosition.LEFT_CENTER + style: google.maps.ZoomControlStyle.LARGE + + oldVenueMarker = new google.maps.Marker + map: relocateMap + draggable: false + position: new google.maps.LatLng(venuedata.location.lat, venuedata.location.lng) + title: venuedata.name + " (current location)" + zindex: -50 + icon: '/img/gray-mapicon.png' + + venueMarker = new google.maps.Marker + position: new google.maps.LatLng(venuedata.location.lat, venuedata.location.lng) + map: relocateMap + draggable: true + title: venuedata.name + + + setNewPosition = (position) -> + venueMarker.setPosition(position) + popover.find(".venuedetails_controlgroup").removeClass('error') + popover.find(".venuedetails_ll").val(position.lat() + "," + position.lng()).removeClass('error').trigger('change') + if (!relocateMap.getBounds().contains(position)) + relocateMap.fitBounds(relocateMap.getBounds().extend(position)) + + google.maps.event.addListener(relocateMap, 'click', (e) -> + setNewPosition(e.latLng) + ) + + google.maps.event.addListener(venueMarker, 'dragend', (e) -> + setNewPosition(venueMarker.getPosition()) + ) + + popover.find(".venuedetails_ll").blur (e) -> + val = popover.find(".venuedetails_ll").val() + [latstring, lngstring] = val.split(',') + [lat, lng] = [parseFloat(latstring), parseFloat(lngstring)] + isFloat = (s) -> + /^(\-|\+)?([0-9]+(\.[0-9]+)?)$/.test(s) + + if (isFloat(lat) && isFloat(lng) && lat >= -90.0 && lat <= 90.0 && lng >= -180.0 && lng <= 180.0) + setNewPosition(new google.maps.LatLng(lat, lng), false) + else + popover.find(".venuedetails_ll").addClass('error') + popover.find(".venuedetails_controlgroup").addClass('error') + + mapsInitialized = true + ) + + setupHoursEditor: (popover) -> + timeoutId = null + oldtext = null + popover.find(".hours-freeform").on "keyup paste change", (e) => + window.clearTimeout(timeoutId) if timeoutId + timeoutId = window.setTimeout(() => + text = popover.find(".hours-freeform").val() + return if text == oldtext + oldtext = text + hours = Hours.parse(text) + + if hours + hours.validateForVenue(@venueresult.id, + success: (data) => + @updateHoursField(popover, data, hours) + error: () -> + data: + status: "ERROR" + message: "Could not verify hours." + @updateHoursField(popover, data, hours) + ) + else + @updateHoursField(popover, {status: "OK", hours: []}, new Hours([])) + , 200) + + updateHoursField: (popover, response, hours) -> + popover.find("input.venuedetails_hours").toggleClass("error", response.status == "ERROR") + popover.find("input.venuedetails_hours").val(hours.asProposedEdit()).trigger('change') + popover.find(".existinghours").html(Handlebars.partials['venues/edit_venue_details/_humanhours'](response)) + + setupEditPopover: (attach) -> + self = this + attach.popover + html: true + trigger: 'click' + placement: "right" + title: () => "Edit Details for place: #{@venueresult.venuedata.name}" + " " + content: () => HandlebarsTemplates['venues/edit_venue_details/edit_venue_details'] + venue: @venueresult.venuedata + hours: @venueresult.hours + hoursProposedEdit: @venueresult.hours.asProposedEdit() + + container: ".attach-popover" + template: '

' + .on "shown", (e) => + if $(e.target).hasClass('disabled') + $(e.target).popover('hide') + return + attach.addClass('active') + BootstrapUtils.repositionPopover($(e.target).data('popover')) + $(".open-popover").not(e.target).popover('hide') + $(e.target).addClass("open-popover") + popoverobj = $(e.target).data('popover') + popover = popoverobj.tip() + popover.find(".popover-close").click (e) -> + e.preventDefault() + popoverobj.hide() + popover.find(".venueedit .submittable").on 'keyup paste change', (e) -> + window.setTimeout( #Paste needs a timeout, since the event fires before the element is changed + () => + if (original = $(e.target).data('originalvalue')) + changed = original.replace("empty","") != e.target.value + else + changed = e.target.defaultValue.trim() != e.target.value.trim() + self.editChanged(popover, this, changed) + , 20 + ) + popover.find(".venueedit .submittable").trigger("keyup") + popover.find(".editpopoverexternal").click (e) -> + return false if $(this).hasClass("disabled") + + popover.find(".submitbtn").click (e) -> + e.preventDefault() + return if $(this).hasClass("disabled") + edits = {'oldvalues': {}, 'newvalues': {}} + for i in popover.find(".submittable.changed").not(".error") + if (original = $(i).data('originalvalue')) + edits.oldvalues[$(i).data('keyname')] = $(i).data('originalvalue').replace("empty",'') + else + edits.oldvalues[$(i).data('keyname')] = i.defaultValue + edits.newvalues[$(i).data('keyname')] = i.value + + editflag = self.venueresult.createFlag "EditVenueFlag", + edits: edits + comment: popover.find(".venuedetails_comment").val() + + FlagSubmissionService.get().submitFlags [editflag], new VenueSubmitListener(self.venueResultElement) + popoverobj.hide() + + @setupParentEditor(popover) + @setupHoursEditor(popover) + @setupMapEditor(popover) + + .on "hidden", (e) -> + attach.removeClass('active') + $(e.target).removeClass("open-popover") + +window.DetailsEditor = DetailsEditor diff --git a/app/assets/javascripts/search/Explorer.js.coffee b/app/assets/javascripts/search/Explorer.js.coffee new file mode 100644 index 0000000..0646528 --- /dev/null +++ b/app/assets/javascripts/search/Explorer.js.coffee @@ -0,0 +1,290 @@ +class Explorer + constructor: (@elem) -> + @listeners = new Listeners(['updatedselectedcount', 'submitautomaticallychanged']) + + @zoomedVenue = undefined + @oldZoom = undefined + + @selected = {} + @selectedcountText = @elem.find(".selectedcount") + + @filterContainer = new FilterContainer('.filtercontainer') + @filterContainer.listeners.add "filtersChanged", (filters) => @results?.filterUpdated(filters, @map) + @setupMap(new google.maps.LatLng(lat, lng)) + @setupSubmitwhen() + @setupPopoverButtons() + @setupSearchTabs() + @setupSorting() + @sort = JSON.parse($.cookie("sort")) || + type: "natural" + name: "Natural" + dir: "up" + icon: "alt" # alt = generic, name = alpha, number = numeric + @sortResults() # Just to update icon + + @disableShiftSelection() + @pinnedResults = new PinnedResults(@elem.find(".pinnedvenues")) + + @setupDynamicSizing() + + @deserializeSearch() + @setupHashListener() + + fitButtons = new FitButtons(this, @map) + + setupSorting: -> + @elem.find('.sortresults a.performsort').click (e) => + e.preventDefault() + @sort.type = $(e.target).data('sorttype') + @sort.icon = $(e.target).data('sorticon') + @sort.name = $(e.target).data('sortname') + @sortResults() + + @elem.find(".sortresults .sortdirbutton").click (e) => + e.preventDefault() + @sort.dir = if @sort.dir == "up" then "down" else "up" + @sortResults() + + @elem.find(".sortrefreshbutton").click (e) => + e.preventDefault() + @sortResults() + + setupDynamicSizing: () -> + $(window).resize (e) -> + availableY = $(window).height() - $(".searchcontrols").height() - $(".navbar-fixed-top").height() - $(".footer").height() - 60 + + $("#map_canvas").height(availableY) + $(".allvenues").height(availableY - $(".venuelistcontrols").height() - 5) + $(window).trigger('resize') + + disableShiftSelection: () -> + @elem.keydown (e) => + keynum = e.keyCode || e.which + if keynum == 16 + @elem.addClass("unselectable") + @elem.keyup (e) => + keynum = e.keyCode || e.which + if keynum == 16 + @elem.removeClass("unselectable") + + sortResults: -> + # save sort prefs + $.cookie("sort", JSON.stringify @sort) + + #update sort button + @elem.find(".sortrefreshbutton").addClass("hide") + @elem.find(".sortdirbutton i").removeClass().addClass("i-sort-#{@sort.icon}-#{@sort.dir}") + @elem.find(".activesort").text("Sort: " + @sort.name) + + #do sort + @results?.sortBy(@sort, @elem.find(".retrieved_venues")) + + setupMap: (initialCenter) -> + @map = new google.maps.Map document.getElementById("map_canvas"), + zoom: 15 + center: initialCenter + mapTypeId: google.maps.MapTypeId.ROADMAP + mapTypeControl: true + mapTypeControlOptions: + style: google.maps.MapTypeControlStyle.HORIZONTAL_BAR + position: google.maps.ControlPosition.LEFT_BOTTOM + + google.maps.event.addListener @map, 'center_changed', () => + @results?.recentered(@map.getCenter()) + @pinnedResults?.recentered(@map.getCenter()) + @elem.find(".sortrefreshbutton").toggleClass("hide", @sort.type != 'distance') + @zoomedVenue?.setZoomState(false) + @zoomedVenue = undefined + + setupHashListener: () -> + $(window).on "hashchange", () => + $(".modal").modal('hide') + @deserializeSearch() + @filterContainer.listeners.add "filtersChanged", (e) => + @saveSearchInHash(@lastSearch) + + setupSearchTabs: () -> + locationManager = new LocationManager(@map, new CenterRadiusSearchLocation(new google.maps.LatLng(lat, lng), 25000)) + @managers = {} + + tabsIdsToClass = + 'venuesearch': PrimaryVenueSearchTab + 'globalsearch': GlobalSearchTab + 'specificvenuesearch': SpecificVenueSearchTab + 'usersearch': UserSearchTab + 'uncategorizedsearch': UncategorizedVenuesSearchTab + 'listsearch': ListSearchTab + 'recentlycreated': RecentlyCreatedTab + 'pagesearch': PageVenuesSearchTab + 'queuesearch': FlaggedVenuesSearchTab + 'myhistory': MyHistorySearchTab + 'dupsearch': DuplicateSearchTab + + for own id, klass of tabsIdsToClass + @managers[id] = new klass($("#tab-#{id}"), this, locationManager) + @managers[id].setupEvents() + + @managers['venuesearch'].shown() + + deserializeSearch: () -> + window.clearTimeout(@timeoutId) if @timeoutId + + @timeoutId = window.setTimeout( () => + hash = (location.href.split("#")[1] || "").replace(/^#/,'') # http://stackoverflow.com/questions/4835784/firefox-automatically-decoding-encoded-parameter-in-url-does-not-happen-in-ie + if hash == @dontUpdateHash + # @dontUpdateHash = "" + return + + if @lastSearch && hash == "" + return + + hash = hash.replace(/\+/g, "%20") + + obj = {} + for e in hash.split("&") + [k,v] = e.split("=") + obj[k] = decodeURIComponent(v) + + try + search = Search.deserialize(obj) + + if search.searchTab of @managers + @managers[search.searchTab].displaySearch(search) + search.options?.loadMoreContainer = @managers[search.searchTab].tab.find('.loadmorecontainer') + else + throw "Unknown search type" + catch e + console.log("Problem deserializing search", e) if search?.searchTab + search = @managers['venuesearch'].createSearch() + + if obj.filter? + @filterContainer.showFilter(obj.filter) + + search.location.fitMapToLocation(@map) + + @performSearch(search) + , 500) + + setupPopoverButtons: () -> + new MakeHomeFlagPopover(this, @elem.find(".mass-home")).attach() + new MergeFlagPopover(this, @elem.find(".mass-merge")).attach() + new RemoveFlagPopover(this, @elem.find(".mass-remove")).attach() + new MakePrivateFlagPopover(this, @elem.find(".mass-private")).attach() + new RecategorizeFlagPopover(this, @elem.find(".mass-recategorize")).attach() + new CloseFlagPopover(this, @elem.find(".mass-close")).attach() + new RefreshAction(this, @elem.find(".mass-refresh")).attach() + new ExportAction(this, @elem.find(".mass-export")).attach() + + setupSubmitwhen: -> + $(".submitwhen .submitautomatically").click (e) => + e.preventDefault() + $.cookie("submitwhen", "automatically") + @listeners.notify("submitautomaticallychanged", true) + $(".submitwhen .submitwait").click (e) => + e.preventDefault() + $.cookie("submitwhen", "wait") + @listeners.notify("submitautomaticallychanged", false) + + # Initialize to value of cookie + submitwhen = $.cookie("submitwhen") || 'automatically' + $(".submitwhen .submit#{submitwhen}").click() + + $(".submitwhen-help").popover( + html: true + title: "Submit automatically vs review" + placement: "top" + trigger: "hover" + content: HandlebarsTemplates['venues/about_autosubmit']() + ) + + updateSelectedCount: () -> + count = (key for key of @selected).length + @selectedcountText?.text(count) + @listeners.notify 'updatedselectedcount', count + + performSearch: (search) -> + @lastSearch?.searchResults?.clearResults() + @lastSearch = search + + self = this + + @results = new SearchResults(search, @pinnedResults) + search.setSearchResults(@results) + search.setResultsDiv($(".venuediv")) + + @results.listeners.add "newsearchrequested", (newSearch) => + @performSearch(newSearch) + @managers[newSearch.searchTab]?.updateSearch(newSearch) + + search.listeners.add 'resultsready', (search, results) => + @results.display($(".venuediv"), @map) + + search.listeners.add 'geotoobig', (search, results) => + @results.display($(".venuediv"), @map, {tooBig: true}) + + @results.listeners.add 'resultsupdated', (results) => + @results.filterUpdated(@filterContainer.filters, @map) + @sortResults() + + search.listeners.add 'extrasready', (extras) => + extras.render($(".venuediv .extrasholder")) + + search.listeners.add 'extrasfailed', () => + # FIXME: should we notify users in any way? It looks ugly as is + # $(".venuediv .extrasholder").html HandlebarsTemplates['search_extras/extraserror']() + + @results.listeners.add 'clearedresults', (clearedresults) => + self.selected = @pinnedResults.selected() + self.updateSelectedCount() + self.oldZoom = undefined + self.zoomedVenue = undefined + $(".venuediv .extrasholder").html("") # Clear extras + + @results.listeners.add 'resultadded', (results, venue) => + venue.listeners.add 'selected', (venue) => + self.selected[venue.venueresult.id] = venue + self.updateSelectedCount() + + venue.listeners.add 'unselected', (venue) => + delete self.selected[venue.venueresult.id] + self.updateSelectedCount() + + venue.listeners.add 'requestzoomin', (position) => + return if @zoomedVenue?.venueresult.id == venue.venueresult.id + + @zoomedVenue?.setZoomState(false) # Old zoomed venue + + @oldZoom = + center: @map.getCenter() + zoom: @map.getZoom() + @map.panTo(venue.venueresult.position()) + @map.setZoom(16) + @zoomedVenue = venue + + venue.listeners.add 'requestzoomout', () => + if @oldZoom + @map.setZoom(@oldZoom.zoom) + @map.panTo(@oldZoom.center) + + @zoomedVenue = undefined + @oldZoom = undefined + + venue.listeners.add 'pin', (pinnedResult) => + @pinnedResults.addResult(pinnedResult) + + search.perform() + @saveSearchInHash(search) + + saveSearchInHash: (search) -> + return unless search + toSave = $.extend {}, search.serialize(), @filterContainer.serialize() + hash = ("#{key}=#{encodeURIComponent(val)}" for own key, val of toSave when val).join("&") + hash = hash. + replace(/%2C/g, ','). + replace(/\+/g, '%2B'). + replace(/%20/g, '+'). + replace(/%3B/g,';') #make this a bit more readable + @dontUpdateHash = hash + location.hash = hash + +window.Explorer = Explorer diff --git a/app/assets/javascripts/search/Filters/FilterContainer.js.coffee b/app/assets/javascripts/search/Filters/FilterContainer.js.coffee new file mode 100644 index 0000000..9b8884c --- /dev/null +++ b/app/assets/javascripts/search/Filters/FilterContainer.js.coffee @@ -0,0 +1,227 @@ +class FilterContainer + EMPTY_FILTER = + field: "any" + type: "text" + arity: 2 + values: [] + operator: + operator: "contains" + opposite: "notcontains" + + constructor: (filterselector, @filters = []) -> + @container = $(filterselector) + @listeners = new Listeners(['filtersChanged']) + @setup() + + setup: () -> + # First, set up the show/hide buttons on the filterdiv + @container.find(".showfilter").click (e) => + e.preventDefault() + @showFilter() + + @container.find('.hidefilter').click (e) => + e.preventDefault() + @hideFilter() + + @showFilter() if $.cookie("showfilter") == 'true' + + @container.find(".filtererror").tooltip + trigger: "manual" + title: "Filter cannot be parsed" + placement: "bottom" + + @setupFilterEditor() + + $(".hidefilter").tooltip() + + $(".filter-help").popover + html: true + title: "About Filters" + placement: "bottom" + trigger: "hover" + container: ".attach-popover" + template: '

', + content: HandlebarsTemplates['filters/about_filters']() + + # Don't submit anything on enter + @container.find(".filter").keydown (e) -> + if e.keyCode == 13 + e.preventDefault() + false + + @container.find(".filterrow input").keyup (e) => + e.preventDefault() + try + @filters = advancedsearch.parse(@container.find(".filter").val().trim()) + @container.find(".filterrow.control-group").removeClass("error") + @container.find(".filtererror").tooltip("hide") + @filtererrorshown = false + @listeners.notify "filtersChanged", @filters + @updateFilterRows() + catch error + if error.name == "SyntaxError" + @container.find(".filterrow.control-group").addClass("error") + @container.find(".filtererror").tooltip("show") unless @filtererrorshown + @filtererrorshown = true + else + throw error + + showFilter: (val) -> + @container.find(".filterlink").hide() + @container.find(".filterform").show() + $.cookie("showfilter", "true") + if val + @container.find("input.filter").val(val) + @container.find("input.filter").keyup() + + hideFilter: () -> + @container.find("input.filter").val("") + @container.find("input.filter").keyup() + @container.find(".filterlink").show() + @container.find(".filterform").hide() + $.cookie("showfilter", "false") + + setupFilterEditor: () -> + filter = @container.find(".filter") + filter.popover + html: true, + trigger: "manual", + placement: 'bottom', + title: "Edit Filter ", + content: () => + HandlebarsTemplates['filters/edit_filters']({filters: @filters}) + container: ".filterpopovercontainer", + template: '

', + + @container.find(".editfilter").click (e) => + e.preventDefault() + filter.popover("toggle") + + filter.on "shown", (e) => + $(".open-popover").not(e.target).popover('hide').removeClass("open-popover") + filter.addClass('open-popover') + @popover = $(e.target).data('popover').tip() + popover = @popover + + @updateFilterRows() + + popover.find(".filteredit-popover-close").click (e) => + e.preventDefault() + filter.popover('hide') + popover.find(".addrow").click (e) => + e.preventDefault() + popover.find(".addfilter").append(@filterRow(EMPTY_FILTER)) + popover.find(".setfilter").click (e) => + e.preventDefault() + @setFilterFromInputs() + + filter.on "hidden", (e) => + filter.removeClass("open-popover") + @popover = undefined + + updateFilterRows: () -> + @popover?.find(".addfilter").children().remove() + if @filters.length == 0 + @popover?.find(".addfilter").append(@filterRow(EMPTY_FILTER)) + for filterobj in @filters + @popover?.find(".addfilter").append(@filterRow(filterobj)) + + filterRow: (filter) -> + resolveNegated = (expression) -> + result = $.extend {}, expression.target #clone + result.operator = {operator: expression.target.operator.opposite} + result.predicate = (venue) -> !(expression.target.predicate) + return result + + if filter.type == 'negated' + filter = resolveNegated(filter) + + filterrow = $(HandlebarsTemplates['filters/filterrow']({filter: filter})) + filterrow.find('.fieldsselect').val(filter.field) + filterrow.find('.opselect').val(filter.operator.operator) + + if filter.arity == 2 + switch filter.type + when 'numeric' + filterrow.find(".numericinput input").val(filter.value) + when 'text' + filterrow.find(".textinput input").val(filter.values?.map(@escapeString).join(',')) + when 'duration' + filterrow.find(".durationinput input").val(filter.value.count) + filterrow.find('.durationinput select').val(filter.value.unit) + + filterrow.on "change", ".fieldsselect", (e) => + fieldtype = filterrow.find('.fieldsselect option:selected').data('type') + filterrow.find(".operatorposition").html(Handlebars.partials["filters/_operatorselect"]({type: fieldtype})) + filterrow.find(".opselect").trigger('change') + if $(e.target).find("option:selected").data('operandplaceholder') + filterrow.find(".operand").attr('placeholder', $(e.target).find("option:selected").data('operandplaceholder')) + + filterrow.on "change", ".opselect", (e) => + fieldtype = filterrow.find('.fieldsselect option:selected').data('type') + arity = filterrow.find(".opselect option:selected").data('arity') + filterrow.find(".operandposition").html(Handlebars.partials["filters/_operand"]({type: fieldtype, arity: arity})) + + filterrow.on "change", ".operand", (e) => @setFilterFromInputs() + + filterrow.find(".btn.remove").click (e) => + filterrow.remove() + @setFilterFromInputs() + filterrow + + escapeString: (s) -> + result = s.replace(/\//g, "\\\\").replace(/"/g, "\\\"") + "\"#{result}\"" + + setFilterFromInputs: () -> + failed = false + results = [] + @popover?.find(".filterlist .filterrow").each (i, filterrow) => + result = "" + field = $(filterrow).find(".fieldsselect") + op = $(filterrow).find(".opselect") + $(filterrow).removeClass("error") + + if ($(filterrow).find("option:selected").hasClass("negated")) + result += "-" + + result += field.val() + result += op.find("option:selected").data('optext') + if (op.find("option:selected").data('arity') == 2) + operand = $(filterrow).find('.operand') + + operandType = field.find("option:selected").data('type') + try + operandval = switch (operandType) + when "numeric" + advancedsearch.parse(operand.val(), {startRule: 'integer'}) + when "duration" + advancedsearch.parse(operand.val() + " " + $(filterrow).find('.durationinput .durationtype').val() + "s", {startRule: 'duration'}).text + when "text" + @textParse(operand.val()) + result += operandval + catch error + if error.name == "SyntaxError" + $(filterrow).addClass("error") + failed = true + else + throw error + results.push(result) + + unless failed + @container.find(".filter").val(results.join(" AND ")).trigger("keyup") + + textParse: (text = "") -> + try + result = advancedsearch.parse(text, {startRule: 'textval'}).map(@escapeString).join(",") + catch error + if error.name == "SyntaxError" + result = advancedsearch.parse(text, {startRule: 'catchall'}).map(@escapeString).join(",") + else + throw error + result + + serialize: () -> + filter: @container.find("input.filter")?.val() || "" + +window.FilterContainer = FilterContainer diff --git a/app/assets/javascripts/search/FlagSubmissionService.js.coffee b/app/assets/javascripts/search/FlagSubmissionService.js.coffee new file mode 100644 index 0000000..0ba422d --- /dev/null +++ b/app/assets/javascripts/search/FlagSubmissionService.js.coffee @@ -0,0 +1,211 @@ +# A service to submit flags to 4sweep's backend, produce notifications +# to users on successful (and failed) submissions. +# +# Also supports undoing flag submissions and running them immediately +class FlagSubmissionService + @get: () -> + @instance ?= new FlagSubmissionService() + + runImmediatelyStatus: () -> + $(".submitautomatically").hasClass("active") + + # Takes an array of flags and a listener argument, which must be an object that + # responds to he following calls: + # * objectType(): String the type of object that this flag represents, such as "venues", "tips", "photos", etc + # * processSubmit(flag) + # * processUndo(flag) + # * processRunImmediately(flag) + submitFlags: (flags, listener) => + self = this + submitnotice = if (flags.length >= 25) + $.pnotify + title: "Submitting flags" + width: '450px' + insert_brs: false + type: "info" + text: "This may take a few seconds" + stack: @stackForObject(listener) + addclass: @addclassForObject(listener) + icon: false + hide: false + + runimmediately = @runImmediatelyStatus() + $.ajax + type: "POST" + url: "/flags" + dataType: "json" + data: + flags: flags + runimmediately: runimmediately + success: (data) -> + submitnotice?.pnotify_remove() + self.displaySubmitSuccess(data, listener, submitnotice, runimmediately) + error: @displaySubmitError + + displaySubmitError: (xhr, textStatus, errorThrown) -> + errorText = switch + when xhr.status == 0 then "Could not connect to server, please check your network and try again." + when xhr.status >= 500 and xhr.status then "A server error occurred, please try again later." + when textStatus == 'timeout' then "The request timed out. Please try again." + else + # Rollbar.error("AJAX error: ", {xhr: xhr, textStatus: textStatus, errorThrown: errorThrown}) + "An unknown error occurred. Try again, and if the problem continues, please email 4sweep@4sweep.com" + $.pnotify + title: "An error occurred" + text: "\n" + errorText + icon: false + type: "error" + width: '350px' + + displaySubmitSuccess: (data, listener, submitnotice, runimmediately) -> + notify_text = "" + type = listener.objectType() + flag_cutoff = switch type + when "venues" then 20 + when "tips" then 5 + when "photos" then 40 + else throw "Don't know this type (#{type})" + + notify_context = + objectType: type + description: data.flags[0]['friendly_name'] # This isn't technically correct, but all flags currently submitted are always of the same type + top_flags: data.flags[0...flag_cutoff] + total_count: data.flags.length + remaining_flags_count: data.flags.length - flag_cutoff + has_remaining: data.flags.length > flag_cutoff + run_text: switch + when data.flags[0]['scheduled_at'] != null + "Your flag(s) will run " + moment(data.flags[0]['scheduled_at']).fromNow() + when runimmediately + "Your flag(s) will automatically run in about 5 minutes" + when !runimmediately + "Click on the Flags Tab to review and run your flags" + compactView: (type == 'venues') and (data.flags.length == 1) + + notify_content = HandlebarsTemplates["explore/confirm_box"](notify_context) + + submitnotice?.pnotify_remove() + + notice = $.pnotify( + title: HandlebarsTemplates["explore/confirm_title"](notify_context) + width: '450px' + icon: false + insert_brs: false, + type: "success", + text: notify_content + stack: @stackForObject(listener) + addclass: @addclassForObject(listener) + ) + + self = this + notice.find(".undoflags").click (e) -> + e.preventDefault() + return if $(this).hasClass("disabled") + self.undoFlags(data.flags, listener) + $(this).addClass("disabled").text("Canceling Flag") + notice.find(".submitnow").addClass('disabled') + + notice.find(".submitnow").click (e) -> + e.preventDefault() + return if $(this).hasClass("disabled") + self.submitImmediately(data.flags, listener) + $(this).addClass("disabled").text("running now") + notice.find(".undoflags").addClass('disabled') + notice.pnotify_remove() + + notice.find(".reselect").click (e) -> + e.preventDefault() + listener.processReselect?() + + for flag in data.flags + listener.processSubmit(flag) + + $("#flagcount").text(data.newcount) + + undoFlags: (flags, listener) -> + $.ajax + type: "POST" + url: "/flags/cancel/" + data: + ids: flags.map (e) -> e.id + success: () => + $.pnotify + title: "Canceled #{flags.length} " + if flags.length > 1 then "flags" else "flag" + type: "error" + icon: false + stack: @stackForObject(listener) + addclass: @addclassForObject(listener) + width: '450px' + for flag in flags + listener.processUndo(flag) + error: () => + $.pnotify + title: "Unable to cancel #{flags.length} " + if flags.length > 1 then "flags" else "flag" + type: "error" + icon: true + stack: @stackForObject(listener) + addclass: @addclassForObject(listener) + width: '450px' + + submitImmediately: (flags, listener) -> + $.ajax + type: "POST" + url: "/flags/run/" + data: + ids: flags.map (e) -> e.id + success: () => + $.pnotify + title: "Queued #{flags.length} " + (if flags.length > 1 then "flags" else "flag" ) + " for immediate processing" + type: "success" + icon: false + stack: @stackForObject(listener) + addclass: @addclassForObject(listener) + width: '450px' + for flag in flags + listener.processRunImmediately(flag) + error: () => + $.pnotify + title: "Unable to immediately process " + (if flags.length > 1 then "flags" else "flag" ) + type: "error" + icon: true + stack: @stackForObject(listener) + addclass: @addclassForObject(listener) + width: '450px' + + stackForObject: (listener) -> + if listener.objectType() == "venues" + STACK_BOTTOMRIGHT + else + STACK_BOTTOMLEFT + + addclassForObject: (listener) -> + if listener.objectType() == "venues" + "stack-bottomright" + else + "stack-bottomleft" + + getAlreadyFlaggedStatuses: (itemIds, options = {}) -> + flagTypes = switch options.type || "venue" + when 'venue' + ["MergeFlag", "DeleteFlag", "UndeleteFlag", "AddCategoryFlag", + "MakePrimaryCategoryFlag", "RemoveCategoryFlag", "ReplaceAllCategoriesFlag", + "CloseFlag", "ReopenFlag", "MakePrivateFlag", "MakePublicFlag", "MakeHomeFlag", + "EditVenueFlag"] + when 'TipFlag' + ['TipFlag'] + when 'PhotoFlag' + ['PhotoFlag'] + else throw Exception("Unknown type #{type} for getAlreadyFlaggedStatuses") + data = {types: flagTypes} + data[options.fetchBy || "venue_ids"] = itemIds + data.forcecheck = true if options.forcecheck + + $.ajax + type: "POST" + url: '/flags/statuses' + dataType: 'json' + data: data + success: options.success + error: options.error + +window.FlagSubmissionService = FlagSubmissionService diff --git a/app/assets/javascripts/search/FlagsPopover.js.coffee b/app/assets/javascripts/search/FlagsPopover.js.coffee new file mode 100644 index 0000000..7a1f1a2 --- /dev/null +++ b/app/assets/javascripts/search/FlagsPopover.js.coffee @@ -0,0 +1,73 @@ +class FlagsPopover + constructor: (@venueResultElement, @attach) -> + @venueresult = @venueResultElement.venueresult + @setupFlagsPopover() + @attach.click (e) => + e.preventDefault() + + toggleShown: () -> + @attach.toggleClass("hide", @flagArray().length == 0) + if @flagArray().length == 0 + popover = @attach.data('popover')?.tip() + popover?.find('.arrow').hide() + + flagArray: () -> + flag for own id, flag of @venueresult.existingFoursweepFlags + + setupFlagsPopover: () -> + self = this + @attach.popover + html: true + trigger: 'click' + placement: 'right' + title: () => "Pending 4sweep Flags for place: #{@venueresult.venuedata.name}" + " " + content: () => HandlebarsTemplates['venues/pending_4sweep_flags'] + flags: @flagArray() + venue: @venueresult.venuedata + + container: ".attach-popover" + template: '

' + .on "shown", (e) => + if $(e.target).hasClass('disabled') + $(e.target).popover('hide') + return + @attach.addClass('active') + BootstrapUtils.repositionPopover($(e.target).data('popover')) + $(".open-popover").not(e.target).popover('hide') + $(e.target).addClass("open-popover") + popoverobj = $(e.target).data('popover') + popover = popoverobj.tip() + popover.find(".popover-close").click (e) -> + e.preventDefault() + popoverobj.hide() + @setupActions(popover) + + .on "hidden", (e) => + @attach.removeClass('active') + $(e.target).removeClass("open-popover") + + setupActions: (popover) -> + popover.on "click", ".flagaction", (e) => + e.preventDefault() + return if $(e.target).hasClass('disabled') + $(e.target).addClass('disabled') + flagid = $(e.target).parents(".flagrow").data('flagid') + action = $(e.target).data('action') + $.ajax + type: "POST" + url: "/flags/#{action}" + data: + ids: [flagid] + success: (data) => + flag = data.flags[0].flag + flagrow = popover.find("[data-flagid=#{flag.id}]") + flagrow.html( Handlebars.partials['venues/_pending_4sweep_flag'](flag)) + if flag.status not in ['new', 'queued', 'scheduled', 'submitted'] + @venueresult.refreshEverything(true) + error: () => + $(e.target).removeClass("disabled") + $.pnotify + title: "Error" + type: "error" + text: "\nAn error occurred during your last request. Please try again." +window.FlagsPopover = FlagsPopover diff --git a/app/assets/javascripts/search/FuzzyStringService.js.coffee b/app/assets/javascripts/search/FuzzyStringService.js.coffee new file mode 100644 index 0000000..4924ddd --- /dev/null +++ b/app/assets/javascripts/search/FuzzyStringService.js.coffee @@ -0,0 +1,55 @@ +class FuzzyStringService + @stringMap: + 'α': 'a' + 'β': 'v' + 'γ': 'g' + 'δ': 'd' + 'ε': 'e' + 'ζ': 'z' + 'η': 'i' + 'θ': 'th' + 'ι': 'i' + 'κ': 'k' + 'λ': 'l' + 'μ': 'm' + 'ν': 'n' + 'ξ': 'ks' + 'ο': 'o' + 'π': 'p' + 'ρ': 'r' + 'σ': 's' + 'τ': 't' + 'υ': 'u' + 'φ': 'f' + 'χ': 'h' + 'ψ': 'ps' + 'ω': 'o' + 'ς': 's' + 'ά': 'a' + 'έ': 'e' + 'ή': 'i' + 'ί': 'i' + 'ό': 'o' + 'ύ': 'y' + 'ώ': 'o' + 'ϊ': 'i' + 'ϋ': 'y' + 'ΐ': 'i' + 'ΰ': 'u' + + @fuzzyString: (string) -> + result = @articleRemoval @greeklishToEnglish string.toLocaleLowerCase() + result + + @articleRemoval: (text) -> + text + .replace( /^(the|an?|to|ta|o|i|el|la) /i, "") + .replace( /[\.,-\/#!$%\^&\'\"\*;:{}@=–\-_<>`~()\s]/ig, "") # This is a dirty hack. Let's see if it works. + + @greeklishToEnglish: (text) -> + for own k, v of @stringMap + text = text.replace(new RegExp(k, 'g'), v) # In this special case, no need for RegExp escaping + text + + +window.FuzzyStringService = FuzzyStringService diff --git a/app/assets/javascripts/search/Hours.js.coffee b/app/assets/javascripts/search/Hours.js.coffee new file mode 100644 index 0000000..6202fc7 --- /dev/null +++ b/app/assets/javascripts/search/Hours.js.coffee @@ -0,0 +1,59 @@ +class Hours + @parse: (text) -> + result = fourSq.util.HoursParser.parse(text) + if result + new Hours(result.timeframes) + else + null + + constructor: (@timeframes = []) -> + + asProposedEdit: () -> + # The hours for the venue, as a semi-colon separated list of open segments and named segments + # (e.g., brunch or happy hour). Open segments are formatted as day,start,end. Named segments + # additionally have a label, formatted as day,start,end,label. Days are formatted as integers + # with Monday = 1,...,Sunday = 7. Start and End are formatted as [+]HHMM format. Use 24 hour + # format (no colon), prefix with 0 for HH or MM less than 10. Use '+' prefix, i.e., +0230 to + # represent 2:30 am past midnight into the following day. + result = [] + for timeframe in @timeframes + for day in timeframe.days + for segment in timeframe.open + result.push "#{day},#{segment.start},#{segment.end}" + + result.sort().join(";") || "" + + validateForVenue: (venueid, options = {}) -> + # Attempts to validate the hours against the Foursquare venue. + # Pass in a success(response) function and it will be called + # after validation. response will have a field called 'status', + # which is known to take on the following values: + # 'ERROR', 'POPULARHOURSWARNING', 'OK' + # if the status field is 'POPULARHOURSWARNING' or 'ERROR', + # a message field with human readable text will be shown. + # + # if the status field is 'OK', or 'POPULARHOURSWARNING', an 'hours' + # field will be included, which is of the same human-optimized + # format as in a full venue response + + # Let's set up some semi-aggressive caching + if Hours.cache?[venueid]?[@asProposedEdit()] + return options.success(Hours.cache[venueid][@asProposedEdit()]) + + $.ajax + dataType: "json" + url: "https://api.foursquare.com/v2/venues/#{venueid}/validatehours" + type: "POST" + success: (data) => + Hours.cache = Hours.cache || {} + Hours.cache[venueid] = Hours.cache[venueid] || {} + Hours.cache[venueid][@asProposedEdit()] = data.response + options.success(data.response) + error: options.error + data: + hours: @asProposedEdit() + m: 'swarm' + v: API_VERSION + oauth_token: token + +window.Hours = Hours diff --git a/app/assets/javascripts/search/Listeners.js.coffee b/app/assets/javascripts/search/Listeners.js.coffee new file mode 100644 index 0000000..bbf20e4 --- /dev/null +++ b/app/assets/javascripts/search/Listeners.js.coffee @@ -0,0 +1,34 @@ +class Listeners + # events is the list of known events that could be fired. an exception is + # thrown if somebody tries to subscribe to an unknown event or if somebody + # tries to notify on an unknown event + constructor: (events = []) -> + @listeners = {} + @listeners[e] = {} for e in events + + # returns an ID that can be used to remove the listener later + add: (event, listener) -> + if event.match(" ") + return (@add(e, listener) for e in (event.split(" "))) + + throw "Unknown event #{event}" unless @listeners[event] + + uuid = + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) -> + r = Math.random() * 16 | 0 + v = if c is 'x' then r else (r & 0x3|0x8) + v.toString(16) + ) + @listeners[event][uuid] = listener + uuid + + notify: (event, args...) -> + throw "Unknown event #{event}" unless @listeners[event] + for own id, listener of (@listeners[event]) + listener(args...) + + remove: (event, listenerId) -> + throw "Unknown event #{event}" unless @listeners[event] + delete @listeners[event][listenerId] + +window.Listeners = Listeners diff --git a/app/assets/javascripts/search/LocationLoadMore.js.coffee b/app/assets/javascripts/search/LocationLoadMore.js.coffee new file mode 100644 index 0000000..001ea08 --- /dev/null +++ b/app/assets/javascripts/search/LocationLoadMore.js.coffee @@ -0,0 +1,143 @@ +class LocationLoadMore + SEARCH_SIZE = 10 + + constructor: (@search, containers, map, results, @tooBig = false) -> + throw "Indivisible location" unless @search.location.divisible + @searchableLocations = @divideBounds(@search.location.bounds()) + $(containers.buttons).html(HandlebarsTemplates['explore/load_more_button']()) + @warn = $(containers.warning) + if @tooBig + @warn.html(HandlebarsTemplates['explore/too_big_warning']()) + @elems = $(containers.buttons).find('.loadmore') + @elems.click (e) => + e.preventDefault() + return if $(e.target).hasClass('disabled') + @perform(map, results) + if @tooBig + @elems.text("Search Subareas") + + showSearchSubareas: (map, results) -> + SEARCH_SIZE = @searchableLocations.length + 1 + nextLocations = @searchableLocations[0..(SEARCH_SIZE-1)] + @searchableLocations = @searchableLocations[SEARCH_SIZE..] + prefix = @search.searchPath() + for o in @search.overlays[1..] + o.setMap(null) + + overlayExtras = + strokeColor: "#FFFF00" + strokeWeight: 2 + fillColor: "#FFFF00" + map: map + + for location in nextLocations + @search.addOverlay(overlay) for overlay in location.mapOverlays(overlayExtras) + @searchableLocations = @searchableLocations.concat(@divideBounds(location.bounds())) + + @elems.removeClass('disabled').text("Load More") + + perform: (map, results) -> + nextLocations = @searchableLocations[0..(SEARCH_SIZE-1)] + @searchableLocations = @searchableLocations[SEARCH_SIZE..] + prefix = @search.searchPath() + throw "No more locations to search" if nextLocations.length == 0 + + # Remove original overlays from map + # for overlay in @search.location.mapOverlays() + # overlay.setMap(null) + + overlayExtras = + strokeColor: "#FFFF00" + strokeWeight: 2 + fillColor: "#FFFF00" + map: map + + for location in nextLocations + @search.addOverlay(overlay) for overlay in location.mapOverlays(overlayExtras) + + searchParams = nextLocations.map (location) => + p = $.extend @search.searchParameters(), location.values() + prefix + "?" + ("#{k}=#{encodeURIComponent(v)}" for own k, v of p when v).join("&") + + @tooBig = false + @elems.addClass('disabled').text("Loading…") + $.ajax + url: "https://api.foursquare.com/v2/multi" + dataType: "json" + data: + requests: searchParams.join "," + m: "swarm" + v: API_VERSION + oauth_token: token + success: (data) => + for response, i in data.response.responses + @processResponse(map, results, response, nextLocations[i]) + for location in nextLocations + for overlay in location.mapOverlays() + overlay.setOptions + strokeColor: "#B7C9C8" + strokeWeight: 0.5 + fillColor: "#B7C9C8" + fillOpacity: 0.2 + + results.displayNewResults(map) + + if @hasMore() + if @tooBig + @elems.removeClass('disabled').text("Search Subareas") + @warn.html(HandlebarsTemplates['explore/too_big_warning']()) + else + @warn.html("") + @elems.removeClass('disabled').text("Load More") + else + @elems.addClass("disabled").text("Loaded All") + @warn.html("") + error: () => + alert("FIXME: Problem with load more") + + processResponse: (map, results, response, location) => + switch + when response.meta?.code == 200 + venues = @search.parseVenueResults(response) + for venue in venues + unless results.has(venue.id) + vr = new VenueResult(venue, @search.maxId++) + if (@search.location.containsPoint == undefined) || @search.location.containsPoint(vr.position()) + results.addResult(new VenueResultElement(vr)) + if venues.length > @search.hasMoreLength + @searchableLocations = @searchableLocations.concat(@divideBounds(location.bounds())) + when response.meta?.errorType == 'geocode_too_big' + @searchableLocations = @searchableLocations.concat(@divideBounds(location.bounds())) + @tooBig = true + else + alert("FIXME: other error with this response") + + hasMore: () -> + return @searchableLocations.length > 0 + + divideBounds: (bounds) -> + result = [] + + minLat = bounds.getSouthWest().lat() + maxLat = bounds.getNorthEast().lat() + centerLat = (minLat + maxLat) / 2 + + minLng = bounds.getSouthWest().lng() + maxLng = bounds.getNorthEast().lng() + centerLng = (minLng + maxLng) / 2 + + GLatLng = google.maps.LatLng + result.push new BoundingBoxSearchLocation(new GLatLng(maxLat, centerLng), new GLatLng(centerLat, minLng)) + result.push new BoundingBoxSearchLocation(new GLatLng(maxLat, maxLng), new GLatLng(centerLat, centerLng)) + result.push new BoundingBoxSearchLocation(new GLatLng(centerLat, maxLng), new GLatLng(minLat, centerLng)) + result.push new BoundingBoxSearchLocation(new GLatLng(centerLat, centerLng), new GLatLng(minLat, minLng)) + + if @search.location.intersectsRectangle + result = result.filter (box) => @search.location.intersectsRectangle(box) + + result + + clear: () -> + @elems.remove() + +window.LocationLoadMore = LocationLoadMore diff --git a/app/assets/javascripts/search/LocationManager.js.coffee b/app/assets/javascripts/search/LocationManager.js.coffee new file mode 100644 index 0000000..9ba9791 --- /dev/null +++ b/app/assets/javascripts/search/LocationManager.js.coffee @@ -0,0 +1,152 @@ +class LocationManager + constructor: (@map, @lastSearchLocation) -> + # Set up listeners + # Set up map elements and listeners on them + @setupDrawing(); + unless @lastSearchLocation instanceof GlobalLocation + @lastFiniteLocation = @lastSearchLocation + @lastBoundedLocation = @lastSearchLocation if @lastSearchLocation.bounded + + setupDrawing: () -> + @setupDrawingModes() + @globalControl = new GlobalControl(); + @radiusControl = new RadiusDropdown() + @nearControl = new NearButton() + + @radiusControl.elem.change (e) => + e.preventDefault() + radius = @radiusControl?.val() || 25000 + extended = @lastBoundedLocation.extendToRadius(radius) + shape = extended.drawingWithOptions({map: @map}) + @processLocationDraw(extended, shape) + @globalControl.reset() + @nearControl.close() + + @nearControl.elem.find("button.executeNear").click (e) => + e.preventDefault() + @processLocationDraw(@nearControl.getGeoLocation()) + + @globalControl.elem.children('div').on 'click', (e) => + @globalControl.select() + @drawingManager.setDrawingMode(null) + @processLocationDraw(new GlobalLocation()) + @nearControl.close() + + google.maps.event.addListener @map, "click", (event) => + radius = @radiusControl.val() + circle = new google.maps.Circle($.extend @circleOpts, {radius: radius, center: event.latLng, map: @map}) + @processLocationDraw(new CenterRadiusSearchLocation(event.latLng, radius), circle) + @globalControl.reset() + + setupDrawingModes: (options = []) -> + @drawingManager?.setMap(null) + @map.controls[google.maps.ControlPosition.TOP_LEFT].clear() + drawingModes = [] + drawingModes.push google.maps.drawing.OverlayType.RECTANGLE if "box" in options + drawingModes.push google.maps.drawing.OverlayType.CIRCLE if "circle" in options + drawingModes.push google.maps.drawing.OverlayType.POLYGON if "polygon" in options + + @drawingManager = new google.maps.drawing.DrawingManager + map: @map + drawingMode: null + drawingControlOptions: + drawingModes: drawingModes + drawingControl: true + rectangleOptions: + strokeWeight: 1 + editable: false + fillOpacity: 0.2 + strokeOpacity: 0.2 + fillColor: "#FFFF00" + zIndex: 1 + clickable: false + circleOptions: + fillOpacity: 0.05 + editable: false + clickable: false + strokeWeight: 1 + fillColor: "#FFFF00" + + google.maps.event.addListener @drawingManager, "drawingmode_changed", (e) => + @globalControl.reset() + @nearControl.close() + # if @drawingManager.drawingMode == 'rectangle' + # @radiusControl.elem.hide() + # else + # @radiusControl.elem.show() + + if "box" in options + google.maps.event.addListener @drawingManager, 'rectanglecomplete', (rectangle) => + boxLocation = new BoundingBoxSearchLocation(rectangle.getBounds().getNorthEast(), rectangle.getBounds().getSouthWest()) + @radiusControl.addTempRadius(boxLocation.radius()) + @processLocationDraw(boxLocation, rectangle) + @nearControl.close() + + if "circle" in options + google.maps.event.addListener @drawingManager, 'circlecomplete', (circle) => + radius = circle.getRadius() + @radiusControl.addTempRadius(radius) + @processLocationDraw(new CenterRadiusSearchLocation(circle.getCenter(), circle.getRadius()), circle) + @nearControl.close() + @map.controls[google.maps.ControlPosition.TOP_LEFT].push(@radiusControl.control()) + + if "polygon" in options + google.maps.event.addListener @drawingManager, 'polygoncomplete', (polygon) => + @processLocationDraw(new PolygonSearchLocation(polygon.getPath().getArray()), polygon) + @nearControl.close() + + if "global" in options + @map.controls[google.maps.ControlPosition.TOP_LEFT].push(@globalControl.control()) + + if "near" in options + @map.controls[google.maps.ControlPosition.TOP_LEFT].push(@nearControl.control()) + + setGlobal: () -> + @lastSearchLocation = new GlobalLocation() + @globalControl.select() + @drawingManager.setDrawingMode(null) + + processLocationDraw: (location, shape = undefined) -> + @lastSearchLocation.clear() + @lastFiniteLocation?.clear() + + @lastSearchLocation = location + @lastFiniteLocation = location unless location instanceof GlobalLocation + @lastBoundedLocation = location if location.bounded + search = @activeTab.performSearchAt @lastSearchLocation + search.listeners.add 'resultsready geotoobig searchfailed', () -> + shape?.setMap(null) + + if location instanceof NearGeoLocation + search.listeners.add 'searchgeocoded', (geocode) => + @nearControl.setGeocode(geocode) + @lastBoundedLocation = new BoundingBoxSearchLocation( + new google.maps.LatLng(geocode.feature.geometry.bounds.ne.lat, geocode.feature.geometry.bounds.ne.lng), + new google.maps.LatLng(geocode.feature.geometry.bounds.sw.lat, geocode.feature.geometry.bounds.sw.lng) + ) + + displaySearchLocation: (search) -> + location = search.location + @lastSearchLocation = location + @lastFiniteLocation = location unless location instanceof GlobalLocation + @lastBoundedLocation = location if location.bounded + + clearFunction = location.display + map: @map + nearControl: @nearControl + globalControl: @globalControl + radiusControl: @radiusControl + + search.listeners.add 'resultsready geotoobig searchfailed', () -> + clearFunction() + + showControls: (actions) -> + @setupDrawingModes(actions) + + location: (finiteOnly) -> + # This is the location currently selected on the map + if finiteOnly then @lastFiniteLocation else @lastSearchLocation + + setActiveTab: (@activeTab) -> + +window.LocationManager = LocationManager diff --git a/app/assets/javascripts/search/Maps/FitButtons.js.coffee b/app/assets/javascripts/search/Maps/FitButtons.js.coffee new file mode 100644 index 0000000..22363f2 --- /dev/null +++ b/app/assets/javascripts/search/Maps/FitButtons.js.coffee @@ -0,0 +1,15 @@ +class FitButtons + constructor: (@explorer, @map) -> + buttons = $ HandlebarsTemplates['explore/map_controls/fit_buttons']() + @map.controls[google.maps.ControlPosition.RIGHT_BOTTOM].push(buttons[0]) + buttons.find(".fit-venues").click (e) => + e.preventDefault() + bounds = @explorer.results?.resultsBounds() + bounds = bounds.union(@explorer.pinnedResults.pinnedBounds()) + @map.fitBounds(bounds) unless bounds.isEmpty() + + buttons.find(".fit-searchlocation").click (e) => + e.preventDefault() + @explorer.lastSearch?.location.fitMapToLocation(@map) + +window.FitButtons = FitButtons diff --git a/app/assets/javascripts/search/Maps/GlobalControl.js.coffee b/app/assets/javascripts/search/Maps/GlobalControl.js.coffee new file mode 100644 index 0000000..5da092f --- /dev/null +++ b/app/assets/javascripts/search/Maps/GlobalControl.js.coffee @@ -0,0 +1,14 @@ +class GlobalControl + constructor: () -> + @elem = $ HandlebarsTemplates['explore/map_controls/global']() + + control: () -> + @elem[0] + + reset: () -> + @elem.removeClass('clicked') + + select: () -> + @elem.addClass('clicked') + +window.GlobalControl = GlobalControl diff --git a/app/assets/javascripts/search/Maps/NearButton.js.coffee b/app/assets/javascripts/search/Maps/NearButton.js.coffee new file mode 100644 index 0000000..3827b34 --- /dev/null +++ b/app/assets/javascripts/search/Maps/NearButton.js.coffee @@ -0,0 +1,36 @@ +class NearButton + constructor: () -> + @elem = $ HandlebarsTemplates['explore/map_controls/near_button']() + @elem.click (e) => + e.preventDefault() + @open() + @focus() + @elem.find("input").keyup (e) => + if e.keyCode == 13 #enter + @elem.find(".executeNear").trigger("click") + + control: () -> + @elem[0] + + focus: () -> + @elem.find("input").focus() + + getGeoLocation: () -> + new NearGeoLocation(@elem.find("input.nearString").val().trim()) + + close: () -> + @elem.find(".nearInput").addClass("hide") + @elem.addClass("closed").removeClass("open") + + open: () -> + @elem.find(".nearInput").removeClass("hide") + @elem.removeClass("closed").addClass("open") + + setGeocode: (geocode) -> + @elem.find("input").val(geocode.feature.displayName) + + show: (val) -> + @elem.find("input").val(val) + @open() + +window.NearButton = NearButton diff --git a/app/assets/javascripts/search/Maps/RadiusDropdown.js.coffee b/app/assets/javascripts/search/Maps/RadiusDropdown.js.coffee new file mode 100644 index 0000000..35e5e1f --- /dev/null +++ b/app/assets/javascripts/search/Maps/RadiusDropdown.js.coffee @@ -0,0 +1,35 @@ +class RadiusDropdown + constructor: () -> + @elem = $ HandlebarsTemplates['explore/map_controls/radius_dropdown']() + # @elem.find("#radiusdropdown").focus (e) -> $(e.target).blur() + + val: () -> + parseInt(@elem.find("#radiusdropdown").val()) + + control: () -> + @elem[0] + + addTempRadius: (val) -> + @resetTempRadius() + val = parseInt(val) + + if @elem.find("#radiusdropdown option[value=#{val}]").length == 0 + textVal = val + if val > 1000 + fixed = if val < 10000 then 1 else 0 + textVal = (val /1000.0).toFixed(fixed) + " km" + else + textVal += " m" + + @elem.find("#radiusdropdown").append("") + options = @elem.find("#radiusdropdown option").detach() + options.sort( (a,b) -> a.value - b.value) + @elem.find("#radiusdropdown").append(options) + + @elem.find("#radiusdropdown").val(val) + + resetTempRadius: () -> + @elem.find(".tempradius").remove() + + +window.RadiusDropdown = RadiusDropdown diff --git a/app/assets/javascripts/search/PaginatedLoadMore.js.coffee b/app/assets/javascripts/search/PaginatedLoadMore.js.coffee new file mode 100644 index 0000000..3bfec46 --- /dev/null +++ b/app/assets/javascripts/search/PaginatedLoadMore.js.coffee @@ -0,0 +1,63 @@ +# PaginatedLoadMore allows perform() to load the next set of venues +# from a result list that contains a known item count and allows +# limit and offset parameters. +class PaginatedLoadMore + constructor: (@search, options) -> + @pageSize = options.pageSize || @search.pageSize + @totalItems = options.totalItems + @increment = options.increment || @pageSize + @currentOffset = options.initialOffset || 0 + + attachToElements: (containers, map, results) -> + $(containers.buttons).html(HandlebarsTemplates['explore/load_more_button']()) + @elems = $(containers.buttons).find('.loadmore') + @elems.click (e) => + e.preventDefault() + return if $(e.target).hasClass('disabled') + @perform(map, results) + containers.pagination.html "" + + perform: (map, results) -> + @elems.addClass('disabled').removeClass('btn-warning').addClass('btn-info').text("Loading…") + + # Could do this via multi instead? + $.ajax + url: "https://api.foursquare.com/v2#{@search.searchPath()}" + dataType: "json" + data: $.extend @search.searchParameters(), (@search.location?.values() || {}), + limit: @pageSize + offset: @currentOffset + m: "swarm" + v: API_VERSION + oauth_token: token + success: (data) => + venues = @search.parseVenueResults(data) + @lastReturnedCount = venues.length + for venue in venues when !results.has(venue.id) + vr = new VenueResult(venue, @search.maxId++) + if (@search.location.containsPoint == undefined) || @search.location.containsPoint(vr.position()) + results.addResult(new VenueResultElement(vr)) + + results.displayNewResults(map) + + @currentOffset += @increment + + if @hasMore() + @elems.removeClass('disabled').text("Load More") + else + @elems.addClass("disabled").text("Loaded All") + + error: () => + @elems.addClass('btn-warning').removeClass('btn-info').removeClass("disabled").text("Try Again") + + hasMore: () -> + if @totalItems + @currentOffset < @totalItems + else + @lastReturnedCount > 0 + + clear: () -> + @elems.remove() + + +window.PaginatedLoadMore = PaginatedLoadMore diff --git a/app/assets/javascripts/search/Pagination/KnownSizePagination.js.coffee b/app/assets/javascripts/search/Pagination/KnownSizePagination.js.coffee new file mode 100644 index 0000000..b60fac4 --- /dev/null +++ b/app/assets/javascripts/search/Pagination/KnownSizePagination.js.coffee @@ -0,0 +1,21 @@ +#= require search/Pagination/Pagination +class KnownSizePagination extends Pagination + template: 'explore/known_size_pagination' + + constructor: (options) -> + @current = options.currentPage + @pageSize = options.pageSize + @totalItems = options.totalItems + + @totalPages = Math.ceil @totalItems/@pageSize + @searchAtPage = options.searchAtPage + + @onLastPage = @current == @totalPages + + @showpages = for i in [1..@totalPages] + pagenum: i + active: i == @current + classes: if i == @current then "active" else "" + show: Math.abs(@current - i) < 5 + +window.KnownSizePagination = KnownSizePagination diff --git a/app/assets/javascripts/search/Pagination/Pagination.js.coffee b/app/assets/javascripts/search/Pagination/Pagination.js.coffee new file mode 100644 index 0000000..63758e7 --- /dev/null +++ b/app/assets/javascripts/search/Pagination/Pagination.js.coffee @@ -0,0 +1,10 @@ +class Pagination + render: (performSearchFunction) -> + result = $(HandlebarsTemplates[@template](this)) + result.on "click", "li", (e) => + e.preventDefault() + return if $(e.target).parent().hasClass('disabled') or $(e.target).parent().hasClass('active') + performSearchFunction(@searchAtPage($(e.target).data('pagenum'))) + result + +window.Pagination = Pagination diff --git a/app/assets/javascripts/search/Pagination/UnknownSizePagiantion.js.coffee b/app/assets/javascripts/search/Pagination/UnknownSizePagiantion.js.coffee new file mode 100644 index 0000000..9330629 --- /dev/null +++ b/app/assets/javascripts/search/Pagination/UnknownSizePagiantion.js.coffee @@ -0,0 +1,14 @@ +#= require search/Pagination/Pagination +class UnknownSizePagination extends Pagination + template: 'explore/unknown_size_pagination' + + constructor: (options) -> + @current = options.currentPage + @pageSize = options.pageSize + @searchAtPage = options.searchAtPage + @onLastPage = options.onLastPage + @displayPagination = @current > 1 || !@onLastPage + @prevPage = if @current > 1 then @current-1 else 1 + @nextPage = @current + 1 + +window.UnknownSizePagination = UnknownSizePagination diff --git a/app/assets/javascripts/search/PinnedResults.js.coffee b/app/assets/javascripts/search/PinnedResults.js.coffee new file mode 100644 index 0000000..77e3544 --- /dev/null +++ b/app/assets/javascripts/search/PinnedResults.js.coffee @@ -0,0 +1,47 @@ +class PinnedResults + constructor: (@elem) -> + @pinned = {} + + addResult: (venueelement) -> + @pinned[venueelement.venueresult.id] = venueelement + rendered = venueelement.render() + rendered.on "click", ".clear_venue", (e) => + e.preventDefault() + @unPin(venueelement) + @elem.append(rendered) + + venueelement.listeners.add "unpin", (e) => + @unPin(venueelement) + + # TODO: add listener to close on scroll, etc? Review SearchResults's approach + @showHideSeparator() + + unPin: (venueelement) -> + delete @pinned[venueelement.venueresult.id] + venueelement.remove() + @showHideSeparator() + + selected: () -> + result = {} + for own id, venueresult of @pinned when venueresult.status.clicked + result[id] = venueresult + result + + recentered: (newCenter) -> + for id, venueresult of @pinned + venueresult.updateDistance(newCenter) + + showHideSeparator: () -> + @elem.toggleClass("haspins", (id for own id of @pinned).length > 0) + + get: (venueid) -> + # returns the pinned venue with this id, or undefined if none exists + @pinned[venueid] + + pinnedBounds: () -> + bounds = new google.maps.LatLngBounds() + for own id, venueelement of @pinned + bounds.extend(venueelement.venueresult.position()) + bounds + +window.PinnedResults = PinnedResults diff --git a/app/assets/javascripts/search/PinnedVenuesContainer.js.coffee b/app/assets/javascripts/search/PinnedVenuesContainer.js.coffee new file mode 100644 index 0000000..ded3275 --- /dev/null +++ b/app/assets/javascripts/search/PinnedVenuesContainer.js.coffee @@ -0,0 +1,5 @@ +class PinnedVenuesContainer + constructor: (@container) -> + add: (venueResultElement) -> + @container.append(venueResultElement.pinnedElement()) +window.PinnedVenuesContainer = PinnedVenuesContainer diff --git a/app/assets/javascripts/search/SearchExtras/SearchExtras.js.coffee b/app/assets/javascripts/search/SearchExtras/SearchExtras.js.coffee new file mode 100644 index 0000000..e1c0062 --- /dev/null +++ b/app/assets/javascripts/search/SearchExtras/SearchExtras.js.coffee @@ -0,0 +1,12 @@ +class SearchExtras + render: (extrasDiv) -> + +window.SearchExtras = SearchExtras + +class ListSearchExtras extends SearchExtras + constructor: (@listResponse) -> + + render: (extrasDiv) -> + extrasDiv.html(HandlebarsTemplates['search_extras/listextras'](@listResponse)) +window.ListSearchExtras = ListSearchExtras + diff --git a/app/assets/javascripts/search/SearchExtras/UserExtras.js.coffee b/app/assets/javascripts/search/SearchExtras/UserExtras.js.coffee new file mode 100644 index 0000000..0caff47 --- /dev/null +++ b/app/assets/javascripts/search/SearchExtras/UserExtras.js.coffee @@ -0,0 +1,60 @@ +class UserExtras extends SearchExtras + @userExtrasCache = {} + + @getOrCreate: (userid, options) -> + if (userid || "").trim() == "" + return options.error?() + + if @userExtrasCache[userid] + options.success @userExtrasCache[userid] + else + requests = [ + {key: "user", url: "/users/#{userid}"}, + # {key: "followers", url: "/users/#{userid}/followers?limit=1"}, + # {key: "following", url: "/users/#{userid}/following?limit=1"}, + {key: "venuelikes", url: "/users/#{userid}/venuelikes?limit=1"}, + {key: "lists", url: "/users/#{userid}/lists?limit=1"} + ] + $.ajax + url: "https://api.foursquare.com/v2/multi" + dataType: 'json' + data: + requests: (k.url for k in requests).join(",") + v: API_VERSION + oauth_token: token + m: "swarm" + success: (data) => + userDetails = {} + for req, i in requests + if data.response.responses[i].meta.code == 200 + userDetails[req.key] = data.response.responses[i].response + unless userDetails.user + return options.error() if options.error + extras = new UserExtras(userDetails) + UserExtras.userExtrasCache[userid] = extras + options.success(extras) + error: (xhr, textStatus, errorThrown) => + options.error(xhr, textStatus, errorThrown) if options.error + + constructor: (@userDetails) -> + @user = @userDetails.user.user + @id = @user.id + + listCounts: () -> + counts = {created: 0, followed: 0} + for listcount in @userDetails.lists.lists.groups + counts[listcount.type] = listcount.count + counts + + render: (extrasDiv) -> + elem = $ HandlebarsTemplates['search_extras/userextras']($.extend @user, @userDetails, {listCounts: @listCounts()}, interactive: true) + elem.find(".edittips").click (e) => + e.preventDefault() + new UserTipModal(this).show() + elem.find(".editphotos").click (e) => + e.preventDefault() + new UserPhotoModal(this).show() + + extrasDiv.html(elem) + +window.UserExtras = UserExtras diff --git a/app/assets/javascripts/search/SearchLocation/BoundingBoxSearchLocation.js.coffee b/app/assets/javascripts/search/SearchLocation/BoundingBoxSearchLocation.js.coffee new file mode 100644 index 0000000..22aee28 --- /dev/null +++ b/app/assets/javascripts/search/SearchLocation/BoundingBoxSearchLocation.js.coffee @@ -0,0 +1,70 @@ +class BoundingBoxSearchLocation extends SearchLocation + divisible: true + bounded: true + + renderable: () -> true + constructor: (@ne, @sw) -> + + values: (options = {}) -> + if options.asLlBounds + llBounds: @asLlBounds() + else + ne: "#{@ne.lat()},#{@ne.lng()}" + sw: "#{@sw.lat()},#{@sw.lng()}" + + asLlBounds: () -> + "#{@ne.lat()},#{@ne.lng()},#{@sw.lat()},#{@sw.lng()}" + + mapOverlays: (extras = {}) -> + return @overlays if @overlays + rect = @drawingWithOptions $.extend + strokeWeight: 1 + editable: false + fillOpacity: 0.1 + strokeOpacity: 0.2 + zIndex: 1 + editable: false + draggable: false + clickable: false + , extras + @overlays = [rect] + + getCenter: () -> + @bounds().getCenter() + + bounds: () -> + new google.maps.LatLngBounds(@sw, @ne) + + fitMapToLocation: (map) -> + map.fitBounds(@bounds()) + + @deserialize: (values) -> + new BoundingBoxSearchLocation(SearchLocation.parseLatLng(values['ne']), SearchLocation.parseLatLng(values['sw'])) + + drawingWithOptions: (options = {}) -> + new google.maps.Rectangle $.extend + bounds: @bounds() + , options + + extendToRadius: (newRadius) -> + new BoundingBoxSearchLocation( + google.maps.geometry.spherical.computeOffset(@getCenter(), newRadius, 135), + google.maps.geometry.spherical.computeOffset(@getCenter(), newRadius, 315) + ) + + serialize: () -> + ne: "#{@ne.lat().toFixed(6)},#{@ne.lng().toFixed(6)}" + sw: "#{@sw.lat().toFixed(6)},#{@sw.lng().toFixed(6)}" + + radius: () -> + # for a box, we're giving the radius of the smallest circle that contains the + # rectangle + google.maps.geometry.spherical.computeDistanceBetween(@ne, @getCenter()) + + display: (controls) -> + box = @drawingWithOptions({map: controls.map}) + controls.radiusControl.addTempRadius(@radius()) + return () -> + box.setMap(null) + +window.BoundingBoxSearchLocation = BoundingBoxSearchLocation diff --git a/app/assets/javascripts/search/SearchLocation/CenterRadiusSearchLocation.js.coffee b/app/assets/javascripts/search/SearchLocation/CenterRadiusSearchLocation.js.coffee new file mode 100644 index 0000000..3d8537f --- /dev/null +++ b/app/assets/javascripts/search/SearchLocation/CenterRadiusSearchLocation.js.coffee @@ -0,0 +1,81 @@ +class CenterRadiusSearchLocation extends SearchLocation + divisible: true + bounded: true + + renderable: () -> true + + constructor: (@center, @radius) -> + + values: () -> + ll: "#{@center.lat()},#{@center.lng()}" + radius: @radius + + mapOverlays: () -> + return @overlays if @overlays + + circle = @drawingWithOptions + strokeWeight: 1 + fillOpacity: 0.05 + editable: false + clickable: false + + centerMarker = new google.maps.Marker + position: @center + icon: '/img/dot.png' + zIndex: 10 + + @overlays = [circle, centerMarker] + + serialize: () -> + ll: "#{@center.lat().toFixed(6)},#{@center.lng().toFixed(6)}" + radius: @radius.toFixed(0) + + getCenter: () -> + @center + + fitMapToLocation: (map) -> + map.fitBounds(@bounds()) + + bounds: () -> + new google.maps.Circle + center: @center + radius: @radius + .getBounds() + + extendToRadius: (newRadius) -> + new CenterRadiusSearchLocation(@center, newRadius) + + drawingWithOptions: (options = {}) -> + new google.maps.Circle $.extend + center: @center + radius: @radius + , options + + @deserialize: (values) -> + new CenterRadiusSearchLocation(SearchLocation.parseLatLng(values['ll']), parseInt(values['radius'])) + + intersectsRectangle: (boundingbox) -> + bounds = boundingbox.bounds() + [lat_lo,lng_lo,lat_hi,lng_hi] = [bounds.getSouthWest().lat(), bounds.getSouthWest().lng(), + bounds.getNorthEast().lat(), bounds.getNorthEast().lng()] + + # if any of the corners of this rectangle are less than radius away from the center, + # return true + + corners = [new google.maps.LatLng(lat_lo, lng_lo), new google.maps.LatLng(lat_hi, lng_lo), + new google.maps.LatLng(lat_hi, lng_hi), new google.maps.LatLng(lat_lo, lng_hi)] + + for corner in corners + return true if @containsPoint(corner) + + false + + display: (controls) -> + circle = @drawingWithOptions({map: controls.map}) + controls.radiusControl.addTempRadius(@radius) + return () -> circle.setMap(null) + + containsPoint: (point) -> + google.maps.geometry.spherical.computeDistanceBetween(@center, point) <= @radius + +window.CenterRadiusSearchLocation = CenterRadiusSearchLocation diff --git a/app/assets/javascripts/search/SearchLocation/GlobalLocation.js.coffee b/app/assets/javascripts/search/SearchLocation/GlobalLocation.js.coffee new file mode 100644 index 0000000..f1e5db3 --- /dev/null +++ b/app/assets/javascripts/search/SearchLocation/GlobalLocation.js.coffee @@ -0,0 +1,29 @@ +class GlobalLocation extends SearchLocation + renderable: () -> false + + activateMapOverlay: () -> + # This might be a bit hacky: + $(".globalButton").addClass("clicked") + + asLlBounds: () -> + null + + values: () -> + {} + + serialize: () -> + {global: "global"} + + @deserialize: () -> + new GlobalLocation() + + display: (controls) -> + controls.globalControl?.select() + return () -> + + fitMapToLocation: (map) -> + worldBounds = new google.maps.LatLngBounds(new google.maps.LatLng(-85,-180), + new google.maps.LatLng(85,180)) + map.fitBounds(worldBounds) + +window.GlobalLocation = GlobalLocation diff --git a/app/assets/javascripts/search/SearchLocation/NearGeoLocation.js.coffee b/app/assets/javascripts/search/SearchLocation/NearGeoLocation.js.coffee new file mode 100644 index 0000000..e54fdd7 --- /dev/null +++ b/app/assets/javascripts/search/SearchLocation/NearGeoLocation.js.coffee @@ -0,0 +1,22 @@ +class NearGeoLocation extends SearchLocation + renderable: () -> false + + constructor: (@geoString) -> + + values: () -> + near: @geoString + + serialize: () -> + 'near': @geoString + + @deserialize: (values) -> + new NearGeoLocation(values['near']) + + display: (controls) -> + controls.nearControl?.show(@geoString) + return () -> + + fitMapToLocation: (map) -> + # This is not a mappable location, so we'll make this a NO-OP + +window.NearGeoLocation = NearGeoLocation diff --git a/app/assets/javascripts/search/SearchLocation/PolygonSearchLocation.js.coffee b/app/assets/javascripts/search/SearchLocation/PolygonSearchLocation.js.coffee new file mode 100644 index 0000000..3b452c9 --- /dev/null +++ b/app/assets/javascripts/search/SearchLocation/PolygonSearchLocation.js.coffee @@ -0,0 +1,102 @@ +class PolygonSearchLocation extends SearchLocation + divisible: true + bounded: true + + renderable: () -> true + + constructor: (@points) -> + @latLngBounds = new google.maps.LatLngBounds() + for p in @points + @latLngBounds.extend(p) + + @polygon = new google.maps.Polygon + path: @points + + values: (options = {}) -> + ne = @latLngBounds.getNorthEast() + sw = @latLngBounds.getSouthWest() + + if options.asLlBounds + "#{ne.lat()},#{ne.lng()},#{sw.lat()},#{sw.lng()}" + else + ne: "#{ne.lat()},#{ne.lng()}" + sw: "#{sw.lat()},#{sw.lng()}" + + getCenter: () -> + @latLngBounds().getCenter() + + bounds: () -> + @latLngBounds + + fitMapToLocation: (map) -> + map.fitBounds(@bounds()) + + serialize: () -> + polygon: (@points.map (point) -> point.lat().toFixed(6) + "," + point.lng().toFixed(6)).join(";") + + @deserialize: (values) -> + path = [] + for point in values['polygon'].split(';') + [lat, lng] = point.split(',') + path.push new google.maps.LatLng(lat,lng) + + new PolygonSearchLocation(path) + + mapOverlays: (extras = {}) -> + return @overlays if @overlays + poly = @drawingWithOptions $.extend + strokeWeight: 1 + editable: false + fillOpacity: 0.1 + strokeOpacity: 0.2 + zIndex: 1 + editable: false + draggable: false + clickable: false + , extras + @overlays = [poly] + + drawingWithOptions: (options = {}) -> + new google.maps.Polygon $.extend + paths: @points + , options + + display: (controls) -> + poly = @drawingWithOptions({map: controls.map}) + return () -> poly.setMap(null) + + intersectsRectangle: (boundingbox) -> + bounds = boundingbox.bounds() + [lat_lo,lng_lo,lat_hi,lng_hi] = [bounds.getSouthWest().lat(), bounds.getSouthWest().lng(), + bounds.getNorthEast().lat(), bounds.getNorthEast().lng()] + + corners = [new google.maps.LatLng(lat_lo, lng_lo), new google.maps.LatLng(lat_hi, lng_lo), + new google.maps.LatLng(lat_hi, lng_hi), new google.maps.LatLng(lat_lo, lng_hi)] + + # first, if any of the corners are inside the polygon, we know we intersect + for corner in corners + return true if @containsPoint(corner) + + # next, if none of the corners are in the polygon, we still must test to see if + # the polygon exists within the rectangle by checking for line intersections: + for i in [0...@points.length] + for j in [0...corners.length] + return true if @linesIntersect(corners[j], corners[(j+1)%corners.length], + @points[i], @points[(i+1)%@points.length]) + + false + + containsPoint: (point) -> + google.maps.geometry.poly.containsLocation(point, @polygon) + + # From http://stackoverflow.com/questions/9043805/test-if-two-lines-intersect-javascript-function/16725715#16725715 + ccw: (p1, p2, p3) -> + a = p1.lng(); b = p1.lat(); + c = p2.lng(); d = p2.lat(); + e = p3.lng(); f = p3.lat(); + (f - b) * (c - a) > (d - b) * (e - a); + + linesIntersect: (p1, p2, p3, p4) -> + return (@ccw(p1, p3, p4) != @ccw(p2, p3, p4)) && (@ccw(p1, p2, p3) != @ccw(p1, p2, p4)); + +window.PolygonSearchLocation = PolygonSearchLocation diff --git a/app/assets/javascripts/search/SearchLocation/SearchLocation.js.coffee b/app/assets/javascripts/search/SearchLocation/SearchLocation.js.coffee new file mode 100644 index 0000000..c2cdf56 --- /dev/null +++ b/app/assets/javascripts/search/SearchLocation/SearchLocation.js.coffee @@ -0,0 +1,44 @@ +class SearchLocation + @overlays = null + + # Returns an array of mappable items that need to have .setMap(map) called + # on them to display + mapOverlays: () -> + [] + + activateMapOverlay: () -> + + clear: () -> + overlay.setMap(null) for overlay in @mapOverlays() + + @deserialize: (values) -> + values.containsKeys = (keys) -> + for k in keys + return false unless values.hasOwnProperty k + true + + type = switch + when values.containsKeys ['ll', 'radius'] + CenterRadiusSearchLocation + when values.containsKeys ['ne', 'sw'] + BoundingBoxSearchLocation + when values.containsKeys ['near'] + NearGeoLocation + when values.containsKeys ['polygon'] + PolygonSearchLocation + when values.containsKeys ['global'] + GlobalLocation + else + GlobalLocation + type.deserialize(values) + + @parseLatLng: (str) -> + # Expects a comma separated lat lng and returns a google.maps.LatLng + # value, or throws an exception if the lat lng was not valid + [lat, lng] = str.split(',').map (e) -> parseFloat(e) + unless (lat >= -90.0 and lat <= 90.0) and (lng >= -180.0 and lng <=180.0) + throw "Deserialization Problem: latlng not in range" + new google.maps.LatLng(lat, lng) + + +window.SearchLocation = SearchLocation diff --git a/app/assets/javascripts/search/SearchManagerTab.js.coffee b/app/assets/javascripts/search/SearchManagerTab.js.coffee new file mode 100644 index 0000000..610440f --- /dev/null +++ b/app/assets/javascripts/search/SearchManagerTab.js.coffee @@ -0,0 +1,147 @@ +class SearchManagerTab + constructor: (@tab, @explorer, @locationManager) -> + + toggles: () -> + $("a[href=\##{@tab.attr('id')}][data-toggle='tab']") + + shown: () -> + @locationManager.setActiveTab(this) + @locationManager.showControls(@displayControls) + if @setLocationTypeOnShown == 'global' + @locationManager.setGlobal() + + performSearchAt: (location) -> + search = @createSearch(location) + @explorer.performSearch(search) + search + + displaySearch: (search) -> + for own key, val of search + toset = @tab.find("[data-deserialize=#{key}]") + if toset.hasClass('select2') + toset.select2('val', val) + else + toset.val(val) + @toggles().tab('show') + + @locationManager.displaySearchLocation(search) + + updateSearch: (search) -> + + setupEvents: () -> + @tab.find(".defaultsearch").click (e) => + e.preventDefault() + search = @createSearch() + @explorer.performSearch(search) if search + @toggles().on('shown', (e) => @shown(e)) +window.SearchManagerTab = SearchManagerTab + +class PrimaryVenueSearchTab extends SearchManagerTab + displayControls: ['near', 'box', 'circle', 'polygon'] + + setupEvents: () -> + new CategorySelector().setupCategories @tab.find("input.categories"), + allowMultiple: true + rotateButtonsSpanSelector: @tab.find(".catRotateButtons") + super() + + createSearch: (location = @locationManager.location(true)) -> + new PrimaryVenueSearch(@tab.find(".query").val(), location, @tab.find("input.categories").select2('val'), {loadMoreContainer: @tab.find(".loadmorecontainer")}) +window.PrimaryVenueSearchTab = PrimaryVenueSearchTab + +class GlobalSearchTab extends SearchManagerTab + displayControls: ['global'] + setLocationTypeOnShown: 'global' + createSearch: () -> + new GlobalVenueSearch(@tab.find(".query").val(), @tab.find(".categories").select2('val')) + setupEvents: () -> + new CategorySelector().setupCategories @tab.find("input.categories"), + allowMultiple: true + rotateButtonsSpanSelector: @tab.find(".catRotateButtons") + super() +window.GlobalSearchTab = GlobalSearchTab + +class UserSearchTab extends SearchManagerTab + displayControls: ['global'] + setLocationTypeOnShown: 'global' + createSearch: () -> + switch @tab.find(".usersearch-type").val() + when 'venuescreated' + new UserCreatedVenueSearch(@tab.find(".userid").val(), 1, {loadMoreContainer: @tab.find(".loadmorecontainer")}) + when 'venuesliked' + new UserVenueLikesSearch(@tab.find(".userid").val(), 1, {loadMoreContainer: @tab.find(".loadmorecontainer")}) + when 'venuesphotoed' + new UserPhotoVenueSearch(@tab.find(".userid").val(), 1, {loadMoreContainer: @tab.find(".loadmorecontainer")}) + when 'venuestipped' + new UserTipVenueSearch(@tab.find(".userid").val(), 1, {loadMoreContainer: @tab.find(".loadmorecontainer")}) + else + throw "Unknown User search type" + +window.UserSearchTab = UserSearchTab + +class SpecificVenueSearchTab extends SearchManagerTab + displayControls: ['global'] + setLocationTypeOnShown: 'global' + createSearch: () -> + switch @tab.find(".specificvenuessearch-type").val() + when 'specificvenue' + new SpecificVenueSearch(@tab.find(".venueid").val()) + when 'venuechildren' + new VenueChildrenSearch(@tab.find(".venueid").val()) + else + throw "Unknown Specific Venue Search Type" + +window.SpecificVenueSearchTab = SpecificVenueSearchTab + +class UncategorizedVenuesSearchTab extends SearchManagerTab + displayControls: ['global', 'near', 'box', 'circle', 'polygon'] + createSearch: (location = @locationManager.location()) -> + new UncategorizedQueueSearch(location, {loadMoreContainer: @tab.find(".loadmorecontainer")}) +window.UncategorizedVenuesSearchTab = UncategorizedVenuesSearchTab + +class FlaggedVenuesSearchTab extends SearchManagerTab + displayControls: ['global', 'near', 'box', 'circle', 'polygon'] + createSearch: (location = @locationManager.location()) -> + new QueueSearch(@tab.find("#queue-name").val(), location) +window.FlaggedVenuesSearchTab = FlaggedVenuesSearchTab + +class RecentlyCreatedTab extends SearchManagerTab + displayControls: ['near', 'box', 'circle', 'polygon'] + createSearch: (location = @locationManager.location(true)) -> + new RecentlyCreatedVenueSearch(location, {loadMoreContainer: @tab.find(".loadmorecontainer")}) +window.RecentlyCreatedTab = RecentlyCreatedTab + +class MyHistorySearchTab extends SearchManagerTab + displayControls: ['global'] + setLocationTypeOnShown: 'global' + createSearch: ()-> + new MyCheckinHistorySearch(@tab.find(".categories").select2('val'), @tab.find(".myhistory-start").val(), @tab.find(".myhistory-end").val(), 1, {loadMoreContainer: @tab.find(".loadmorecontainer")}) + setupEvents: () -> + new CategorySelector().setupCategories @tab.find("input.categories"), + allowMultiple: true + rotateButtonsSpanSelector: @tab.find(".catRotateButtons") + @tab.find('.input-daterange').datepicker + todayBtn: true + todayHighlight: true + endDate: new Date() + autoclose: true + format: "yyyy-mm-dd" + super() + +window.MyHistorySearchTab = MyHistorySearchTab + +class ListSearchTab extends SearchManagerTab + setLocationTypeOnShown: 'global' + displayControls: ['global', 'box', 'polygon'] + + setupEvents: () -> + new CategorySelector().setupCategories @tab.find("input.categories"), + allowMultiple: true + rotateButtonsSpanSelector: @tab.find(".catRotateButtons") + super() + + createSearch: (location = @locationManager.location()) -> + new ListSearchByUrl(@tab.find(".listurl").val(), location, @tab.find(".categories").select2('val')) +window.ListSearchTab = ListSearchTab + + diff --git a/app/assets/javascripts/search/SearchResults.js.coffee b/app/assets/javascripts/search/SearchResults.js.coffee new file mode 100644 index 0000000..6ce6a38 --- /dev/null +++ b/app/assets/javascripts/search/SearchResults.js.coffee @@ -0,0 +1,230 @@ +class SearchResults + TOGGLEABLE_FIELDS = [ + {field: 'home', name: "home(s)", default: false}, + {field: 'private', name: "private place(s)", default: false}, + {field: 'closed', name: "closed place(s)", default: true}, + {field: 'deleted', name: "deleted place(s)", default: true}, + {field: 'alreadyflagged', named: "already flagged place(s)", default: true} + ] + + constructor: (@search, @pinned) -> + @results = {} + @displayedResults = {} + @lastClicked = undefined + @listeners = new Listeners(['clearedresults', 'resultadded', 'newsearchrequested', 'resultsupdated']) + @toggles = {} + for field in TOGGLEABLE_FIELDS + @toggles[field.field] = if $.cookie("show#{field.field}") == undefined then field.default else ($.cookie("show#{field.field}") == "true") + + addResult: (venue) -> + if @results[venue.venueresult.id] + return # noop, venue is already known + + if pinnedVenue = @pinned.get(venue.venueresult.id) + order = venue.venueresult.order + venue = pinnedVenue.createPinnedVersion() + venue.venueresult.order = order + venue.venueresult.refreshEverything(true) + + venue.venueresult.listeners.add "markedflagged", (e) => @showStats() + venue.venueresult.listeners.add "unmarkedflagged", (e) => @showStats() + + if sulevel >= 2 + venue.listeners.add "multiselectionrequested", (endVenue) => + @selectRange(endVenue) if @lastClicked + + venue.listeners.add "clicked", (venue) => + @lastClicked = venue + + @results[venue.venueresult.id] = venue + @listeners.notify "resultadded", this, venue + this + + has: (id) -> + id of @results + + selectRange: (endVenue) -> + [start, end] = [@lastClicked.elem.index(), endVenue.elem.index()].sort( (a,b) -> a-b ) + for e in @resultslist.children("li.venue")[start..end] + vre = @results[$(e).data('venueid')] + vre.toggleSelection(endVenue.status.clicked) unless vre.isHidden() + + setExtras: (@extras) -> this + + sortBy: (sort, targetdiv) -> + venues = for own id, venue of @results + venue + + v.elem.detach() for v in venues + sorted = venues.sort (a, b) -> + a.compareTo(b, sort.type) * if sort.dir == 'down' then -1 else 1 + targetdiv.append(v.elem) for v in sorted + + # On sort, clear out last selected + @lastClicked = undefined + + filterUpdated: (filters, map) -> + unless @search.suppressFilters + for own key, venueresultelement of @results + venueresultelement.applyFilters(filters, @toggles, map) + @showStats() + + # Display the result on a map / list, initially + display: (resultsdiv, map, options = {}) -> + resultsdiv.find(".loading").addClass('hide') + resultsdiv.find(".noresults").remove() + @search.displayOverlaysOnMap(map) + @resultslist = resultsdiv.find(".retrieved_venues") + @statsRow = resultsdiv.find(".searchstats") + self = this + + @displayNewResults(map) + + if (id for own id, keys of @results).length == 0 + @resultslist.append(HandlebarsTemplates['explore/no_venues_found']()) unless options.tooBig + + if (@search.location.renderable()) + @search.location.fitMapToLocation(map) + else + @fitMapToResults(map) + + $(resultsdiv).find(".allvenues").off("scroll").on "scroll", () => + @resultslist.find('.open-popover').popover('hide') + + @statsRow.off("click").on "click", ".hideshow", (e) -> + e.preventDefault() + self.toggleShownStatus(map, this) + + @resultslist.on "click", ".clear_venue", (e) => + @removeResult($(e.target).data('venueid')) + + @paginationholder = resultsdiv.find(".paginationholder") + if @extras?.pagination + @paginationholder.append(@extras.pagination.render((search) => @listeners.notify("newsearchrequested", search))) + + loadMoreContainers = + buttons: resultsdiv.find(".loadmorecontainer").add(@search.options?.loadMoreContainer) + warning: resultsdiv.find(".loadmorewarning") + pagination: @paginationholder + + if @search.location.divisible && @search.supportsLoadMore + @loadMore = new LocationLoadMore(@search, loadMoreContainers, map, this, options.tooBig == true) + + if @extras?.paginatedLoadMore + @loadMore = @extras?.paginatedLoadMore + @loadMore.attachToElements(loadMoreContainers, map, this) + + displayNewResults: (map) -> + self = this + allvenues = @resultslist.parents(".allvenues") + + for id, venueresult of @results when !(id of @displayedResults) + do (id, venueresult) -> + self.resultslist.append(venueresult.render()) + venueresult.showMarker(map) + venueresult.toggleVisibilityByStatuses(map, self.toggles) unless self.search.suppressFilters + + google.maps.event.addListener venueresult.marker, 'click', () -> + # When you click on a venue marker, scroll to it in this result + scrollTo = allvenues.scrollTop() + venueresult.elem.position().top - allvenues.position().top + allvenues.scrollTop(scrollTo) + + self.displayedResults[id] = true + + @fetchAlreadyFlagged(map) + @showStats() + @listeners.notify 'resultsupdated', this + + toggleShownStatus: (map, elem) -> + unless @search.suppressFilters + item = $(elem).data('status') + @toggles[item] = !@toggles[item] + $.cookie("show#{item}", @toggles[item]) + for id, venueresult of @results + venueresult.toggleVisibilityByStatuses(map, @toggles) + @showStats() + + showStats: () -> + unless @search.suppressFilters + stats = @calculateStats() + + @statsRow?.html(HandlebarsTemplates['explore/searchstats'] + stats: @calculateStats() + toggles: @toggles + fields: TOGGLEABLE_FIELDS + suppressplaces: stats.home > 0 or stats.private > 0 or stats.deleted > 0 or stats.closed > 0 or stats.filtered > 0 or stats.alreadyflagged > 0 + .replace(/(\r\n|\n|\r)/gm, '') + ) + + @resultslist?.find(".allresultsfiltered").remove() + if stats.displayed == 0 and (id for own id of @results).length > 0 + @resultslist.append(HandlebarsTemplates['explore/allresultsfiltered'](stats)) + + calculateStats: () -> + @stats = + home: 0 + private: 0 + filtered: 0 + closed: 0 + deleted: 0 + displayed: 0 + alreadyflagged: 0 + total: 0 + + for id, venueelement of @results + @stats.total++ + for item in ['home', 'private', 'closed', 'deleted', 'alreadyflagged', 'filtered'] + @stats[item]++ if venueelement.status[item] or venueelement.venueresult.venuedata?[item] or venueelement.venueresult.status?[item] + @stats['displayed']++ unless venueelement.status.hidden + + @stats + + recentered: (newCenter) -> + for id, venueresult of @results + venueresult.updateDistance(newCenter) + + fetchAlreadyFlagged: (map) -> + FlagSubmissionService.get().getAlreadyFlaggedStatuses (id for own id of @results), + type: 'venue' + success: (flags) => + for flag in (flags || []) + for venueelement in [@results[flag.venueId], @results[flag.secondaryVenueId]] when venueelement + venueelement.venueresult.markFlagged(flag) + for id, venueresult of @results + venueresult.toggleVisibilityByStatuses(map, @toggles) unless @search.suppressFilters + @showStats() + error: () => + # FIXME: What to do here? Report to rollbar? Retry logic? Ignore? + + removeResult: (venueid) -> + venueresult = @results[venueid]?.remove() + delete @results[venueid] + @showStats() + + clearResults: () -> + @resultslist?.find(".open-popover").popover('hide') + @removeResult(id) for own id, venueresult of @results + + @search.clear() + @statsRow?.html "" + @paginationholder?.html "" + @results = {} + @loadMore?.clear() + + @listeners.notify 'clearedresults', this + + resultsBounds: () -> + bounds = new google.maps.LatLngBounds() + for own id, venueelement of @results when venueelement.status.filtered == false + bounds.extend(venueelement.venueresult.position()) + bounds + + fitMapToResults: (map) -> + # FIXME: how to deal with @results size of 0, 1 + bounds = new google.maps.LatLngBounds() + for id, venueelement of @results when venueelement.status.hidden isnt true + bounds.extend(venueelement.venueresult.position()) + + map.fitBounds(bounds) unless bounds.isEmpty() + +window.SearchResults = SearchResults diff --git a/app/assets/javascripts/search/SearchTabs/DuplicateSearchTab.js.coffee b/app/assets/javascripts/search/SearchTabs/DuplicateSearchTab.js.coffee new file mode 100644 index 0000000..e169796 --- /dev/null +++ b/app/assets/javascripts/search/SearchTabs/DuplicateSearchTab.js.coffee @@ -0,0 +1,64 @@ +class DuplicateSearchTab extends SearchManagerTab + displayControls: ['circle', 'box', 'polygon', 'near'] + setLocationTypeOnShown: 'circle' + + createSearch: (overrideLocation) -> + locations = @tab.find(".locations").val() + page = @tab.find(".currentPage").val() || 1 + query = @tab.find(".query").val() + radius = @tab.find(".radius").val() + @locationManager.radiusControl?.addTempRadius(radius) + new DuplicateVenuesSearch(locations, page, query, radius, overrideLocation) + + setupEvents: () -> + super() + @tab.find('.locations').change (e) => + locs = @locations() + currentLoc = @tab.find(".currentPage").val() + @tab.find(".locationsCount").val(currentLoc + " of " + locs.length) + + @tab.find(".dupsearchhelp").click (e) => + e.preventDefault() + @showHelpModal() + + @tab.find('.editLocations').click (e) => + e.preventDefault() + @showLocationsEditor() + + showHelpModal: () -> + modalparent = $('.attach-modal').html HandlebarsTemplates['explore/dupsearch_help']() + modal = $(modalparent).children("#dupsearch-help") + $(modal).modal('show') + + showLocationsEditor: () -> + modalparent = $('.attach-modal').html HandlebarsTemplates['explore/locationseditor'] + locations: @locations() + modal = $(modalparent).children("#locationseditor") + modal.find(".setlocations").click (e) => + e.preventDefault() + + try + locations = DuplicateVenuesSearch.locationsFromString(modal.find("#locationsbox").val()) + @tab.find(".currentPage").val(1) + @tab.find(".locations").val(locations.join(";")).trigger("change") + $(modal).modal('hide') + @tab.find(".defaultsearch").click() + + catch e + throw e unless e.name == "SyntaxError" + modal.find("#locationsbox").parents(".control-group").addClass("error") + modal.find(".alert.locationlisterror").removeClass('hide') + + $(modal).modal('show') + + locations: () -> + DuplicateVenuesSearch.locationsFromString(@tab.find('.locations').val()) + + displaySearch: (search) -> + super(search) + @tab.find('.locations').trigger('change') + + updateSearch: (search) -> + @displaySearch(search) + +window.DuplicateSearchTab = DuplicateSearchTab diff --git a/app/assets/javascripts/search/SearchTabs/PageVenueSearchTab.js.coffee b/app/assets/javascripts/search/SearchTabs/PageVenueSearchTab.js.coffee new file mode 100644 index 0000000..8ef07c4 --- /dev/null +++ b/app/assets/javascripts/search/SearchTabs/PageVenueSearchTab.js.coffee @@ -0,0 +1,47 @@ +class window.PageVenuesSearchTab extends SearchManagerTab + displayControls: ['global', 'box', 'circle', 'polygon', 'near'] + setLocationTypeOnShown: 'global' + + createSearch: (location = @locationManager.location()) -> + options = {loadMoreContainer: @tab.find(".loadmorecontainer")} + if (@tab.find(".pagesearch-type").val() == 'id') + return new PageVenuesSearch(@tab.find('.pagesearch-value').val(), location, 1, options) + else + @performPageSearch(@tab.find(".pagesearch-type").val(), @tab.find(".pagesearch-value").val(), location, options) + return false + + performPageSearch: (searchType, searchText, location, options) -> + # We perform a search of the given type. If it returns one value, we perform a search on that value. + # Otherwise, we display a modal with the search results + val = {} + val[searchType] = searchText + if searchText.trim() == "" + return + + $.ajax + url: "https://api.foursquare.com/v2/users/search" + dataType: "json" + data: $.extend val, + oauth_token: token + v: API_VERSION + m: 'swarm' + limit: 200 + success: (data) => + if data.response.results.length == 1 + @explorer.performSearch(new PageVenuesSearch(data.response.results[0].id, location, 1, options)) + else + @displaySearchResultsModal(data.response.results, searchType, searchText, location, options) + error: (xhr, textStatus, errorThrown) => + alert("A search error has occurred. Please check your input and try again.") + + displaySearchResultsModal: (results, searchType, searchText, location, options) -> + modalparent = $('.attach-modal').html HandlebarsTemplates['explore/pagesearch_results'] + results: results.filter((e) -> e.type == 'chain').sort( (a, b) -> b.followers?.count - a.followers?.count ) + + modal = $(modalparent).children("#pagepicker") + $(modal).find(".selectpage").click (e) => + e.preventDefault() + id = $(e.target).data('pageid') + @explorer.performSearch(new PageVenuesSearch(id, location, 1, options)) + $(modal).modal('hide') + $(modal).modal('show') diff --git a/app/assets/javascripts/search/Searches/DuplicateVenuesSearch.js.coffee b/app/assets/javascripts/search/Searches/DuplicateVenuesSearch.js.coffee new file mode 100644 index 0000000..2f0c7b0 --- /dev/null +++ b/app/assets/javascripts/search/Searches/DuplicateVenuesSearch.js.coffee @@ -0,0 +1,64 @@ +class DuplicateVenuesSearch extends VenueSearch + searchTab: 'dupsearch' + supportsLoadMore: LocationLoadMore + hasMoreLength: 30 + + constructor: (@locationsString = "", @pagenum = 1, @query = "", @radius = 1000, overrideLocation) -> + @pagenum = parseInt(@pagenum) || 1 + @locations = @locationsString.split(";") + @radius = parseInt(@radius) || 1000 + @location = switch + when overrideLocation then overrideLocation + when @locations[@pagenum - 1] + CenterRadiusSearchLocation.deserialize + ll: @locations[@pagenum - 1] || "0,0" + radius: @radius + else + undefined + + super(@location) + + searchPath: () -> + "/venues/search" + + searchParameters: () -> + query: @query + intent: "browse" + limit: 50 + + resultsExtras: () -> + pagination: + new UnknownSizePagination + totalItems: @locations.length + currentPage: @pagenum + pageSize: 1 + onLastPage: @pagenum == @locations.length + searchAtPage: (pagenum) => new DuplicateVenuesSearch(@locations.join(';'), pagenum, @query, @radius) + + perform: () -> + unless @location + @displayError + errorText: "Please specify a list of search locations. You can select venues from another search and export them here." + return + @foursquareVenueAjax "https://api.foursquare.com/v2/#{@searchPath()}", @searchParameters() + + parseVenueResults: (data) -> + data.response.venues + + serialize: () -> + s: @searchTab + q: @query + locations: @locationsString + radius: @radius + p: @pagenum + + @deserialize: (values) -> + new DuplicateVenuesSearch values['locations'], values['p'], values['q'], values['radius'] + + # Throws error with error.name = "SyntaxError" if the location list cannot be parsed + @locationsFromString: (text) -> + # Using the pegjs js for advancedsearch just to save the complication of two parsers + locations = advancedsearch.parse(text, {startRule: 'locationlist'}) + + +window.DuplicateVenuesSearch = DuplicateVenuesSearch diff --git a/app/assets/javascripts/search/Searches/GlobalVenueSearch.js.coffee b/app/assets/javascripts/search/Searches/GlobalVenueSearch.js.coffee new file mode 100644 index 0000000..83239d6 --- /dev/null +++ b/app/assets/javascripts/search/Searches/GlobalVenueSearch.js.coffee @@ -0,0 +1,28 @@ +class GlobalVenueSearch extends VenueSearch + supportsLoadMore: false + searchTab: 'globalsearch' + + constructor: (@query = "", @categories = []) -> + super(new GlobalLocation()) + + perform: () -> + if @query.trim().length == 0 + @displayError + errorText: "Please specify some search keywords." + return + @foursquareVenueAjax("https://api.foursquare.com/v2/venues/search", + limit: 250 + intent: "global" + query: @query + categoryId: @categories.join(",") + ) + + serialize: () -> + s: @searchTab + q: @query + cats: @categories.join(",") + + @deserialize: (values) -> + new GlobalVenueSearch(values['q'], Search.parseCategories(values['cats'])) + +window.GlobalVenueSearch = GlobalVenueSearch diff --git a/app/assets/javascripts/search/Searches/ListSearch.js.coffee b/app/assets/javascripts/search/Searches/ListSearch.js.coffee new file mode 100644 index 0000000..0499381 --- /dev/null +++ b/app/assets/javascripts/search/Searches/ListSearch.js.coffee @@ -0,0 +1,49 @@ +class ListSearch extends VenueSearch + supportsLoadMore: false + searchTab: 'list' + + constructor: (@listId = "", @location = new GlobalLocation(), @categories = []) -> + super(@location) + + perform: () -> + limit = 200 + @foursquareVenueAjax("https://api.foursquare.com/v2/lists/#{@listId}", + limit: limit + # offset: (@page - 1) * limit + categoryId: @categories.join(",") + , + asLlBounds: true + ) + + publishExtras: (data) -> + extras = new ListSearchExtras(data) + @listeners.notify 'extrasready', extras + + parseVenueResults: (data) -> + @publishExtras(data.response.list) + + venues = data.response.list.listItems.items.map (e) -> + if e.venue + v = e.venue + else + v = switch e.type + when 'venue' then e.venue + when 'tip' then e.tip.venue + else throw "Can't find venue for e" + if v == undefined + console.log "Possibly deleted venue #{e.id}" + v + + $.grep venues, (e) -> e + + serialize: () -> + $.extend @location.serialize, + s: @searchTab + cats: @categories.join(",") + listid: @listId + # page: @page + + @deserialize: (values) -> + new ListSearch values['listid'], SearchLocation.deserialize(values), cats.split(',') + +window.ListSearch = ListSearch diff --git a/app/assets/javascripts/search/Searches/ListSearchByUrl.js.coffee b/app/assets/javascripts/search/Searches/ListSearchByUrl.js.coffee new file mode 100644 index 0000000..cfd3ad3 --- /dev/null +++ b/app/assets/javascripts/search/Searches/ListSearchByUrl.js.coffee @@ -0,0 +1,73 @@ +class ListSearchByUrl extends ListSearch + searchTab: "listsearch" + + constructor: (@listUrl = "", @location = new GlobalLocation(), @categories = []) -> + @listUrl = @listUrl.trim() + super null, @location, @categories + + perform: () -> + @performListSearchFromUrl(@listUrl) + + # Private methods: + + performListSearchFromUrl: (url) -> + @clearErrors() + + if result = url.match(/foursquare.com\/user\/([0-9]+)\/list\/([^?\/]+)/i) + @performListSearchFromUserIdAndList(result[1], result[2], "/user/#{result[1]}/list/#{result[2]}") + else if result = url.match(/foursquare.com\/(.*)\/list\/([^?\/]+)/i) + @performListSearchFromUsernameAndList(result[1], result[2]) + else + @displayError + errorText: "Cannot recognize list URL. Please check it." + + performListSearchFromUsernameAndList: (username, list) -> + @clearErrors() + + UserCreatedVenueSearch.lookupByTwitter(username, + success: (userid) => + @performListSearchFromUserIdAndList userid, list, "/#{username}/list/#{list}" + fail: () => + @displayError + errorText: "Could not find this list. Please double check the URL" + error: (xhr, textStatus, errorThrown) => + error = @parseError(xhr,textStatus, errorThrown) + @displayError(error, () => @performListSearchFromUsernameAndList(username, list)) + ) + + performListSearchFromUserIdAndList: (userid, list, targetpath, tryoffset = 0) -> + limit = 200 + @clearErrors() + + $.ajax + url: "https://api.foursquare.com/v2/users/#{userid}/lists" + data: + group: 'created' + offset: tryoffset + limit: limit + v: API_VERSION + oauth_token: token + m: 'swarm' + success: (data) => + if (lists = (data.response.lists.items.filter (e) -> e.url.toLowerCase() == targetpath.toLowerCase())).length > 0 + @listId = lists[0].id + ListSearchByUrl.__super__.perform.call(this) # hacky, but essentially super.perform() + else if data.response.lists.count > tryoffset+limit + @performListSearchFromUserIdAndList userid, list, targetpath, tryoffset+limit + else + @displayError + errorText: "Could not find this list. Please double check the URL" + error: (xhr, textStatus, errorThrown) => + error = @parseError(xhr,textStatus, errorThrown) + @displayError error, () => + @performListSearchFromUserIdAndList(userid, list, targetpath, tryoffset) + + serialize: () -> + s: @searchTab + listurl: @listUrl + cats: @categories.join(',') + + @deserialize: (values) -> + new ListSearchByUrl(values['listurl'], SearchLocation.deserialize(values), Search.parseCategories(values['cats'])) + +window.ListSearchByUrl = ListSearchByUrl diff --git a/app/assets/javascripts/search/Searches/MyCheckinHistorySearch.js.coffee b/app/assets/javascripts/search/Searches/MyCheckinHistorySearch.js.coffee new file mode 100644 index 0000000..5b9b8c3 --- /dev/null +++ b/app/assets/javascripts/search/Searches/MyCheckinHistorySearch.js.coffee @@ -0,0 +1,58 @@ +class MyCheckinHistorySearch extends VenueSearch + pageSize: 200 + searchTab: 'myhistory' + + constructor: (@categories = [], @start, @end, @pagenum = 1, @options = {}) -> + super(new GlobalLocation()) + + perform: () -> + @foursquareVenueAjax "https://api.foursquare.com/v2#{@searchPath()}", @searchParameters() + + searchPath: () -> + "/users/self/venuehistory" + + searchParameters: () -> + limit: @pageSize + offset: (@pagenum-1)*@pageSize + m: 'swarm' + categoryId: @categories.join(',') + beforeTimestamp: @parseTime(@end) + afterTimestamp: @parseTime(@start) + + parseTime: (timestring) -> + if moment(timestring, "YYYY-MM-DD").isValid() + moment(timestring, "YYYY-MM-DD").format("X") #To UNIX timestamp + else + undefined + + resultsExtras: (data) -> + pagination: new KnownSizePagination + totalItems: data.response.venues.count + currentPage: @pagenum + pageSize: @pageSize + searchAtPage: (pagenum) => new MyCheckinHistorySearch(@categories, @start, @end, pagenum) + + paginatedLoadMore: + new PaginatedLoadMore(this, + totalItems: data.response.venues.count + increment: @pageSize + initialOffset: @pageSize + ) + + parseVenueResults: (data) -> + data.response.venues.items.map (e) -> e.venue + + serialize: () -> + s: @searchTab + p: @pagenum if @pagenum > 1 + cats: @categories.join(',') + start: @start + end: @end + + @deserialize: (values) -> + new MyCheckinHistorySearch(Search.parseCategories(values['cats']), + values['start'], + values['end'], + parseInt(values['p']) || 1) + +window.MyCheckinHistorySearch = MyCheckinHistorySearch diff --git a/app/assets/javascripts/search/Searches/PageVenuesSearch.js.coffee b/app/assets/javascripts/search/Searches/PageVenuesSearch.js.coffee new file mode 100644 index 0000000..e2850d6 --- /dev/null +++ b/app/assets/javascripts/search/Searches/PageVenuesSearch.js.coffee @@ -0,0 +1,68 @@ +class PageVenuesSearch extends VenueSearch + pageSize: 100 + @extrasCache = {} + searchTab: 'pagesearch' + + constructor: (@pageid = "", @location = new GlobalLocation(), @pagenum = 1, @options = {}) -> + @pageid = @pageid.toString().trim() + @pagesearchtype = 'id' + super(@location) + + perform: () -> + if @pageid == "" + @displayError + errorText: "Please provide a valid page ID" + return + + @foursquareVenueAjax "https://api.foursquare.com/v2#{@searchPath()}", @searchParameters() + + @performExtrasSearch() + + searchPath: () -> + "/pages/#{@pageid}/venues" + + searchParameters: () -> + limit: @pageSize + offset: (@pagenum-1) * @pageSize + + performExtrasSearch: () -> + UserExtras.getOrCreate(@pageid, + success: (userExtras) => + @listeners.notify "extrasready", userExtras + error: (xhr, textStatus, errorThrown) => + @listeners.notify "extrasfailed" + ) + + parseVenueResults: (data) -> + data.response.venues.items + + resultsExtras: (data) -> + pagination: + if @location instanceof GlobalLocation + new KnownSizePagination + totalItems: data.response.venues.count + currentPage: @pagenum + pageSize: @pageSize + searchAtPage: (pagenum) => new PageVenuesSearch(@pageid, @location, pagenum) + else + new UnknownSizePagination + currentPage: @pagenum + pageSize: @pageSize + onLastPage: data.response.venues.items.length < (0.75 * @pageSize) + searchAtPage: (pagenum) => new PageVenuesSearch(@pageid, @location, pagenum) + + paginatedLoadMore: + new PaginatedLoadMore(this, + totalItems: data.response.venues.count + ) + + serialize: () -> + $.extend @location.serialize(), + s: @searchTab + pageid: @pageid + p: @pagenum if @pagenum > 1 + + @deserialize: (values) -> + new PageVenuesSearch(values['pageid'], SearchLocation.deserialize(values), parseInt(values['p']) || 1) + +window.PageVenuesSearch = PageVenuesSearch diff --git a/app/assets/javascripts/search/Searches/PrimaryVenueSearch.js.coffee b/app/assets/javascripts/search/Searches/PrimaryVenueSearch.js.coffee new file mode 100644 index 0000000..bbb3a1f --- /dev/null +++ b/app/assets/javascripts/search/Searches/PrimaryVenueSearch.js.coffee @@ -0,0 +1,35 @@ +class PrimaryVenueSearch extends VenueSearch + supportsLoadMore: LocationLoadMore + hasMoreLength: 30 + searchTab: 'venuesearch' + + constructor: (@query = "", @location, @categories = [], @options = {}) -> + super(@location) + + perform: () -> + @foursquareVenueAjax "https://api.foursquare.com/v2#{@searchPath()}", @searchParameters() + + searchPath: () -> + "/venues/search" + + searchParameters: () -> + query: @query + categoryId: @categories.join(",") + intent: "browse" + limit: 50 + + parseVenueResults: (data) -> + data.response.venues + + serialize: () -> + $.extend @location.serialize(), + s: @searchTab + q: @query + cats: @categories.join(',') + + @deserialize: (values) -> + new PrimaryVenueSearch values['q'], + SearchLocation.deserialize(values), + Search.parseCategories(values['cats']) + +window.PrimaryVenueSearch = PrimaryVenueSearch diff --git a/app/assets/javascripts/search/Searches/QueueSearch.js.coffee b/app/assets/javascripts/search/Searches/QueueSearch.js.coffee new file mode 100644 index 0000000..d773e3d --- /dev/null +++ b/app/assets/javascripts/search/Searches/QueueSearch.js.coffee @@ -0,0 +1,37 @@ +class QueueSearch extends VenueSearch + supportsLoadMore: false + searchTab: 'queuesearch' + pageSize: 50 + + constructor: (@queueType, @location = new GlobalLocation()) -> + super(@location) + + perform: () -> + @loadMore() + + parseVenueResults: (data) -> + data.response.venues.items + + resultsExtras: (data) -> + paginatedLoadMore: + new PaginatedLoadMore(this, {}) + + searchParameters: () -> + type: @queueType + limit: @pageSize + + searchPath: () -> + "/venues/flagged" + + loadMore: () -> + @foursquareVenueAjax "https://api.foursquare.com/v2#{@searchPath()}", @searchParameters() + + serialize: () -> + $.extend @location.serialize(), + s: @searchTab + queue: @queueType + + @deserialize: (values) -> + new QueueSearch values['queue'], SearchLocation.deserialize(values) + +window.QueueSearch = QueueSearch diff --git a/app/assets/javascripts/search/Searches/RecentlyCreatedVenueSearch.js.coffee b/app/assets/javascripts/search/Searches/RecentlyCreatedVenueSearch.js.coffee new file mode 100644 index 0000000..495cc27 --- /dev/null +++ b/app/assets/javascripts/search/Searches/RecentlyCreatedVenueSearch.js.coffee @@ -0,0 +1,26 @@ +class RecentlyCreatedVenueSearch extends VenueSearch + supportsLoadMore: LocationLoadMore + hasMoreLength: 150 + searchTab: "recentlycreated" + + constructor: (@location, @options = {}) -> + super(@location) + + perform: () -> + @foursquareVenueAjax "https://api.foursquare.com/v2#{@searchPath()}", @searchParameters() + + searchPath: () -> + "/venues/search" + + searchParameters: () -> + intent: 'recentcreate' + limit: 200 + + serialize: () -> + $.extend @location.serialize(), + s: @searchTab + + @deserialize: (values) -> + new RecentlyCreatedVenueSearch SearchLocation.deserialize values + +window.RecentlyCreatedVenueSearch = RecentlyCreatedVenueSearch diff --git a/app/assets/javascripts/search/Searches/Search.js.coffee b/app/assets/javascripts/search/Searches/Search.js.coffee new file mode 100644 index 0000000..e4c9e44 --- /dev/null +++ b/app/assets/javascripts/search/Searches/Search.js.coffee @@ -0,0 +1,135 @@ +class Search + constructor: (@location, @searchresults) -> + @maxId = 1 + @listeners = new Listeners(['resultsready', 'searchfailed', 'extrasready', 'geotoobig', 'searchgeocoded', 'extrasfailed']) + @overlays = @location?.mapOverlays().slice() || [] + + setSearchResults: (@searchResults) -> this + + setResultsDiv: (@resultsDiv) -> this + + addOverlay: (overlay) -> + @overlays.push overlay + + clearErrors: () -> + @resultsDiv?.find(".loading").removeClass('hide') + @resultsDiv?.find(".errorcontainer").html "" + @resultsDiv?.find(".noresults").remove() + @resultsDiv?.find(".searcherror").remove() + + foursquareVenueAjax: (url, params, locationOptions) -> + @clearErrors() + + $.ajax + url: url + dataType: "json" + data: + $.extend({v: API_VERSION, oauth_token: token, m: "swarm"}, @location.values(locationOptions), params) + success: (data) => + if data.response.geocode + @setSearchLocation(data.response.geocode) + @listeners.notify "searchgeocoded", data.response.geocode + @processVenueResponse(data) + error: (xhr, textStatus, errorThrown) => + if xhr.responseJSON?.meta?.errorType == 'geocode_too_big' && @location.divisible + @geoTooBig() + else + error = @parseError(xhr,textStatus, errorThrown) + @displayError(error, () => @foursquareVenueAjax(url, params, locationOptions)) + + parseError: (xhr, textStatus, errorThrown) -> + if (xhr?.responseJSON?.meta?.errorDetail) + errorDetails = xhr?.responseJSON?.meta?.errorDetail + errorText = "Foursquare API Error" + else + errorText = switch + when xhr.status == 0 then "Foursquare server error or network connection failure."; + when xhr.status >= 500 and xhr.status then "A server error occurred, please try again later." + when textStatus == 'timeout' then "The request timed out. Please try again." + else + # Rollbar.error("AJAX error: ", {xhr: xhr, textStatus: textStatus, errorThrown: errorThrown}) + "An unknown error occurred. Try again, and if the problem continues, please email 4sweep@4sweep.com" + + return { + errorDetails: errorDetails + errorText: errorText + } + + displayError: (error, retryFunction) -> + @listeners.notify "searchfailed", this + @resultsDiv?.find(".loading").addClass("hide") + error.retryable = retryFunction != undefined + errorDiv = $ HandlebarsTemplates['explore/venue_load_error'](error) + + errorDiv.find(".retry").click (e) => + e.preventDefault() + retryFunction() + + @resultsDiv?.find(".errorcontainer").html errorDiv + + geoTooBig: () -> + @listeners.notify "geotoobig", this, @result + + processVenueResponse: (data) -> + @searchResults = @resultsFromVenues(@parseVenueResults(data), @resultsExtras(data)) + @listeners.notify "resultsready", this, @result + + resultsFromVenues: (venues, extras) -> + for venue in venues + vr = new VenueResult(venue, @maxId++) + if (@location.containsPoint == undefined) || @location.containsPoint(vr.position()) + @searchResults.addResult(new VenueResultElement(vr)) + @searchResults.setExtras extras if extras + @searchResults + + resultsExtras: (data) -> {} + + displayOverlaysOnMap: (map) -> + for overlay in @overlays + overlay.setMap(map) + + # This methods should be renamed. Its used for non-overlay + # map indicators, such as global and near + @location.activateMapOverlay() if map + + setSearchLocation: (geocode) -> + @location = new BoundingBoxSearchLocation( + new google.maps.LatLng(geocode.feature.geometry.bounds.ne.lat, geocode.feature.geometry.bounds.ne.lng), + new google.maps.LatLng(geocode.feature.geometry.bounds.sw.lat, geocode.feature.geometry.bounds.sw.lng) + ) + + perform: () -> + [] + + clear: () -> + @displayOverlaysOnMap null + @overlays = null + + serialize: () -> + throw "Don't know how to serialize this" + + @deserialize: (values) -> + type = switch values['s'] + when 'globalsearch' then GlobalVenueSearch + when 'listsearch' then ListSearchByUrl + when 'venuesearch' then PrimaryVenueSearch + when 'recentlycreated' then RecentlyCreatedVenueSearch + when 'specificvenuesearch' then SpecificVenueSearch + when 'uncategorizedsearch' then UncategorizedQueueSearch + when 'usersearch' then UserCreatedVenueSearch # For backward compatibility + when 'usercreated' then UserCreatedVenueSearch + when 'venuesliked' then UserVenueLikesSearch + when 'venuesphotoed' then UserPhotoVenueSearch + when 'venuestipped' then UserTipVenueSearch + when 'myhistory' then MyCheckinHistorySearch + when 'pagesearch' then PageVenuesSearch + when 'queuesearch' then QueueSearch + when 'dupsearch' then DuplicateVenuesSearch + when 'childrensearch' then VenueChildrenSearch + else throw "Don't know how to deserialize #{values['s']}" + + type.deserialize(values) + + @parseCategories: (string) -> + (string?.split(',').filter (e) -> e && e.match(/[0-9a-f]{24}/)) || [] +window.Search = Search diff --git a/app/assets/javascripts/search/Searches/SpecificVenueSearch.js.coffee b/app/assets/javascripts/search/Searches/SpecificVenueSearch.js.coffee new file mode 100644 index 0000000..0025aef --- /dev/null +++ b/app/assets/javascripts/search/Searches/SpecificVenueSearch.js.coffee @@ -0,0 +1,27 @@ +class SpecificVenueSearch extends VenueSearch + supportsLoadMore: false + suppressFilters: true + searchTab: 'specificvenuesearch' + + constructor: (@venueid = "") -> + @specificvenuetype = "specificvenue" + super(new GlobalLocation()) + + perform: () -> + if @venueid.trim().match(/^[0-9a-f]{24}$/) + @foursquareVenueAjax("https://api.foursquare.com/v2/venues/#{@venueid.trim()}") + else + @displayError + errorText: "Please enter a valid Foursquare venue ID" + + parseVenueResults: (data) -> + [data.response.venue] + + serialize: () -> + s: @searchTab + venueid: @venueid + + @deserialize: (values) -> + new SpecificVenueSearch(values['venueid']) + +window.SpecificVenueSearch = SpecificVenueSearch diff --git a/app/assets/javascripts/search/Searches/UncategorizedQueueSearch.js.coffee b/app/assets/javascripts/search/Searches/UncategorizedQueueSearch.js.coffee new file mode 100644 index 0000000..cabce16 --- /dev/null +++ b/app/assets/javascripts/search/Searches/UncategorizedQueueSearch.js.coffee @@ -0,0 +1,14 @@ +class UncategorizedQueueSearch extends QueueSearch + supportsLoadMore: false + searchTab: 'uncategorizedsearch' + + constructor: (@location = new GlobalLocation(), @options = {}) -> + super('uncategorized', @location) + + serialize: () -> + $.extend @location.serialize(), + s: @searchTab + + @deserialize: (values) -> + new UncategorizedQueueSearch SearchLocation.deserialize values +window.UncategorizedQueueSearch = UncategorizedQueueSearch diff --git a/app/assets/javascripts/search/Searches/UserCreatedVenueSearch.js.coffee b/app/assets/javascripts/search/Searches/UserCreatedVenueSearch.js.coffee new file mode 100644 index 0000000..fb50add --- /dev/null +++ b/app/assets/javascripts/search/Searches/UserCreatedVenueSearch.js.coffee @@ -0,0 +1,44 @@ +class UserCreatedVenueSearch extends UserSearch + pageSize: 200 + supportsLoadMore: false + + constructor: (@user = "", @pagenum = 1, @options = {}) -> + @usersearchtype = "venuescreated" + super(@user, @pagenum, @options) + + performFromUserId: (@userid) -> + if sulevel >= 1 + @foursquareVenueAjax("https://api.foursquare.com/v2" + @searchPath(), + limit: @pageSize + offset: (@pagenum-1) * @pageSize + ) + else + @displayError + errorText: "Search Unavailable" + errorDetails: "This search is only available to Foursquare superusers. " + + "Apply at https://foursquare.com/edit/join" + + searchPath: () -> + "/users/#{@userid}/venues" + + searchParameters: () -> {} + + parseVenueResults: (data) -> + data.response.venues + + resultsExtras: (data) -> + pagination: new UnknownSizePagination + currentPage: @pagenum + pageSize: @pageSize + onLastPage: data.response.venues.length < (0.75 * @pageSize) + searchAtPage: (pagenum) => new UserCreatedVenueSearch(@user, pagenum) + paginatedLoadMore: + new PaginatedLoadMore(this, + initialOffset: 200 + increment: 100 + ) + + @deserialize: (values) -> + new UserCreatedVenueSearch(values['user'], parseInt(values['p']) || 1) + +window.UserCreatedVenueSearch = UserCreatedVenueSearch diff --git a/app/assets/javascripts/search/Searches/UserSearch.js.coffee b/app/assets/javascripts/search/Searches/UserSearch.js.coffee new file mode 100644 index 0000000..9d514ee --- /dev/null +++ b/app/assets/javascripts/search/Searches/UserSearch.js.coffee @@ -0,0 +1,90 @@ +class UserSearch extends VenueSearch + searchTab: 'usersearch' + + @userDetailsCache = {} + + @twitterUserIdCache = {} + + constructor: (@user = "", @pagenum = 1, @options = {}) -> + switch + when result = @user.match(/https?:\/\/.*foursquare\.com\/us?e?r?\/([0-9]+)/i) + @user = result[1] + when result = @user.match(/https?:\/\/.*foursquare\.com\/([^\/]+)/i) + @user = result[1] + + super(new GlobalLocation()) + + perform: () -> + if @user.trim().length == 0 + @displayError + errorText: "Please provide a user ID or Twitter name." + return + else if @user.match(/^[0-9]+$/) or @user == 'self' + @performFromUserId(@user) + @performExtrasSearch(@user) + else + UserCreatedVenueSearch.lookupByTwitter(@user, + success: (userid) => + @performFromUserId(userid) + @performExtrasSearch(userid) + fail: () => + @displayError + errorText: "Could not find a user with this ID or Twitter name." + error: (xhr, textStatus, errorThrown) => + error = @parseError(xhr,textStatus, errorThrown) + @displayError(error, () => @perform()) + ) + + performFromUserId: (@userid) -> + @foursquareVenueAjax("https://api.foursquare.com/v2" + @searchPath(), + limit: @pageSize + offset: (@pagenum-1) * @pageSize + ) + + resultsExtras: (data) -> + pagination: new UnknownSizePagination + currentPage: @pagenum + pageSize: @pageSize + onLastPage: data.response.venues.length < (0.75 * @pageSize) + searchAtPage: (pagenum) => new UserCreatedVenueSearch(@user, pagenum) + paginatedLoadMore: + new PaginatedLoadMore(this, + initialOffset: 200 + increment: 100 + ) + + performExtrasSearch: (userid) -> + UserExtras.getOrCreate(userid, + success: (userExtras) => + @listeners.notify "extrasready", userExtras + ) + + serialize: () -> + s: @usersearchtype + user: @user + p: @pagenum if @pagenum > 1 + + @lookupByTwitter: (twitterName, options) -> + if UserCreatedVenueSearch.twitterUserIdCache[twitterName] + options.success(UserCreatedVenueSearch.twitterUserIdCache[twitterName]) + else + $.ajax + url: "https://api.foursquare.com/v2/users/search" + data: + twitter: twitterName + v: API_VERSION + oauth_token: token + m: 'swarm' + success: (response) -> + userid = response.response.results?[0]?.id + if userid + UserCreatedVenueSearch.twitterUserIdCache[twitterName] = userid + options.success(userid) + else + options.fail() + error: options.error + + @deserialize: (values) -> + new UserCreatedVenueSearch(values['user'], parseInt(values['p']) || 1) + +window.UserSearch = UserSearch diff --git a/app/assets/javascripts/search/Searches/UserTipVenueSearch.js.coffee b/app/assets/javascripts/search/Searches/UserTipVenueSearch.js.coffee new file mode 100644 index 0000000..ba7f3df --- /dev/null +++ b/app/assets/javascripts/search/Searches/UserTipVenueSearch.js.coffee @@ -0,0 +1,32 @@ +class UserTipVenueSearch extends UserSearch + pageSize: 200 + supportsLoadMore: true + + constructor: (@user = "", @pagenum = 1, @options = {}) -> + @usersearchtype = "venuestipped" + super(@user, @pagenum, @options) + + searchPath: () -> + "/lists/#{@userid}/tips" + + searchParameters: () -> {} + + parseVenueResults: (data) -> + data.response.list.listItems.items.filter( (t) -> t.venue).map (t) -> t.venue + + resultsExtras: (data) -> + pagination: new KnownSizePagination + totalItems: data.response.list.listItems.count + currentPage: @pagenum + pageSize: @pageSize + searchAtPage: (pagenum) => new UserTipVenueSearch(@user, pagenum) + paginatedLoadMore: + new PaginatedLoadMore(this, + initialOffset: @pageSize + increment: 100 + ) + + @deserialize: (values) -> + new UserTipVenueSearch(values['user'], parseInt(values['p']) || 1) + +window.UserTipVenueSearch = UserTipVenueSearch diff --git a/app/assets/javascripts/search/Searches/UserVenueLikesSearch.js.coffee b/app/assets/javascripts/search/Searches/UserVenueLikesSearch.js.coffee new file mode 100644 index 0000000..ccedbf3 --- /dev/null +++ b/app/assets/javascripts/search/Searches/UserVenueLikesSearch.js.coffee @@ -0,0 +1,32 @@ +class UserVenueLikesSearch extends UserSearch + pageSize: 200 #FIXME: is this right? + supportsLoadMore: true + + constructor: (@user = "", @pagenum = 1, @options = {}) -> + @usersearchtype = "venuesliked" + super(@user, @pagenum, @options) + + searchPath: () -> + "/users/#{@userid}/venuelikes" + + searchParameters: () -> {} + + parseVenueResults: (data) -> + data.response.venues.items + + resultsExtras: (data) -> + pagination: new KnownSizePagination + totalItems: data.response.venues.count + currentPage: @pagenum + pageSize: @pageSize + searchAtPage: (pagenum) => new UserVenueLikesSearch(@user, pagenum) + paginatedLoadMore: + new PaginatedLoadMore(this, + initialOffset: @pageSize + increment: 100 + ) + + @deserialize: (values) -> + new UserVenueLikesSearch(values['user'], parseInt(values['p']) || 1) + +window.UserVenueLikesSearch = UserVenueLikesSearch diff --git a/app/assets/javascripts/search/Searches/VenueChildrenSearch.js.coffee b/app/assets/javascripts/search/Searches/VenueChildrenSearch.js.coffee new file mode 100644 index 0000000..c1dbfe5 --- /dev/null +++ b/app/assets/javascripts/search/Searches/VenueChildrenSearch.js.coffee @@ -0,0 +1,26 @@ +class VenueChildrenSearch extends VenueSearch + supportsLoadMore: false + searchTab: 'specificvenuesearch' + + constructor: (@venueid = "") -> + @specificvenuetype = "venuechildren" + super(new GlobalLocation()) + + perform: () -> + if @venueid.trim().match(/^[0-9a-f]{24}$/) + @foursquareVenueAjax("https://api.foursquare.com/v2/venues/#{@venueid.trim()}/children") + else + @displayError + errorText: "Please enter a valid Foursquare venue ID" + + parseVenueResults: (data) -> + data.response.children.groups.map((e) -> e.items).reduce((a, b) -> a.concat(b)) + + serialize: () -> + s: 'childrensearch' + venueid: @venueid + + @deserialize: (values) -> + new VenueChildrenSearch(values['venueid']) + +window.VenueChildrenSearch = VenueChildrenSearch diff --git a/app/assets/javascripts/search/Searches/VenueSearch.js.coffee b/app/assets/javascripts/search/Searches/VenueSearch.js.coffee new file mode 100644 index 0000000..51baca4 --- /dev/null +++ b/app/assets/javascripts/search/Searches/VenueSearch.js.coffee @@ -0,0 +1,9 @@ +class VenueSearch extends Search + constructor: (@location) -> + super(@location) + + # A default method for venue search results, some might override this + parseVenueResults: (data) -> + data.response.venues + +window.VenueSearch = VenueSearch diff --git a/app/assets/javascripts/search/Searches/YourFlagsVenueSearch.js.coffee b/app/assets/javascripts/search/Searches/YourFlagsVenueSearch.js.coffee new file mode 100644 index 0000000..73d86e2 --- /dev/null +++ b/app/assets/javascripts/search/Searches/YourFlagsVenueSearch.js.coffee @@ -0,0 +1,20 @@ +class YourFlagsVenueSearch extends VenueSearch + supportsLoadMore: true + + # Flag search options are: + # reporter: true (user reported it) / false (user just voted on it) + # resolved: true / false / missing ( = both) + # decision: rejected / accepted (missing?) + # woeType: info/duplicate/etc (one at a time) + constructor: (@flagSearchOptions) -> + super(new GlobalLocation()) + + parseVenueResults: (data) -> + data.response.venues.items + + perform: () -> + @foursquareVenueAjax "https://api.foursquare.com/v2/users/self/flaggedvenues", + $.extend @flagSearchOptions, + limit: 100 + +window.YourFlagsVenueSearch = YourFlagsVenueSearch diff --git a/app/assets/javascripts/search/SortStrategies.js.coffee b/app/assets/javascripts/search/SortStrategies.js.coffee new file mode 100644 index 0000000..e69de29 diff --git a/app/assets/javascripts/search/SubmitListener.js.coffee b/app/assets/javascripts/search/SubmitListener.js.coffee new file mode 100644 index 0000000..095b88a --- /dev/null +++ b/app/assets/javascripts/search/SubmitListener.js.coffee @@ -0,0 +1,20 @@ +class SubmitListener + objectType: () -> + throw "Flag type not specified" + processSubmit: (flag) -> + processUndo: (flag) -> + processRunImmediately: (flag) -> +window.SubmitListener = SubmitListener + +class VenueSubmitListener extends SubmitListener + constructor: (@venueresultelement) -> + @venueresult = @venueresultelement.venueresult + objectType: () -> "venues" + processSubmit: (flag) -> + @venueresult.markFlagged(flag) + processUndo: (flag) -> + @venueresult.undoMarkedFlagged(flag) + processReselect: () -> + @venueresultelement.toggleSelection() + +window.VenueSubmitListener = VenueSubmitListener diff --git a/app/assets/javascripts/search/UserPhotoVenueSearch.js.coffee b/app/assets/javascripts/search/UserPhotoVenueSearch.js.coffee new file mode 100644 index 0000000..2d54517 --- /dev/null +++ b/app/assets/javascripts/search/UserPhotoVenueSearch.js.coffee @@ -0,0 +1,32 @@ +class UserPhotoVenueSearch extends UserSearch + pageSize: 500 + supportsLoadMore: true + + constructor: (@user = "", @pagenum = 1, @options = {}) -> + @usersearchtype = "venuesphotoed" + super(@user, @pagenum, @options) + + searchPath: () -> + "/users/#{@userid}/photos" + + searchParameters: () -> {} + + parseVenueResults: (data) -> + data.response.photos.items.filter( (p) -> p.venue).map (p) -> p.venue # Deduplication handled by search results + + resultsExtras: (data) -> + pagination: new KnownSizePagination + totalItems: data.response.photos.count + currentPage: @pagenum + pageSize: @pageSize + searchAtPage: (pagenum) => new UserPhotoVenueSearch(@user, pagenum) + paginatedLoadMore: + new PaginatedLoadMore(this, + initialOffset: @pageSize + increment: 100 + ) + + @deserialize: (values) -> + new UserPhotoVenueSearch(values['user'], parseInt(values['p']) || 1) + +window.UserPhotoVenueSearch = UserPhotoVenueSearch diff --git a/app/assets/javascripts/search/VenueActionPopovers/CloseFlagPopover.js.coffee b/app/assets/javascripts/search/VenueActionPopovers/CloseFlagPopover.js.coffee new file mode 100644 index 0000000..d7984dc --- /dev/null +++ b/app/assets/javascripts/search/VenueActionPopovers/CloseFlagPopover.js.coffee @@ -0,0 +1,47 @@ +#= require search/VenueActionPopovers/VenueFlagPopover +class CloseFlagPopover extends VenueFlagPopover + template: "explore/massflags/close" + tooltipTitle: () -> "Flag as Closed" + title: () -> + "Close #{@selectedcount} place(s):" + showPopover: (e) -> + super(e) + popover = $(".attach-popover .popover") + + # Set up schedule close stuff + popover.find(".btn.schedule").click (e) -> + e.preventDefault() + popover.find(".date").show(); + popover.find(".describesubmitwhen").hide() + + popover.find(".btn.immediate").click (e) -> + e.preventDefault(); + popover.find(".date").hide() + popover.find(".describesubmitwhen").show() + + popover.find(".date").datepicker( + startDate: new Date() + ).on 'changeDate', () -> + popover.find(".date").datepicker('hide') + + popover.find(".date").change () => + val = popover.find("#scheduled_close").val() + if @closeTime(val).isValid() + popover.find(".closetext").text("Will submit " + @closeTime(val).format("llll (Z)")) + else + popover.find(".closetext").text("") + + closeTime: (val) -> + moment(val, "YYYY-MM-DD").add(1, 'day').add(4, 'hour') + + flagExtras: () -> + popover = @trigger.data('popover')?.tip() + val = popover.find("#scheduled_close").val() + + extras = if (popover.find(".schedule.active").length > 0 and @closeTime(val).isValid()) + { scheduled_at: @closeTime(val).utc().toISOString() } + else + {} + $.extend super(), extras + +window.CloseFlagPopover = CloseFlagPopover diff --git a/app/assets/javascripts/search/VenueActionPopovers/ExportAction.js.coffee b/app/assets/javascripts/search/VenueActionPopovers/ExportAction.js.coffee new file mode 100644 index 0000000..8bc56e6 --- /dev/null +++ b/app/assets/javascripts/search/VenueActionPopovers/ExportAction.js.coffee @@ -0,0 +1,199 @@ +#= require search/VenueActionPopovers/VenueActionPopover + +class ExportAction extends VenueActionPopover + requireSelectedCount: 1 + template: "explore/massflags/export" + + userCreatedLists: (options = {}) -> + return options.success?(@createdListCache) if @createdListCache + $.ajax + url: "https://api.foursquare.com/v2/users/self/lists" + data: + oauth_token: token + v: API_VERSION + m: 'swarm' + group: 'created' + limit: 200 + offset: 0 + success: (data) => + @createdListCache = data.response.lists.items + options.success?(@createdListCache) + error: options.error? + + title: () -> + "Export #{@selectedcount} place(s):" + + tooltipTitle: () -> "Export Selected Items" + + openGeneratedLink: (options = {}) -> + link = document.createElement "a" + link.download = options.download if options.download + link.href = options.href + link.target = options.target if options.target + + # Firefox needs it attached to the doc + popover = @trigger.data('popover')?.tip() + popover.find(".linkdump").append(link) + link.click() + popover.find(".linkdump").html("") + + contentExtras: () -> + for own id, element of @explorer.selected + querytext = element.venueresult.venuedata.name + break + dupquery: querytext + + showPopover: (e) -> + super(e) + popover = @trigger.data('popover')?.tip() + self = this + + @userCreatedLists + success: (lists) -> + popover.find(".list-chooser").select2 + data: lists.map (list) -> {id: list.id, text: list.name, list: list} + placeholder: "Choose a list" + popover.find(".loadinglists").hide() + + popover.find(".list-chooser").on "change", () => popover.find(".addaction").removeClass('disabled') + + error: () -> + popover.find(".error").removeClass("hide").text("Could not load your lists") + + popover.find(".addaction").click (e) -> + e.preventDefault() + return if $(this).hasClass("disabled") + self.addSelectedToList(popover.find('.list-chooser').select2('data')) + + popover.find(".exportvenueids").click (e) -> + e.preventDefault() + return if $(this).hasClass("disabled") + self.exportVenueIds() + self.deselectAndHide() + + popover.find(".exportvenuecsv").click (e) -> + e.preventDefault() + return if $(this).hasClass("disabled") + self.exportCSV() + self.deselectAndHide() + + popover.find(".searchfordups").click (e) -> + e.preventDefault() + self.searchForDuplicates() + self.deselectAndHide() + + popover.find(".elioupload").click (e) -> + e.preventDefault() + self.openInElio() + self.deselectAndHide() + + deselectAndHide: () -> + for id, venueelement of @explorer.selected + venueelement.toggleSelection(false) + @trigger.popover("hide") + + addSelectedToList: (list) -> + for own venueid, venueelement of @explorer.selected + do (venueid, venueelement) => + $.ajax + type: "POST" + url: "https://api.foursquare.com/v2/lists/#{list.id}/additem" + data: + m: 'swarm' + v: API_VERSION + oauth_token: token + venueId: venueid + success: (data) => + @trigger.popover('hide') + venueelement.toggleSelection(false) + @notifyWithTimeout(data.response.item, list.list, true) + error: (xhr, textStatus, errorThrown) => + @notifyWithTimeout({venue: venueelement.venueresult.venuedata}, list.list, false) + venueelement.toggleSelection(false) + @trigger.popover('hide') + + notifyWithTimeout: (listItem, listObj, success) -> + # FIXME: add timeout and grouping? + $.pnotify + text: HandlebarsTemplates['explore/massflags/addlist_confirm']({venue: listItem.venue, list: listObj, success: success}).replace(/[\n\r]/,"") + type: if success then "success" else "error" + stack: STACK_BOTTOMRIGHT + addclass: "stack-bottomright" + icon: false + width: "450px" + + searchForDuplicates: () -> + locations = for own id, element of @explorer.selected + venuedata = element.venueresult.venuedata + parseFloat(venuedata.location.lat).toFixed(6) + "," + parseFloat(venuedata.location.lng).toFixed(6) + + popover = @trigger.data('popover')?.tip() + query = popover.find('.dupquery').val() + + @openGeneratedLink + target: "_blank" + href: "#s=dupsearch&q=#{encodeURIComponent(query)}&locations=#{locations.join(';')}" + + exportVenueIds: () -> + data = (id for own id, element of @explorer.selected) + @openGeneratedLink + download: "4sweep_export.txt" + href: "data:text/plain;charset=utf-8," + encodeURIComponent(data.join("\n")) + + openInElio: () -> + data = (id for own id, element of @explorer.selected) + @openGeneratedLink + target: "_blank" + href: "http://4sq.neuralab.cc/load.php?venues=" + data.join(",") + + exportCSV: () -> + header = [ + 'venue', + 'name', + 'address', + 'crossStreet', + 'city', + 'state', + 'zip', + 'twitter', + 'phone', + 'url', + 'description', + 'venuell', + 'categoryId', + 'facebook' + ] + + data = for own id, element of @explorer.selected + venuedata = element.venueresult.venuedata + [ + venuedata.id, + venuedata.name, + venuedata.location.address, + venuedata.location.crossStreet, + venuedata.location.city, + venuedata.location.state, + venuedata.location.postalCode, + venuedata.contact.twitter, + venuedata.contact.phone, + venuedata.url, + venuedata.description, + venuedata.location.lat + "," + venuedata.location.lng, + venuedata.categories[0]?.id, + venuedata.contact.facebookUsername || venuedata.contact.facebook + ] + + csv = header.join(";") + "\n" + csv += data.map (row) -> + row.map (field) -> + field = (field || "").replace(/\\/g, '\\\\') + field = field.replace(/"/g,'\\"') + "\"#{field}\"" + .join(";") + .join("\n") + + @openGeneratedLink + download: "4sweep_export.csv" + href: "data:text/csv;charset=UTF-8," + window.encodeURIComponent(csv) + +window.ExportAction = ExportAction diff --git a/app/assets/javascripts/search/VenueActionPopovers/MakeHomeFlagPopover.js.coffee b/app/assets/javascripts/search/VenueActionPopovers/MakeHomeFlagPopover.js.coffee new file mode 100644 index 0000000..056a2cd --- /dev/null +++ b/app/assets/javascripts/search/VenueActionPopovers/MakeHomeFlagPopover.js.coffee @@ -0,0 +1,11 @@ +#= require search/VenueActionPopovers/VenueFlagPopover +class MakeHomeFlagPopover extends VenueFlagPopover + template: "explore/massflags/makehome" + tooltipTitle: () -> "Change Category to Home" + title: () -> + "Re-categorize #{@selectedcount} place(s) as home:" + + requiresExtraConfirmation: (flags = [], selected) -> + @requiresExtraConfirmationOnDistinctUsers(flags, selected, 15) + +window.MakeHomeFlagPopover = MakeHomeFlagPopover diff --git a/app/assets/javascripts/search/VenueActionPopovers/MakePrivateFlagPopover.js.coffee b/app/assets/javascripts/search/VenueActionPopovers/MakePrivateFlagPopover.js.coffee new file mode 100644 index 0000000..2042408 --- /dev/null +++ b/app/assets/javascripts/search/VenueActionPopovers/MakePrivateFlagPopover.js.coffee @@ -0,0 +1,11 @@ +#= require search/VenueActionPopovers/VenueFlagPopover +class MakePrivateFlagPopover extends VenueFlagPopover + template: "explore/massflags/makeprivate" + tooltipTitle: () -> "Make Venue Private" + title: () -> + "Mark #{@selectedcount} place(s) private:" + + requiresExtraConfirmation: (flags = [], selected) -> + @requiresExtraConfirmationOnDistinctUsers(flags, selected, 15) + +window.MakePrivateFlagPopover = MakePrivateFlagPopover diff --git a/app/assets/javascripts/search/VenueActionPopovers/MergeFlagPopover.js.coffee b/app/assets/javascripts/search/VenueActionPopovers/MergeFlagPopover.js.coffee new file mode 100644 index 0000000..35cff2f --- /dev/null +++ b/app/assets/javascripts/search/VenueActionPopovers/MergeFlagPopover.js.coffee @@ -0,0 +1,53 @@ +#= require search/VenueActionPopovers/VenueFlagPopover +class MergeFlagPopover extends VenueFlagPopover + requireSelectedCount: 2 + template: "explore/massflags/merge" + tooltipTitle: () -> "Flag as Duplicate" + title: () -> + "Mark #{@selectedcount} places as duplicates:" + + createFlags: () -> + maxCheckinVenue = @maxCheckinVenue() + for venueid, venueelement of @explorer.selected when venueid isnt maxCheckinVenue.id + flag = maxCheckinVenue.createMergeFlag venueelement.venueresult, @flagExtras() + + contentExtras: () -> + d = @maxSelectedDistance() + + maxDistance: Math.round(d) + venueCount: (a for a,b of @explorer.selected).length + warnClass: switch + when d > 10000 then 'danger' + when d > 1000 then 'warning' + else 'info' + + updatedSelectedCount: (count, popover) -> + super(count, popover) + + # Also, update the distance, if available + popoverelement = $(popover.trigger).data('popover')?.tip() + popoverelement?.find('.distancewarning').html( + Handlebars.partials['explore/massflags/_merge_distance_warning'](@contentExtras()) + ) + + maxCheckinVenue: () -> + (venueelement.venueresult for venueid, venueelement of @explorer.selected).reduce (a, b) -> + if a.venuedata.stats.checkinsCount > b.venuedata.stats.checkinsCount then a else b + + maxSelectedDistance: () -> + return 0 if (venueid for own venueid, venueelem of @explorer.selected).length < 2 + target = @maxCheckinVenue() + + + distances = (venueelement.venueresult for venueid, venueelement of @explorer.selected when venueid != target.id) + .map (venueresult) -> target.distanceFromPoint(venueresult.position()) + + Math.max distances... + + requiresExtraConfirmation: (flags = []) -> + if flags.length >= 5 + return ["You are requesting to merge #{flags.length + 1} different venues together." + + " Please triple check that these venues are EXACT duplicates and are not subvenues."] + return false + +window.MergeFlagPopover = MergeFlagPopover diff --git a/app/assets/javascripts/search/VenueActionPopovers/MultiVenueListener.js.coffee b/app/assets/javascripts/search/VenueActionPopovers/MultiVenueListener.js.coffee new file mode 100644 index 0000000..8f24a53 --- /dev/null +++ b/app/assets/javascripts/search/VenueActionPopovers/MultiVenueListener.js.coffee @@ -0,0 +1,20 @@ +#= require search/SubmitListener + +class MultiVenueListener extends SubmitListener + constructor: (selectedvenues) -> + @venues = $.extend({}, selectedvenues) # Clone venues + + objectType: () -> "venues" + processSubmit: (flag) -> + @venues[flag.venueId]?.venueresult.markFlagged(flag) + @venues[flag.secondaryVenueId]?.venueresult.markFlagged(flag) + + processUndo: (flag) -> + @venues[flag.venueId]?.venueresult.undoMarkedFlagged(flag) + @venues[flag.secondaryVenueId]?.venueresult.undoMarkedFlagged(flag) + + processReselect: () -> + for id, venue of @venues + venue.toggleSelection() + +window.MultiVenueListener = MultiVenueListener diff --git a/app/assets/javascripts/search/VenueActionPopovers/RecategorizeFlagPopover.js.coffee b/app/assets/javascripts/search/VenueActionPopovers/RecategorizeFlagPopover.js.coffee new file mode 100644 index 0000000..8c31fed --- /dev/null +++ b/app/assets/javascripts/search/VenueActionPopovers/RecategorizeFlagPopover.js.coffee @@ -0,0 +1,34 @@ +#= require search/VenueActionPopovers/VenueFlagPopover +class RecategorizeFlagPopover extends VenueFlagPopover + template: "explore/massflags/recategorize" + + tooltipTitle: () -> "Change Category" + + title: () -> + "Change categories for #{@selectedcount} place(s):" + + showPopover: (e) -> + super(e) + popover = @trigger.data('popover')?.tip() + new CategorySelector().setupCategories popover.find(".cat-chooser"), + allowMultiple: false + recentChoicesSelector: popover.find(".recentlychosen") + + $(".recategorize-help").popover( + html: true + title: "Change Venue Categories" + placement: "right" + trigger: "hover" + content: HandlebarsTemplates['explore/massflags/about_recategorize']() + ) + + popover.find(".cat-chooser").select2("focus") + + flagExtras: () -> + popover = @trigger.data('popover')?.tip() + extras = + itemId: popover.find(".cat-chooser").select2('val') + itemName: popover.find(".cat-chooser").select2('data').text + $.extend super(), extras + +window.RecategorizeFlagPopover = RecategorizeFlagPopover diff --git a/app/assets/javascripts/search/VenueActionPopovers/RefreshAction.js.coffee b/app/assets/javascripts/search/VenueActionPopovers/RefreshAction.js.coffee new file mode 100644 index 0000000..58755f6 --- /dev/null +++ b/app/assets/javascripts/search/VenueActionPopovers/RefreshAction.js.coffee @@ -0,0 +1,17 @@ +#= require search/VenueActionPopovers/VenueActionPopover + +class RefreshAction extends VenueActionPopover + requireSelectedCount: 1 + tooltipTitle: () -> "Reload extended venue details" + + attach: () -> + # Don't call super + @trigger.click (e) => + e.preventDefault() + for own venueid, venueelement of @explorer.selected + do (venueelement) -> + lid = venueelement.venueresult.listeners.add "pulling-full-done", () -> + venueelement.toggleSelection(false) + venueelement.venueresult.listeners.remove "pulling-full-done", lid + venueelement.venueresult.refreshEverything(true) +window.RefreshAction = RefreshAction diff --git a/app/assets/javascripts/search/VenueActionPopovers/RemoveFlagPopover.js.coffee b/app/assets/javascripts/search/VenueActionPopovers/RemoveFlagPopover.js.coffee new file mode 100644 index 0000000..a07eb7b --- /dev/null +++ b/app/assets/javascripts/search/VenueActionPopovers/RemoveFlagPopover.js.coffee @@ -0,0 +1,24 @@ +class RemoveFlagPopover extends VenueFlagPopover + template: "explore/massflags/removevenue" + tooltipTitle: () -> "Flag to Remove Venue" + title: () -> + "Remove #{@selectedcount} place(s):" + showPopover: (e) -> + super(e) + + # Remove flag popovers have a link encouraging people to + # make venues private instead: + popover = $(e).data('popover')?.tip() + popover.find(".privateflag").click (e) -> + e.preventDefault() + $(".mass-private").click() + + requiresExtraConfirmation: (flags = [], selected) -> + result = [] + + for venueid, venueelement of selected when venueelement.venueresult.venuedata.stats.usersCount > 15 + venuedata = venueelement.venueresult.venuedata + result.push "Venue #{venuedata.name} has been visited by #{venuedata.stats.usersCount} distinct users." + + if result.length > 0 then result else false +window.RemoveFlagPopover = RemoveFlagPopover diff --git a/app/assets/javascripts/search/VenueActionPopovers/VenueActionPopover.js.coffee b/app/assets/javascripts/search/VenueActionPopovers/VenueActionPopover.js.coffee new file mode 100644 index 0000000..4c66a50 --- /dev/null +++ b/app/assets/javascripts/search/VenueActionPopovers/VenueActionPopover.js.coffee @@ -0,0 +1,61 @@ +class VenueActionPopover + + constructor: (@explorer, @trigger) -> + @updatedSelectedCount(0, this) + @explorer.listeners.add 'updatedselectedcount', (count) => @updatedSelectedCount(count, this) + @trigger.parents(".massaction-tooltip").tooltip + title: @tooltipTitle() + placement: 'top' + container: 'body' + html: false + + attach: () -> + self = this + + @trigger.click (e) -> e.preventDefault() + + @popover = @trigger.popover( + html: true + placement: 'bottom' + title: () => @title() + @closeButton() + content: () => @content() + container: ".attach-popover" + ).on("shown", (e) -> + self.showPopover(this) + ).on("hidden", (e) -> + $(this).removeClass('open-popover') + ) + + tooltipTitle: () -> + "" + + closeButton: () -> + " " + + updatedSelectedCount: (count, popover) -> + popover.trigger.toggleClass "disabled", @requireSelectedCount > count + popover.selectedcount = count + + popoverelement = $(popover.trigger).data('popover')?.tip() + popoverelement?.find(".selectedcount").text(count) + popoverelement?.find(".btn.pushflag").toggleClass 'disabled', @requireSelectedCount > count + + content: () -> + HandlebarsTemplates[@template](@contentExtras()) + + contentExtras: () -> {} + + showPopover: (e) -> + # If this popover is disabled, hide it immediately + if @trigger.hasClass('disabled') + $(e).popover("hide") + return + # Close all other open popovers + $(".open-popover").not(e).popover('hide') + @trigger.addClass("open-popover") + popoverelement = $(e).data('popover')?.tip() + popoverelement.find(".popover-close").click (e) => + e.preventDefault() + @trigger.popover('hide') + +window.VenueActionPopover = VenueActionPopover diff --git a/app/assets/javascripts/search/VenueActionPopovers/VenueFlagPopover.js.coffee b/app/assets/javascripts/search/VenueActionPopovers/VenueFlagPopover.js.coffee new file mode 100644 index 0000000..ba197df --- /dev/null +++ b/app/assets/javascripts/search/VenueActionPopovers/VenueFlagPopover.js.coffee @@ -0,0 +1,81 @@ +#= require search/VenueActionPopovers/VenueActionPopover +#= require search/VenueActionPopovers/MultiVenueListener + +class VenueFlagPopover extends VenueActionPopover + requireSelectedCount: 1 + + attach: () -> + super() + $('.attach-popover').on "click", ".addcomment", (e) -> + e.preventDefault() + $(this).hide() + $(".attach-popover .commentfield").show().focus() + @explorer.listeners.add 'submitautomaticallychanged', @setDescribeSubmitWhen + + setDescribeSubmitWhen: (automatic) -> + if automatic + $(".attach-popover .describesubmitwhen").html("Your flag will be automatically submitted after about 5 minutes. Until then, you can cancel it on the queued flags page.") + else + $(".attach-popover .describesubmitwhen").html("When you're ready, review your flag and submit it on the new flags page.") + + showPopover: (e) -> + super(e) + self = this + popoverelement = $(e).data('popover').tip() + + popoverelement.find(".btn.pushflag").click (e) -> + e.preventDefault() + return if $(this).hasClass("disabled") + flags = self.createFlags(this) + if extraConfirmation = self.requiresExtraConfirmation(flags, self.explorer.selected) + self.showConfirmModal(flags, extraConfirmation) + else + FlagSubmissionService.get().submitFlags(flags, new MultiVenueListener(self.explorer.selected)) + self.trigger.popover('hide') + + @setDescribeSubmitWhen(FlagSubmissionService.get().runImmediatelyStatus()) + + requiresExtraConfirmation: (flags) -> + false + + createFlags: (button) -> + flagtype = $(button).data('flagtype') + + # For single venue flags + for venueid, venueelement of @explorer.selected + venueelement.venueresult.createFlag flagtype, + $.extend {problem: $(button).data('problem')}, @flagExtras() + + flagExtras: () -> + popoverelement = @trigger.data('popover')?.tip() + comment = popoverelement.find(".comment")?.val()?.trim() + if comment + {comment: comment} + else + {} + + showConfirmModal: (flags, extraConfirmation) -> + self = this + modalparent = $(".attach-modal").html HandlebarsTemplates["explore/massflags/confirm_modal"] + extraConfirmation: extraConfirmation + + modal = $(modalparent).children('#confirmmodal') + $(modal).find(".confirm").click (e) => + e.preventDefault() + FlagSubmissionService.get().submitFlags(flags, new MultiVenueListener(self.explorer.selected)) + modal.modal('hide') + + $(modal).modal('show') + + requiresExtraConfirmationOnDistinctUsers: (flags = [], selected, distinctMin = 15) -> + # A convenience function that subclasses can rely on to require a confirmation dialog when + # flagging venues with a lot of unique users + result = [] + + for venueid, venueelement of selected when venueelement.venueresult.venuedata.stats.usersCount >= distinctMin + venuedata = venueelement.venueresult.venuedata + result.push "Venue #{venuedata.name} has been visited by #{venuedata.stats.usersCount} distinct users." + + if result.length > 0 then result else false + +window.VenueFlagPopover = VenueFlagPopover diff --git a/app/assets/javascripts/search/VenueResult.js.coffee b/app/assets/javascripts/search/VenueResult.js.coffee new file mode 100644 index 0000000..b31b833 --- /dev/null +++ b/app/assets/javascripts/search/VenueResult.js.coffee @@ -0,0 +1,428 @@ +class VenueResult + HOME_CAT: '4bf58dd8d48988d103941735' + USED_FB_KEYS: ['name', 'is_permanently_closed', 'is_unclaimed', 'cover', 'category_list', 'description', 'about' + 'phone', 'founded', 'location', 'attire', 'price_range', 'were_here_count', 'likes', 'checkins', + 'category', 'public_transit', 'payment_options', 'parking', 'culinary_team', 'general_manager', + 'restaurant_services', 'restaurant_specialties', 'talking_about_count', 'id', 'link', 'is_community_page', + 'website', 'can_post', 'has_added_app', 'is_published', 'username', 'hours', 'parent_page', + 'mission', 'products', 'company_overview', 'awards', 'general_info'] + + KNOWN_BITMASK_FIELDS: [ # To find: 256 + 'PhoneNA', # 0 1 + 'AddressNA', # 1 2 + 'UrlNA', # 2 4 + 'CrossNA', # 3 8 + 'CityNA', # 4 16 + 'StateNA', # 5 32 + 'ZipNA', # 6 64 + 'TwitterNA', # 7 128 + 'PriceNA', # 9 512 + 'PrivateVenue', # 10 1024 + 'NoEvents', # 11 2048 + 'CountryCodeOverridden', # 12 4096 + 'DontCanonicalizeAddress' # 13 8192 + 'UserEnteredNeighborhoodAsCity', # 14 16384 + 'UserEnteredSubhoodAsCity', # 15 32768 + 'UserEnteredMacrohoodAsCity', # 16 65536 + 'IsCityFromRevGeo', # 17 131072 + 'IsCountyFromRevGeo', # 18 262144 + 'IsStateFromRevGeo', # 19 524288 + 'UserEnteredNeighborhood', # 20 1048576 + 'UserEnteredSubhood', # 21 2097152 + 'UserEnteredMacrohood', # 22 4194304 + 'UserEnteredCountyAsCity', # 23 8388608 + 'IsServiceAreaBusiness', # 25 33554432 + 'IsBlacklistedFromProactiveRecs', # 26 67108864 + 'DontCheckPunctuationEmoji', # 28 268435456 + ] + + MAJOR_EDIT_FIELDS = ['address', 'categories', 'chainUrl', 'city', 'fbId', 'description', 'crossStreet', + 'phone', 'hours', 'state', 'twitterName', 'url', 'userId', 'venuename', 'zip', 'latlng', + 'deleted', 'closed', 'parentId'] + + MAJOR_FLAG_TYPES: ["at", "category", "hours", "info", "missingaddress", "missingphone", "primarycategory", "remove", + "uncategorized", 'duplicate', 'manualDuplicate', 'removecategory', 'privatevenue', 'mislocated', + 'publicvenue', 'unremove', 'editName', 'mi', 'menu'] + + MINOR_FLAG_TYPES: ['suspicious', 'price', 'svd', 'explorespam', 'phrank', 'ph', 'tip', 'geo', + 'suspicioushours', 'atvc', 'sv'] + + KNOWN_REMOVE_REASONS: ['inappropriate', 'doesnt_exist', 'remove_home', 'event_over', 'closed', 'created_in_error', ''] + KNOWN_UNREMOVE_REASONS: ['notclosed', 'undelete'] + + constructor: (@venuedata, @order) -> + unless @venuedata + throw "Tried to create a VenueResult without venue data" + @id = @venuedata.id + @existingFoursweepFlags = {} + @listeners = new Listeners(['fullvenuecomplete', 'merged', 'gone', 'pulling-statuschanged', 'markedflagged', + 'unmarkedflagged', 'pulling-edits-done', 'pulling-flags-done', 'pulling-full-done', + 'pulling-foursweep-done', 'pulling-attributes-done', 'pulling-children-done', + 'pulling-hours-done']) + @currentDistance = @venuedata.location.distance || false + @editHistory = [] + @pendingFlags = [] + @children = [] + @facebookDetails = null + + @venuedata.gone = false + @venuedata.merged = false + + @setVenueStatus() + + @pulling = # states are: 'none', 'pulling', 'failed', 'done' + edits: 'none' + flags: 'none' + full: 'none' + foursweep: 'none' + attributes: 'none' + children: 'none' + hours: 'none' + + auditDetails: () -> + name: @venuedata.name + location: @venuedata.location + closed: @venuedata.closed? + deleted: @venuedata.deleted? + locked: @venuedata.locked? + private: @venuedata.private? + stats: @venuedata.stats + categories: @venuedata.categories.map( (cat) -> {id: cat.id, name: cat.name}) + photos: {count: @venuedata.photos?.count} + tips: {count: @venuedata.tips?.count} + + categories: () -> + flags = (flag for own id, flag of @existingFoursweepFlags) + + removedCategoryIds = flags.filter (flag) -> + flag.flag_type == "RemoveCategoryFlag" + .map (flag) -> flag.itemId + + replaceAllCategoryIds = flags.filter (flags) -> + flag.flag_type == "ReplaceAllCategoriesFlag" + .map (flag) -> flag.itemId + + hasMakeHome = flags.filter((flags) -> flag.flag_type == "MakeHomeFlag").length > 0 + + makePrimaryCategoryIds = flags.filter (flag) -> + flag.flag_type == "MakePrimaryCategoryFlag" + .map (flag) -> flag.itemId + + result = @venuedata.categories.map (e) => + e = $.extend {}, e #clone + e.foursweepRemovePending = (e.id in removedCategoryIds) or + (replaceAllCategoryIds.length > 0 && e.id not in replaceAllCategoryIds) or + (hasMakeHome and e.id != @HOME_CAT) + e.foursweepMakePrimaryPending = (e.id in makePrimaryCategoryIds) or + (e.id in replaceAllCategoryIds) or + (hasMakeHome and e.id == @HOME_CAT) + e + + pending = flags.filter (flag) => + flag.flag_type in ["MakeHomeFlag", "ReplaceAllCategoriesFlag", "AddCategoryFlag", "MakePrimaryCategoryFlag"] and + flag.itemId not in (@venuedata.categories.map (e) -> e.id) + .map (e) -> + name: e.itemName + id: e.itemId + + return { + existing: result + pending: pending + } + + # Return a negative number, 0, or a positive number if the VenueResult other + # is before, equal to, or after this result, respectively + compareTo: (other, field) -> + switch field + when 'createdat' then @id.localeCompare(other.id) + when 'name' then @venuedata.name.localeCompare(other.venuedata.name) + when 'namefuzzy' then @fuzzyName().localeCompare(other.fuzzyName()) + when 'address' then (@venuedata.location.address || "").localeCompare(other.venuedata.location.address || "") + when 'checkins' then @venuedata.stats.checkinsCount - other.venuedata.stats.checkinsCount + when 'users' then @venuedata.stats.usersCount - other.venuedata.stats.usersCount + when 'distance' then @distance() - other.distance() + when 'natural' then @order - other.order + when 'category' then (@venuedata.categories[0]?.name || "").localeCompare(other.venuedata.categories[0]?.name || "") + when 'herenow' then (@venuedata.hereNow?.count || 0) - (other.venuedata.hereNow?.count || 0) + when 'phone' then (@venuedata.contact?.phone || "").localeCompare(other.venuedata.contact?.phone || "") + when 'city' then (@venuedata.location?.city || "").localeCompare(other.venuedata.location?.city || "") + else throw "Unknown field #{field}" + + createFlag: (type, extras = {}) -> + flag = + type: type + venueId: @venuedata.id + primaryName: @venuedata.name + venues_details: [@auditDetails()] + $.extend(flag, extras) + + createMergeFlag: (secondaryVenue, extras = {}) -> + flag = @createFlag "MergeFlag", + secondaryVenueId: secondaryVenue.id + secondaryName: secondaryVenue.venuedata.name + venues_details: [@auditDetails(), secondaryVenue.auditDetails()] + $.extend flag, extras + + # Return last updated distance in meters, or, if unavailable, + # the distance from the search location according to Foursquare, + # or, failing that, false + distance: () -> + @currentDistance || @venuedata.location.distance || false + + distanceFromPoint: (point) -> + google.maps.geometry.spherical.computeDistanceBetween @position(), point + + fuzzyName: () -> + return @fuzzyNameCache if @fuzzyNameCache? + # Returns a name devoid of beginning articles and with transliterations applied + @fuzzyNameCache = FuzzyStringService.fuzzyString(@venuedata.name) + + getFacebookData: (options) -> + return if @facebookDetails + $.ajax + dataType: 'json' + url: "https://graph.facebook.com/#{@venuedata.contact.facebook}" + success: (data) => + @facebookDetails = data + for own key, val of @facebookDetails + if key not in @USED_FB_KEYS + console.log("UNUSED FB DATA", {venue: @venuedata.name, id: @id, key: key, val: val}) + options.success() + error: () => + options.error() + + hasOldMajorFlags: () -> + @majorFlags().filter( (e) -> e.isOld).length > 0 + + majorEdits: () -> + @editHistory.filter (e) -> !e.isMinor + + majorFlags: () -> + # Effectively, sorting by id is sorting by date + @pendingFlags.filter((e) -> !e.isMinor).sort (a,b) -> if a.id > b.id then -1 else 1 + + markFlagged: (flag) -> + @existingFoursweepFlags[flag.id] = flag + @listeners.notify 'markedflagged', flag + + # Returns true if this venue matches all filter listed + matchesAllFilter: (filters) -> + for filter in filters + return false if !filter.predicate(@venuedata) + true + + photos: () -> + @venuedata.photos?.groups.filter((e) -> e.type == 'venue')[0]?.items || [] + + processAttributes: (response) -> + @attributes = response + @updatePullingStatus ['attributes'], 'done' + + processChildren: (response) -> + @children = [].concat.apply [], response.children.groups.map (e) -> e.items + @updatePullingStatus ['children'], 'done' + + processHours: (response) -> + @hours = new Hours(response.hours?.timeframes || []) + @updatePullingStatus ['hours'], 'done' + + processEditHistory: (response) -> + # Edit delta names that we care about: + + @editHistory = response.items + @knownEditCount = response.count + + for edit in @editHistory + edit.isMinor = true + edit.isMinor = false if edit.editType in ['create', 'merge', 'rollback'] + if edit.editType == 'create' + @created = + app: edit.app + time: edit.createdAt + user: edit.approvingUsers[0] + for delta in edit.deltas + if delta.name in MAJOR_EDIT_FIELDS + edit.isMinor = false + else + if delta.name == 'flags' + edit.isMinor = false if delta.new?.value?.match /PrivateVenue/ + [bitmask, texts...] = delta.new?.value?.split(/\s+/) + for text in texts when text.replace("+", "").replace("-","") not in @KNOWN_BITMASK_FIELDS + warn = "flags bitmask for #{text} (in #{texts}) found in #{@id}. old: #{delta.old.value}, new: #{delta.new.value}" + console.log warn + @updatePullingStatus ['edits'], 'done' + + processFullVenue: (response) -> + oldvenuedata = @venuedata + @venuedata = response + @setVenueStatus() + + @listeners.notify "fullvenuecomplete", oldvenuedata + @updatePullingStatus ['full'], 'done' + + processGone: -> + @venuedata.deleted = true + @venuedata.gone = true + @listeners.notify "gone" + + processMerge: (newvenue) -> + @venuedata.merged = true + @listeners.notify "merged", newvenue + + processPendingFlags: (response) -> + @pendingFlagCount = response.count + @pendingFlags = response.items + for flag in @pendingFlags + flag.createdAt = parseInt(flag.id.slice(0,8), 16) * 1000 + flag.isOld = (new Date().getTime() - flag.createdAt) > 1000*60*60*24*30 # Older than 30 days? + if flag.type in @MINOR_FLAG_TYPES + flag.isMinor = true + else if flag.type in @MAJOR_FLAG_TYPES + if flag.type == 'at' and flag.value == undefined + # Not sure why this happens, but we don't need to show empty attribute flags + flag.isMinor = true + else + flag.isMinor = false + if (flag.type == 'remove' and flag.value.reason and flag.value.reason not in @KNOWN_REMOVE_REASONS) + warn = "encountered unknown remove reason #{flag.value.reason} in #{@id}" + console.log warn, flag + + if (flag.type == 'unremove' and flag.value not in @KNOWN_UNREMOVE_REASONS) + warn = "encountered unknown unremove reason #{flag.value} in #{@id}" + console.log warn, flag + + else + flag.isMinor = false + warn = "encountered unknown flag type #{flag.type} found in #{@id}" + console.log warn, flag + @updatePullingStatus ['flags'], 'done' + + position: () -> + new google.maps.LatLng(@venuedata.location.lat, @venuedata.location.lng) + + refreshEverything: (force = false) -> + @refreshAlreadyFlaggedStatus(force) + @upgradeWithFullData(force) + + refreshAlreadyFlaggedStatus: (force) -> + @updatePullingStatus ['foursweep'], 'pulling' + + FlagSubmissionService.get().getAlreadyFlaggedStatuses [@id], + type: 'venue' + forcecheck: force + success: (flags) => + @existingFoursweepFlags = {} + for flag in (flags || []) + @markFlagged(flag) + @updatePullingStatus ['foursweep'], 'done' + error: => + @updatePullingStatus ['foursweep'], 'failed' + + setVenueStatus: () -> + @venuedata = $.extend @venuedata, + home: @venuedata.categories[0]?.id == @HOME_CAT + + tips: () -> + [].concat.apply [], @venuedata.tips?.groups.map (e) -> e.items + + topChildren: (n) -> + return [] unless @children + # Return top n children of this venue, if loaded. + totalChildren: @children.length + items: @children[0...n] + remaining: Math.max(0, @children.length - n) + + undoMarkedFlagged: (flag) -> + delete @existingFoursweepFlags[flag.id] + @listeners.notify 'unmarkedflagged', flag + + updateDistance: (@currentDistance) -> + + updatePullingStatus: (fields = [], status) -> + @pulling[field] = status for field in fields + + @listeners.notify "pulling-statuschanged", @pulling + if status == 'done' + for field in fields + @listeners.notify "pulling-#{field}-done" + + upgradeWithFullData: (force = false) -> + return if (@pulling.full != 'none' and @pulling.edits != 'none' and @pulling.flags != 'none') unless force + @updatePullingStatus ['full', 'edits', 'flags', 'attributes', 'children'], 'pulling' + + $.ajax + url: "https://api.foursquare.com/v2/multi" + dataType: 'json' + data: + v: API_VERSION + oauth_token: token + m: 'swarm' # Unless m=swarm, friendVisits returns odd results only from brands + requests: ["/venues/#{@id}", + "/venues/#{@id}/flags?limit=20", + "/venues/#{@id}/edits?limit=20", + "/venues/#{@id}/attributes", + "/venues/#{@id}/children", + "/venues/#{@id}/hours" + ].join(',') + success: (data) => + # Full Venue Response on responses[0] + venueresponse = data.response.responses[0] + if venueresponse.meta.code == 400 && venueresponse.meta.errorDetail.match /has been deleted/ + @processGone() + @updatePullingStatus ['full', 'flags', 'edits', 'attributes', 'children'], 'done' + return + if venueresponse.meta.code == 200 + if venueresponse.response.venue.id != @venuedata.id + # Venue has been merged + @processMerge(venueresponse.response.venue) + @updatePullingStatus ['full', 'flags', 'edits', 'attributes', 'children'], 'done' + return + else + @processFullVenue(venueresponse.response.venue) + else + @updatePullingStatus ['full'], 'failed' + + # Flags returned on responses[1] + if data.response.responses[1].meta.code == 200 + @processPendingFlags(data.response.responses[1].response.flags) + else if data.response.responses[1].meta.errorType == 'not_authorized' + @processPendingFlags({count: 0, items: []}) + # This is a home venue, it's not an error + else + @pendingFlags = [] + @pendingFlagCount = 0 + @updatePullingStatus ['flags'], 'failed' + + # Edit history on response[2] + if data.response.responses[2].meta.code == 200 + @processEditHistory(data.response.responses[2].response.edits) + else if data.response.responses[2].meta.errorType == 'not_authorized' + @processEditHistory({count: 0, items: []}) + else + @updatePullingStatus ['edits'], 'failed' + + if data.response.responses[3].meta.code == 200 + @processAttributes(data.response.responses[3].response) + else if data.response.responses[3].meta.errorType == 'not_authorized' + @updatePullingStatus ['attributes'], 'done' + else + @updatePullingStatus ['attributes'], 'failed' + + if data.response.responses[4].meta.code == 200 + @processChildren(data.response.responses[4].response) + else if data.response.responses[4].meta.errorType == 'not_authorized' + @updatePullingStatus ['children'], 'done' + else + @updatePullingStatus ['children'], 'failed' + + if data.response.responses[5].meta.code == 200 + @processHours(data.response.responses[5].response) + else if data.response.responses[5].meta.errorType == 'not_authorized' + @updatePullingStatus ['hours'], 'done' + else + @updatePullingStatus ['hours'], 'failed' + + error: () => + @updatePullingStatus ['full', 'edits', 'flags', 'attributes', 'children'], 'failed' + +window.VenueResult = VenueResult diff --git a/app/assets/javascripts/search/VenueResultElement.js.coffee b/app/assets/javascripts/search/VenueResultElement.js.coffee new file mode 100644 index 0000000..d0102db --- /dev/null +++ b/app/assets/javascripts/search/VenueResultElement.js.coffee @@ -0,0 +1,604 @@ +# This class is essentially a controller for VenueResults, allowing +# users to interact with them and setting up the UI elements +# and interactivity for them +class VenueResultElement + + constructor: (@venueresult) -> + @listeners = new Listeners ['selected', 'unselected', 'hidden', 'unhidden', + 'requestzoomin', 'requestzoomout', 'multiselectionrequested', + 'clicked', 'pin', 'unpin'] + @status = + clicked: false + hovering: false + alreadyflagged: false + filtered: false + pinned: false + hidden: false + zoomhold: false + + @venueresult.listeners.add 'merged', (newvenue) => @displayMerged(newvenue) + @venueresult.listeners.add 'gone', => @displayGone() + @venueresult.listeners.add 'fullvenuecomplete', (oldvenuedata) => @displayFullVenue(oldvenuedata) + @venueresult.listeners.add 'pulling-statuschanged', (statuses) => @displayPullStatus(statuses) + @venueresult.listeners.add 'markedflagged', (flag) => @markFlagged(flag) + @venueresult.listeners.add 'unmarkedflagged', (flag) => @undoMarkedFlagged(flag) + @venueresult.listeners.add 'pulling-foursweep-done', () => @updateFlaggedStatus() + + @venueresult.listeners.add 'pulling-flags-done', () => + @elem.find(".pendingflagscontainer").html(Handlebars.partials["venues/parts/_pendingflagscount"]({flags: @venueresult.majorFlags()})) + + @venueresult.listeners.add 'pulling-edits-done', () => + @elem.find(".majoreditcontainer").html Handlebars.partials["venues/parts/_majoreditdate"] + venue: @venueresult.venuedata + majorEdits: @venueresult.majorEdits() + @elem.find('.addressrow').html Handlebars.partials["venues/parts/_addressrow"] + venue: @venueresult.venuedata + created: @venueresult.created + children: @venueresult.topChildren(0) + + @venueresult.listeners.add 'pulling-children-done', () => + @elem.find('.addressrow').html Handlebars.partials["venues/parts/_addressrow"] + venue: @venueresult.venuedata + created: @venueresult.created + children: @venueresult.topChildren(0) + + @venueresult.listeners.add 'pulling-full-done', () => + @elem.find(".editdetails").toggleClass('disabled', @venueresult.pulling.full != 'done') + + applyFilters: (filters, toggles, map) -> + before = @status.filtered + @status.filtered = !@venueresult.matchesAllFilter(filters) + @updateClasses if before != @status.filtered + @toggleVisibilityByStatuses map, toggles + + createPinnedVersion: () -> + @status.pinned = true + + vre = new VenueResultElement(@venueresult) + vre.status = $.extend {}, @status + + vre.listeners = $.extend true, {}, @listeners + vre.marker = @marker + + @listeners.add 'selected unselected', (e) => + vre.status.clicked = @status.clicked + vre.updateClasses() + + vre.listeners.add 'selected unselected', (e) => + @status.clicked = vre.status.clicked + @updateClasses() + + @listeners.add "unpin", (e) => + vre.status.pinned = false + vre.listeners.notify "unpin" + vre.updateClasses() + + vre.listeners.add "unpin", (e) => + @status.pinned = false + @elem.find(".pinVenue").removeClass('active') + @updateClasses() + + return vre + + compareTo: (other, type) -> + @venueresult.compareTo(other.venueresult, type) + + displayCategories: () -> + # Hide category popovers before replacing them so they aren't orphaned + @elem.find(".categories .open-popover").popover('hide') + @elem.find('.categories').html(Handlebars.partials["venues/parts/_categories"]({categories: @venueresult.categories()})) + + displayFullVenue: (oldvenuedata) -> + @updateClasses() + + # FIXME: Add radius circles + context = + venue: @venueresult.venuedata + distance: @venueresult.distance() + status: @status + + @elem.find('.namerow').html(Handlebars.partials["venues/parts/_namerow"](context)) + @elem.find('.addressrow').html(Handlebars.partials["venues/parts/_addressrow"](context)) + @elem.find('.stats').html(Handlebars.partials["venues/parts/_statsrow"](context)) + + @displayCategories() + + if oldvenuedata.location.lat != @venueresult.venuedata.location.lat or oldvenuedata.location.lng != @venueresult.venuedata.location.lng + @marker.setPosition(@venueresult.position()) + + # only do this if venue.closed, venue.deleted, venue.private changed + if oldvenuedata.private? != @venueresult.venuedata.private? || + oldvenuedata.deleted? != @venueresult.venuedata.deleted? || + oldvenuedata.closed? != @venueresult.venuedata.closed? + @elem.find('.venueactionscontainer').html(Handlebars.partials["venues/parts/_venueactions"](context)) + + if oldvenuedata.categories[0]?.id != @venueresult.venuedata.categories[0]?.id + @elem.find(".category-icon").html(Handlebars.partials["venues/parts/_categoryicon"](context)) + @updateIcon() + + @elem.find(".namerow [rel=tooltip]").tooltip() + + displayGone: -> + @toggleHover(false) + @showMarker(null) + @elem.find('.namerow').html(Handlebars.partials["venues/parts/_namerow"](status: @status, venue: @venueresult.venuedata)) + @elem.find(".info .details").html(Handlebars.partials["venues/parts/_gone"]({venue: @venueresult.venuedata})) + @elem.find(".open-popover").popover('hide') + @updateClasses() + + + displayMerged: (newvenue) -> + @toggleHover(false) + @showMarker(null) # Marker needs to be removed + @elem.find('.namerow').html(Handlebars.partials["venues/parts/_namerow"](status: @status, venue: @venueresult.venuedata)) + @elem.find(".info .details").html(Handlebars.partials["venues/parts/_merged"](newvenue: newvenue, venue: @venueresult.venuedata)) + @elem.find(".open-popover").popover('hide') + @updateClasses() + + displayPullStatus: (pullStatuses) -> + allstatuses = (s for own k,s of pullStatuses) + if 'failed' in allstatuses and 'pulling' not in allstatuses + @elem?.find(".refreshEverything").removeClass('btn-info').addClass('btn-warning') + @elem?.find(".refreshEverything i").tooltip + trigger: 'manual' + placement: 'bottom' + title: "Failed to load some additional venue data. Click this button to try again." + .tooltip("show") + window.setTimeout( (() => @elem?.find(".refreshEverything i").tooltip('hide')), 4500) + else + @elem?.find(".refreshEverything").addClass('btn-info').removeClass('btn-warning') + + @elem?.find(".refreshEverything i").toggleClass 'animate-spin', 'pulling' in allstatuses + + hide: () -> + return if @status.hidden + @elem.hide() + @status.hidden = true + @listeners.notify "hidden" + @marker.setMap(null) + if @status.clicked and !@status.pinned + @status.reselectOnUnhide = true + @toggleSelection(false) + + # type is a string that can be any of: + # "default": a normal gray icon + # "alreadyflagged": grayed out icon + # "hovering": green icon indicating venue is being hovered over + # "clicked": orange icon indicating venue has been selected + iconUrl: (type = "default") -> + url = if @venueresult.venuedata.categories.length > 0 + @venueresult.venuedata.categories[0].icon.prefix.replace(/^.*\/img\/categories_v2\//, "https://s3.amazonaws.com/4sweep-assets/") + "32_bordered.png" # REPLACE_ME + else + "https://s3.amazonaws.com/4sweep-assets/none_32_bordered.png" # REPLACE_ME + + typestr = switch type + when "default" then "bordered" + when "clicked" then "orange" + when "hovering" then "green" + when "alreadyflagged" then "faded" + else throw("Don't know type #{type}") + + url.replace(/32_[a-z]+.png/, "32_#{typestr}.png") + + isHidden: () -> + @status['hidden'] + + isVisible: () -> + @elem.is(":visible") + + markFlagged: (flag) -> + @updateFlaggedStatus() + @toggleSelection() if @status.clicked + @displayCategories() + @setupFlagsPopover() + + remove: () -> + @elem.find(".open-popover").popover('hide') + @elem.remove() + @showMarker(null) unless @status.pinned + # FIXME: remove venue radius circles if present + + render: () -> + @elem = $ HandlebarsTemplates['venues/venue_item'] + venue: @venueresult.venuedata + status: @status + distance: @venueresult.distance() + flags: @venueresult.majorFlags() + majorEdits: @venueresult.majorEdits() + created: @venueresult.created + children: @venueresult.topChildren(0) + pulling: @venueresult.pulling + categories: @venueresult.categories() + + self = this + @elem.on 'click', (e) -> + return if self.elem.find("a").children().is($(e.target)) or self.elem.find("a").is($(e.target)) or + self.elem.find(".full_category").is($(e.target)) # Ignore even if happened on a link + self.toggleSelection() + if e.shiftKey + self.listeners.notify "multiselectionrequested", self + self.listeners.notify "clicked", self + + @elem.hover( (() => @toggleHover(true)), (() => @toggleHover(false))) + + @elem.on "click", "a.flag", (e) -> + e.preventDefault() + flag = self.venueresult.createFlag($(this).data('flagtype'), {problem: $(this).data('problem')}) + FlagSubmissionService.get().submitFlags([flag], new VenueSubmitListener(self)) + + @elem.on "click", ".refreshEverything", (e) -> + e.preventDefault() + self.elem.find('.refreshEverything i').tooltip('hide') + self.venueresult.refreshEverything(true) + + @elem.on "click", ".pinVenue", (e) => + e.preventDefault() + if @status.pinned + @status.pinned = false + @listeners.notify "unpin" + @elem.find(".pinVenue").removeClass("active") + else + toPin = @createPinnedVersion() + @listeners.notify "pin", toPin + @elem.find(".pinVenue").addClass("active") + @updateClasses() + + @setupHoverPopover + attachselector: '.photocountcontainer' + hoverselector: '.photocount.hasphotos' + content: () => HandlebarsTemplates['venues/venue_photos_preview']({photos: self.venueresult.photos()[0..6]}) + title: () => "Top photos at #{self.venueresult.venuedata.name} (click to edit)" + arrow: true + runAfterHover: (e) => + $(e.target).data('popover').tip().find("img").on('load', () => BootstrapUtils.repositionPopover($(e.target).data('popover'))) + + @setupHoverPopover + attachselector: ".tipcountcontainer" + hoverselector: ".tipscount.hastips" + content: () => HandlebarsTemplates['venues/venue_tips_preview']({tips: self.venueresult.tips()[0..6]}) + title: () => "Popular Tips at #{self.venueresult.venuedata.name} (click to edit)" + + @setupHoverPopover + attachselector: ".listedcountcontainer" + hoverselector: ".listcount.islisted" + content: () => HandlebarsTemplates['venues/venue_listed_preview']({lists: self.venueresult.venuedata.listed}) + title: () => "Lists that include #{self.venueresult.venuedata.name}" + + @setupHoverPopover + attachselector: ".majoreditcontainer" + hoverselector: ".lasteditdate" + content: () => HandlebarsTemplates['venues/edit_history']({venue: @venueresult.venuedata, edits: @venueresult.editHistory[0...5], editsCount: @venueresult.knownEditCount}) + title: () => "Recent Edits at #{self.venueresult.venuedata.name} (click for more)" + arrow: false + widthClass: 'superduperwide' + + @setupHoverPopover + attachselector: ".pendingflagscontainer" + hoverselector: ".pendingflagcount" + content: () => HandlebarsTemplates['venues/pending_flags'] + flags: @venueresult.majorFlags() + flagsCount: @venueresult.pendingFlagCount + venue: @venueresult.venuedata + hasOldMajorFlags: @venueresult.hasOldMajorFlags() + title: () => HandlebarsTemplates['venues/pending_flags_title']({venue: @venueresult.venuedata}) + arrow: true + clicktokeep: true + + @setupHoverPopover + attachselector: '.facebooklinkcontainer' + hoverselector: ".facebooklink" + content: () => HandlebarsTemplates['venues/facebook_details'] + venue: @venueresult.venuedata + facebook: @venueresult.facebookDetails + title: () => HandlebarsTemplates['venues/facebook_popover_title'] + venue: @venueresult.venuedata + arrow: true + widthClass: "superduperwide" + clicktokeep: true + runAfterHover: (e) => @venueresult.getFacebookData + success: => + popover = $(e.target) + popover.data('popover').tip().find(".popover-content").html( + HandlebarsTemplates['venues/facebook_details']({venue: @venueresult.venuedata, facebook: @venueresult.facebookDetails}) + ) + BootstrapUtils.repositionPopover($(e.target).data('popover')) + popover.data('popover').tip().find(".popover-close").click (e) -> + e.preventDefault() + popover.popover('hide') + error: => + popover = $(e.target) + popover.data('popover').tip().find(".popover-content").html( + HandlebarsTemplates['venues/facebookload_failed']() + ) + popover.data('popover').tip().find(".popover-close").click (e) -> + e.preventDefault() + popover.popover('hide') + + @elem.hoverIntent( + () => @venueresult.upgradeWithFullData(false), + () => + ) + + @setupDetailsPopovers() + @setupCategoryEditEvents() + new DetailsEditor(this, @elem.find(".editdetails")) + @setupFlagsPopover() + @setupZoom() + + @setupMarker() + @elem.hide() if @status.hidden + + @elem.find(".venuebuttons [rel=tooltip]").tooltip() + + @elem.on "click", ".photocount", (e) => + e.preventDefault() + photomodal = new VenuePhotoModal(@venueresult) + photomodal.show() + + @elem.on "click", ".tipscount", (e) => + e.preventDefault() + tipmodal = new VenueTipModal(@venueresult) + tipmodal.show() + @elem.find(".namerow [rel=tooltip]").tooltip() + + @elem + + setupDetailsPopovers: () -> + self = this + @setupHoverPopover + attachselector: ".descriptioncontainer" + hoverselector: ".foursquare-description.present" + content: () => HandlebarsTemplates['venues/details/description']({venue: @venueresult.venuedata}) + title: () => "Description of #{self.venueresult.venuedata.name}" + placement: 'bottom' + @setupHoverPopover + attachselector: ".hourscontainer" + hoverselector: ".foursquare-hours.present" + content: () => HandlebarsTemplates['venues/details/hours']({venue: @venueresult.venuedata}) + title: () => "Hours at #{self.venueresult.venuedata.name}" + placement: 'bottom' + @setupHoverPopover + attachselector: ".userscontainer" + hoverselector: ".foursquare-users.present" + content: () => HandlebarsTemplates['venues/details/users']({venue: @venueresult.venuedata}) + title: () => "People at #{self.venueresult.venuedata.name}" + placement: 'bottom' + @setupHoverPopover + attachselector: ".attributescontainer" + hoverselector: ".foursquare-attributes.present" + widthClass: "superduperwide" + content: () => HandlebarsTemplates['venues/details/attributes']({venue: @venueresult.venuedata, attributes: @venueresult.attributes}) + title: () => "Attributes at #{self.venueresult.venuedata.name}" + placement: 'bottom' + @setupHoverPopover + attachselector: ".createdcontainer" + hoverselector: ".foursquare-created.present" + # widthClass: "superduperwide" + content: () => HandlebarsTemplates['venues/details/created']({venue: @venueresult.venuedata, created: @venueresult.created}) + title: () => "Creator of #{self.venueresult.venuedata.name} (click for more venues created by this user)" + placement: 'bottom' + @setupHoverPopover + attachselector: ".childrencontainer" + hoverselector: ".foursquare-children.present" + widthClass: "superduperwide" + arrow: true + content: () => HandlebarsTemplates['venues/details/children']({venue: @venueresult.venuedata, children: @venueresult.topChildren(16)}) + title: () => "Places inside #{self.venueresult.venuedata.name}" + placement: 'right' + @setupHoverPopover + attachselector: ".chaincontainer" + hoverselector: ".foursquare-chain.present" + content: () => HandlebarsTemplates['search_extras/userextras']($.extend @venueresult.venuedata.page.user, {"storeId": @venueresult.venuedata.storeId}) + title: () => "Chain information for #{self.venueresult.venuedata.name} (click for more venues)" + placement: 'bottom' + + setupCategoryEditEvents: () -> + self = this + @elem.find(".categories").popover( + selector: ".full_category" + content: () -> + HandlebarsTemplates['venues/category_edit_popover']({venue: self.venueresult.venuedata, category_name: $(this).text(), category_primary: $(this).data('primary')}) + title: () -> + HandlebarsTemplates['venues/category_edit_popover_title']({category_name: $(this).text()}) + html: true + trigger: "click" + position: "right" + container: 'body' + ).on("shown", (e) -> + $(e.target).addClass("open-popover") + + $(".open-popover").not(e.target).popover('hide') + + popover = $(e.target).data('popover') + popover.tip().find(".close").click (click) -> popover.hide() + popover.tip().find(".flagbutton").click (click) -> + e.preventDefault() + return if $(this).hasClass('disabled') + + flag = self.venueresult.createFlag $(this).data('flagtype'), + itemId: $(e.target).data('catid') + itemName: $(e.target).text() + + FlagSubmissionService.get().submitFlags [flag], new VenueSubmitListener(self) + popover.hide() + + ).on("hidden", (e) -> + $(e.target).removeClass("open-popover") + ) + + setupFlagsPopover: () -> + (@flagPopover ||= new FlagsPopover(this, @elem.find(".foursweepflagsbutton"))).toggleShown() + + # Set up a popover on this element that happens via popover on a dynamic selector. + # It seems that some bootstrap bug I can't work around prevents trigger: "hover" with selector if + # that selector is dynamically added after attaching the popover + # + # options: + # 'attachselector' + # 'hoverselector' + # 'title' + # 'content' + # 'arrow' + # 'clicktokeep' + setupHoverPopover: (options) -> + arrowDiv = if options.arrow? then 'arrow' else '' + widthclass = if options.widthClass? then options.widthClass else 'superwide' + @elem.find(options.attachselector).click (e) -> + e.preventDefault() if ($(e.target).parents('a').attr('href') == '#') || ($(e.target).is("a") && $(e.target).attr('href') == '#') + + attachelem = @elem.find(options.attachselector) + attachelem.popover + trigger: "manual" # Hover doesn't work, we have to use a workaround + selector: options.hoverselector + html: true + title: options.title + content: options.content + placement: options.placement || 'right' + template: '

' + container: ".attach-widepopover" + .on "shown", (e) -> + e.stopPropagation() + $(e.target).addClass("open-popover") + popover = $(e.target).data('popover') + BootstrapUtils.repositionPopover(popover) + popover.tip().find('.popover-close').click (e) -> + e.preventDefault() + attachelem.popover('hide') + options.runAfterHover(e) if (options.runAfterHover) + .on "hidden", (e) => + e.stopPropagation() + $(e.target).removeClass('open-popover') + attachelem.data('openstate', '') + + if options.clicktokeep == true + attachelem.on "click", options.hoverselector, (e) => + if attachelem.data('openstate') == 'clicked' + attachelem.popover('hide') + else + attachelem.data('openstate', 'clicked') + attachelem.popover('show') unless attachelem.hasClass('open-popover') + attachelem.data('popover').tip().addClass('openstate-clicked').removeClass('openstate-hover') + BootstrapUtils.repositionPopover(attachelem.data('popover')) + + attachelem.on "mouseenter mouseleave", options.hoverselector, (e) => + if (attachelem.data('openstate') != 'clicked') + attachelem.popover(if (e.type == 'mouseenter') then 'show' else 'hide') + attachelem.data('openstate', if (e.type == 'mouseenter') then 'hover' else '') + attachelem.data('popover').tip().removeClass('openstate-clicked').addClass('openstate-hover') + + setupMarker: () -> + unless @marker + @marker = new google.maps.Marker + position: @venueresult.position() + icon: + anchor: new google.maps.Point(10,10) # Anchor in center of icon + size: new google.maps.Size(32,32) + scaledSize: new google.maps.Size(20,20) + url: @iconUrl("default") + title: @venueresult.venuedata.name + zIndex: 15 + draggable: false + clickable: true + + google.maps.event.addListener @marker, 'mouseover', => @elem.addClass("hoveronicon"); @toggleHover(true) + google.maps.event.addListener @marker, 'mouseout', => @elem.removeClass("hoveronicon"); @toggleHover(false) + + setupZoom: () -> + zoombutton = @elem.find(".zoomtovenue") + zoombutton.hoverIntent( + () => + @listeners.notify "requestzoomin" unless @status.zoomhold + @status.zoomHover = true + ,() => + @listeners.notify "requestzoomout" unless @status.zoomhold + @status.zoomHover = false + ) + + zoombutton.click (e) => + @status.zoomhold = !@status.zoomhold + zoombutton.toggleClass("active", @status.zoomhold) + @listeners.notify (if @status.zoomhold then "requestzoomin" else "requestzoomout") + + setZoomState: (zoomstate) -> + @status.zoomhold = zoomstate + @elem.find(".zoomtovenue").toggleClass('active', zoomstate) + + showMarker: (map) -> + if @venueresult.gone or @venueresult.merged + @marker.setMap(null) + else + @marker.setMap(map) + + toggleHover: (hoveringIn) -> + return if (@venueresult.venuedata.gone or @venueresult.venuedata.merged) and hoveringIn and !@status.hovering # Allow hoverout if necessary on merge + @status.hovering = hoveringIn + @elem.toggleClass("hovering", hoveringIn) + @updateIcon() + # @listeners.notify (if @status.hovering then 'hoverin' else 'hoverout'), this + + toggleSelection: (onOff) -> + if @venueresult.venuedata.merged or @venueresult.venuedata.gone + onOff = false + @status.clicked = if onOff == undefined then !@status.clicked else onOff + @elem.toggleClass("clicked", @status.clicked) + @updateIcon() + @listeners.notify (if @status.clicked then 'selected' else 'unselected'), this + + toggleVisibilityByStatuses: (map, toggles) -> + toggles = $.extend toggles, {filtered: false} # always hide filtered values + for own name, show of toggles when show is false and (@status[name] is true or @venueresult.venuedata[name] is true) + @hide() + return false + @unhide(map); true + + undoMarkedFlagged: (flag) -> + @updateFlaggedStatus() + @displayCategories() + @setupFlagsPopover() + + unhide: (map) -> + return unless @status.hidden + @elem?.show() + @status.hidden = false + @listeners.notify "unhidden" + @showMarker map + if @status.reselectOnUnhide + @toggleSelection(true) + delete @status.reselectOnUnhide + + updateClasses: () -> + @elem.attr('class', Handlebars.partials["venues/parts/_venueclasses"]({status: @status, venue: @venueresult.venuedata})) + + updateDistance: (newCenter) -> + distance = @venueresult.distanceFromPoint(newCenter) + @venueresult.updateDistance(distance) + @elem.find(".distance").text("[" + Math.round(distance).toLocaleString() + " m]") + + updateIcon: () -> + iconChoice = 'default' + + for s in ['clicked', 'hovering', 'alreadyflagged'] # Select icon in this order, if multiple are true + if @status[s] + iconChoice = s + break + + @marker.setIcon + anchor: new google.maps.Point(10,10) + url: @iconUrl(iconChoice) + size: new google.maps.Size(32,32) + scaledSize: new google.maps.Size(20,20) + + switch iconChoice + when 'hovering' then @marker.setZIndex(25) + when 'clicked' then @marker.setZIndex(20) + else @marker.setZIndex(15) + + updateFlaggedStatus: () -> + @status.alreadyflagged = (k for k of @venueresult.existingFoursweepFlags).length isnt 0 + @elem.toggleClass("alreadyflagged", @status.alreadyflagged, 500) + @updateIcon() + @displayCategories() + @setupFlagsPopover() + + +window.VenueResultElement = VenueResultElement diff --git a/app/assets/javascripts/session.js.coffee b/app/assets/javascripts/session.js.coffee new file mode 100644 index 0000000..7615679 --- /dev/null +++ b/app/assets/javascripts/session.js.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/ diff --git a/app/assets/javascripts/static_pages.js.coffee b/app/assets/javascripts/static_pages.js.coffee new file mode 100644 index 0000000..7615679 --- /dev/null +++ b/app/assets/javascripts/static_pages.js.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/ diff --git a/app/assets/javascripts/stats.js.coffee b/app/assets/javascripts/stats.js.coffee new file mode 100644 index 0000000..7615679 --- /dev/null +++ b/app/assets/javascripts/stats.js.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/ diff --git a/app/assets/javascripts/templates/explore/allresultsfiltered.hbs b/app/assets/javascripts/templates/explore/allresultsfiltered.hbs new file mode 100644 index 0000000..0b2759e --- /dev/null +++ b/app/assets/javascripts/templates/explore/allresultsfiltered.hbs @@ -0,0 +1,3 @@ +

+ Your search returned {{total}} results, but none are displayed due to your filters. +

diff --git a/app/assets/javascripts/templates/explore/confirm_box.hbs b/app/assets/javascripts/templates/explore/confirm_box.hbs new file mode 100644 index 0000000..2b3c462 --- /dev/null +++ b/app/assets/javascripts/templates/explore/confirm_box.hbs @@ -0,0 +1,51 @@ +{{#if compactView}} + {{#with top_flags.[0]}} +
{{../description}}: {{primaryName}} {{#if secondaryName}} / {{secondaryName}} {{/if}}
+ {{#if details}}{{nl2br details}}
{{/if}} + {{/with}} +{{else}} + +
{{description}}:
+ + + +{{#if has_remaining}} +

– And {{remaining_flags_count}} others –

+{{/if}} + +{{/if}} + +{{{run_text}}} (or run immediately) + {{#is objectType "venues"}} + [Reselect Venues] +
+ {{/is}} +
+ + diff --git a/app/assets/javascripts/templates/explore/confirm_title.hbs b/app/assets/javascripts/templates/explore/confirm_title.hbs new file mode 100644 index 0000000..9b5a358 --- /dev/null +++ b/app/assets/javascripts/templates/explore/confirm_title.hbs @@ -0,0 +1,3 @@ +Created {{total_count}} new {{#is total_count 1}}flag{{else}}flags{{/is}}: + + diff --git a/app/assets/javascripts/templates/explore/dupsearch_help.hbs b/app/assets/javascripts/templates/explore/dupsearch_help.hbs new file mode 100644 index 0000000..d4c70b3 --- /dev/null +++ b/app/assets/javascripts/templates/explore/dupsearch_help.hbs @@ -0,0 +1,24 @@ + diff --git a/app/assets/javascripts/templates/explore/known_size_pagination.hbs b/app/assets/javascripts/templates/explore/known_size_pagination.hbs new file mode 100644 index 0000000..d851762 --- /dev/null +++ b/app/assets/javascripts/templates/explore/known_size_pagination.hbs @@ -0,0 +1,13 @@ +{{#if totalItems}} + +{{/if}} diff --git a/app/assets/javascripts/templates/explore/load_more_button.hbs b/app/assets/javascripts/templates/explore/load_more_button.hbs new file mode 100644 index 0000000..4e2e403 --- /dev/null +++ b/app/assets/javascripts/templates/explore/load_more_button.hbs @@ -0,0 +1 @@ +Load More diff --git a/app/assets/javascripts/templates/explore/locationseditor.hbs b/app/assets/javascripts/templates/explore/locationseditor.hbs new file mode 100644 index 0000000..1ce3439 --- /dev/null +++ b/app/assets/javascripts/templates/explore/locationseditor.hbs @@ -0,0 +1,23 @@ + diff --git a/app/assets/javascripts/templates/explore/map_controls/fit_buttons.hbs b/app/assets/javascripts/templates/explore/map_controls/fit_buttons.hbs new file mode 100644 index 0000000..6fb1ca5 --- /dev/null +++ b/app/assets/javascripts/templates/explore/map_controls/fit_buttons.hbs @@ -0,0 +1,7 @@ +
+
+ Fit Map To: +
Venues Found
+
Search Location
+
+
diff --git a/app/assets/javascripts/templates/explore/map_controls/global.hbs b/app/assets/javascripts/templates/explore/map_controls/global.hbs new file mode 100644 index 0000000..6f0c8c5 --- /dev/null +++ b/app/assets/javascripts/templates/explore/map_controls/global.hbs @@ -0,0 +1 @@ +
Global
diff --git a/app/assets/javascripts/templates/explore/map_controls/near_button.hbs b/app/assets/javascripts/templates/explore/map_controls/near_button.hbs new file mode 100644 index 0000000..4af277a --- /dev/null +++ b/app/assets/javascripts/templates/explore/map_controls/near_button.hbs @@ -0,0 +1,11 @@ +
+
+ Near + + + + + + +
+
diff --git a/app/assets/javascripts/templates/explore/map_controls/radius_dropdown.hbs b/app/assets/javascripts/templates/explore/map_controls/radius_dropdown.hbs new file mode 100644 index 0000000..32d3bd3 --- /dev/null +++ b/app/assets/javascripts/templates/explore/map_controls/radius_dropdown.hbs @@ -0,0 +1,21 @@ +
+
+ +
+
diff --git a/app/assets/javascripts/templates/explore/massflags/_merge_distance_warning.hbs b/app/assets/javascripts/templates/explore/massflags/_merge_distance_warning.hbs new file mode 100644 index 0000000..31ee7e5 --- /dev/null +++ b/app/assets/javascripts/templates/explore/massflags/_merge_distance_warning.hbs @@ -0,0 +1,8 @@ +{{#is venueCount '>=' 2}} +

+ You are merging {{venueCount}} venues that are {{#is venueCount '>' 2}}at most {{/is}}{{num maxDistance}}m + away from {{#is venueCount '>' 2}}the most popular venue{{else}}each other{{/is}}. +

+{{else}} +

Please select 2 or more venues to merge

+{{/is}} diff --git a/app/assets/javascripts/templates/explore/massflags/about_recategorize.hbs b/app/assets/javascripts/templates/explore/massflags/about_recategorize.hbs new file mode 100644 index 0000000..a125cab --- /dev/null +++ b/app/assets/javascripts/templates/explore/massflags/about_recategorize.hbs @@ -0,0 +1,15 @@ +
+
Replace All
+
Set this venue's category to the selected category and remove all other existing categories.
+
Make Primary
+
Make the selected category the primary category of the venue. If it is not already a category, it will be added.
+
Add
+
Add the selected category to the venue. If the venue is currently uncategorized, it will become the primary category. Otherwise, it will become a secondary category for the venue.
+
Remove
+
Remove the selected category from the venue, if it is present.
+
+ +
+ Warning: If you create multiple category flags for a venue, they may not run in the order you intended. For example, if you "Replace All" categories with one category and then "Add" a different category, the second category might be removed by the "Replace All" flag. +
+ diff --git a/app/assets/javascripts/templates/explore/massflags/addlist_confirm.hbs b/app/assets/javascripts/templates/explore/massflags/addlist_confirm.hbs new file mode 100644 index 0000000..e62b069 --- /dev/null +++ b/app/assets/javascripts/templates/explore/massflags/addlist_confirm.hbs @@ -0,0 +1,6 @@ +{{#if success}} +Added {{venue.name}} to list {{list.name}} +{{else}} +Failed to add {{venue.name}} to list {{list.name}} +This often means the venue was already on the list. +{{/if}} diff --git a/app/assets/javascripts/templates/explore/massflags/close.hbs b/app/assets/javascripts/templates/explore/massflags/close.hbs new file mode 100644 index 0000000..8ae5f1e --- /dev/null +++ b/app/assets/javascripts/templates/explore/massflags/close.hbs @@ -0,0 +1,24 @@ +

Use this flag for places that have permanently closed or temporary events that are now over. You can also schedule venues to be closed at a future date.

+
+
+ + +
+ +
+
+
+ When?: +
+ + +
+

+
+ Comment:
+ + +
+ + +
diff --git a/app/assets/javascripts/templates/explore/massflags/confirm_modal.hbs b/app/assets/javascripts/templates/explore/massflags/confirm_modal.hbs new file mode 100644 index 0000000..567cc18 --- /dev/null +++ b/app/assets/javascripts/templates/explore/massflags/confirm_modal.hbs @@ -0,0 +1,15 @@ + diff --git a/app/assets/javascripts/templates/explore/massflags/export.hbs b/app/assets/javascripts/templates/explore/massflags/export.hbs new file mode 100644 index 0000000..8225bfc --- /dev/null +++ b/app/assets/javascripts/templates/explore/massflags/export.hbs @@ -0,0 +1,39 @@ +
Add to a Foursquare list:
+ +
+ Loading lists from Foursquare… +
+ + + +
+ +

+
+ +
+
+
Export to Elio Tools
+

Elio tools is available at http://4sq.neuralab.cc/. It is a Portuguese-only (use a translation browser plugin if you need to) tool for mass editing venue details.

+
+ +
+ +
+
Search for Duplicates:
+ +
+ + +
+ + +
+
Export to a file:
+ +
+ + +
+ +
diff --git a/app/assets/javascripts/templates/explore/massflags/makehome.hbs b/app/assets/javascripts/templates/explore/massflags/makehome.hbs new file mode 100644 index 0000000..451e409 --- /dev/null +++ b/app/assets/javascripts/templates/explore/massflags/makehome.hbs @@ -0,0 +1,12 @@ +

Use this flag for private homes.

+
+

Add a comment for reviewers? » +

+
+ Comment:
+ +
+
+ + +
diff --git a/app/assets/javascripts/templates/explore/massflags/makeprivate.hbs b/app/assets/javascripts/templates/explore/massflags/makeprivate.hbs new file mode 100644 index 0000000..8782919 --- /dev/null +++ b/app/assets/javascripts/templates/explore/massflags/makeprivate.hbs @@ -0,0 +1,12 @@ +

Use this flag for places that are not open to the public or are relevant to only a small group of people.

+
+

Add a comment for reviewers? » +

+
+ Comment:
+ +
+
+ + +
diff --git a/app/assets/javascripts/templates/explore/massflags/merge.hbs b/app/assets/javascripts/templates/explore/massflags/merge.hbs new file mode 100644 index 0000000..04f506c --- /dev/null +++ b/app/assets/javascripts/templates/explore/massflags/merge.hbs @@ -0,0 +1,14 @@ +

Do the selected venues refer to the same place? (Do not merge subvenues into a main venue, please!)

+
+

Add a comment for reviewers? » +

+ +
{{>explore/massflags/_merge_distance_warning}}
+
+ Comment:
+ +
+
+ + +
diff --git a/app/assets/javascripts/templates/explore/massflags/recategorize.hbs b/app/assets/javascripts/templates/explore/massflags/recategorize.hbs new file mode 100644 index 0000000..13a6af0 --- /dev/null +++ b/app/assets/javascripts/templates/explore/massflags/recategorize.hbs @@ -0,0 +1,21 @@ +
+ + +

+
+
+ +

Add a comment for reviewers? » +

+
+ Comment:
+ +
+
+ + + + + + +
diff --git a/app/assets/javascripts/templates/explore/massflags/removevenue.hbs b/app/assets/javascripts/templates/explore/massflags/removevenue.hbs new file mode 100644 index 0000000..0c34d04 --- /dev/null +++ b/app/assets/javascripts/templates/explore/massflags/removevenue.hbs @@ -0,0 +1,13 @@ +

Use this flag for offensive venues and venues that should be deleted from Foursquare entirely. If a venue isn't hurting anyone, consider flagging it private instead.

+ +

Please use this flag sparingly: Remember that your edits affect people's check-in history and experience.

+
+
+ Comment:
+ +
+
+ + + +
diff --git a/app/assets/javascripts/templates/explore/no_venues_found.hbs b/app/assets/javascripts/templates/explore/no_venues_found.hbs new file mode 100644 index 0000000..b2e9373 --- /dev/null +++ b/app/assets/javascripts/templates/explore/no_venues_found.hbs @@ -0,0 +1,3 @@ +

+ No venues found for your search. Try expanding the search area or changing your search terms. +

diff --git a/app/assets/javascripts/templates/explore/pagesearch_results.hbs b/app/assets/javascripts/templates/explore/pagesearch_results.hbs new file mode 100644 index 0000000..3a8d393 --- /dev/null +++ b/app/assets/javascripts/templates/explore/pagesearch_results.hbs @@ -0,0 +1,51 @@ + diff --git a/app/assets/javascripts/templates/explore/recently_used_categories.hbs b/app/assets/javascripts/templates/explore/recently_used_categories.hbs new file mode 100644 index 0000000..622c8a1 --- /dev/null +++ b/app/assets/javascripts/templates/explore/recently_used_categories.hbs @@ -0,0 +1,6 @@ +{{#if recent}}Other recently selected:{{/if}} + diff --git a/app/assets/javascripts/templates/explore/searchstats.hbs b/app/assets/javascripts/templates/explore/searchstats.hbs new file mode 100644 index 0000000..73cf7c1 --- /dev/null +++ b/app/assets/javascripts/templates/explore/searchstats.hbs @@ -0,0 +1,40 @@ +— {{stats.displayed}} place(s) displayed +{{#if suppressplaces}} + ( +{{#if stats.filtered}} +{{stats.filtered}} place(s) filtered +{{/if}} +{{!-- No easy way to iterate through fields, sadly --}} +{{#if stats.home}} + +{{stats.home}} home(s) + {{#if toggles.home}}shown{{else}}hidden{{/if}} + +{{/if}} +{{#if stats.private}} + +{{stats.private}} private place(s) + {{#if toggles.private}}shown{{else}}hidden{{/if}} + +{{/if}} +{{#if stats.closed}} + +{{stats.closed}} closed place(s) + {{#if toggles.closed}}shown{{else}}hidden{{/if}} + +{{/if}} +{{#if stats.deleted}} + +{{stats.deleted}} deleted place(s) + {{#if toggles.deleted}}shown{{else}}hidden{{/if}} + +{{/if}} +{{#if stats.alreadyflagged}} + +{{stats.alreadyflagged}} already flagged place(s) + {{#if toggles.alreadyflagged}}shown{{else}}hidden{{/if}} + +{{/if}} +) +{{/if}} + — diff --git a/app/assets/javascripts/templates/explore/too_big_warning.hbs b/app/assets/javascripts/templates/explore/too_big_warning.hbs new file mode 100644 index 0000000..014a757 --- /dev/null +++ b/app/assets/javascripts/templates/explore/too_big_warning.hbs @@ -0,0 +1,5 @@ +
+
Search Area Too Big
+

Foursquare can't search an area this big. Would you like to split your search into smaller areas?

+
+
diff --git a/app/assets/javascripts/templates/explore/unknown_size_pagination.hbs b/app/assets/javascripts/templates/explore/unknown_size_pagination.hbs new file mode 100644 index 0000000..d775f10 --- /dev/null +++ b/app/assets/javascripts/templates/explore/unknown_size_pagination.hbs @@ -0,0 +1,6 @@ +{{#if displayPagination}} + +{{/if}} diff --git a/app/assets/javascripts/templates/explore/venue_load_error.hbs b/app/assets/javascripts/templates/explore/venue_load_error.hbs new file mode 100644 index 0000000..43b2db5 --- /dev/null +++ b/app/assets/javascripts/templates/explore/venue_load_error.hbs @@ -0,0 +1,13 @@ +
+

{{errorText}}

+ + {{#if errorDetails}} + {{errorDetails}} + {{/if}} + + {{#if retryable}} +
+ Retry +
+ {{/if}} +
diff --git a/app/assets/javascripts/templates/filters/_operand.hbs b/app/assets/javascripts/templates/filters/_operand.hbs new file mode 100644 index 0000000..fa05f27 --- /dev/null +++ b/app/assets/javascripts/templates/filters/_operand.hbs @@ -0,0 +1,23 @@ +{{#is arity 1}} +   +{{else}} + + {{#is type "numeric"}} + + {{/is}} + {{#is type "duration"}} + + + {{/is}} + {{#is type "text"}} + + {{/is}} + +{{/is}} diff --git a/app/assets/javascripts/templates/filters/_operatorselect.hbs b/app/assets/javascripts/templates/filters/_operatorselect.hbs new file mode 100644 index 0000000..54311ff --- /dev/null +++ b/app/assets/javascripts/templates/filters/_operatorselect.hbs @@ -0,0 +1,34 @@ +{{#is type "bool"}} + +{{/is}} +{{#isin type "numeric" "duration"}} + +{{/isin}} +{{#is type "text"}} + +{{/is}} diff --git a/app/assets/javascripts/templates/filters/about_filters.hbs b/app/assets/javascripts/templates/filters/about_filters.hbs new file mode 100644 index 0000000..f79813d --- /dev/null +++ b/app/assets/javascripts/templates/filters/about_filters.hbs @@ -0,0 +1,20 @@ +

Filters help you narrow down the results of an existing search.

+ +

First, search for a keyword, category, and area in the search bar above the results. +Next, specify a filter here to narrow your results. Type a filter or use the Edit +button to construct it from a drop-down.

+

Tips:

+ +

Examples:

+ diff --git a/app/assets/javascripts/templates/filters/edit_filters.hbs b/app/assets/javascripts/templates/filters/edit_filters.hbs new file mode 100644 index 0000000..8063bb0 --- /dev/null +++ b/app/assets/javascripts/templates/filters/edit_filters.hbs @@ -0,0 +1,20 @@ +
+
+ + + + + + + + + + + + +
 
+ +
+
+
+ diff --git a/app/assets/javascripts/templates/filters/filterrow.hbs b/app/assets/javascripts/templates/filters/filterrow.hbs new file mode 100644 index 0000000..35bb0b6 --- /dev/null +++ b/app/assets/javascripts/templates/filters/filterrow.hbs @@ -0,0 +1,67 @@ +{{#with filter}} + + + + + + {{>filters/_operatorselect}} + + + {{>filters/_operand}} + + + +{{/with}} diff --git a/app/assets/javascripts/templates/items/retry_placeholder.hbs b/app/assets/javascripts/templates/items/retry_placeholder.hbs new file mode 100644 index 0000000..2021647 --- /dev/null +++ b/app/assets/javascripts/templates/items/retry_placeholder.hbs @@ -0,0 +1,8 @@ +
+

{{errorText}}

+ +
+
+
+
+
diff --git a/app/assets/javascripts/templates/photos/actions.hbs b/app/assets/javascripts/templates/photos/actions.hbs new file mode 100644 index 0000000..029abff --- /dev/null +++ b/app/assets/javascripts/templates/photos/actions.hbs @@ -0,0 +1,5 @@ +{{{problem_description}}} + +

+ +
diff --git a/app/assets/javascripts/templates/photos/grid.hbs b/app/assets/javascripts/templates/photos/grid.hbs new file mode 100644 index 0000000..aed4f13 --- /dev/null +++ b/app/assets/javascripts/templates/photos/grid.hbs @@ -0,0 +1,12 @@ +
+ +
+ + Loading Photos

+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/templates/photos/modal.hbs b/app/assets/javascripts/templates/photos/modal.hbs new file mode 100644 index 0000000..07b7cf9 --- /dev/null +++ b/app/assets/javascripts/templates/photos/modal.hbs @@ -0,0 +1,23 @@ + + diff --git a/app/assets/javascripts/templates/photos/modal_header.hbs b/app/assets/javascripts/templates/photos/modal_header.hbs new file mode 100644 index 0000000..9178135 --- /dev/null +++ b/app/assets/javascripts/templates/photos/modal_header.hbs @@ -0,0 +1,55 @@ + +{{#is sourceType "venue"}} +

Review photos at {{source.venuedata.name}}

+{{/is}} +{{#is sourceType "user"}} +

Review photos by {{source.user.firstName}} {{source.user.lastName}}

+{{/is}} + +
+Flag 0 selected photos for removal: + + Spam / Scam + Is this photo spammy or scammy? Spam photos include unrelated promotional content. + + + Nudity + Is there nudity in this photo? + + + Hate / Violence + Does this photo depict hate or violence? + + + Illegal + Is this photo illegal? + + + Blurry + Is this photo blurry or too low quality to be useful? + + + + Unrelated + + + Unrelated photos include: +
    +
  • Photos not of the venue or not relevant to the venue
  • +
  • Photos of people and pets, unless they help to get a sense of the venue
  • +
  • Selfies
  • +
  • Screenshots and memes
  • +
+
+
+
+ +{{#is sourceType "user"}} +
+ Photos Loaded | + at deleted venues ({{#if statusVisibility.deleted}}shown{{else}}hidden{{/if}}) | + at homes ({{#if statusVisibility.home}}shown{{else}}hidden{{/if}}) | + at private venues ({{#if statusVisibility.private}}shown{{else}}hidden{{/if}}) | + at closed venues ({{#if statusVisibility.closed}}shown{{else}}hidden{{/if}}) +
+{{/is}} diff --git a/app/assets/javascripts/templates/photos/photos.hbs b/app/assets/javascripts/templates/photos/photos.hbs new file mode 100644 index 0000000..293a505 --- /dev/null +++ b/app/assets/javascripts/templates/photos/photos.hbs @@ -0,0 +1,57 @@ +{{#each items.items}} + +
+ + + + + +
+ {{#if venue}} + {{#if venue.categories.[0]}} + {{venue.categories.[0].name}} + {{else}} + Uncategorized Venue + {{/if}} + {{/if}} + {{#if user}} + + {{/if}} + {{#if user}} + – {{user.firstName}} {{user.lastName}} + {{/if}} + {{#if venue}} + At {{venue.name}} + {{#if venue.locked}} + + {{/if}} + {{#if venue.verified}} + + {{/if}} + {{#if venue.private}} + + {{/if}} +
+ {{#if venue.categories.[0]}} + + {{venue.categories.[0].name}} + + {{else}}Uncategorized Venue + {{/if}} in {{#location venue.location}}{{/location}}{{#if venue.private}} (Private){{/if}}{{#if venue.locked}} (Locked){{/if}}{{#if venue.closed}} (Closed){{/if}}{{/if}} +
{{#moment createdAt}}{{/moment}} via {{source.name}}
+ +
+
+
+{{/each}} + +
+ All Photos Displayed

(Your own photos may not be displayed) +
+
+ Loading More Photos

+
+
+
+
+
diff --git a/app/assets/javascripts/templates/photos/sort.hbs b/app/assets/javascripts/templates/photos/sort.hbs new file mode 100644 index 0000000..98bb412 --- /dev/null +++ b/app/assets/javascripts/templates/photos/sort.hbs @@ -0,0 +1,5 @@ +Sort Photos By: + diff --git a/app/assets/javascripts/templates/photos/zoommodal.hbs b/app/assets/javascripts/templates/photos/zoommodal.hbs new file mode 100644 index 0000000..6c15f4d --- /dev/null +++ b/app/assets/javascripts/templates/photos/zoommodal.hbs @@ -0,0 +1,36 @@ + +
+{{#with photo}} + {{#if venue}} + {{#if venue.categories.[0]}} + {{venue.categories.[0].name}} + {{else}} + Uncategorized Venue + {{/if}} + {{/if}} + {{#if user}} + + {{/if}} + {{#if user}} + by {{user.firstName}} {{user.lastName}} + {{/if}} + {{#if venue}} + At {{venue.name}}
+ {{#if venue.categories.[0]}} + + {{venue.categories.[0].name}} + + {{else}}Uncategorized Venue + {{/if}} in {{#location venue.location}}{{/location}}{{#if venue.private}} (Private){{/if}}{{#if venue.closed}} (Closed){{/if}}{{/if}}
{{#moment createdAt}}{{/moment}} via {{source.name}} +{{/with}} +{{#if tip}} +
+ Tip: +

{{tip.text}}

+
+{{/if}} +
+ +
+ +
diff --git a/app/assets/javascripts/templates/search_extras/extraserror.hbs b/app/assets/javascripts/templates/search_extras/extraserror.hbs new file mode 100644 index 0000000..535ecf5 --- /dev/null +++ b/app/assets/javascripts/templates/search_extras/extraserror.hbs @@ -0,0 +1,3 @@ +
+ Could not load additional search details. +
diff --git a/app/assets/javascripts/templates/search_extras/listextras.hbs b/app/assets/javascripts/templates/search_extras/listextras.hbs new file mode 100644 index 0000000..bf048ac --- /dev/null +++ b/app/assets/javascripts/templates/search_extras/listextras.hbs @@ -0,0 +1,29 @@ +
+ {{#if photo}} +
+ Photo by {{photo.user.firstName}} {{photo.user.lastName}} +
+ {{/if}} +
+
{{name}} by {{user.firstName}} {{user.lastName}}
+
+
Venues
+
{{num venueCount}} ({{num visitedCount}} visited)
+ + {{#if saves.summary}} +
Saves
+
{{num saves.summary}}
+ {{/if}} + {{#if createdAt}} +
Created
+
{{moment createdAt}} ({{moment-ago createdAt}})
+ {{/if}} + {{#if updatedAt}} +
Updated
+
{{moment updatedAt}} ({{moment-ago updatedAt}})
+ {{/if}} +
+
+
+ {{#if description}}

{{description}}

{{/if}} +
diff --git a/app/assets/javascripts/templates/search_extras/userextras.hbs b/app/assets/javascripts/templates/search_extras/userextras.hbs new file mode 100644 index 0000000..44d8555 --- /dev/null +++ b/app/assets/javascripts/templates/search_extras/userextras.hbs @@ -0,0 +1,48 @@ +
+ + + + +
{{firstName}} {{lastName}} + {{#is type 'chain'}}(Chain){{/is}} + {{#is type 'celebrity'}}(Celebrity){{/is}} + {{#is type 'venuePage'}}(Venue Page){{/is}} + {{#is type 'page'}}(Page){{/is}} + {{#if superuser}}(SU{{superuser}}){{/if}} +
+ {{#if homeCity}}{{homeCity}}
{{/if}} + + {{#is relationship 'self'}}This is your profile
{{/is}} + {{#is relationship 'friend'}}You are friends with {{firstName}} {{lastName}}
{{/is}} + {{#is relationship 'pendingMe'}}You have a pending friend request from {{firstName}} {{lastName}}
{{/is}} + {{#is relationship 'pendingThem'}}You have sent {{firstName}} {{lastName}} a friend request
{{/is}} + {{#is relationship 'followingThem'}}You are following {{firstName}} {{lastName}}
{{/is}} +
+ {{#if contact.facebook}} Facebook{{/if}} + {{#if contact.twitter}} @{{contact.twitter}}{{/if}} +
+ {{#if pageInfo.links.count}}{{pageInfo.links.items.0.url}}
{{/if}} + +
+ + {{#if photos.count}}{{num photos.count}} photos {{#if interactive}}Edit{{/if}}{{/if}} + {{#if tips.count}}{{num tips.count}} tips {{#if interactive}}Edit{{/if}}{{/if}} +
+ {{#if checkins.count}}{{num checkins.count}} checkins
{{/if}} + {{#if followers.count}}{{num followers.count}} followers
{{/if}} + {{#if friends.count}}{{num friends.count}} friends
{{/if}} + {{#if venuelikes.venues.count}}{{num venuelikes.venues.count}} venues liked
{{/if}} + {{#if listCounts}}Lists: {{num listCounts.created}} created, {{num listCounts.followed}} followed{{/if}} +
+
+ + {{#if pageInfo.description}} +

{{pageInfo.description}}

+ {{else}} + {{#if bio}} +

{{bio}}

+ {{/if}} + {{/if}} + +
+{{#if storeId}}

Store ID: {{storeId}}

{{/if}} diff --git a/app/assets/javascripts/templates/tips/actions.hbs b/app/assets/javascripts/templates/tips/actions.hbs new file mode 100644 index 0000000..8c58e04 --- /dev/null +++ b/app/assets/javascripts/templates/tips/actions.hbs @@ -0,0 +1,5 @@ +{{{problem_description}}} + +

+ +
diff --git a/app/assets/javascripts/templates/tips/grid.hbs b/app/assets/javascripts/templates/tips/grid.hbs new file mode 100644 index 0000000..c38a59f --- /dev/null +++ b/app/assets/javascripts/templates/tips/grid.hbs @@ -0,0 +1,8 @@ +
+ + Loading Tips

+
+
+
+
+
diff --git a/app/assets/javascripts/templates/tips/modal.hbs b/app/assets/javascripts/templates/tips/modal.hbs new file mode 100644 index 0000000..12168cd --- /dev/null +++ b/app/assets/javascripts/templates/tips/modal.hbs @@ -0,0 +1,18 @@ + + diff --git a/app/assets/javascripts/templates/tips/modal_header.hbs b/app/assets/javascripts/templates/tips/modal_header.hbs new file mode 100644 index 0000000..1aa6a54 --- /dev/null +++ b/app/assets/javascripts/templates/tips/modal_header.hbs @@ -0,0 +1,50 @@ + +{{#is sourceType "venue"}} +

Review tips at {{source.venuedata.name}}

+{{/is}} +{{#is sourceType "user"}} +

Review tips by {{source.user.firstName}} {{source.user.lastName}}

+{{/is}} + + +
+Flag 0 selected tips for removal: + + No Longer Relevant + +

Are these tips outdated (for example about an event that's already happened, or something that is no longer true of the venue)?

+

Important!: Do not remove a tip because you disagree with it or because it is negative.

+ Comment (optional):
+ +
+
+ + Spam + +

Are these tips spam? Spam includes unrelated promotional content.

+

Important!: Do not remove a tip because you disagree with it or because it is negative.

+ + Comment (optional):
+ +
+
+ + Offensive + +

Are these tips offensive, hateful, vulgar, harassing, or obscene?

+

Important!: Do not remove a tip because you disagree with it or because it is negative.

+ + Comment (optional):
+ +
+
+
+ +{{#is sourceType "user"}} +
+ Tips Loaded | + no longer relevant ({{#if statusVisibility.no_longer_relevant}}shown{{else}}hidden{{/if}}) | + at homes ({{#if statusVisibility.home}}shown{{else}}hidden{{/if}}) | + at private venues ({{#if statusVisibility.private}}shown{{else}}hidden{{/if}}) +
+{{/is}} diff --git a/app/assets/javascripts/templates/tips/sort.hbs b/app/assets/javascripts/templates/tips/sort.hbs new file mode 100644 index 0000000..f976889 --- /dev/null +++ b/app/assets/javascripts/templates/tips/sort.hbs @@ -0,0 +1,5 @@ + Sort Tips By: + diff --git a/app/assets/javascripts/templates/tips/tips.hbs b/app/assets/javascripts/templates/tips/tips.hbs new file mode 100644 index 0000000..f355727 --- /dev/null +++ b/app/assets/javascripts/templates/tips/tips.hbs @@ -0,0 +1,75 @@ +{{#each items.items}} +{{#is flags.[0] "spam"}} +{{else}} + +
+
+ + + +
+ {{#if photo}} + + {{/if}} + {{#is type "merchant_tip"}}Merchant tip: {{/is}}{{text}} + {{#if endAt}}
+ Tip Expires: {{moment endAt}} ({{moment-ago endAt}})
+ {{/if}} + {{#if url}} +
+ URL: {{url}} + {{/if}} +
+ – {{user.firstName}} {{user.lastName}} · + {{#moment createdAt}}{{/moment}} · + + {{#if venue}} +
+ At {{venue.name}} + ({{#if venue.categories.[0]}}{{venue.categories.[0].name}} + {{else}}Uncategorized Venue{{/if}} in {{#location venue.location}}{{/location}}) + {{#if venue.private}} (Private){{/if}} + {{#if venue.closed}} (Closed){{/if}} + {{#if venue.locked}} (Locked){{/if}} + + {{/if}} +
+ {{#is flags.[0] "no_longer_relevant"}} +
This tip is suppressed because it is no longer relevant + {{/is}} + {{#if venue}} + {{#if venue.closed}} +
This tip was left at a venue that is now closed + {{/if}} + {{#if venue.locked}} +
This tip was left at a locked venue + {{/if}} + {{#if venue.private}} +
This tip was left at a venue that is private + {{/if}} + {{#if venue.categories}} + {{#is venue.categories.[0].id "4bf58dd8d48988d103941735"}} +
This tip was left at a private home + {{/is}} + {{/if}} + {{/if}} + +
+
+
+
+
+{{/is}} +{{/each}} + + +
+ All Tips Displayed +
+
+ Loading More Tips

+
+
+
+
+
diff --git a/app/assets/javascripts/templates/venues/_pending_4sweep_flag.hbs b/app/assets/javascripts/templates/venues/_pending_4sweep_flag.hbs new file mode 100644 index 0000000..09e1660 --- /dev/null +++ b/app/assets/javascripts/templates/venues/_pending_4sweep_flag.hbs @@ -0,0 +1,45 @@ + {{friendly_name}} + {{moment created_at}} + {{status}} {{resolved_details}} + + {{#is flag_type "MergeFlag"}} + {{#is id venueId}} + With: {{secondaryName}} + {{else}} + With: {{primaryName}} + {{/is}} + {{/is}} + {{#is flag_type "EditVenueFlag"}} + {{#each details}} + {{this}}
+ {{/each}} + {{/is}} + {{#is status "scheduled"}} + Scheduled for {{moment scheduled_at}} + {{/is}} + + {{#if comment}}

Comment: {{comment}}

{{/if}} + + {{#if last_checked}}{{moment last_checked}}{{else}}–{{/if}} + + {{#isin status "new" "queued"}} + +
+ {{/isin}} + {{#isin status "new" "queued" "scheduled" "submitted"}} + +
+ {{/isin}} + {{#isin status "submitted"}} + +
+ {{/isin}} + {{#isin status "new" "queued" "scheduled"}} + +
+ {{/isin}} + {{#isin status "submitted"}} + +
+ {{/isin}} + diff --git a/app/assets/javascripts/templates/venues/about_autosubmit.hbs b/app/assets/javascripts/templates/venues/about_autosubmit.hbs new file mode 100644 index 0000000..043f17b --- /dev/null +++ b/app/assets/javascripts/templates/venues/about_autosubmit.hbs @@ -0,0 +1,5 @@ +

Submit automatically: Your flags will be submitted about 5 minutes +after you create them. You can cancel your flags in the Queued section of the +Flags tab within those 5 minutes.

+

Review on Flags Tab: Any flags you create will be added to the New +section of the Flags tab. You can review them at your leisure before submitting them to Foursquare. diff --git a/app/assets/javascripts/templates/venues/category_edit_popover.hbs b/app/assets/javascripts/templates/venues/category_edit_popover.hbs new file mode 100644 index 0000000..822c617 --- /dev/null +++ b/app/assets/javascripts/templates/venues/category_edit_popover.hbs @@ -0,0 +1,5 @@ +What would you like to do with category {{category_name}} on {{venue.name}}?

+ + + + diff --git a/app/assets/javascripts/templates/venues/category_edit_popover_title.hbs b/app/assets/javascripts/templates/venues/category_edit_popover_title.hbs new file mode 100644 index 0000000..7924acf --- /dev/null +++ b/app/assets/javascripts/templates/venues/category_edit_popover_title.hbs @@ -0,0 +1 @@ +Category {{category_name}} diff --git a/app/assets/javascripts/templates/venues/details/attributes.hbs b/app/assets/javascripts/templates/venues/details/attributes.hbs new file mode 100644 index 0000000..05e9e22 --- /dev/null +++ b/app/assets/javascripts/templates/venues/details/attributes.hbs @@ -0,0 +1,11 @@ +

+{{#each attributes.attributes.groups}} +
{{name}}
+
+ {{#each items}} +
{{displayName}}:
+
{{#if displayValue}}{{displayValue}}{{else}}{{availability}}{{/if}}
+ {{/each}} +
+{{/each}} +
diff --git a/app/assets/javascripts/templates/venues/details/children.hbs b/app/assets/javascripts/templates/venues/details/children.hbs new file mode 100644 index 0000000..3abc690 --- /dev/null +++ b/app/assets/javascripts/templates/venues/details/children.hbs @@ -0,0 +1,32 @@ +
+{{#each children.items}} + +   + + {{#if photos}} + Photo of {{name}} + {{else}} + {{#if categories.[0]}} + {{categories.0.name}} + {{else}} + Unknown Category + {{/if}} + {{/if}} + + + {{name}}
+ {{#if categories.0.name}}{{categories.0.name}}{{else}}No Category{{/if}}
+ {{truncate location.formattedAddress.[0] 35}} +
+
+ +{{#ifIsModPlus1 @index 2}} +
+
+{{/ifIsModPlus1}} + +{{/each}} +
+ +{{#if children.remaining}}

And {{children.remaining}} more

{{/if}} + diff --git a/app/assets/javascripts/templates/venues/details/created.hbs b/app/assets/javascripts/templates/venues/details/created.hbs new file mode 100644 index 0000000..8c73fd4 --- /dev/null +++ b/app/assets/javascripts/templates/venues/details/created.hbs @@ -0,0 +1,22 @@ +{{#if created}} + +

This venue was created at {{moment created.time}} ({{moment-ago created.time}})

+ +{{#if created.app}}

Created via: {{created.app.name}}

{{/if}} + +{{#if created.user}} +{{#with created.user}} +
+ {{firstName}} {{lastName}} +
{{firstName}} {{lastName}} {{#if superuser}}(SU{{superuser}}){{/if}}
+ {{#if homeCity}}{{homeCity}}
{{/if}} + {{#if bio}}

{{bio}}

{{/if}} +
+ {{#if contact.twitter}}@{{contact.twitter}}{{/if}} + {{#if contact.facebook}}{{contact.facebook}}{{/if}} +
+
+{{/with}} +{{/if}} + +{{/if}} diff --git a/app/assets/javascripts/templates/venues/details/description.hbs b/app/assets/javascripts/templates/venues/details/description.hbs new file mode 100644 index 0000000..0990273 --- /dev/null +++ b/app/assets/javascripts/templates/venues/details/description.hbs @@ -0,0 +1,5 @@ +{{#if venue.description}} +{{venue.description}} +{{else}} +No description provided on Foursquare +{{/if}} diff --git a/app/assets/javascripts/templates/venues/details/hours.hbs b/app/assets/javascripts/templates/venues/details/hours.hbs new file mode 100644 index 0000000..69d13d4 --- /dev/null +++ b/app/assets/javascripts/templates/venues/details/hours.hbs @@ -0,0 +1,36 @@ +{{#ifany venue.hours venue.popular}} +{{#with venue.hours}} + + +
+
Currently
+
{{status}}
+ {{#each timeframes}} +
{{days}}
+
+ {{#each open}} + {{renderedTime}}
+ {{/each}} +
+ {{/each}} +
+
+{{/with}} +{{!-- {{#with venue.popular}} +
+
Popular Hours
+
+ + {{#each timeframes}} +
{{days}}
+
+ {{#each open}} + {{renderedTime}}
+ {{/each}} +
+ {{/each}} +
+{{/with}} --}} +{{else}} +No hours specified +{{/ifany}} diff --git a/app/assets/javascripts/templates/venues/details/users.hbs b/app/assets/javascripts/templates/venues/details/users.hbs new file mode 100644 index 0000000..a31684f --- /dev/null +++ b/app/assets/javascripts/templates/venues/details/users.hbs @@ -0,0 +1,25 @@ +{{#if venue.friendVisits}} +

{{venue.friendVisits.summary}}

+ +{{/if}} + +
+{{#if venue.rating}} +

Rating

+

+ Rating: + {{venue.rating}} / 10.0 (based on {{venue.ratingSignals}} votes) +

+ + {{#if venue.likes.summary}}

Likes: {{venue.likes.summary}}

{{/if}} +{{/if}} + diff --git a/app/assets/javascripts/templates/venues/edit_history.hbs b/app/assets/javascripts/templates/venues/edit_history.hbs new file mode 100644 index 0000000..0c970d3 --- /dev/null +++ b/app/assets/javascripts/templates/venues/edit_history.hbs @@ -0,0 +1,63 @@ +
Showing {{edits.length}} of {{editsCount}} edits
+
+ +{{#each edits}} + + + + + {{#each deltas}} + {{#is @index '>' -1}}{{/is}} + + + {{#if listObj}} + + {{else}} + + + {{/if}} + {{#is @index '>' 0}}{{/is}} + {{/each}} + + + +{{else}}No edits known +{{/each}} +
{{editType}}
+ {{moment createdAt}}
+ ({{#if isAutomatedEdit}}Automated{{else}}Manual{{/if}}) +
+
+ {{#if reportingUsers.[0]}}Reporters: {{#each reportingUsers}}{{>venues/edithistories/_user}}{{/each}}{{/if}} + {{#if approvingUsers.[0]}}Approvers: {{#each approvingUsers}}{{>venues/edithistories/_user}}{{/each}}{{/if}} + {{#if app}}via {{app.name}}{{/if}} +
+
+ {{#is editType 'merge'}} +
+ Merge with {{oldVenue.name}} ({{pointDistance ../../venue.location oldVenue.location}} {{pointsDirection ../../venue.location oldVenue.location}} from current location)
+ {{#each oldVenue.location.formattedAddress}}{{this}}
{{/each}}
+ {{oldVenue.stats.checkinsCount}} Checkin(s) | {{oldVenue.stats.usersCount}} User(s) | {{oldVenue.stats.tipCount}} Tip(s) +
+ {{/is}} +
+ {{#is op "modify"}}Change{{/is}} + {{#is op "change_head"}}Change{{/is}} + {{#is op "add"}} Add{{/is}} + {{#is op "remove"}} Remove{{/is}} + {{#if displayName}}{{displayName}}{{else}}{{name}}{{/if}} + {{#if listObj.value}} + {{listObj.value}} + {{else}} + {{#each listObj.categories}} + {{>venues/edithistories/_category}} + {{/each}} + {{/if}} + + {{#with old}}{{> venues/edithistories/_deltavalue}}{{/with}} + + + {{#with new}}{{> venues/edithistories/_deltavalue}}{{/with}} + {{#ifall old.ll new.ll}}
({{pointDistance old.ll new.ll}}){{/ifall}} +
+
diff --git a/app/assets/javascripts/templates/venues/edit_venue_details/_basics.hbs b/app/assets/javascripts/templates/venues/edit_venue_details/_basics.hbs new file mode 100644 index 0000000..bb2bc2c --- /dev/null +++ b/app/assets/javascripts/templates/venues/edit_venue_details/_basics.hbs @@ -0,0 +1,104 @@ +
+
+ +
+
+ + +
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ @ + + +
+
+
+ +
+ +
+
+ + +
+
+
+ +
+ +
+
+ + +
+
+
+ +
+ +
+
+ + +
+
+
+ +
+ +
+ +
+
+ + +
+ diff --git a/app/assets/javascripts/templates/venues/edit_venue_details/_description.hbs b/app/assets/javascripts/templates/venues/edit_venue_details/_description.hbs new file mode 100644 index 0000000..b4fdbe4 --- /dev/null +++ b/app/assets/javascripts/templates/venues/edit_venue_details/_description.hbs @@ -0,0 +1,8 @@ +
+ +
+ + +
+
+ diff --git a/app/assets/javascripts/templates/venues/edit_venue_details/_hours.hbs b/app/assets/javascripts/templates/venues/edit_venue_details/_hours.hbs new file mode 100644 index 0000000..78ef25d --- /dev/null +++ b/app/assets/javascripts/templates/venues/edit_venue_details/_hours.hbs @@ -0,0 +1,15 @@ +
+ +
+ + +
+
+ {{#with venue}} + {{>venues/edit_venue_details/_humanhours}} + {{/with}} +
+
+
+ diff --git a/app/assets/javascripts/templates/venues/edit_venue_details/_humanhours.hbs b/app/assets/javascripts/templates/venues/edit_venue_details/_humanhours.hbs new file mode 100644 index 0000000..cfef43a --- /dev/null +++ b/app/assets/javascripts/templates/venues/edit_venue_details/_humanhours.hbs @@ -0,0 +1,23 @@ +{{#if hours.timeframes}} + + {{#each hours.timeframes}} + + + + + {{/each}} +
+ {{days}} + + {{#each open}} + {{renderedTime}} + {{/each}} +
+{{else}} + {{#isin status "ERROR" "POPULARHOURSWARNING"}} +
{{message}}
+ {{else}} +
No hours set
+ {{/isin}} +{{/if}} + diff --git a/app/assets/javascripts/templates/venues/edit_venue_details/_machinehours.hbs b/app/assets/javascripts/templates/venues/edit_venue_details/_machinehours.hbs new file mode 100644 index 0000000..55be8fb --- /dev/null +++ b/app/assets/javascripts/templates/venues/edit_venue_details/_machinehours.hbs @@ -0,0 +1,24 @@ + + {{#each hours.timeframes}} + + + + + {{/each}} +
+ {{#each days}} + + {{#is this 1}}Mon{{/is}} + {{#is this 2}}Tue{{/is}} + {{#is this 3}}Wed{{/is}} + {{#is this 4}}Thu{{/is}} + {{#is this 5}}Fri{{/is}} + {{#is this 6}}Sat{{/is}} + {{#is this 7}}Sun{{/is}} + + {{/each}} + + {{#each open}} + {{renderHour start}} – {{renderHour end}}
+ {{/each}} +
diff --git a/app/assets/javascripts/templates/venues/edit_venue_details/_relocate.hbs b/app/assets/javascripts/templates/venues/edit_venue_details/_relocate.hbs new file mode 100644 index 0000000..bfdef69 --- /dev/null +++ b/app/assets/javascripts/templates/venues/edit_venue_details/_relocate.hbs @@ -0,0 +1,11 @@ +
+
+
+
+ +
+ + +
+ +
diff --git a/app/assets/javascripts/templates/venues/edit_venue_details/edit_venue_details.hbs b/app/assets/javascripts/templates/venues/edit_venue_details/edit_venue_details.hbs new file mode 100644 index 0000000..65e38b4 --- /dev/null +++ b/app/assets/javascripts/templates/venues/edit_venue_details/edit_venue_details.hbs @@ -0,0 +1,32 @@ +
+ +
+ +
+
+ {{>venues/edit_venue_details/_basics}} +
+
{{>venues/edit_venue_details/_description}}
+
+ {{>venues/edit_venue_details/_relocate}} +
+
{{>venues/edit_venue_details/_hours}}
+
+
+ +
+
+ + + + + + + +
+
diff --git a/app/assets/javascripts/templates/venues/edit_venue_details/parentcandidate.hbs b/app/assets/javascripts/templates/venues/edit_venue_details/parentcandidate.hbs new file mode 100644 index 0000000..222775e --- /dev/null +++ b/app/assets/javascripts/templates/venues/edit_venue_details/parentcandidate.hbs @@ -0,0 +1,14 @@ +{{#with candidate}} +
+ {{#if categories.[0]}} + {{categories.0.name}} + {{else}} + Unknown Category + {{/if}} + +
{{name}}
+
+ {{#if categories.0.name}}{{categories.0.name}}{{else}}No Category{{/if}} | {{pointDistance ../venue.location location}} {{pointsDirection ../venue.location location}} +
+
+{{/with}} diff --git a/app/assets/javascripts/templates/venues/edithistories/_category.hbs b/app/assets/javascripts/templates/venues/edithistories/_category.hbs new file mode 100644 index 0000000..a6982f6 --- /dev/null +++ b/app/assets/javascripts/templates/venues/edithistories/_category.hbs @@ -0,0 +1 @@ +{{name}} {{name}} diff --git a/app/assets/javascripts/templates/venues/edithistories/_deltavalue.hbs b/app/assets/javascripts/templates/venues/edithistories/_deltavalue.hbs new file mode 100644 index 0000000..a71369d --- /dev/null +++ b/app/assets/javascripts/templates/venues/edithistories/_deltavalue.hbs @@ -0,0 +1,8 @@ +{{#if ll}}Lat: {{ll.lat}}
Lng: {{ll.lng}}{{/if}} +{{#if venue}} + {{venue.name}} +{{else}} + {{#if value}}{{value}}{{/if}} +{{/if}} +{{#if values}} {{#each values}}{{this}}
{{/each}} {{/if}} +{{#if category}}{{#with category}}{{>venues/edithistories/_category}}{{/with}}{{/if}} diff --git a/app/assets/javascripts/templates/venues/edithistories/_user.hbs b/app/assets/javascripts/templates/venues/edithistories/_user.hbs new file mode 100644 index 0000000..8887f3d --- /dev/null +++ b/app/assets/javascripts/templates/venues/edithistories/_user.hbs @@ -0,0 +1 @@ +{{!-- {{firstName}} {{lastName}} --}}{{firstName}} {{#if superuser}}(SU{{superuser}}){{/if}} diff --git a/app/assets/javascripts/templates/venues/facebook_details.hbs b/app/assets/javascripts/templates/venues/facebook_details.hbs new file mode 100644 index 0000000..6fc42de --- /dev/null +++ b/app/assets/javascripts/templates/venues/facebook_details.hbs @@ -0,0 +1,185 @@ +{{#if facebook}} +{{#with facebook}} +
+
+

{{name}} {{#if is_permanently_closed}}(Permanently Closed){{/if}} {{#if is_unclaimed}}(Unclaimed){{/if}}

+ {{#if category_list.[0]}} +
+ {{#each category_list}} + {{name}} + {{/each}} +
+ {{/if}} +
+ {{#if about}}

{{truncate about 350 ' ' '…' true}}

{{/if}} + {{#if description}}

{{truncate description 150 ' ' '…' true}}

{{/if}} + + {{#if website}} + Website: {{replace website " " " · "}} + {{/if}} +
+
+
+ + {{#ifany location.street location.city location.state location.zip}} +
Location
+
{{#if location.street}}{{location.street}}
{{/if}} + {{location.city}} {{location.state}} {{location.zip}} +
+ {{/ifany}} + {{#if founded}} +
Founded
+
{{founded}}
+ {{/if}} + + {{#if phone}} +
Phone
+
{{phone}}
+ {{/if}} + + {{#if attire}} +
Attire
+
{{attire}}
+ {{/if}} + + {{#if price_range}} +
Price Range
+
{{price_range}}
+ {{/if}} + + {{#ifany were_here_count likes checkins}} +
Stats
+
+ {{#if were_here_count}}{{were_here_count}} Were here
{{/if}} + {{#if likes}}{{likes}} Facebook Likes
{{/if}} + {{#if checkins}}{{checkins}} Facebook Checkins
{{/if}} + {{#if talking_about_count}}{{talking_about_count}} Talking about
{{/if}} +
+ {{/ifany}} + {{#if category}} +
Category
+
{{category}}
+ {{/if}} + + {{#if parent_page}} +
Chain / Parent
+
{{parent_page.name}}
+ {{/if}} +
+
+
+
+ {{#if public_transit}} +
Public Transit
+
{{public_transit}}
+ {{/if}} + + {{#ifany parking.valet parking.street parking.lot}} +
Parking
+
+ {{#if parking.valet}}Valet{{/if}} + {{#if parking.street}}Street{{/if}} + {{#if parking.lot}}Lot{{/if}} +
+ {{/ifany}} + + {{#if payment_options}} +
Payment Options
+
+ {{#if payment_options.cash_only}}Cash Only{{/if}} + {{#if payment_options.visa}}Visa{{/if}} + {{#if payment_options.mastercard}}MasterCard{{/if}} + {{#if payment_options.discover}}Discover{{/if}} + {{#if payment_options.amex}}American Express{{/if}} +
+ {{/if}} + + {{#if culinary_team}} +
Culinary Team
+
{{nl2separator culinary_team " · "}}
+ {{/if}} + + {{#if general_manager}} +
General Manager
+
{{general_manager}}
+ {{/if}} + + + {{#if restaurant_specialties}} + {{#with restaurant_specialties}} + {{#ifany lunch drinks dinner coffee breakfast}} +
Specialties
+
+ {{#if breakfast}}Breakfast{{/if}} + {{#if lunch}}Lunch{{/if}} + {{#if dinner}}Dinner{{/if}} + {{#if drinks}}Drinks{{/if}} + {{#if coffee}}Coffee{{/if}} +
+ {{/ifany}} + {{/with}} + {{/if}} + + {{#if restaurant_services}} + {{#with restaurant_services}} + {{#ifany walkins deliver catering groups kids outdoor reserve takeout waiter}} +
Services
+
+ {{#if deliver}}Delivery Service{{/if}} + {{#if catering}}Catering{{/if}} + {{#if groups}}Group-friendly{{/if}} + {{#if kids}}Kid-friendly{{/if}} + {{#if outdoor}}Outdoor seating{{/if}} + {{#if reserve}}Accepts Reservations{{/if}} + {{#if takeout}}Takeout{{/if}} + {{#if waiter}}Waiter Service{{/if}} + {{#if walkins}}Walk-ins Welcome{{/if}} +
+ {{/ifany}} + {{/with}} + {{/if}} + + {{#ifany hours.mon_1_open hours.tue_1_open hours.wed_1_open hours.thu_1_open hours.fri_1_open hours.sat_1_open hours.sun_1_open}} +
Hours
+
+ Sun: {{formatFacebookHours hours "sun_1"}} + {{#if hours.sun_2_open}}
{{formatFacebookHours hours "sun_2"}}
{{/if}} +

+ + Mon: {{formatFacebookHours hours "mon_1"}} + {{#if hours.mon_2_open}}
{{formatFacebookHours hours "mon_2"}}
{{/if}} +

+ + Tue: {{formatFacebookHours hours "tue_1"}} + {{#if hours.tue_2_open}}
{{formatFacebookHours hours "tue_2"}}
{{/if}} +

+ + Wed: {{formatFacebookHours hours "wed_1"}} + {{#if hours.web_2_open}}
{{formatFacebookHours hours "wed_2"}}
{{/if}} +

+ + Thu: {{formatFacebookHours hours "thu_1"}} + {{#if hours.thu_2_open}}
{{formatFacebookHours hours "thu_2"}}
{{/if}} +

+ + Fri: {{formatFacebookHours hours "fri_1"}} + {{#if hours.fri_2_open}}
{{formatFacebookHours hours "fri_2"}}
{{/if}} +

+ + Sat: {{formatFacebookHours hours "sat_1"}} + {{#if hours.sat_2_open}}
{{formatFacebookHours hours "sat_2"}}
{{/if}} +

+
+ {{/ifany}} +
+
+
+
+{{/with}} +{{else}} +Loading... +{{/if}} +
+ Open in Facebook (new window) + +
diff --git a/app/assets/javascripts/templates/venues/facebook_popover_title.hbs b/app/assets/javascripts/templates/venues/facebook_popover_title.hbs new file mode 100644 index 0000000..c7fe5b5 --- /dev/null +++ b/app/assets/javascripts/templates/venues/facebook_popover_title.hbs @@ -0,0 +1,6 @@ +Facebook Details for {{venue.name}} + + Open in Facebook + +(Click to keep open) + diff --git a/app/assets/javascripts/templates/venues/facebookload_failed.hbs b/app/assets/javascripts/templates/venues/facebookload_failed.hbs new file mode 100644 index 0000000..cc0301b --- /dev/null +++ b/app/assets/javascripts/templates/venues/facebookload_failed.hbs @@ -0,0 +1 @@ +Could not load Facebook details. diff --git a/app/assets/javascripts/templates/venues/parts/_addressrow.hbs b/app/assets/javascripts/templates/venues/parts/_addressrow.hbs new file mode 100644 index 0000000..11a1bca --- /dev/null +++ b/app/assets/javascripts/templates/venues/parts/_addressrow.hbs @@ -0,0 +1,30 @@ +{{venue.location.address}} +{{#if venue.location.crossStreet}}({{venue.location.crossStreet}}){{/if}} +{{venue.location.city}} {{venue.location.state}} {{venue.location.postalCode}} +{{#if venue.parent.name}}[at {{venue.parent.name}}]{{/if}} +
+ +{{#if venue.contact.formattedPhone}}{{venue.contact.formattedPhone}} | {{/if}} +{{#if venue.contact.twitter}} @{{venue.contact.twitter}} | {{/if}} + +{{#if venue.contact.facebook}} {{#if venue.contact.facebookUsername}}{{truncate venue.contact.facebookUsername 20}}{{else}}{{truncate venue.contact.facebookName 20}}{{/if}} | {{/if}} + + + + + + + + + + + + + +{{#if children.totalChildren}}({{children.totalChildren}}){{/if}} + + + +{{!-- + + --}} diff --git a/app/assets/javascripts/templates/venues/parts/_categories.hbs b/app/assets/javascripts/templates/venues/parts/_categories.hbs new file mode 100644 index 0000000..f83ed68 --- /dev/null +++ b/app/assets/javascripts/templates/venues/parts/_categories.hbs @@ -0,0 +1,9 @@ +{{#each categories.existing}} + {{name}} +{{else}} + No Category +{{/each}} +{{#each categories.pending}} + (Pending: {{name}}) +{{/each}} +
diff --git a/app/assets/javascripts/templates/venues/parts/_categoryicon.hbs b/app/assets/javascripts/templates/venues/parts/_categoryicon.hbs new file mode 100644 index 0000000..43220f8 --- /dev/null +++ b/app/assets/javascripts/templates/venues/parts/_categoryicon.hbs @@ -0,0 +1 @@ +{{categoryTitle venue.categories}} diff --git a/app/assets/javascripts/templates/venues/parts/_gone.hbs b/app/assets/javascripts/templates/venues/parts/_gone.hbs new file mode 100644 index 0000000..efaf130 --- /dev/null +++ b/app/assets/javascripts/templates/venues/parts/_gone.hbs @@ -0,0 +1,3 @@ +
This venue has been deleted. + (clear) +
diff --git a/app/assets/javascripts/templates/venues/parts/_majoreditdate.hbs b/app/assets/javascripts/templates/venues/parts/_majoreditdate.hbs new file mode 100644 index 0000000..b29d480 --- /dev/null +++ b/app/assets/javascripts/templates/venues/parts/_majoreditdate.hbs @@ -0,0 +1 @@ +{{#if majorEdits.[0]}} | Last Major Edit: {{moment majorEdits.0.createdAt}}{{/if}} diff --git a/app/assets/javascripts/templates/venues/parts/_merged.hbs b/app/assets/javascripts/templates/venues/parts/_merged.hbs new file mode 100644 index 0000000..a270c52 --- /dev/null +++ b/app/assets/javascripts/templates/venues/parts/_merged.hbs @@ -0,0 +1,3 @@ +
Merged into venue: {{newvenue.name}}. + (clear) +
diff --git a/app/assets/javascripts/templates/venues/parts/_namerow.hbs b/app/assets/javascripts/templates/venues/parts/_namerow.hbs new file mode 100644 index 0000000..f8d6c5d --- /dev/null +++ b/app/assets/javascripts/templates/venues/parts/_namerow.hbs @@ -0,0 +1,28 @@ +{{#if venue.locked}} + +{{/if}} +{{#if venue.verified}} + +{{/if}} +{{#if venue.private}} + +{{/if}} + +{{venue.name}} + {{#if venue.deleted}} + (Deleted) + {{/if}} + {{#if venue.closed}} + (Closed) + {{/if}} + {{#if venue.private}} + (Private) + {{/if}} + {{#if venue.merged}} + (merged) + {{/if}} + +{{ratioText}} + {{#if distance}} [{{round distance}} m] {{/if}} +{{#if venue.discussionUrl}}[Discussion Thread]{{/if}} +
diff --git a/app/assets/javascripts/templates/venues/parts/_pendingflagscount.hbs b/app/assets/javascripts/templates/venues/parts/_pendingflagscount.hbs new file mode 100644 index 0000000..b45ea1c --- /dev/null +++ b/app/assets/javascripts/templates/venues/parts/_pendingflagscount.hbs @@ -0,0 +1 @@ +{{#if flags.[0]}} | Pending Flags: {{flags.length}}{{/if}} diff --git a/app/assets/javascripts/templates/venues/parts/_statsrow.hbs b/app/assets/javascripts/templates/venues/parts/_statsrow.hbs new file mode 100644 index 0000000..29bc607 --- /dev/null +++ b/app/assets/javascripts/templates/venues/parts/_statsrow.hbs @@ -0,0 +1,12 @@ +{{num venue.stats.checkinsCount}} Checkins, +{{num venue.stats.usersCount}} Users, +{{#if venue.hereNow}} + {{num venue.hereNow.count}} Here Now, +{{/if}} +{{#if venue.listed}} + {{num venue.listed.count}} Lists, +{{/if}} +{{num venue.stats.tipCount}} Tips{{#if venue.photos}}, +{{num venue.photos.count}} Photos +{{/if}} +
diff --git a/app/assets/javascripts/templates/venues/parts/_venueactions.hbs b/app/assets/javascripts/templates/venues/parts/_venueactions.hbs new file mode 100644 index 0000000..5ae8c98 --- /dev/null +++ b/app/assets/javascripts/templates/venues/parts/_venueactions.hbs @@ -0,0 +1,33 @@ +
+ Actions + + +
diff --git a/app/assets/javascripts/templates/venues/parts/_venueclasses.hbs b/app/assets/javascripts/templates/venues/parts/_venueclasses.hbs new file mode 100644 index 0000000..a9945bf --- /dev/null +++ b/app/assets/javascripts/templates/venues/parts/_venueclasses.hbs @@ -0,0 +1 @@ +venue venue_{{venue.id}} {{#if status.alreadyflagged}}alreadyflagged{{/if}} {{#if status.clicked}}clicked{{/if}} {{#if venue.private}}private{{/if}} {{#if venue.closed}}closed{{/if}} {{#if venue.home}}home{{/if}} {{#if venue.deleted}}deleted{{/if}} {{#if venue.merged}}merged{{/if}} {{#if venue.gone}}gone{{/if}} {{#if status.filtered}}filtered{{/if}} {{#if status.pinned}}pinned{{/if}} diff --git a/app/assets/javascripts/templates/venues/pending_4sweep_flags.hbs b/app/assets/javascripts/templates/venues/pending_4sweep_flags.hbs new file mode 100644 index 0000000..3cc7c05 --- /dev/null +++ b/app/assets/javascripts/templates/venues/pending_4sweep_flags.hbs @@ -0,0 +1,32 @@ +
+{{#is flags.length 1}} +You have {{flags.length}} pending flag at {{venue.name}} +{{else}} +You have {{flags.length}} pending flags at {{venue.name}} +{{/is}} +
+ +{{#if flags.length}} + + + + + + + + + +{{#each flags}} + +{{>venues/_pending_4sweep_flag}} + +{{/each}} + + + + + +
TypeCreated AtStatusDetailsLast CheckedActions
+ +
+{{/if}} diff --git a/app/assets/javascripts/templates/venues/pending_flags.hbs b/app/assets/javascripts/templates/venues/pending_flags.hbs new file mode 100644 index 0000000..ba549a9 --- /dev/null +++ b/app/assets/javascripts/templates/venues/pending_flags.hbs @@ -0,0 +1,171 @@ +{{#if hasOldMajorFlags}}

This list contains old (>30 days) pending flags that probably will never surface in the queues and are effectively rejected.

{{/if}} +

Pending flags are informational only – you can only approve or reject flags in the Foursquare queues.

+
+ +{{#each flags}} + + + + + + + + + + +{{else}} + + + +{{/each}} +
+ {{#if canonicalPath}} + + {{/if}} + {{#isin type "at"}}Attribute{{/isin}} + {{#isin type "info"}}Venue Info{{/isin}} + {{#isin type "duplicate"}}Duplicate{{/isin}} + {{#isin type "manualDuplicate"}}Duplicate
(Both Claimed)
{{/isin}} + {{#isin type "remove"}} + {{#isin value.reason "closed" "event_over"}}Closed{{/isin}} + {{#isin value.reason "inappropriate" "doesnt_exist" "remove_home" "created_in_error"}}Delete{{/isin}} + {{#is value.reason undefined}}Remove{{/is}} + {{/isin}} + {{#isin type "unremove"}} + {{#isin value "notclosed"}}Reopen
(Not Closed) + {{else}} + {{#isin value "undelete"}}Undelete venue + {{else}} + Unremove + {{/isin}} + {{/isin}} + {{/isin}} + {{#isin type "privatevenue"}}Make Private{{/isin}} + {{#isin type "publicvenue"}}Make Public{{/isin}} + + {{#isin type "primarycategory"}}Primary Category{{/isin}} + {{#isin type "category"}}Add Category{{/isin}} + {{#isin type "removecategory"}}Remove Category{{/isin}} + {{#isin type "missingaddress" "missingphone" "mi" "menu"}}Missing Info{{/isin}} + {{#isin type "uncategorized"}}Uncategorized{{/isin}} + {{#isin type "hours"}}Hours{{/isin}} + {{#isin type "mislocated"}}Mislocated{{/isin}} + {{#isin type "editName"}}Alternate Name{{/isin}} + + {{#if canonicalPath}}
{{/if}} +
+ {{#if reporters.[0]}}Reporters: {{#each reporters}} + {{firstName}} {{lastName}} + {{/each}} + {{else}}[User Missing]{{/if}} + {{timeFromMongoId id}} + {{#if app.name}}· via {{app.name}}{{/if}} + +
+ {{#isin type "at" "info" "editName"}} + {{#if currentValue}} + Change {{#if displayName}}{{displayName}}{{else}}{{field}}{{/if}} from "{{currentValue}}" to "{{displayValue}}" + {{else}} + Add {{#if displayName}}{{displayName}}{{else}}{{field}}{{/if}}: {{displayValue}} + {{/if}} + {{/isin}} + + {{#isin type "duplicate" "manualDuplicate"}} + Merge with: {{value.duplicate.name}}
+ {{#if value.duplicate.categories.0.name}}{{value.duplicate.categories.0.name}}
{{/if}} + {{value.duplicate.location.address}} + {{#if value.duplicate.location.crossStreet}}({{value.duplicate.location.crossStreet}}){{/if}} + {{value.duplicate.location.city}} {{value.duplicate.location.state}} {{value.duplicate.location.postalCode}}
+ {{pointDistance ../../venue.location value.duplicate.location}} {{pointsDirection ../../venue.location value.duplicate.location}} from current location + {{/isin}} + + {{#isin type "hours"}} + Set hours to: +
+ {{#each value.hours.timeframes}} +
{{days}}
+
{{#each open}}{{renderedTime}}
{{/each}}
+ {{/each}} +
+ {{/isin}} + + {{#isin type "remove"}} + {{#isin value.reason "closed"}} + This venue should be closed because it is out of business or permanently closed. + {{/isin}} + {{#isin value.reason "event_over"}} + This venue should be closed because it refers to an event that is over. + {{/isin}} + {{#isin value.reason "inappropriate"}} + This venue should be deleted because it is inappropriate. + {{/isin}} + {{#isin value.reason "doesnt_exist"}} + This venue should be deleted because it does not exist. + {{/isin}} + {{#isin value.reason "remove_home"}} + This venue should be deleted because it is a home. + {{/isin}} + {{#isin value.reason "created_in_error"}} + This venue should be deleted because it was created in error. + {{/isin}} + {{#is value.reason undefined}} + This venue should be removed. + {{/is}} + {{/isin}} + + {{#isin type "unremove"}} + {{#isin value "notclosed"}} + This venue should be reopened because it is not closed. + {{/isin}} + {{#isin value "undelete"}} + This venue should be undeleted. + {{/isin}} + {{/isin}} + + {{#isin type "uncategorized"}} + This venue is uncategorized. + {{/isin}} + + {{#isin type "category" "removecategory" "primarycategory"}} + {{#is value.action "addCategory"}}Add category:{{/is}} + {{#is value.action "removeCategory"}}Remove category:{{/is}} + {{#is value.action "primaryCategory"}}Make primary category:{{/is}} +
+ {{value.category.name}} {{value.category.name}} + {{/isin}} + + {{#isin type "missingaddress"}} + This venue is missing an address. + {{/isin}} + + {{#isin type "missingphone"}} + This venue is missing a phone number. + {{/isin}} + + {{#isin type "mi"}} + This venue is missing a {{#if displayName}}{{displayName}}{{else}}{{field}}{{/if}}. + {{#if motivation}}
{{motivation}}{{/if}} + {{/isin}} + + {{#isin type "menu"}} + The menu is incorrect. + {{/isin}} + + {{#isin type "privatevenue"}} + This venue should be marked private. + {{/isin}} + + {{#isin type "publicvenue"}} + This venue should be marked public. + {{/isin}} + + {{#isin type "mislocated"}} + This venue should be moved to a different location.
+ The proposed location is {{pointDistance ../../venue.location value}} {{pointsDirection ../../venue.location value}} from the current location. + {{/isin}} +
{{#each comments}}{{this}}
{{/each}}
No Flags Seen
+
+ +
+ +
diff --git a/app/assets/javascripts/templates/venues/pending_flags_title.hbs b/app/assets/javascripts/templates/venues/pending_flags_title.hbs new file mode 100644 index 0000000..1eb9345 --- /dev/null +++ b/app/assets/javascripts/templates/venues/pending_flags_title.hbs @@ -0,0 +1,3 @@ +Pending Foursquare Flags at {{venue.name}} +(Click to keep open) + diff --git a/app/assets/javascripts/templates/venues/user_extras.hbs b/app/assets/javascripts/templates/venues/user_extras.hbs new file mode 100644 index 0000000..66e5d6b --- /dev/null +++ b/app/assets/javascripts/templates/venues/user_extras.hbs @@ -0,0 +1,2 @@ +Review {{num photos.count}} photo(s) by {{firstName}} {{lastName}} +Review {{num tips.count}} tip(s) by {{firstName}} {{lastName}} diff --git a/app/assets/javascripts/templates/venues/venue_item.hbs b/app/assets/javascripts/templates/venues/venue_item.hbs new file mode 100644 index 0000000..7759dfe --- /dev/null +++ b/app/assets/javascripts/templates/venues/venue_item.hbs @@ -0,0 +1,47 @@ +
  • + + {{> venues/parts/_categoryicon}} + + {{>venues/parts/_venueactions}} + +
    + + + + + + + + +
    + +
    + {{>venues/parts/_namerow}} +
    + + {{>venues/parts/_categories}} + + + + + {{>venues/parts/_statsrow}} + + + + + + + + {{>venues/parts/_addressrow}} + +
    + + + Created: {{timeFromMongoId venue.id}} + {{>venues/parts/_majoreditdate}} + {{>venues/parts/_pendingflagscount}} + +
    + +
    +
  • diff --git a/app/assets/javascripts/templates/venues/venue_listed_preview.hbs b/app/assets/javascripts/templates/venues/venue_listed_preview.hbs new file mode 100644 index 0000000..1c25460 --- /dev/null +++ b/app/assets/javascripts/templates/venues/venue_listed_preview.hbs @@ -0,0 +1,23 @@ +
    +{{#each lists.groups}} +{{#if count}} +
    {{name}}
    + +{{/if}} +{{/each}} +This venue is listed on {{lists.count}} lists. Click to see all. +
    diff --git a/app/assets/javascripts/templates/venues/venue_photos_preview.hbs b/app/assets/javascripts/templates/venues/venue_photos_preview.hbs new file mode 100644 index 0000000..aba740c --- /dev/null +++ b/app/assets/javascripts/templates/venues/venue_photos_preview.hbs @@ -0,0 +1,14 @@ + diff --git a/app/assets/javascripts/templates/venues/venue_tips_preview.hbs b/app/assets/javascripts/templates/venues/venue_tips_preview.hbs new file mode 100644 index 0000000..a9cf8d2 --- /dev/null +++ b/app/assets/javascripts/templates/venues/venue_tips_preview.hbs @@ -0,0 +1,19 @@ +
    + {{#each tips}} +
    +
    + + Photo of {{user.firstName}} {{user.lastName}} + +
    + {{text}} +
    + – {{user.firstName}} {{user.lastName}} · {{moment createdAt}} +
    +
    +
    +
    + {{else}} +

    No tips found

    + {{/each}} +
    diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 0000000..2d0f4ab --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,16 @@ +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, + * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path. + * + * You're free to add application-wide styles to this file and they'll appear at the top of the + * compiled file, but it's generally better to create a new file per style scope. + * + *= require select2 + *= require bootstrap-datepicker + *= require jquery.pnotify.default + *= require_self + *= require_tree . + */ diff --git a/app/assets/stylesheets/custom.css.scss b/app/assets/stylesheets/custom.css.scss new file mode 100644 index 0000000..c2a7c04 --- /dev/null +++ b/app/assets/stylesheets/custom.css.scss @@ -0,0 +1,116 @@ +@import "bootstrap"; +@import "bootstrap-responsive"; +@import '4sweep_fontello'; +@import "animation"; + +html { + overflow-y: scroll; + height: 100%; +} + +body { + height: 100%; +} + +#wrap > .container, #wrap > .container-fluid { + padding-top: 50px; +} +section { + overflow: auto; +} + +textarea { + resize: vertical; +} + +.center { + text-align: center; +} + +.center h1 { + margin-bottom: 10px; +} + +/* typography */ + +h1, h2, h3, h4, h5, h6 { + line-height: 1; +} + +h1 { + font-size: 3em; + letter-spacing: -2px; + margin-bottom: 30px; + text-align: center; +} + +h2 { + font-size: 1.7em; + letter-spacing: -1px; + margin-bottom: 30px; + text-align: center; + font-weight: normal; + color: #999; +} + +p { + font-size: 1.1em; + line-height: 1.7em; +} + +#flagcount { + margin-left: 0.25em; + background-color: #3A87AD; + padding: 1px 3px 2px 3px; + color: white; + border-radius: 2px; +} + +#wrap { + min-height: 95%; + height: auto !important; + height: 95%; + margin: 0 auto -27px; +} + +#footer { + height: 30px; +} + +.footer { + border-top: 2px solid #333; + padding: 5px 0 5px 3em; + text-align: center; +} + +.footer img { + height: 30px; + vertical-align: top; + top: -5px; + position: relative; +} + +#footermiddle { + margin: auto; + .submitwhen { + margin-top: 0; + } +} + +/* Lastly, apply responsive CSS fixes as necessary */ +@media (max-width: 767px) { + #footer { + margin-left: -20px; + margin-right: -20px; + padding-left: 20px; + padding-right: 20px; + } +} + +select.small-select { + height: 20px +} + +.navbar-fixed-bottom { + background-color: white; +} diff --git a/app/assets/stylesheets/explorer.css.scss b/app/assets/stylesheets/explorer.css.scss new file mode 100644 index 0000000..228f9b5 --- /dev/null +++ b/app/assets/stylesheets/explorer.css.scss @@ -0,0 +1,462 @@ +// Place all the styles related to the Explorer controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ + +.icon { + vertical-align: bottom; +} +#map_canvas { + border: 1px solid #aaa; + height: 538px; + margin-top: 0px; + border-radius: 5px; + // min-height: 100%; +} + +#primarysearch { + position: relative; +} + +#venue_details { + height: 40px; + border: 1px solid #ddd; +} + +#advlink { + position: absolute; + font-size: 10px; + text-decoration: underline; + bottom: 1px; + left: 2px; +} +.searchstats { + font-size: 70%; + text-align: center; + clear: both; +} +.allvenues { + overflow-y: scroll; + height: 500px; +} +.pinned { + margin-top: 2px; +} + +a:visited, li a:visited { + color: black; +} + +.massactions a.btn:visited { + color: black; +} + +.venues li { + font-size: 95%; + line-height: 1.3; + + &:hover, &.hoveronicon { + background-color: #b7cda9; + } + + &.clicked { + background-color: #FFAF7A; + border-width: 2px; + } + + .ratio { + position: relative; + bottom: 2px; + right: 2px; + } + + .categories { + + .primary { + font-weight: bold; + } + } + + .discussion { + border-bottom: 1px dashed gray; + color: red; + } + + img.icon { + float: left; + } + + a.venuename { + font-weight: bold; + } + + .info { + margin-left: 50px; + } + + margin-bottom: 4px; +} + +.venuediv { + .uservenuenotice { + font-size: 80%; + font-weight: bold; + } + + li.venue { + border-radius: 5px; + padding: 5px; + color: #222; + font-size: 80%; + border: 1px solid #aaa; + } + li.venue.pinned { + border: 1px solid #D6B106; + } + li.venue.alreadyflagged { + border-color: #ccc; + img, a, .label, .info i { + opacity: 0.5 + } + .info { + color: gray; + } + } + ul { + margin-left: 0; + list-style: none; + } +} + +.venuediv li:hover { + // background-color: #FF9933; +} + +a.btn:visited { color: #fff; } + +img.icon { + background: url(https://s3.amazonaws.com/4sweep-assets/pin-blue-transparent.png); + padding: 6px; + padding-bottom: 17px; + top: -6px; + left: -6px; +} + +.searchform .categories { + width: 40%; +} + +.searchbar { + + padding-bottom: 0; +} + +.searchdiv.well { + .chzn-container { + vertical-align: top; + } + margin-bottom: 10px; + padding: 12px; + + .input { + margin-bottom: 0; + } +} +.advancedsearch { + display: none; + // position: absolute; + // top: ; + padding-top: 10px; +} +.venuedetails { + border: 1px solid black; + border-radius: 5px; + height: 500px; + overflow: auto; + + h1 { + font-size: 14px; + } +} + +.btn .expandedicon { + display: none; +} + +.btn.visible .expandedicon { + display: inline; +} + +.massactions a { + max-height: 18px; + margin-right: 2px; +} + +.hideshow { + border-bottom: 1px dashed gray; +} + +.home.hidden { + display: none; +} + +.venue .info .i { + font-size: small; +} + +.photocount.hasphotos, .tipscount.hastips, .listcount.islisted { + border-bottom: 1px dashed gray; + cursor: pointer; +} + +.searchdiv label { + font-size: 90%; +} +.venue.private, .venue.home, .venue.merged, .venue.deleted, .venue.closed, .venue.gone { + background-color: #eee; +} + +.venue { + position: relative; + .bottom { + position: absolute; + bottom: 5px; + right: 5px; + } +} + +.catRotateButtons { + position: relative; + left: -2em; + margin-left: -3em; +} + +.full_category.removed { + text-decoration: line-through; + color: gray; +} + +.full_category.madeprimary { + // border-bottom: 1px solid black; + // color: gray; + font-weight: bold; +} +.pending_category { + font-style: italic; +} + +.full_category { + border-bottom: 1px dashed gray; + cursor: pointer; +} +.photocount-inner { + border-bottom: 1px dashed gray; + cursor: pointer; +} + +.popover { + width: 400px; + max-width: 400px; + .close { + margin-top: -3px; + } + .comment { + width: 21em; + } + .popover-title a { + border-bottom: 1px dashed gray; + } + form.venueedit { + .control-label { + width: 120px; + } + .control-group { + margin-bottom: 4px; + } + .controls { + margin-left: 130px; + } + input.venuedetails_twitter { + width: 324px; + } + .form-actions { + padding-left: 0; + } + input { + width: 350px; + } + .tab-content { + padding: 1em; + } + } +} + +.popover.wider { + width: 400px; + max-width: 400px; +} + +.popover.ontop { + z-index: 1050; +} + +.popover.superwide { + width: 625px; + max-width: 625px; +} + +.popover.superduperwide { + width: 800px; + max-width:800px; +} + +.popover .thumbnails li { + margin-left: 0; + margin-right: 10px; +} +div.submitwhen { + margin-bottom: 1em; +} + +.describesubmitwhen { + font-size: 70%; + a { + border-bottom: 1px dashed gray + } + margin-bottom: 3px; + line-height: 1.2; +} + +.sortbuttons { + margin-top: 3px; + a.sortbutton.btn-mini { + font-size: 75%; + } +} + +.closetext { + font-size: 70%; + margin-top: -10px; + margin-left: 55px; + margin-bottom: 10px; +} +.addcomment a, a.privateflag { + border-bottom: 1px dashed gray; +} +a.chooserecentcat { + :hover { + text: white !important; + background-color: white !important; + text-decoration: none !important; + } +} +.bubblecontainer { + position: relative; +} +.thumbnail .attribution { + max-height: 2em; + height: 2em; + overflow-y: hidden; +} + +.bubble { + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + border-radius: 3px; + -moz-box-shadow: rgba(0, 0, 0, 0.05) 2px 2px 4px 0; + -webkit-box-shadow: rgba(0, 0, 0, 0.05) 2px 2px 4px 0; + box-shadow: rgba(0, 0, 0, 0.05) 2px 2px 4px 0; + background: #fdf7d8; + border: 1px solid #f0ebcd; + float: left; + margin-bottom: 10px; + margin-left: 13px; + margin-top: 5px; + padding: 6px; + width: 525px; + z-index: 1; +} +.bubble:before { + margin-top: -10px; + border-left-style: none; + left: 32px; + top: 20px; + z-index: 2; + border-width: 10px; + border-color: transparent; + border-right-color: #fdf7d8; + border-style: solid; + content: ""; + height: 0; + position: absolute; + width: 0; + content: ""; + display: block; +} +.bubblecontainer img { + float: left; + position: relative; + top: 6px; + border-radius: 4px; + border: 1px solid black; +} + +div.clear { + clear: both; +} + +.attribution { + text-align: right; + font-size: 70%; +} + +.venuestatus { + font-weight: bold; + color: red; +} +.venue.filtered { + display:none; +} +.photo_item img { + height: 150px; +} + +.filterrow { + text-align: center; +} + +.filterlist table { + width: 100%; +} + +.filterlist { + select.opselect, select.fieldselect { + width: 100%; + } + .removeposition { + text-align: right; + } + .center { + text-align: center; + } + .noinput { + width: 206px; + } +} +.form-horizontal .filtercontrol.control-group { + margin-bottom: 0; + label { + margin-bottom: 0; + } +} +form.filterform.form-horizontal { + margin-bottom: 4px; +} + +.filterlink { + text-align: left; + margin-top: -1em; +} + +.hidefilter { + left: 20px; +} diff --git a/app/assets/stylesheets/explorer2.css.scss b/app/assets/stylesheets/explorer2.css.scss new file mode 100644 index 0000000..4b4d966 --- /dev/null +++ b/app/assets/stylesheets/explorer2.css.scss @@ -0,0 +1,636 @@ +@import "compass/css3/columns"; + +.foursweep-search { + .nav-tabs li a { + font-size: 80%; + border: 1px dashed #ddd; + line-height: 10px; + border-bottom: 1px solid transparent; + } + .nav-tabs li.active a { + background-color: #efdfbd; + font-weight: bold; + } + + .tab-pane { + padding: 1em; + background-color: #efdfbd; + } + + .tab-content { + overflow: visible; + } +} + +.cat_container + .cat_container::before { + content: " · " +} + +.coolseparator + .coolseparator::before { + content: " · " +} + +.venue.gone, .venue.merged { + .venueactions, .venuebuttons, .label.ratio { + display: none; + } + .venuemissing { + min-height: 3em; + padding-top: 1em; + a { + border-bottom: 1px dashed black; + } + } +} + +.venuelistcontrols { + text-align: center; +} + +.edit_history { + .deltaop { + width: 6em; + } + .spacer { + height: 1em; + } + + .editors { + font-size: 80%; + } + .editor + .editor::before { + content: " · " + } + .oldvalue, .newvalue { + word-break: break-word; + } +} + +.text-capitalize { + text-transform: capitalize; +} + +.facebookdetails { + .permanently-closed { + font-weight: bold; + color: red; + } + .small { + font-size: 85%; + } + .hours2val { + margin-left: 3em; + } +} + +.addressrow { + .absent i { + color: gray; + } +} + +i.nudge { + position: relative; + bottom: 1px; + right: 1px; +} + +#radiusdropdown { + position: relative; + top: 5px; + + height: 26px; + line-height: 26px; + background-clip: padding-box; + background-color: white; + box-shadow: rgba(0, 0, 0, 0.29804) 0px 1px 4px -1px; + border: 1px solid rgba(0, 0, 0, 0.14902); + border-radius: 2px; + padding: 3px 8px; + font-size: 11px; + cursor: pointer; +} + +.namerow, .itemcontainer .details { + i { + margin-right: -5px; + } + .locked .i-lock { + color: darkblue; + } + .verified .i-ok-circled { + color: green; + } +} + +.friendVisits li { + float: left; + font-size: 95%; + margin-bottom: 5px; + width: 170px; + line-height: 1.1; + img { + border-radius:3px; + height: 40px; + width: 40px; + padding-right: 5px; + } + .liked { + color: orange; + } + .disliked { + color: brown; + } + .visitcount { + color: #999999; + } +} + +.sortdirbutton { + border-left: none; +} +.sortrefreshbutton { + border-left: none; +} + + +.pendingflagslist { + border-collapse: separate; + border-spacing: 0.5em; + width: 100%; + + th.flagtype { + border: 1px solid black; + } + td { + padding-left: 1em; + } + .small { + font-size: 80%; + } + .reporters { + background-color: #ddd; + height: 1.2em; + } + + .categoryicon { + border: 1px solid gray; + border-radius: 5px; + // margin-left: 1em; + } + + .flagtype { + text-align: center; + width: 25%; + font-weight: bold; + padding: 1em; + font-size: 120%; + .details { + font-size: 65%; + } + } + + .dl-horizontal { + margin: 0; + } + + .flagtype-at { + background-color: #7FCAFF; + } + .flagtype-info, .flagtype-editName, .flagtype-menu { + background-color: #7F97FF; + } + .flagtype-duplicate { + background-color: #FF9C7E; + } + .flagtype-manualDuplicate { + background-color: #FF7FB0; + } + .flagtype-hours { + background-color: #F3FF7E; + } + .flagtype-remove.reason-event_over, .flagtype-remove.reason-closed { + background-color: #E77FFF; + } + + .flagtype-remove.reason-inappropriate, .flagtype-remove.reason-doesnt_exist, .flagtype-remove.reason-remove_home, .flagtype-remove.reason-created_in_error { + background-color: #FF9C7E; + } + + .flagtype-uncategorized { + background-color: #7FCAFF; + } + + .flagtype-category { + background-color: #CAF562; + } + .flagtype-removecategory { + background-color: #FFF17E; + } + .flagtype-primarycategory { + background-color: #62F5C8; + } + .flagtype-privatevenue, .flagtype-publicvenue { + background-color: #FF7FB0; + } + .flagtype-missingphone, .flagtype-missingaddress, .flagtype-mi { + background-color: #FFBD7E; + } + .flagtype-mislocated { + background-color: #FFD77E; + } + .flagtype-unremove { + background-color: #CAF562; + } +} + +.dl-compact { + dd { + margin-bottom: 0.1em; + } +} + +h5.attributeheader { + background-color: tan; + padding: 0.3em; +} + +div.attributes { + @include columns(3); + .attributeheader { + @include column-break-after(avoid); + break-after: avoid; + margin: 0; + } + dl { + @include column-break-before(avoid); + break-before: avoid; + margin-top: 0.5em; + margin-bottom: 0; + } + dd, dt { + @include column-break-before(avoid); + break-before: avoid; + } +} + +.venuediv .pagination, .venuediv .pager { + margin: 0; + margin-bottom: 2px; +} + +.globalButton { + padding-top: 5px; +} + +.globalButton>div { + background-clip: padding-box; + background-color: white; + box-shadow: rgba(0, 0, 0, 0.298039) 0px 1px 4px -1px; + border: 1px solid rgba(0, 0, 0, 0.14902); + border-radius: 2px; + padding: 3px 8px; + font-size: 11px; + cursor: pointer; + :hover { + background-color: rgb(235, 235, 235); + } +} + +.noselect { + user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} + +.fitButtons div { + background-clip: padding-box; + background-color: white; + box-shadow: rgba(0, 0, 0, 0.298039) 0px 1px 4px -1px; + border: 1px solid rgba(0, 0, 0, 0.14902); + border-radius: 2px; + padding: 3px 8px; + font-size: 11px; + cursor: pointer; + :hover { + background-color: rgb(235, 235, 235); + } + .fitButton { + display: inline; + padding: 1px 3px 1px 3px; + } +} + +.globalButton.clicked>div { + font-weight: bold; + border-color: black; + padding: 2px 8px; + border-width: 2px; +} + +.nearButton { + user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + padding-top: 5px; + &.open { + padding-top: 1px; + .ellipsis { + display: none; + } + } + div { + background-clip: padding-box; + background-color: white; + box-shadow: rgba(0, 0, 0, 0.298039) 0px 1px 4px -1px; + border: 1px solid rgba(0, 0, 0, 0.14902); + border-radius: 2px; + padding: 3px 8px; + font-size: 11px; + cursor: pointer; + :hover { + background-color: rgb(235, 235, 235); + } + + .input-append { + margin-bottom: 0; + } + + input { + height: 16px; + } + } +} + +.loading { + text-align: center; + padding-top: 3em; + margin: auto; + width: 50%; + .progress { + margin-top: 0.5em; + } +} + +.extras { + margin-bottom: 3px; + min-height: 68px; + padding: 5px; + h5 { + margin-top: 2px; + margin-bottom: 0; + } + + .photocontainer { + text-align: center; + img { + margin-left: 1em; + // margin-top: 1em; + } + } + .relationship { + color: blue; + } + i.i-facebook { + margin-left: -5px; + } +} + +.listextras { + img { + height: 150px; + } +} + +.list_preview { + list-style: none; + background-color: #eedebd; + border-radius: 5px; + margin: 5px; + margin-left: 0; + padding: 0.5em 1em 0.5em 1em; + + img { + border-radius: 10px; + border: 1px solid black; + margin: 5px; + margin-right: 10px; + height: 150px; + } +} + +.previewphoto { + border-radius: 10px; + border: 1px solid black; + margin: 5px; + margin-right: 10px; +} + +div.clear { + height: 0; + clear: both; +} + +.form-nobottom { + margin-bottom: 0; +} + +.unselectable { + -moz-user-select: none; + -o-user-select: none; + -khtml-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.searcherror { + margin-top: 2em; + .retry { + margin-top: 1em; + } +} + +.childvenue { + padding-bottom: 5px; + img { + border-radius: 8px; + border: 1px solid black; + } +} + +.select2-results { + + .optionbold { + font-weight: bold; + } + .category-indent-2 { + padding-left: 1em; + } + .category-indent-3 { + padding-left: 2em; + } + .category-indent-4 { + padding-left: 3em; + } + .category-indent-5 { + padding-left: 4em; + } + +} + +.venueedit { + .select2-choice { + line-height: 18px; + min-height: 40px; + } + .select2-choice.select2-default { + min-height: inherit; + line-height: 26px; + } +} + +.parentcandidate { + img { + float: left; + border-radius: 5px; + border: 1px solid black; + margin-top: 2px; + margin-right: 5px; + } + .name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: bold; + } + .address { + color: gray; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.distance-warning .distance { + color: red; +} + +.subtle { + color: #aaa; + margin-right: -3px; +} + +.truncateurl { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.loadinglists { + margin-top: 1em; + margin-bottom: 0; +} + +.input-daterange .add-on { + border-left: none; +} + +ul.haspins { + border-bottom: 1px solid #aaa; + padding-bottom: 10px; + margin-bottom: 15px; +} + +.compactchain { + margin-bottom: 1em; + border-radius: 5px; + padding: 3px; + + .btn.selectpage { + margin-top: 1em; + } + &:nth-child(2n) { + background-color: #ddd; + } +} + +hr.thinmargin { + margin: 10px 0; +} + +.venuebuttons .foursweepflagsbutton { + opacity: 0.7 !important; +} + +.pending4sweepflags { + .commenttext { + color: orange; + } + .flagaction { + margin-bottom: 1px; + } + .break-word { + word-wrap: break-all; + } +} + +.limitedheight { + max-height: 550px; + overflow-y: auto; +} + +.openstate-clickonly, .openstate-hoveronly { + display: none; +} + +.openstate-hover .openstate-hoveronly, .openstate-clicked .openstate-clickonly { + display: inherit; +} + +.openstate-clicked .popover-title a.facebookmini { + margin-left: 0.5em; + border-bottom: none; + margin-top: -3px; + display: inline-block; +} + +.popover.openstate-clicked { + border: 1px solid black; + .arrow { + border-right-color: black; + } +} + +.monospaced { + font-family: monospace; +} + +.allvenues .loadmorecontainer { + margin-bottom: 0.5em; +} + +#dupsearch-help a { + border-bottom: 1px dashed gray; +} + +.nopadding #wrap > .container-fluid { + padding-top: 0; +} + +div.relocateMap { + height: 500px; + width: 100%; +} + +.gmnoprint img { + // http://stackoverflow.com/questions/9904379/google-map-zoom-controls-not-displaying-correctly + max-width: none; +} diff --git a/app/assets/stylesheets/flags.css.scss b/app/assets/stylesheets/flags.css.scss new file mode 100644 index 0000000..310f2c8 --- /dev/null +++ b/app/assets/stylesheets/flags.css.scss @@ -0,0 +1,55 @@ +// Place all the styles related to the Flags controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ + +.tab-content { + border: 1px solid #ddd; + border-top: none; +} + +ul.nav-tabs { + margin-bottom: 0; + margin-top: 1em; +} + +td.flagname { + max-width: 15%; + width: 15%; +} + +.currentlyprocessing { + width: 30em; + margin: auto; +} + +.editeddetails { + font-size: 90%; + color: blue; + margin-left: 2em; +} + +.flagcomment { + font-size: 90%; + color: orange; + margin-left: 2em; +} + +.flaggedItem a { + border-bottom: 1px dashed gray; +} + +.flags .tiptext { + font-size: 90%; + margin-left: 2em; +} + +.include_types input[type=checkbox] { + margin-top: 0px; +} + +.flags i.i-foursquare { + font-size: 90%; + margin-left: -4px; + margin-right: -4px; + color: #F94877; +} diff --git a/app/assets/stylesheets/items.css.scss b/app/assets/stylesheets/items.css.scss new file mode 100644 index 0000000..51f61bf --- /dev/null +++ b/app/assets/stylesheets/items.css.scss @@ -0,0 +1,278 @@ +.itemsmodal { + .sizeradios { + input, label { + display: inline; + } + input { + position: relative; + bottom: 3px; + } + label: { + margin-left: 2em; + } + margin-left: 5em; + position: relative; + bottom: 3px; + } + .item.selected { + background-color: #FFAF7A; + } + + .items { + margin-left: auto; + margin-right: auto; + } + + .items .span3, .photos .span4 { + margin-left: 0; + margin-right: 10px; + } + + &.photomodal .items { + .photos_large { + margin-right: 10px; + margin-left: 0; + } + .photos_medium { + margin-right: 10px; + margin-left: 0; + } + .photos_small { + margin-right: 5px; + margin-left: 0; + } + .photos_tiny { + margin-right: 3px; + margin-left: 0; + } + } + .details { + text-align: right; + font-size: 75%; + line-height: 1.1; + a { + border-bottom: 1px dashed gray; + } + } + .photos_small .details { + font-size: 70%; + } + .photos_tiny .details { + display: none; + } + .itemactions { + a.btn:visited { + color: black; + } + margin-bottom: 5px; + text-align: center; + } + .items .item:hover:not(.selected) { + background-color: #b7cda9; + } + .items .alreadyflagged { + background-color: #eee !important; + img { + opacity: 0.33; + } + color: gray; + a { + color: gray; + } + } + .placeholder { + .loadmorestatus { + font-size: 1.3em; + text-align: center; + display: none; + } + &.nomore .nomoretext { + display: block; + } + &.loading .loadingtext { + display: block; + } + } + &.photomodal .placeholder .loadmorestatus { + padding-top: 100px; + min-height: 150px; + } + &.tipmodal .placeholder { + text-align: center; + } +} + +.notifythumbnailcontainer { + margin-left: 0; +} + +.tipmodal { + width: 60%; + left: 20% !important; + margin-top: 0 !important; + height: 75%; + .modal-body { + margin: auto; + min-height: 70%; + } + .tipfiltercontainer { + margin-left: 2em; + } + .tipphoto img { + top: 0; + margin-right: 1em; + } + .bubblecontainer { + width: 700px; + .bubble { + width: 600px; + } + } +} + +.photomodal { + width: 90%; + left: 5% !important; + top: 5% !important; + margin-top: 0 !important; + height: 90% !important; + .modal-body { + min-height: 80%; + } +} + +#photozoommodal { + width: 70%; + height: 85%; + left: 35% !important; + top: 10% !important; + .modal-body { + min-height: 90%; + img { + border: 1px solid black; + } + } + .zoomdetails { + margin-right: 1em; + padding: 1em; + background-color: #eee; + .photo_cat_icon { + background-color: #ccc; + } + } +} + +.zoomableimage { + position: relative; + .zoomicon, .tipicon { + position: absolute; + background-color: gray; + &:hover { + background-color: orange; + } + } +} + +.photos_tiny .zoomicon { + top: 20px; + left: 0px; + width: 20px; +} +.photos_small .zoomicon { + top: 64px; + left: 0px; + width: 25px; +} +.photos_medium .zoomicon { + top: 110px; + left: 0px; + width: 30px; +} +.photos_large .zoomicon { + top: 150px; + left: 0px; + width: 40px; +} + +.noknowntip .tipicon { + visibility: hidden; +} +.hasTip .tipicon { + visibility: visible; +} +.hasTip .tooltip .tooltip-inner { + min-width: 200px; + max-width: 200px; +} + +.photos_tiny .tipicon { + top: 20px; + right: 0px; + width: 20px; +} +.photos_small .tipicon { + top: 64px; + right: 0px; + width: 25px; +} +.photos_medium .tipicon { + top: 110px; + right: 0px; + width: 30px; +} +.photos_large .tipicon { + top: 150px; + right: 0px; + width: 40px; +} + + +.itemsmodal { + .toggles a { + border-bottom: 1px dashed gray; + } + .item { + margin-bottom: 2px; + } + .item_deleted, .item_closed, .item_home, .item_private, .item_no_longer_relevant { + background-color: #ddd; + // border: 1px solid red; + } + margin-left: 0; + .modal-body { + margin: auto; + } + .item_home .details .photo_cat_icon { + border-color: red; + } + .tip .attribution { + line-height: 1.1; + } + .warn { + color: red; + } + .details { + .photo_cat_icon, .photo_user_icon { + height: 3em; + width: 3em; + border: 1px solid #999; + border-radius: 0.5em; + margin-left: 3px; + background-color: #ccc; + } + .photo_cat_icon { + margin-top: 6px; + } + .photo_user_icon { + margin-top: 2px; + } + max-height: 4.1em; + min-height: 4.1em; + overflow-y: hidden; + } +} + +.tipholder { + border-bottom: 1px dotted gray; + font-weight: bold; +} diff --git a/app/assets/stylesheets/scaffolds.css.scss b/app/assets/stylesheets/scaffolds.css.scss new file mode 100644 index 0000000..05188f0 --- /dev/null +++ b/app/assets/stylesheets/scaffolds.css.scss @@ -0,0 +1,56 @@ +body { + background-color: #fff; + color: #333; + font-family: verdana, arial, helvetica, sans-serif; + font-size: 13px; + line-height: 18px; } + +p, ol, ul, td { + font-family: verdana, arial, helvetica, sans-serif; + font-size: 13px; + line-height: 18px; } + +pre { + background-color: #eee; + padding: 10px; + font-size: 11px; } + +a { + color: #000; + &:visited { + color: #666; } + &:hover { + color: #fff; + background-color: #000; } } + +div { + &.field, &.actions { + margin-bottom: 10px; } } + +#notice { + color: green; } + +.field_with_errors { + padding: 2px; + background-color: red; + display: table; } + +#error_explanation { + width: 450px; + border: 2px solid red; + padding: 7px; + padding-bottom: 0; + margin-bottom: 20px; + background-color: #f0f0f0; + h2 { + text-align: left; + font-weight: bold; + padding: 5px 5px 5px 15px; + font-size: 12px; + margin: -7px; + margin-bottom: 0px; + background-color: #c00; + color: #fff; } + ul li { + font-size: 12px; + list-style: square; } } diff --git a/app/assets/stylesheets/session.css.scss b/app/assets/stylesheets/session.css.scss new file mode 100644 index 0000000..4fda5af --- /dev/null +++ b/app/assets/stylesheets/session.css.scss @@ -0,0 +1,41 @@ +// Place all the styles related to the Session controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ +body.session { + .jumbotron { + color: white; + background-image: url('/img/congruent_outline.png'); + + h1 { + margin-top: 0.5em; + font-size: 120px; + font-weight: bold; + } + h2 { + font-size: 40px; + text-align: center; + line-height: 1.25; + font-weight: 150; + color: #ddd; + } + min-height: 370px; + margin:0; + } + .container-fluid { + padding: 0; + } + + .navbar { + display: none; + } + + .haspadding { + padding-left: 20px; + padding-right: 20px; + } + + .small { + font-size: 80%; + margin-top: 1em; + } +} diff --git a/app/assets/stylesheets/static_pages.css.scss b/app/assets/stylesheets/static_pages.css.scss new file mode 100644 index 0000000..e2da67c --- /dev/null +++ b/app/assets/stylesheets/static_pages.css.scss @@ -0,0 +1,43 @@ +// Place all the styles related to the static_pages controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ + +h1 { + font-size: 180%; + margin-bottom: 0; + margin-top: 1em; +} +h2 { + text-align: left; + margin-bottom: 0.5em; + margin-top: 0.5em; +} +h3 { + margin-bottom: 0.3em; +} + +.done { + color: green; + opacity: 0.4; +} + +dd { + margin-bottom: 1em; +} + +.change { + max-width: 70em; + margin-left: 2em; + margin-bottom: 1em; +} + +.changelist { + margin-bottom: 5em; + h2 { + font-size: 130%; + } +} + +#moderator-embed-target { + margin:auto; +} diff --git a/app/assets/stylesheets/stats.css.scss b/app/assets/stylesheets/stats.css.scss new file mode 100644 index 0000000..b65de25 --- /dev/null +++ b/app/assets/stylesheets/stats.css.scss @@ -0,0 +1,6 @@ +// Place all the styles related to the Stats controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ +.alert a { + border-bottom: 1px dashed gray; +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..c0e76ac --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,78 @@ +class ApplicationController < ActionController::Base + protect_from_forgery + attr_accessor :current_user + helper_method :current_user + before_filter :set_foursquare_user + + def set_foursquare_user + @current_user = get_current_user + end + + def api_version + "20140825" + end + + def not_found + raise ActionController::RoutingError.new('Not Found') + end + + rescue_from Foursquare2::APIError do |error| + if error.message =~ /invalid_auth/ + redirect_to :controller => :session, :action => :logout + end + logger.error "Foursquare2::APIError: #{error.message}" + # redirect_to :controller=>:session, :action=>:error + return + end + + rescue_from ActionController::RoutingError do |error| + logger.error "Could not find: #{error.message}" + redirect_to :controller=>:session, :action=>:error + end + + rescue_from Faraday::Error::ParsingError do |error| + logger.error "Parsing Error from Faraday: #{error}" + if error.message =~ /backend read error/ + logger.error "Backend read error!" + end + redirect_to :controller=>:session, :action=>:error + end + + def foursquare_userless + @foursquare ||= Foursquare2::Client.new(:client_id => Settings.app_id, :client_secret => Settings.app_secret, :connection_middleware => [Faraday::Response::Logger, FaradayMiddleware::Instrumentation], :api_version => api_version) + end + + private + def require_user + if cookies[:access_token] == nil + redirect_to :controller => :session, :action => :new + return false + end + session[:access_token] = cookies[:access_token] + @current_user = current_user + if @current_user.nil? + redirect_to :controller => :session, :action => :new + elsif !@current_user.allowed? + redirect_to :controller => :session, :action => :not_allowed unless @current_user.allowed? + end + end + + def get_current_user + return nil if cookies[:access_token].blank? + + begin + foursquare = Foursquare2::Client.new(:oauth_token => cookies[:access_token], :connection_middleware => [Faraday::Response::Logger, FaradayMiddleware::Instrumentation], :api_version => api_version) + @current_user ||= User.find_by_token(cookies[:access_token]) + @current_user ||= User.find_by_uid(foursquare.user('self').id) + rescue Foursquare2::APIError + cookies[:access_token] = nil + session[:access_token] = nil + redirect_to :controller => :session, :action=>:new + end + @current_user + end + + def flag_counts + @flagcount = @current_user.flags.where('status IN (?)', ['new','queued']).count() + end +end diff --git a/app/controllers/explorer_controller.rb b/app/controllers/explorer_controller.rb new file mode 100644 index 0000000..abcf64a --- /dev/null +++ b/app/controllers/explorer_controller.rb @@ -0,0 +1,13 @@ +class ExplorerController < ApplicationController + before_filter :require_user + + def explore + @categories = CategoriesCache::latest.categories + @flagcount = Flag.find_all_by_user_id_and_status(@current_user, ['new', 'queued']).count() + @lat, @lng = @current_user.recent_ll + @lat ||= 40.664167 + @lng ||= -73.938611 + @client_id = Settings.app_id + end +end + diff --git a/app/controllers/flags_controller.rb b/app/controllers/flags_controller.rb new file mode 100644 index 0000000..540512a --- /dev/null +++ b/app/controllers/flags_controller.rb @@ -0,0 +1,255 @@ +class FlagsController < ApplicationController + before_filter :require_user + + FLAG_TYPES = { + "merge_flags" => { + :types => ["MergeFlag"], + :name =>"Duplicate" + }, + "category_flags" => { + :types => ["AddCategoryFlag", "MakeHomeFlag", "RemoveCategoryFlag", "MakePrimaryCategoryFlag", "ReplaceAllCategoriesFlag"], + :name => "Category" + }, + "close_venue_flags" => { + :types => ["CloseFlag", "ReopenFlag"], + :name => "Close" + }, + "private_venue_flags" => { + :types => ["MakePrivateFlag", "MakePublicFlag"], + :name => "Private" + }, + "remove_venue_flags" => { + :types => ["DeleteFlag", "UndeleteFlag"], + :name => "Remove" + }, + "photo_flags" => { + :types => ["PhotoFlag"], + :name => "Photo" + }, + "tip_flags" => { + :types => ["TipFlag"], + :name => "Tip" + }, + "edit_venue_flags" => { + :types => ["EditVenueFlag"], + :name => "Venue Details" + } + } + + def index + @status = params[:status] || "new" + + @status_types = [@status] + if params[:status] == 'hidden/canceled' + @status_types = ['hidden', 'canceled', 'cancelled'] + end + + @flag_types = FLAG_TYPES + + @pagesize = (params[:pagesize] || 100).to_i + + if (params[:include_types]) + @selected_types = params[:include_types].split(",").select {|e| FLAG_TYPES.has_key?(e)} + else + @selected_types = FLAG_TYPES.keys + end + + types = @selected_types.map {|e| FLAG_TYPES[e][:types]}.flatten + + @known_statuses = ['new', 'submitted', 'resolved', 'queued', 'scheduled', 'canceled', 'cancelled', 'hidden', 'alternate resolution'] + + @ordertype = (params.has_key? "order_last_checked" and @status == 'submitted') ? "last_checked" : "created_at" + + order = (@ordertype == 'last_checked') ? 'last_checked desc' : 'created_at desc' + + if params[:status] == "other" + @flags = @current_user.flags.where("status NOT IN (?) and type IN (?)", @known_statuses, types).order(order).page(params[:page]).per(@pagesize) + else + @flags = @current_user.flags.where("status IN (?) and type IN (?)", @status_types, types).order(order).page(params[:page]).per(@pagesize) + end + + @pagenum = params[:page] || 1; + @unsubmitted_flags = @flags.select {|e| e.status == 'new'} + @unresolved_flags = @flags.select {|e| e.status == 'submitted'} + @scheduled_flags = @flags.select {|e| e.status == 'scheduled'} + + @flag_tabs = ['new', 'queued', 'submitted', 'resolved', 'scheduled', 'hidden/canceled', 'alternate resolution', 'other'] + + @total_flags_counts = @current_user.flags.count(:group => :status) + @total_flags_counts.default = 0 + + @allflagscount = @total_flags_counts.values.sum + @total_flags_counts['newcount'] = @total_flags_counts['queued'] + @total_flags_counts['new'] + @total_flags_counts['hidden/canceled'] = @total_flags_counts['canceled'] + @total_flags_counts['hidden'] + @total_flags_counts['cancelled'] + + @total_flags_counts['other'] = @allflagscount - @total_flags_counts.select {|k, v| @known_statuses.include? k}.values.sum + + @flagcount = @total_flags_counts['queued'] + @total_flags_counts['new'] + queuesize + + respond_to do |format| + format.html + format.json {render :json => @flags} + end + end + + def newcount + render :json => {:newcount => newflags}, status => 200 + end + + def resubmit + processflags do |flag| + flag.resolved_details = nil + flag.queue_for_submit(Time.zone.now) + flag + end + end + + def hide + processflags do |flag| + flag.status = 'hidden' + flag.resolved_details = nil + flag.save + end + end + + def flagtype(type) + case type + when "AddCategoryFlag" then AddCategoryFlag + when "CloseFlag" then CloseFlag + when "DeleteFlag" then DeleteFlag + when "EditVenueFlag" then EditVenueFlag + when "MergeFlag" then MergeFlag + when "MakeHomeFlag" then MakeHomeFlag + when "MakePrimaryCategoryFlag" then MakePrimaryCategoryFlag + when "MakePrivateFlag" then MakePrivateFlag + when "MakePublicFlag" then MakePublicFlag + when "ReopenFlag" then ReopenFlag + when "RemoveCategoryFlag" then RemoveCategoryFlag + when "ReplaceAllCategoriesFlag" then ReplaceAllCategoriesFlag + when "UndeleteFlag" then UndeleteFlag + when "PhotoFlag" then PhotoFlag + when "TipFlag" then TipFlag + else + raise "Invalid Flag Type" + end + end + + def create + flags = [] + + params[:flags].values.each do |flag| + flag = flagtype(flag[:type]).new(flag) + flag.user = @current_user + flag.save! + if params[:runimmediately] && params[:runimmediately] != 'false' or flag["scheduled_at"] + flag.queue_for_submit(5.minutes.from_now) + end + flags << flag + end + + respond_to do |format| + format.json {render :json => {:flags => flags, :newcount => newflags}, :status => :created } + end + end + + def run + processflags do |flag| + flag.queue_for_submit(Time.now) + end + end + + def check + processflags do |flag| + flag.resolved? + end + end + + def cancel + processflags do |flag| + flag.cancel + end + end + + def statuses + allowed_statuses = ['new', 'submitted', 'queued', 'scheduled'] + + if params.has_key? :venue_ids + @flags = @current_user. + flags. + select('`id`, `venueId`, `user_id`, `secondaryVenueId`, `secondaryName`, `primaryName`, `itemId`, `type`, `status`, `itemName`, `problem`, `edits`, `created_at`, `last_checked`, `comment`, `scheduled_at`, `resolved_details`'). + where('(`venueId` IN (:ids) OR `secondaryVenueId` IN (:ids)) AND (`status` IN (:statuses)) AND (`type` IN (:included_types))', + :user_id => @current_user.id, + :ids => params[:venue_ids], + :statuses => allowed_statuses, + :included_types => params[:types]) + elsif params.has_key? :creator_ids + @flags = @current_user. + flags. + select('`id`, `venueId`, `user_id`, `secondaryVenueId`, `secondaryName`, `primaryName`, `itemId`, `type`, `status`, `itemName`, `problem`, `edits`, `created_at`, `last_checked`, `comment`, `scheduled_at`, `resolved_details`'). + where('creatorId IN (:ids) AND (`status` IN (:statuses)) AND (`type` IN (:included_types))', + :user_id => @current_user.id, + :ids => params[:creator_ids], + :statuses => allowed_statuses, + :included_types => params[:types]) + end + + if params.has_key? :forcecheck + response = @flags.each do |flag| + tryflagaction(flag) do |c| + c.resolved? + end + end + response = response.select do |flag| + allowed_statuses.include? flag.status + end + respond_to do |format| + format.json {render :json => response, :status => 200} + end + else + respond_to do |format| + format.json {render :json => @flags, :status => 200} + end + end + + end + + private + def processflags(&action) + to_run = @current_user.flags.find(params[:ids]) + responses = [] + to_run.each do |flag| + responses.push tryflagaction(flag, &action) + end + respond_to do |format| + format.json {render :json => {:flags => responses, :newcount => newflags}} + end + end + + def tryflagaction(flag) + begin + yield flag + return {:flag => flag} + rescue Foursquare2::APIError => e + if e.message =~ /quota exceeded/i + return {:flag => flag, :message => "Quota Exceeded, Try Again Later"} + elsif e.message =~ /Please retry/i + return {:flag => flag, :message => "Try again later"} + else + # Rollbar.report_exception(e) + return {:flag => flag, :message => "Unknown Error"} + end + rescue Faraday::Error::ParsingError => e + return {:flag => flag, :message => "Foursquare Error: Try again later"} + end + end + + def queuesize + now = Delayed::Job.db_time_now + @queue_size = Delayed::Job.where('failed_at is null and run_at <= ?', Delayed::Job.db_time_now).count + end + + def newflags + @current_user.flags.count(:conditions => "status IN ('new', 'queued')") + end +end diff --git a/app/controllers/heartbeat_controller.rb b/app/controllers/heartbeat_controller.rb new file mode 100644 index 0000000..5ef6673 --- /dev/null +++ b/app/controllers/heartbeat_controller.rb @@ -0,0 +1,53 @@ +# This is called when Pingdom does a health check by hitting https://4sweep.com/heartbeat, roughly every 5 min. +class HeartbeatController < ApplicationController + def heartbeat + submit_cloudwatch() + render :json => {"status" => "OK"}, :status => 200 + end + + private + def submit_cloudwatch + now = Delayed::Job.db_time_now + failed_jobs = Delayed::Job.where("failed_at is not null").count + first_unprocessed = Delayed::Job.where('failed_at is null and run_at <= ?', Delayed::Job.db_time_now).order('run_at').first + age = now - (first_unprocessed.nil? ? now : first_unprocessed.run_at) + first_unprocessed_high_priority = Delayed::Job.where('failed_at is null and priority < 50 and run_at <= ?', Delayed::Job.db_time_now).order('run_at').first + high_priority_oldest_job_age = now - (first_unprocessed_high_priority.nil? ? now : first_unprocessed_high_priority.run_at) + queue_size = Delayed::Job.where('failed_at is null and run_at <= ?', Delayed::Job.db_time_now).count + submit_queue_size = Delayed::Job.where('failed_at is null and queue = "submit" and run_at <= ?', Delayed::Job.db_time_now).count + high_priority_queue_size = Delayed::Job.where('failed_at is null and priority < 50 and run_at <= ?', Delayed::Job.db_time_now).count + + errors = Delayed::Job.where('last_error is not null').count + # HACK ALERT: much faster than .count(), but wrongish. + flags = Flag.maximum(:id) + + metrics = [ + {:metric_name => "failed_jobs", :value => failed_jobs, :unit => "Count"}, + {:metric_name => "oldest_job_age", :value => age, :unit => "Seconds"}, + {:metric_name => "queue_size", :value => queue_size, :unit => "Count"}, + {:metric_name => "error_count", :value => errors, :unit => "Count"}, + {:metric_name => "submit_queue_size", :value => submit_queue_size, :unit => "Count"}, + {:metric_name => "high_priority_queue_size", :value => high_priority_queue_size, :unit => "Count"}, + {:metric_name => "high_priority_oldest_job_age", :value => high_priority_oldest_job_age, :unit => "Seconds"}, + {:metric_name => "flags_count", :value => flags, :unit => "Count"}, + ] + + # HACK ALERT: this will very much break if the app is reset or if there are multiple frontends. + # flags_created should be considered an unreliable metric. + if $LAST_FLAG_REPORT.nil? + $LAST_FLAG_REPORT = flags + new_flags = nil + else + new_flags = flags - $LAST_FLAG_REPORT + $LAST_FLAG_REPORT = flags + metrics << {:metric_name => "flags_created", :value => new_flags, :unit => "Count"} + end + cw = AWS::CloudWatch.new(:access_key_id => Settings.cloudwatch_key, :secret_access_key => Settings.cloudwatch_secret) + cw.put_metric_data( + :namespace => "4sweep_#{Rails.env}", + :metric_data => metrics + ) + Rails.logger.debug("Reported status to cloudwatch: [failed_jobs: #{failed_jobs}, oldest_job_age: #{age}," + + " queue_size: #{queue_size}, error_count: #{errors}, flags_created: #{new_flags}]") + end +end diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb new file mode 100644 index 0000000..a031465 --- /dev/null +++ b/app/controllers/session_controller.rb @@ -0,0 +1,80 @@ +class SessionController < ApplicationController + attr_accessor :code + + def callback + code = params[:code] + unless code + redirect_to :action => :new + return + end + if (params[:error] == "access_denied") + redirect_to :action => :new + return + end + if code + # set up new oauth2 client, get token + begin + + token = oauth_client.auth_code.get_token(code, :redirect_uri => Settings.callback_url) + cookies.permanent[:access_token] = token.token + rescue OAuth2::Error => e + flash[:notice] = "Login Failure: " + e.message + end + + end + + # Now that we have an access token, let's see if we have a user for this person: + foursquare = Foursquare2::Client.new(:oauth_token => cookies[:access_token], :connection_middleware => [Faraday::Response::Logger, FaradayMiddleware::Instrumentation], :api_version => '20140107') + + foursquare_user = foursquare.user('self') + user = User.find_by_uid(foursquare_user.id) + + if user + @current_user = user + @current_user[:token] = cookies[:access_token] + # let's clear their user cache, it seems to be causing problems: + @current_user.user_cache = nil + @current_user.cached_at = nil + @current_user.save + else + @current_user = User.create( + :uid => foursquare_user.id, + :name => "#{foursquare_user.firstName} #{foursquare_user.lastName}".strip, + :token => cookies[:access_token], + :enabled => true) + end + + redirect_to :controller => :explorer, :action => :explore + end + + def new + @current_user ||= User.find_by_token(cookies[:access_token]) + redirect_to :controller => :explorer, :action => :explore if @current_user + + @authorize_url = oauth_client.auth_code.authorize_url(:redirect_uri => Settings.callback_url) + end + + def not_allowed + end + + def error + end + + def logout + cookies[:access_token] = nil + session[:access_token] = nil + redirect_to :action => :new + end + + private + + def oauth_client + client = OAuth2::Client.new( + Settings.app_id, + Settings.app_secret, + :authorize_url => "/oauth2/authorize", + :token_url => "/oauth2/access_token", + :site => 'https://foursquare.com') + end + +end diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb new file mode 100644 index 0000000..dff28bc --- /dev/null +++ b/app/controllers/static_pages_controller.rb @@ -0,0 +1,15 @@ +class StaticPagesController < ApplicationController + before_filter :require_user, :flag_counts + + def suggestions + end + + def faq + end + + def contact + end + + def changelog + end +end diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb new file mode 100644 index 0000000..10325f2 --- /dev/null +++ b/app/controllers/stats_controller.rb @@ -0,0 +1,48 @@ +class StatsController < ApplicationController + before_filter :require_user, :except => :category_changes + + def stats + + user_id = params[:user_id] || nil + + if (!@current_user.is_admin?) + @foruser = @current_user + obj = @current_user.flags + else + if (user_id) + @foruser = User.find(user_id) + obj = @foruser.flags + else + obj = Flag + end + end + + if params[:date] + datefilter = "date(flags.created_at) = ?" + else + datefilter = true #no op + end + + @user_counts = obj.select("users.name, users.level, users.hometown, user_id, count(*) as flag_count").joins(:user).group('user_id').order('flag_count desc').where(datefilter, params[:date]) + @type_counts = obj.select("type, count(*) as flag_count").group("type").order("flag_count desc").where(datefilter, params[:date]) + @problem_counts = obj.select("problem, count(*) as flag_count").group("problem").order("flag_count desc").where("problem is not null").where(datefilter, params[:date]) + @status_counts = obj.select("status, count(*) as flag_count").group("status").order("flag_count desc").where(datefilter, params[:date]) + @day_counts = obj.select("date(created_at) as date, count(*) as flag_count").group("date(created_at)").order("date desc").where(datefilter, params[:date]) + + end + + def category_changes + current_user # provide current user if available + cats = CategoriesCache.order("last_verified desc") + + @diffs = [] + for i in 0...(cats.size - 1) + @diffs << { + :removed => cats[i+1].aslist - cats[i].aslist, + :added => cats[i].aslist - cats[i+1].aslist, + :created_at => cats[i].created_at, + :last_verified => cats[i].last_verified + } + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 0000000..d30c179 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,16 @@ +module ApplicationHelper + + def api_version + "20140825" + end + + def total_flags + #Flag.count() + # Hack alert: this is hacky, but much faster than .count() + Flag.maximum(:id) + end + + def release + "0.30.16" + end +end diff --git a/app/helpers/explorer_helper.rb b/app/helpers/explorer_helper.rb new file mode 100644 index 0000000..5c8eaa6 --- /dev/null +++ b/app/helpers/explorer_helper.rb @@ -0,0 +1,2 @@ +module ExplorerHelper +end diff --git a/app/helpers/flags_helper.rb b/app/helpers/flags_helper.rb new file mode 100644 index 0000000..946ce94 --- /dev/null +++ b/app/helpers/flags_helper.rb @@ -0,0 +1,15 @@ +module FlagsHelper + + def friendly_status(status) + case status + when 'not_authorized' + 'not authorized' + when 'new' + 'not yet submitted' + when 'resolved' + 'accepted' + else + status + end + end +end diff --git a/app/helpers/session_helper.rb b/app/helpers/session_helper.rb new file mode 100644 index 0000000..f867f86 --- /dev/null +++ b/app/helpers/session_helper.rb @@ -0,0 +1,2 @@ +module SessionHelper +end diff --git a/app/helpers/static_pages_helper.rb b/app/helpers/static_pages_helper.rb new file mode 100644 index 0000000..2d63e79 --- /dev/null +++ b/app/helpers/static_pages_helper.rb @@ -0,0 +1,2 @@ +module StaticPagesHelper +end diff --git a/app/helpers/stats_helper.rb b/app/helpers/stats_helper.rb new file mode 100644 index 0000000..65e2f8b --- /dev/null +++ b/app/helpers/stats_helper.rb @@ -0,0 +1,2 @@ +module StatsHelper +end diff --git a/app/mailers/.gitkeep b/app/mailers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/models/add_category_flag.rb b/app/models/add_category_flag.rb new file mode 100644 index 0000000..43cd9dc --- /dev/null +++ b/app/models/add_category_flag.rb @@ -0,0 +1,14 @@ +class AddCategoryFlag < CategoryChangeFlag + + def submithelper + client.propose_venue_edit(venueId, :addCategoryIds => itemId, :comment => comment_text) + end + + def category_resolved?(venue) + (venue.categories.map {|e| e.id}.include?(itemId)) + end + + def friendly_name + "Add Category: " + itemName + end +end diff --git a/app/models/categories_cache.rb b/app/models/categories_cache.rb new file mode 100644 index 0000000..a08ea4e --- /dev/null +++ b/app/models/categories_cache.rb @@ -0,0 +1,64 @@ +class CategoriesCache < ActiveRecord::Base + attr_accessible :categories + + MAX_AGE = 1.hour + + def self.latest + cat = first(:order => "created_at desc") + cat ||= fetch + if (cat.last_verified == nil) or (Time.now - cat.last_verified > MAX_AGE) and (Delayed::Job.where('queue = ?', 'category_refresh').count == 0) + delay(:queue => "category_refresh", :priority => 10).fetch + end + cat + end + + def self.fetch + foursquare_userless = Foursquare2::Client.new(:client_id => Settings.app_id, :client_secret => Settings.app_secret, :api_version => "20140218") + c = foursquare_userless.venue_categories.to_json + latest = first(:order => "created_at desc") + test = new(:categories => c) + if latest && test.digest == latest.digest + latest.last_verified = Time.now + latest.save + return latest + else + test.last_verified = Time.now + test.save + return test + end + + end + + def aslist + c = JSON.parse(categories) + list_helper(c, "") + end + + def categories= (value) + write_attribute(:categories, value) + write_attribute(:digest, set_digest) + end + + def set_digest + # Digest should incorporate other information, like icons + digest = Digest::SHA1.hexdigest(aslist.join("\n")) + end + + private + def list_helper(categories, prefix) + result = [] + # sort categories by name + categories.each do |c| + result << prefix + c['name'] + if c['categories'] + list_helper(c['categories'], prefix + c['name'] + " > ").each do |sub| + result << sub + end + end + # c['categories'].each do |sub| + # result << list_helper(sub, prefix + c['name'] + " > ") + # end + end + result + end +end diff --git a/app/models/category_change_flag.rb b/app/models/category_change_flag.rb new file mode 100644 index 0000000..77762ff --- /dev/null +++ b/app/models/category_change_flag.rb @@ -0,0 +1,57 @@ +class CategoryChangeFlag < Flag + attr_accessible :itemId, :itemName + validates_presence_of :itemId, :on => :create, :message => "can't be blank" + validates_presence_of :itemName, :on => :create, :message => "can't be blank" + + def getVenueOrResolve + begin + venue = client.venue(venueId) + + # if primary_id_changed?(venue) + # self.status = 'alternate resolution' + # self.resolved_details = '(merged)' + # save + + # return true + # end + + if is_closed?(venue) + self.status = 'alternate resolution' + self.resolved_details = '(closed)' + self.save + return true + end + + return venue + rescue Foursquare2::APIError => e + if e.message =~ /has been deleted/ or e.message =~ /is invalid for venue id/ + self.status = 'alternate resolution' + self.resolved_details = '(deleted)' + self.save + return true + else + raise e + end + end + end + + def resolved? + return true if status == 'resolved' + return false if status == 'queued' + + resolved = false + venue = getVenueOrResolve() + if (venue === true) + return true + else + resolved = category_resolved?(venue) + self.status = 'resolved' if resolved + self.resolved_details = nil if resolved + end + + self.last_checked = Time.now + self.save + + resolved + end +end diff --git a/app/models/close_flag.rb b/app/models/close_flag.rb new file mode 100644 index 0000000..0dc0912 --- /dev/null +++ b/app/models/close_flag.rb @@ -0,0 +1,58 @@ +class CloseFlag < Flag + attr_accessible :problem + validates_inclusion_of :problem, :in => %w( event_over closed ), :on => :create, :message => " problem %s is not allowed for CloseFlag" + + def submithelper + client.flag_venue(venueId, :problem => problem, :comment => comment_text) + end + + def resolved? + return true if status == 'resolved' + resolved = false + begin + venue = client.venue(venueId) + rescue Foursquare2::APIError => e + if e.message =~ /has been deleted/ or e.message =~ /is invalid for venue id/ + self.update_attribute('status', 'resolved') + self.update_attribute('resolved_details', '(deleted)') + self.update_attribute('last_checked', Time.now) + return true + else + raise e + end + end + + resolved = is_closed?(venue) + update_attribute('status', 'resolved') if resolved + update_attribute('resolved_details', nil) if resolved + + if (!resolved) + if primary_id_changed?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(merged)') + return true + end + + if is_home?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(home)') + return true + end + + self.update_attribute('last_checked', Time.now) + # self.update_attribute('primaryHasHome', self.has_home?(venue)) + end + + resolved + end + + def friendly_name + "Close: " + + case problem + when "event_over" + "Event Over" + when "closed" + "Closed" + end + end +end diff --git a/app/models/delete_flag.rb b/app/models/delete_flag.rb new file mode 100644 index 0000000..92f06ca --- /dev/null +++ b/app/models/delete_flag.rb @@ -0,0 +1,66 @@ +class DeleteFlag < Flag + attr_accessible :problem + + validates_inclusion_of :problem, :in => %w( mislocated inappropriate closed doesnt_exist event_over ), :on => :create, :message => "value %s is not valid for DeleteFlag" + + def submithelper + client.flag_venue(venueId, :problem => problem, :comment => comment_text) + end + + def resolved? + return true if status == 'resolved' + resolved = false + + begin + venue = client.venue(venueId) + # self.update_attribute('primaryHasHome', has_home?(venue)) + + + if is_home?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(home)') + return true + end + + if is_closed?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(closed)') + return true + end + + if primary_id_changed?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(merged)') + return true + end + + if venue.deleted == true + self.update_attribute('status', 'resolved') + self.update_attribute('resolved_details', nil) + self.update_attribute('last_checked', Time.now) + end + rescue Foursquare2::APIError => e + if e.message =~ /has been deleted/ or e.message =~ /is invalid for venue id/ + resolved = true + self.update_attribute('status', 'resolved') + self.update_attribute('resolved_details', nil) + self.update_attribute('last_checked', Time.now) + else + raise e + end + end + self.update_attribute('last_checked', Time.now) + + resolved + end + + def friendly_name + "Remove: " + + case problem + when "doesnt_exist" + "Doesn't Exist" + when "inappropriate" + "Inappropriate" + end + end +end diff --git a/app/models/edit_venue_flag.rb b/app/models/edit_venue_flag.rb new file mode 100644 index 0000000..850318f --- /dev/null +++ b/app/models/edit_venue_flag.rb @@ -0,0 +1,259 @@ +class EditVenueFlag < Flag + serialize :edits, JSON + + attr_accessible :edits + + validates_presence_of :edits, :on => :create, :message => "can't be blank" + + def self.streetCleanup(text) + # This is just some normalization Foursquare does to + # addresses. This logic is brittle and non-i18n'ed, + # needs more data to derive proper rules. + text.gsub!(/\./,'') + text.gsub!("Street", "St") + text.gsub!("Road", "Rd") + text.gsub!("Avenue", "Ave") + text.gsub!("Boulevard", "Blvd") + text.gsub!("Turnpike", "Tpke") + text.gsub!("Circle", "Cir") + text.gsub!("Drive", "Dr") + text.gsub!("Lane", "Ln") + text.gsub!("Court", "Ct") + text.gsub!("Mount", "Mt") + text.gsub!("Route", "Rte") + text.gsub!("Heights", "Hts") + text.gsub!(/[;,] *Suite *#/, " #") + text.gsub!(/[;,] *Suite/, " Ste") + text.gsub!(/[;,] *Ste/, " Ste") + text.gsub!(/[;,] *#/, ' #') + text.gsub!(/ (#|Suite|Ste)/, "\\1") + return text + end + + DAYS = { + 1 => "Mon", + 2 => "Tue", + 3 => "Wed", + 4 => "Thu", + 5 => "Fri", + 6 => "Sat", + 7 => "Sun" + } + + + KNOWN_FIELDS = { + "name" => { + :friendly_name => "Name", + :value_getter => lambda {|v,h| [v.name || ""]}, + :normalizer => lambda {|t| t.strip} + }, + "address" => { + :friendly_name => "Address", + :value_getter => lambda {|v,h| [v.location.address || ""]}, + :normalizer => lambda {|t| self.streetCleanup(t).strip} + }, + "crossStreet" => { + :friendly_name => "Cross Street", + :value_getter => lambda {|v,h| [v.location.crossStreet || ""]}, + :normalizer => lambda {|t| self.streetCleanup(t).strip} + }, + "city" => { + :friendly_name => "City", + :value_getter => lambda {|v,h| [v.location.city || ""]}, + :normalizer => lambda {|t| t.strip} + }, + "state" => { + :friendly_name => "State", + :value_getter => lambda {|v,h| [v.location.state || ""]}, + :normalizer => lambda {|t| t.strip} + }, + "zip" => { + :friendly_name => "Postal Code", + :value_getter => lambda {|v,h| [v.location.postalCode || ""]}, + :normalizer => lambda {|t| t.gsub(/[ -.]/, '').strip} + }, + "phone" => { + :friendly_name => "Phone", + :value_getter => lambda {|v,h| [v.contact.phone || ""]}, + :normalizer => lambda {|t| t.gsub(/\D/, '')} + }, + "twitter" => { + :friendly_name => "Twitter", + :value_getter => lambda {|v,h| [v.contact.twitter || ""]}, + :normalizer => lambda {|t| + return "" if t.nil? or t.length == 0 + return (t || " ").split('?').first.split('/').last.downcase + } + }, + "facebookUrl" => { + :friendly_name => "Facebook", + :value_getter => lambda {|v,h| [v.contact.facebook || "", v.contact.facebookUsername || ""]}, + :normalizer => lambda {|t| + return "" if t.nil? or t.length == 0 + return (t || " ").split('?').first.split('/').last.downcase + } + }, + "url" => { + :friendly_name => "Web Page", + :value_getter => lambda {|v,h| [v.url || ""]}, + :normalizer => lambda {|t| t.gsub(/\/$/, '').gsub(/https?:\/\//, '').downcase} + }, + "menuUrl" => { + :friendly_name => "Menu URL", + :value_getter => lambda {|v,h| [v.menu.externalUrl || ""]}, + :normalizer => lambda {|t| t.gsub(/\/$/, '').gsub(/https?:\/\//, '').downcase} + }, + "parentId" => { + :friendly_name => "Parent ID", + :value_getter => lambda {|v,h| v.parent ? [v.parent.id] : [""]}, + :normalizer => lambda {|t| t.strip} + }, + "hours" => { + :friendly_name => "Hours", + :value_getter => lambda do |v,h| + result = [] + if !h.hours.timeframes.nil? + h.hours.timeframes.each do |timeframe| + timeframe.days.each do |day| + timeframe.open.each do |segment| + result.push "#{day},#{segment['start']},#{segment['end']}" + end + end + end + end + return [result.sort.join(";")] + end, + :normalizer => lambda {|t| t.split(/;/).sort.uniq.join(";") }, + :friendly_value => lambda do |val| + val.split(/;/).map do |tf| + (day, open, close) = tf.split(',') + next if close.nil? + open = open.gsub(/([0-9][0-9])([0-9][0-9])$/,'\1:\2') + close = close.gsub(/([0-9][0-9])([0-9][0-9])$/,'\1:\2') + "#{DAYS[day.to_i]}: #{open} - #{close}" + end.join(", ") + end + }, + "description" => { + :friendly_name => "Description", + :value_getter => lambda {|v,h| [v.description.nil? ? "" : v.description]}, + :normalizer => lambda {|t| t.strip} + }, + "venuell" => { + :friendly_name => "Lat/Lng", + :value_getter => lambda {|v,h| ["#{v.location.lat},#{v.location.lng}"]}, + :normalizer => lambda {|t| t.split(",").map{|e| e.slice(0,8)}.join(',') } + } + + } + + def submithelper + params = self.edits['newvalues'].keep_if {|k, v| KNOWN_FIELDS.keys.include? k}.merge(:comment => comment_text) + client.propose_venue_edit(venueId, params) + end + + def resolvedhelper?(venue) + result = true + + if edits['newvalues'].include? 'hours' + hours = client.venue_hours(venueId) + else + hours = nil + end + + edits['newvalues'].each do |field, value| + values = KNOWN_FIELDS[field][:value_getter].call(venue, hours) + normalizedValues = values.map do |realValue| + KNOWN_FIELDS[field][:normalizer].call(realValue) + end + + found = normalizedValues.include? KNOWN_FIELDS[field][:normalizer].call(value) + if (!found) + logger.debug "venue {#{venueId}: edit not accepted: #{field}, looking for #{normalizedValues} found #{KNOWN_FIELDS[field][:normalizer].call(value)}" + end + + result &= found + end + if !result + # Let's see if any of the fields have changed: + edits['oldvalues'].each do |field, value| + # values = KNOWN_FIELDS[field][:value_getter].call(venue) + if (! KNOWN_FIELDS[field][:value_getter].call(venue, hours).include? value) + self.status = 'alternate resolution' + self.resolved_details = "(changed)" + end + end + end + result + end + + # TODO: Factor up to flag with good parameters + def getVenueOrResolve + begin + venue = client.venue(venueId) + + if primary_id_changed?(venue) + self.status = 'alternate resolution' + self.resolved_details = '(merged)' + save + + return true + end + + if is_closed?(venue) + self.status = 'alternate resolution' + self.resolved_details = '(closed)' + self.save + return true + end + + return venue + rescue Foursquare2::APIError => e + if e.message =~ /has been deleted/ or e.message =~ /is invalid for venue id/ + self.status = 'alternate resolution' + self.resolved_details = '(deleted)' + self.save + return true + else + raise e + end + end + end + + # TODO: Factor this up too + def resolved? + return true if status == 'resolved' + return false if status == 'queued' + + resolved = false + venue = getVenueOrResolve() + if (venue === true) + return true + else + resolved = resolvedhelper?(venue) + self.status = 'resolved' if resolved + self.resolved_details = nil if resolved + end + + self.last_checked = Time.now + self.save + + resolved + end + + def friendly_name + "Edit Venue Details" + end + + def details + friendly_edited_fields.map {|change| "#{change[:field]}: #{change[:value]}"} + end + + def friendly_edited_fields + edits['newvalues'].keep_if {|k, v| KNOWN_FIELDS.keys.include? k}.map do |k, v| + v = '""' if v.empty? + {:field => KNOWN_FIELDS[k][:friendly_name], + :value => (KNOWN_FIELDS[k].include? :friendly_value) ? KNOWN_FIELDS[k][:friendly_value].call(v) : v } + end.to_a + end +end diff --git a/app/models/flag.rb b/app/models/flag.rb new file mode 100644 index 0000000..306f9f3 --- /dev/null +++ b/app/models/flag.rb @@ -0,0 +1,182 @@ +class Flag < ActiveRecord::Base + belongs_to :user + attr_accessible :created_at, :primaryName, :venueId, + :status, :type, :user, :problem, + :comment, :scheduled_at, :venues_details + + serialize :venues_details, JSON + + validates_presence_of :venueId, :on => :create, :message => "can't be blank" + # validates_inclusion_of :status, :in => %w( new resolved submitted ), :message => "extension %s is not included in the list" + validates_presence_of :user_id, :on => :create, :message => "can't be blank" + HOME_CAT_ID = '4bf58dd8d48988d103941735'; + + def client + user.foursquare_client + end + + def userless_client + @userless_client ||= Foursquare2::Client.new(:client_id => Settings.app_id, :client_secret => Settings.app_secret, :connection_middleware => [Faraday::Response::Logger, FaradayMiddleware::Instrumentation], :api_version => '20140825') + end + + def queue_for_submit(delayed_time = Time.now, queue = 'submit') + if self.job_id + begin + # Delete job if it is already present + Delayed::Job.find(self.job_id).destroy + rescue ActiveRecord::RecordNotFound => e + # No big deal, we can ignore it + end + end + + unless self.scheduled_at.nil? + if (scheduled_at > Time.now) + delayed_time = scheduled_at + else + if type == "MergeFlag" + delayed_time = Time.now + 6.minutes + else + delayed_time = Time.now + 5.minutes + end + end + queue = 'scheduled_close' + self.status = 'scheduled' + else + self.status = 'queued' + end + + if type == 'RemoveCategoryFlag' + # we remove cats first, since they can sometimes affect other flags + priority = 10 + delayed_time = delayed_time - 20.seconds + elsif type == 'PhotoFlag' + priority = 50 + else + priority = 20 + end + + job = Delayed::Job.enqueue(SubmitFlagJob.new(self), :priority => priority, :run_at =>delayed_time, :queue => queue) + self.job_id = job.id + save + end + + def submit + if status == 'canceled' || status == 'resolved' + return + end + begin + result = submithelper + rescue Foursquare2::APIError => e + if e.message =~ /not_authorized/ + self.update_attribute('status', 'not_authorized') + return + end + if e.message =~ /has been deleted/ + self.update_attribute('status', 'resolved') + self.update_attribute('resolved_details', '(deleted)') unless type == 'DeleteFlag' + return + end + raise e + end + if (self.creatorId.nil? && result && result['creator']) + self.creatorId = result['creator']['id'] + self.creatorName = ((result['creator']['firstName'] || "") + " " + (result['creator']['lastName'] || "")).strip + end + self.status = 'submitted' + self.submitted_at = Time.now + self.save + delay(:run_at => 20.seconds.from_now, :queue => 'check', :priority => (type == "PhotoFlag" ? 55 : 45)).resolved? + + flag_json = "NO FLAGS" + if result && result.flags + flag_json = result.flags.to_json + elsif result && result.woes + flag_json = result.woes.to_json + end + Rails.logger.info("RESPONSE: #{id}\t#{type}\t#{flag_json}") + result + end + + def cancel + if status == 'new' or status == 'queued' or status == 'scheduled' + self.status = 'canceled' + if self.job_id + Delayed::Job.find(self.job_id).destroy + self.job_id = nil + end + save + end + end + + def hide + self.update_attribute('status', 'hidden') + end + + # return true if the venue has home as a secondary category + def has_home?(venue) + catIds = venue.categories.map {|e| e.id} + return catIds.length > 0 && catIds.include?(HOME_CAT_ID) && catIds.first != HOME_CAT_ID + end + + # return true if the venue passed has home as a primary category + def is_home?(venue) + catIds = venue.categories.map {|e| e.id} + return catIds.length > 0 && catIds.first == HOME_CAT_ID + end + + # return true if the venue passed has home as a primary category + def primary_id_changed?(primaryVenue) + return primaryVenue.id != venueId + end + + def is_closed?(venue) + return venue.closed == true + end + + def comment_text + if comment.nil? + "" + else + comment.strip + end + end + + def job + if @jobCache + return @jobCache + end + + if self.job_id + begin + @jobCache = Delayed::Job.find(self.job_id) + return @jobCache + rescue ActiveRecord::RecordNotFound => e + # Rollbar.report_exception(e) + return false + end + else + false + end + end + + def scheduled_time + if job + job.run_at + end + end + + def delayed_due_to_rate_limit + job && !job.last_error.nil? && job.last_error.include?("rate_limit_exceeded") + end + + def flag_type + type.to_s + end + + def details + end + + def as_json(options = {}) + super options.merge(:methods => [:friendly_name, :flag_type, :details]) + end +end diff --git a/app/models/make_home_flag.rb b/app/models/make_home_flag.rb new file mode 100644 index 0000000..7bf5e20 --- /dev/null +++ b/app/models/make_home_flag.rb @@ -0,0 +1,30 @@ +class MakeHomeFlag < ReplaceAllCategoriesFlag + after_initialize :set_home_values + + def set_home_values + self.itemName = "Home (Private)" + self.itemId = HOME_CAT_ID + end + + def submithelper + if (user.level.empty? || user.level == "1") + # SU <=1 seem to have bad behavior with the proposeEdit endpoint, so let's use home_recategorize flag + client.flag_venue(venueId, :problem => 'home_recategorize', :comment => comment_text) + else + # We prefer to submit this through the /venue/edit endpoint, since it seems to work way better + super + end + end + + def itemId + HOME_CAT_ID + end + + def itemName + "Home (Private)" + end + + def friendly_name + "Category: Home" + end +end diff --git a/app/models/make_primary_category_flag.rb b/app/models/make_primary_category_flag.rb new file mode 100644 index 0000000..0a6caf9 --- /dev/null +++ b/app/models/make_primary_category_flag.rb @@ -0,0 +1,14 @@ +class MakePrimaryCategoryFlag < CategoryChangeFlag + + def submithelper + client.propose_venue_edit(venueId, :primaryCategoryId => itemId, :comment => comment_text) + end + + def category_resolved?(venue) + (venue.categories.select{|e| e.primary}.map {|e| e.id}.include?(itemId)) + end + + def friendly_name + "Make Primary Category: " + itemName + end +end diff --git a/app/models/make_private_flag.rb b/app/models/make_private_flag.rb new file mode 100644 index 0000000..b22e6cb --- /dev/null +++ b/app/models/make_private_flag.rb @@ -0,0 +1,58 @@ +class MakePrivateFlag < Flag + attr_accessible :problem + + validates_inclusion_of :problem, :in => %w( private ), :on => :create, :message => "problem cannot be %s" + + def submithelper + client.flag_venue(venueId, :problem => problem, :comment => comment_text); + end + + def resolved? + return true if status == 'resolved' + resolved = false + + begin + venue = client.venue(venueId) + + if primary_id_changed?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(merged)') + return true + end + + if is_closed?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(closed)') + return true + end + + if is_home?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(home)') + return true + end + + if venue['private'] == true + self.update_attribute('status', 'resolved') + self.update_attribute('resolved_details', nil) + return true + end + rescue Foursquare2::APIError => e + if e.message =~ /has been deleted/ or e.message =~ /is invalid for venue id/ + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(deleted)') + return true + else + raise e + end + + end + self.update_attribute('last_checked', Time.now) + resolved + end + + def friendly_name + "Make Private" + end + +end diff --git a/app/models/make_public_flag.rb b/app/models/make_public_flag.rb new file mode 100644 index 0000000..f1bff75 --- /dev/null +++ b/app/models/make_public_flag.rb @@ -0,0 +1,58 @@ +class MakePublicFlag < Flag + attr_accessible :problem + + validates_inclusion_of :problem, :in => %w( public ), :on => :create, :message => " problem cannot be %s" + + def submithelper + client.flag_venue(venueId, :problem => problem, :comment => comment_text); + end + + def resolved? + return true if status == 'resolved' + resolved = false + + begin + venue = client.venue(venueId) + + if primary_id_changed?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(merged)') + return true + end + + if is_closed?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(closed)') + return true + end + + if is_home?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(home)') + return true + end + + unless venue['private'] == true + self.update_attribute('status', 'resolved') + self.update_attribute('resolved_details', nil) + return true + end + rescue Foursquare2::APIError => e + if e.message =~ /has been deleted/ or e.message =~ /is invalid for venue id/ + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(deleted)') + return true + else + raise e + end + + end + self.update_attribute('last_checked', Time.now) + resolved + end + + def friendly_name + "Make Public" + end + +end diff --git a/app/models/merge_flag.rb b/app/models/merge_flag.rb new file mode 100644 index 0000000..900333d --- /dev/null +++ b/app/models/merge_flag.rb @@ -0,0 +1,62 @@ +class MergeFlag < Flag + attr_accessible :secondaryJSON, :secondaryName, :secondaryVenueId + + def submithelper + client.flag_venue(venueId, :problem => 'duplicate', :venueId => secondaryVenueId, :comment => comment_text) + end + + def self.from_venues(venue1, venue2) + end + + def resolved? + return true if status == 'resolved' + + begin + primary = client.venue(venueId) + secondary = client.venue(secondaryVenueId) + rescue Foursquare2::APIError => e + if e.message =~ /has been deleted/ or e.message =~ /is invalid for venue id/ + self.status = 'alternate resolution' + self.resolved_details = '(deleted)' + self.save + return true + else + raise e + end + end + self.update_attribute('last_checked', Time.now) + + if primary.id == secondary.id + self.status = 'resolved' + self.resolved_details = nil + self.save + return true + end + + if primary.id != venueId or secondary.id != secondaryVenueId + self.status = 'alternate resolution' + self.resolved_details = '(merged with another venue)' + self.save + return true + end + + if is_home?(primary) or is_home?(secondary) + self.status = 'alternate resolution' + self.resolved_details = '(home)' + self.save + end + + if is_closed?(primary) or is_closed?(secondary) + self.status = 'alternate resolution' + self.resolved_details = '(closed)' + self.save + return true + end + + false + end + + def friendly_name + "Duplicate" + end +end diff --git a/app/models/photo_flag.rb b/app/models/photo_flag.rb new file mode 100644 index 0000000..bf5c8b9 --- /dev/null +++ b/app/models/photo_flag.rb @@ -0,0 +1,99 @@ +# Hack alert: We need to add flag_photo to Foursquare2 for this +module Foursquare2 + module Photos + # Flag a photo as having a problem + # + # @param [String] photo_id - Photo id to flag, required. + # @param [Hash] options + # @option options String :problem - Reason for flag, one of 'spam_scam', 'nudity', 'hate_violence', 'illegal', 'unrelated', 'blurry'. Required. + def flag_photo(photo_id, options={}) + response = connection.post do |req| + req.url "photos/#{photo_id}/flag", options + end + return_error_or_body(response, response.body.response) + end + + end +end + +class PhotoFlag < Flag + attr_accessible :itemId, :itemName, :creatorId, :creatorName + validates_presence_of :itemId, :on => :create, :message => "can't be blank" + validates_inclusion_of :problem, :in => ['spam_scam', 'nudity', 'hate_violence', 'illegal', 'unrelated', 'blurry'], + :on => :create, :message => "problem %s is not valid for Photos" + + def submithelper + begin + result = client.flag_photo(itemId, :problem => problem, :comment => comment) + rescue Foursquare2::APIError => e + if e.message =~ /not authorized to view/ or e.message =~ /Must provide a valid photo ID/ + self.status = 'resolved' + self.resolved_details = nil + save + else + raise e + end + end + result + end + + def resolved? + return true if status == 'resolved' + resolved = false + + begin + photo = client.photo(itemId) + if photo.demoted == true + resolved = true + self.status = 'resolved' + self.resolved_details = nil + end + if photo.venue.nil? + self.status = "alternate resolution" + self.resolved_details = "(venue gone)" + resolved = true + else + if photo.venue.categories[0] && photo.venue.categories[0].id == HOME_CAT_ID + self.status = "alternate resolution" + self.resolved_details = "(venue is home)" + resolved = true + end + if photo.venue.closed + self.status = "alternate resolution" + self.resolved_details = "(venue closed)" + resolved = true + end + end + rescue Foursquare2::APIError => e + if e.message =~ /not authorized to view/ or e.message =~ /Must provide a valid photo ID/ + resolved = true + self.status = 'resolved' + self.resolved_details = nil + else + raise e + end + end + + self.last_checked = Time.now + save + resolved + end + + def friendly_name + "Photo: " + + case problem + when "spam_scam" + "Spam" + when "nudity" + "Nudity" + when "hate_violence" + "Hate/Violence" + when "illegal" + "Illegal" + when "blurry" + "Blurry" + when "unrelated" + "Unrelated" + end + end +end diff --git a/app/models/remove_category_flag.rb b/app/models/remove_category_flag.rb new file mode 100644 index 0000000..ddb9ee1 --- /dev/null +++ b/app/models/remove_category_flag.rb @@ -0,0 +1,14 @@ +class RemoveCategoryFlag < CategoryChangeFlag + + def submithelper + client.propose_venue_edit(venueId, :removeCategoryIds => itemId, :comment => comment_text) + end + + def category_resolved?(venue) + !(venue.categories.map {|e| e.id}.include?(itemId)) + end + + def friendly_name + "Remove Category: " + itemName + end +end diff --git a/app/models/remove_home_flag.rb b/app/models/remove_home_flag.rb new file mode 100644 index 0000000..bc56dd7 --- /dev/null +++ b/app/models/remove_home_flag.rb @@ -0,0 +1,68 @@ +module Foursquare2 + module Venues + def venue_edit(venue_id, options={}) + response = connection.post do |req| + req.url "venues/#{venue_id}/edit", options + end + return_error_or_body(response, response.body.response) + end + end +end + +# This class has been deprecated +class RemoveHomeFlag < Flag + + def submithelper + venue = client.venue(venueId) + catIds = venue.categories.map {|e| e.id} + if (catIds.include?(HOME_CAT_ID)) + newCatIds = catIds.reject {|e| e == HOME_CAT_ID} + client.venue_edit(venueId, :categoryId => newCatIds.join(",")) + else + self.update_attribute("resolved_details", "does not have home cat") + self.update_attribute("status", "resolved") + end + + end + + def resolved? + return true if status == 'resolved' + venue = client.venue(venueId) + catIds = venue.categories.map {|e| e.id} + + begin + unless (catIds.include?(HOME_CAT_ID)) + self.update_attribute("status", "resolved") + self.update_attribute('resolved_details', nil) + return true + end + + if primary_id_changed?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(merged)') + return true + end + + if is_home?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(merged)') + return true + end + rescue Foursquare2::APIError => e + if e.message =~ /has been deleted/ or e.message =~ /is invalid for venue id/ + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(deleted)') + return true + else + raise e + end + end + self.update_attribute('last_checked', Time.now) + + false + end + + def friendly_name + "Remove Home Category" + end +end diff --git a/app/models/reopen_flag.rb b/app/models/reopen_flag.rb new file mode 100644 index 0000000..66d6030 --- /dev/null +++ b/app/models/reopen_flag.rb @@ -0,0 +1,52 @@ +class ReopenFlag < Flag + attr_accessible :problem + validates_inclusion_of :problem, :in => %w( not_closed ), :on => :create, :message => " problem %s is not allowed for reopen flag" + + def submithelper + client.flag_venue(venueId, :problem => problem, :comment => comment_text) + end + + def resolved? + return true if status == 'resolved' + resolved = false + begin + venue = client.venue(venueId) + rescue Foursquare2::APIError => e + if e.message =~ /has been deleted/ or e.message =~ /is invalid for venue id/ + self.update_attribute('status', 'resolved') + self.update_attribute('resolved_details', '(deleted)') + self.update_attribute('last_checked', Time.now) + return true + else + raise e + end + end + + resolved = !is_closed?(venue) + update_attribute('status', 'resolved') if resolved + update_attribute('resolved_details', nil) if resolved + + if (!resolved) + if primary_id_changed?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(merged)') + return true + end + + if is_home?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(home)') + return true + end + + self.update_attribute('last_checked', Time.now) + # self.update_attribute('primaryHasHome', self.has_home?(venue)) + end + + resolved + end + + def friendly_name + "Re-open Venue" + end +end diff --git a/app/models/replace_all_categories_flag.rb b/app/models/replace_all_categories_flag.rb new file mode 100644 index 0000000..1538a2a --- /dev/null +++ b/app/models/replace_all_categories_flag.rb @@ -0,0 +1,31 @@ +class ReplaceAllCategoriesFlag < CategoryChangeFlag + + def submithelper + venue = getVenueOrResolve() + if (venue === true) + return + end + if (category_resolved?(venue)) + self.update_attribute("status", "resolved") + return true + end + removeCategoryIds = venue.categories.map {|e| e.id}.reject{|e| e == itemId}.join(",") + params = { + :primaryCategoryId => itemId, + :comment => comment_text + } + if removeCategoryIds.size > 0 + params['removeCategoryIds'] = removeCategoryIds + end + + client.propose_venue_edit(venueId, params) + end + + def category_resolved?(venue) + (venue.categories.map {|e| e.id}.include?(itemId)) && (venue.categories.size == 1) + end + + def friendly_name + "Set Category To: " + itemName + end +end diff --git a/app/models/request_log.rb b/app/models/request_log.rb new file mode 100644 index 0000000..83c5da9 --- /dev/null +++ b/app/models/request_log.rb @@ -0,0 +1,3 @@ +class RequestLog < ActiveRecord::Base + attr_accessible :request, :src, :user_id, :rate_limit, :limit_remaining +end \ No newline at end of file diff --git a/app/models/settings.rb b/app/models/settings.rb new file mode 100644 index 0000000..e9a7e9d --- /dev/null +++ b/app/models/settings.rb @@ -0,0 +1,4 @@ +class Settings < Settingslogic + source "#{Rails.root}/config/application.yml" + namespace Rails.env +end \ No newline at end of file diff --git a/app/models/submit_flag_job.rb b/app/models/submit_flag_job.rb new file mode 100644 index 0000000..068ca90 --- /dev/null +++ b/app/models/submit_flag_job.rb @@ -0,0 +1,50 @@ +class SubmitFlagJob < Struct.new(:flag) + + def perform + flag.submit + end + + def success(job) + flag.job_id = nil + flag.save + end + + def error(job, exception) + @exception = exception + end + + def failure(job) + flag.job_id = nil + flag.status = "failed" + flag.save + end + + def max_attempts + # Retries happen at 5 + n^4 seconds, where n = number of attempts + # 12 gives last retry at 6 hours + return 12 + end + + def reschedule_at(time_now, attempts) + if (@exception.is_a?(Foursquare2::APIError) && @exception.type == 'rate_limit_exceeded') # Rate limit hit + + # Photo and Tip flags have their own endpoint with their own rate limit + if ["PhotoFlag", "TipFlag"].include? flag.type + flags_of_type = flag.user.flags.where(:status => "queued").where(:type => flag.type).count + rate_limit = 500 + else + flags_of_type = flag.user.flags.where(:status => "queued").where("type NOT IN (?)", ["PhotoFlag", "TipFlag"]).count + rate_limit = 5000 + end + # If this fails due to rate_limit_exceeded, figure out how long it will take to process all flags + # of this type and randomly assign a time in that window. It's hacky and brittle, but better than + # every job retrying at the same time. + # TODO: make this reschedule all likely to fail flags, not just this one + # TODO: create CheckFlagJob so that similar logic could be applied to that + return time_now + (60*60 * ((flags_of_type / rate_limit) + 1) * rand).to_i + else + return time_now + (attempts ** 4) + 5 #default exponential backoff + end + end + +end diff --git a/app/models/tip_flag.rb b/app/models/tip_flag.rb new file mode 100644 index 0000000..4c3c055 --- /dev/null +++ b/app/models/tip_flag.rb @@ -0,0 +1,77 @@ +# Hack alert: We need to add flag_tip to Foursquare2 for this +module Foursquare2 + module Tips + # Flag a photo as having a problem + # + # @param [String] tip_id - Tip id to flag, required. + # @param [Hash] options + # @option options String :problem - Reason for flag, one of 'nolongerrelevant', 'spam', 'offensive' + def flag_tip(tip_id, options={}) + response = connection.post do |req| + req.url "tips/#{tip_id}/flag", options + end + return_error_or_body(response, response.body.response) + end + + end +end + +class TipFlag < Flag + attr_accessible :itemId, :itemName, :creatorId, :creatorName + validates_presence_of :itemId, :on => :create, :message => "can't be blank" + validates_inclusion_of :problem, :in => ['nolongerrelevant', 'spam', 'offensive'], + :on => :create, :message => "problem %s is not valid for tips" + + def submithelper + begin + result = client.flag_tip(itemId, :problem => problem, :comment => comment) + rescue Foursquare2::APIError => e + if e.message =~ /Must provide a valid Tip ID/ or e.message =~ /Tip ID not found/ + self.status = 'resolved' + self.resolved_details = nil + save + else + raise e + end + end + result + end + + def resolved? + return true if status == 'resolved' + resolved = false + + begin + tip = client.tip(itemId) + if (tip.flags and tip.flags.include?("no_longer_relevant")) + resolved=true + self.status = 'resolved' + self.resolved_details = nil + end + rescue Foursquare2::APIError => e + if e.message =~ /Must provide a valid Tip ID/ or e.message =~ /Tip ID not found/ + resolved = true + self.status = 'resolved' + self.resolved_details = nil + else + raise e + end + end + + self.last_checked = Time.now + save + resolved + end + + def friendly_name + "Tip: " + + case problem + when 'nolongerrelevant' + "No Longer Relevant" + when "offensive" + "Offensive" + when "spam" + "Spam" + end + end +end diff --git a/app/models/undelete_flag.rb b/app/models/undelete_flag.rb new file mode 100644 index 0000000..bb48bbe --- /dev/null +++ b/app/models/undelete_flag.rb @@ -0,0 +1,60 @@ +class UndeleteFlag < Flag + attr_accessible :problem + + validates_inclusion_of :problem, :in => %w( un_delete ), :on => :create, :message => " problem %s is not recognized" + + def submithelper + client.flag_venue(venueId, :problem => problem, :comment => comment_text) + end + + def resolved? + return true if status == 'resolved' + resolved = false + + begin + venue = client.venue(venueId) + # self.update_attribute('primaryHasHome', has_home?(venue)) + + + if is_home?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(home)') + return true + end + + if is_closed?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(closed)') + return true + end + + if primary_id_changed?(venue) + self.update_attribute('status', 'alternate resolution') + self.update_attribute('resolved_details', '(merged)') + return true + end + + if venue.deleted == true + resolved = false + else + resolved = true + self.update_attribute('status', 'resolved') + self.update_attribute('resolved_details', nil) + self.update_attribute('last_checked', Time.now) + end + rescue Foursquare2::APIError => e + if e.message =~ /has been deleted/ or e.message =~ /is invalid for venue id/ + resolved = false + else + raise e + end + end + self.update_attribute('last_checked', Time.now) + + resolved + end + + def friendly_name + "Undelete Venue" + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..8a17fbd --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,79 @@ +class User < ActiveRecord::Base + attr_accessible :enabled, :level, :name, :token, :uid + has_many :flags + + serialize :user_cache, JSON + MAX_USER_AGE = 1.hour + + def foursquare_client + foursquare ||= Foursquare2::Client.new(:oauth_token => token, :connection_middleware => [Faraday::Response::Logger, FaradayMiddleware::Instrumentation], :api_version => '20140825') + end + + def foursquare_user + if ((read_attribute :cached_at) == nil) or + (Time.now - cached_at > MAX_USER_AGE) + self.user_cache = filtered_user(foursquare_client.user('self')) + self.name = "#{user_cache['firstName']} #{user_cache['lastName']}".strip + self.cached_at = Time.now + self.level = self.user_cache['superuser'] ? self.user_cache['superuser'] : '' + self.hometown = self.user_cache['homeCity'] + save + end + user_cache + end + + # Take a raw user object from Foursquare and + # keep only the few fields we're interested in caching: + # firstName, photo, + def filtered_user(raw_user) + if raw_user['checkins'] && raw_user. checkins.items.count > 0 + recentCheckin = raw_user.checkins.items.first.venue.location + else + recentCheckin = {'lat' => nil, 'lng' => nil} + end + result = { + 'id' => raw_user['id'], + 'firstName' => (raw_user['firstName'] || "").gsub(/[^\u0000-\uFFFF]/, ''), + 'lastName' => (raw_user['lastName'] || "").gsub(/[^\u0000-\uFFFF]/, ''), + 'photo' => raw_user['photo'].to_hash, + 'homeCity' => (raw_user['homeCity'] || "").gsub(/[^\u0000-\uFFFF]/, ''), + 'superuser' => raw_user['superuser'], + 'checkins' => { + 'items' => [ + { + 'venue' => { + 'location' => { + 'lat' => recentCheckin['lat'], + 'lng' => recentCheckin['lng'] + } + } + } + ] + } + } + end + + def photo_src(size='36x36') + if foursquare_user['photo'] + return "#{foursquare_user['photo']['prefix']}#{size}#{foursquare_user['photo']['suffix']}" + else + # Rollbar.report_message("User missing photo hash: #{foursquare_user}") + return "" + end + end + + def recent_ll + u = foursquare_user + [foursquare_user['checkins']['items'].first['venue']['location']['lat'], + foursquare_user['checkins']['items'].first['venue']['location']['lng']] + end + + def allowed? + enabled + end + + def is_admin? + false # REPLACE_ME + end + +end diff --git a/app/views/application/error.html.erb b/app/views/application/error.html.erb new file mode 100644 index 0000000..a09c9ef --- /dev/null +++ b/app/views/application/error.html.erb @@ -0,0 +1,3 @@ +
    +We're sorry, an error has occurred. +
    diff --git a/app/views/explorer/explore.html.erb b/app/views/explorer/explore.html.erb new file mode 100644 index 0000000..2eac787 --- /dev/null +++ b/app/views/explorer/explore.html.erb @@ -0,0 +1,336 @@ +<% content_for :javascripts do %> + <%= javascript_include_tag 'explorer' %> + <%= javascript_include_tag 'advancedsearch' %> + + + + + +<% end %> +<% content_for :flagcount do %> +<%= @flagcount == 0 ? "" : @flagcount %> +<% end %> + +

    +
    + +
    +
    +
    + +
    +
    +
    + + + With 0 selected: + + + + + + + + | + + + + + + + + Sort: Natural + + + + + + + +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    +
    +
      +
      +
      +
      + Loading Venues… +
      +
      +
      +
      +
      +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        + +
        +
        + + +<% content_for :footer do %> + Submit flags: +
        + + +
        + +<% end %> diff --git a/app/views/flags/index.html.erb b/app/views/flags/index.html.erb new file mode 100644 index 0000000..5d640be --- /dev/null +++ b/app/views/flags/index.html.erb @@ -0,0 +1,183 @@ +<% content_for :javascripts do %> + <%= javascript_include_tag 'flags' %> +<% end %> + +<% content_for :flagcount do %> +<%= @flagcount == 0 ? "" : @flagcount %> +<% end %> + +

        Your Flags (<%= friendly_status(@status) %>)

        +
        + + + +<% if @queue_size > 10 %> +
        +
        +
        + Flags currently processing: <%= @queue_size %> (for all users) +
        +
        +<% end %> + +
        +
        + +
        + + +
        +
        +
        + + Page Size: + + <%= select_tag "pagesize", options_for_select([50, 100, 250, 500, 1000], @pagesize), {'class' => 'input-small pagesize'} %> +
        +
        + + +
        +
          + Show Flag Types: + <% @flag_types.each_pair do |key, type| %> +
        • + +
        • + <% end %> +
        +
        + +
        + <%= paginate @flags, :theme => "twitter-bootstrap", :params => (@ordertype == 'last_checked') ? {'order_last_checked' => 1} : {} %> +
        + + + + + + + + + <% if @status == 'submitted' %> + + <% end %> + <% if @status != 'resolved' %> + + <% end %> + + + + <% @flags.each do |flag| %> + + + + + + + <% if @status == 'submitted' %> + + <% end %> + + <% if @status != 'resolved' %> + + <% end %> + + <% end %> + <% if @flags.empty? %> + + + + <% end %> + +
        TypeDate <% if (@ordertype == 'created_at') %><% end %>Flagged ItemStatusLast checked + <% if (@ordertype == 'last_checked') %><% end %>Action
        <%= flag.friendly_name %>'> + <% if flag.type == 'PhotoFlag' && !flag.itemName.nil? %> + ">Photo by <%= flag.creatorName %> () + at + <% end %> + <% if flag.type == "TipFlag" && !flag.itemName.nil? %> + Tip by <%= flag.creatorName %> () at + <% end %> + <%= flag.primaryName || "venue" %> () + <% if flag.secondaryVenueId %> / + + <%= flag.secondaryName %> () + <% end %> + + <% if flag.type == "EditVenueFlag" %> +
        + <% flag.friendly_edited_fields.each do |change| %> + <%= change[:field] %>: <%= change[:value] %>
        + <% end %> +
        + <% end %> + + <% if flag.type == "TipFlag" %> +
        + "<%= flag.itemName %>" +
        + <% end %> + + <% if flag.comment && flag.comment.strip.length > 0%> +
        + Comment: "<%= flag.comment %>" +
        + <% end %> +
        <%= friendly_status(flag.status).capitalize %> <% if flag.resolved_details %><%= flag.resolved_details %><% end %> + <% if flag.status == 'queued' || flag.status == 'scheduled' %> + <% time = flag.scheduled_time %> + <% if time %> + <% if time < Time.now %> + to run as soon as possible + <% else %> + to run + <% end %> + <% end %> + <% if flag.delayed_due_to_rate_limit %> + + <% end %> + <% end %> + + <% if flag.status == 'new' or flag.status == 'failed' %> + Submit + Cancel + <% elsif flag.status == 'queued' || flag.status == 'scheduled' %> + <% if flag.status == 'queued' %> + Run Now + <% end %> + Cancel + <% end %> + + <% if flag.status == 'submitted' or flag.status == 'alternate resolution' %> + Resubmit + Hide + + <% end %> + + <% if flag.status != 'resolved' and flag.status != 'canceled' and flag.status != 'cancelled' %> + Check + <% end %> + +
        No <%= @status %> flags found.
        +
        + <%= paginate @flags, :theme => "twitter-bootstrap", :params => (@ordertype == 'last_checked') ? {'order_last_checked' => 1} : {} %> +
        + +
        +
        +
        +
        + +
        diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 0000000..c96a5da --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,89 @@ + + + + + 4sweep + <%= stylesheet_link_tag "application", :media => "all" %> + <%= javascript_include_tag "application" %> + <%= yield(:javascripts) %> + + <%= csrf_meta_tags %> + + +
        + + + +
        + <%= yield %> +
        + +
        + <% if @current_user %> + + <% end %> + + diff --git a/app/views/layouts/noheader.html.erb b/app/views/layouts/noheader.html.erb new file mode 100644 index 0000000..b018d91 --- /dev/null +++ b/app/views/layouts/noheader.html.erb @@ -0,0 +1,21 @@ + + + + + Foursweep + <%= stylesheet_link_tag "application", :media => "all" %> + <%= javascript_include_tag "application" %> + <%= yield(:javascripts) %> + + <%= yield(:csses) %> + + <%= csrf_meta_tags %> + + +
        + <%= yield %> +
        + + diff --git a/app/views/session/error.html.erb b/app/views/session/error.html.erb new file mode 100644 index 0000000..a09c9ef --- /dev/null +++ b/app/views/session/error.html.erb @@ -0,0 +1,3 @@ +
        +We're sorry, an error has occurred. +
        diff --git a/app/views/session/new.html.erb b/app/views/session/new.html.erb new file mode 100644 index 0000000..aaa5bd7 --- /dev/null +++ b/app/views/session/new.html.erb @@ -0,0 +1,55 @@ +<% content_for :bodyclasses do %>session nopadding<% end %> + +
        +
        +

        4sweep

        + +

        A power tool for Foursquare Superusers

        + +
        + <%= link_to "Log In with Foursquare", @authorize_url, :class=> "btn btn-primary btn-large center"%> +
        +
        +
        +
        + +
        +
        + +
        +

        Find and Report Duplicate, Closed, Miscategorized, Private, and Inappropriate Venues, Photos, and Tips Quickly

        + Duplicate Flag +
        +
        +

        Filled with useful features:

        +
          +
        • Search by name, categories, location, recently created, creating user, lists, uncategorized, flagged venues, your checkin history, chains, and more
        • +
        • Load hundreds or thousands of venues in an area at once
        • +
        • Edit venue details and categories
        • +
        • Quickly hover to see top photos, tips, recent edits, pending flags, Facebook profiles, venue creator, lists, child venues, attributes, hours, ratings, and more.
        • +
        • Report outdated, spammy, inappropriate, and unrelated photos and tips by venue or user
        • +
        • Schedule venue closings
        • +
        • Send your flags to Foursquare and track their approval
        • +
        +
        +
        +

        Popular Among Foursquare Superusers

        +
          +

          Used by <%= number_with_delimiter(User.count, :delimiter => ',') %> superusers

          +

          Over <%= number_to_human(Flag.count, :delimiter => ",").downcase %> edits made

          +
        +
        + +
        +
        +
        +
        + <%= link_to "Get Started Now", @authorize_url, :class=> "btn btn-primary btn-large center"%> +
        +
        +

        4sweep is a Foursquare API tool that connects to your Foursquare account. 4sweep only uses your Foursquare account to submit flags to Foursquare, and collects only the minimum information needed to support that, such as your name and approximate last known location. +

        + This service uses the Foursquare® application programming interface but is not endorsed or certified by Foursquare Labs, Inc. All of the Foursquare® logos (including all badges) and trademarks displayed on this service are the property of Foursquare Labs, Inc.Some graphics from Subtle Patterns under CC BY-SA 3.0.

        + +
        +
        diff --git a/app/views/session/not_allowed.html.erb b/app/views/session/not_allowed.html.erb new file mode 100644 index 0000000..3a74aa5 --- /dev/null +++ b/app/views/session/not_allowed.html.erb @@ -0,0 +1,14 @@ +

        4sweep


        + +
        +

        Hi there.

        + +

        Foursquare SU3s from your region have reported that your 4sweeep account has been used to make many incorrect edits. Please review the Foursquare Editing guidelines, the Regional Style Guides, and Regional Discussions.

        + +

        Please confirm that you have discussed the venue editing guidelines with your local superusers and ask a SU3 from your area contact 4sweep to reactivate your account. Thanks!

        +

        + Log Out + +
        + + diff --git a/app/views/static_pages/changelog.html.erb b/app/views/static_pages/changelog.html.erb new file mode 100644 index 0000000..a854f05 --- /dev/null +++ b/app/views/static_pages/changelog.html.erb @@ -0,0 +1,514 @@ +<% content_for :flagcount do %> +<%= @flagcount == 0 ? "" : @flagcount %> +<% end %> + +

        4sweep: Changelog

        + +
        +
        +

        Version 0.30.16

        +
        + +
        + +

        Version 0.30.15

        +
        + +
        +

        Version 0.30.14

        +
        + +
        +

        Version 0.30.13

        +
        + +
        +

        Version 0.30.12

        +
        + +
        +

        Version 0.30.11

        +
        + +
        +

        Version 0.30.10

        +
        + +
        +

        Version 0.30.9

        +
        + +
        +

        Version 0.30.8

        +
        + +
        +

        Version 0.30.7

        +
        + +
        +

        Version 0.30.6

        +
        + +
        +

        Version 0.30.5

        +
        + +
        +

        Version 0.30.4

        +
        + +
        +

        Version 0.30.3

        +
        + +
        +

        Version 0.30.2

        +
        + +
        +

        Version 0.30.1

        +
        + +
        +

        Version 0.30.0 [Major Release]

        +
        + +
        +

        Version 0.28.14

        +
        + +
        +

        Version 0.28.13

        +
        + +
        +

        Version 0.28.12

        +
        + +
        +

        Version 0.28.11

        +
        + +
        +

        Version 0.28.10

        +
        + +
        +

        Version 0.28.9

        +
        + +
        +

        Version 0.28.8

        +
        + +
        +

        Version 0.28.7

        +
        + +
        +

        Version 0.28.6

        +
        + +
        +

        Version 0.28.5

        +
        + +
        +

        Version 0.28.4

        +
        + +
        +

        Version 0.28.3

        +
        + +
        +

        Version 0.28.2

        +
        + +
        +

        Version 0.28.1

        +
        + +
        +

        Version 0.28.0

        +
        + +
        +

        Version 0.27.3

        +
        + +
        +

        Version 0.27.2

        +
        + +
        +

        Version 0.27.1

        +
        + +
        +

        Version 0.27.0

        +
        + +
        +

        Version 0.26.11

        +
        + +
        +

        Version 0.26.10

        +
        + +
        +

        Version 0.26.9

        +
        + +
        +

        Version 0.26.8

        +
        + +
        +

        Version 0.26.7

        +
        + +
        +

        Version 0.26.6

        +
        + +
        +

        Version 0.26.5

        +
        + +
        +

        Version 0.26.4

        +
        + +
        +

        Version 0.26.3

        +
        + +
        +

        Version 0.26.2

        +
        + +
        +

        Version 0.26.1

        +
        + +
        + +

        Version 0.26.0 [Major release]

        +
        + New: + + Minor additions/bugfixes: + +
        + +
        +

        Version 0.25.11

        +
        + +
        + +

        Version 0.25.10

        +
        + +
        + +

        Version 0.25.9

        +
        + +
        + +

        Version 0.25.8

        +
        + +
        + +

        Version 0.25.7

        +
        + +
        + +

        Version 0.25.6

        +
        + An internal change to make venue lists cleaner. Unless there are bugs, everything should look and work the same. +
        + +

        Version 0.25.5

        +
        + If venues did not load from Foursquare for some reason, you'll now get a more readable and informative messages instead of simply "error". +
        + +

        Version 0.25.4

        +
        + +
        + +

        Version 0.25.3

        +
        + +
        + +

        Version 0.25.2

        +
        + +
        +
        diff --git a/app/views/static_pages/contact.html.erb b/app/views/static_pages/contact.html.erb new file mode 100644 index 0000000..9115326 --- /dev/null +++ b/app/views/static_pages/contact.html.erb @@ -0,0 +1,35 @@ + +<% content_for :flagcount do %> +<%= @flagcount == 0 ? "" : @flagcount %> +<% end %> + +

        Feedback, Questions, etc.

        +
        + +
        +
        +
        + Want to get in touch? Here are the ways: +
        + + + + + + + + + + + + + + + + + + + +
        SuggestionsCheck out the Google Moderator Forum to make and vote on feature suggestions!
        Email4sweep@4sweep.com
        Twitter@4sweep
         
        +
        +
        diff --git a/app/views/static_pages/faq.html.erb b/app/views/static_pages/faq.html.erb new file mode 100644 index 0000000..a350fe9 --- /dev/null +++ b/app/views/static_pages/faq.html.erb @@ -0,0 +1,61 @@ + +<% content_for :flagcount do %> +<%= @flagcount == 0 ? "" : @flagcount %> +<% end %> + +

        About 4sweep: Frequently Asked Questions

        + +
        + +
        +
        + +
        How does flag submission work?
        +
        +

        Once you create a flag, it'll be put in a queue to be submitted to Foursquare. The queue always waits 5 minutes before submitting your flag (it may take longer if there are a bunch of other flags in the queue). If you prefer to review your flags before submitting, there is a toggle in the footer of 4sweep to let you do that. You can always review and cancel flags before they're submitted on the Flags Tab.

        +

        Your flags might not go through immediately. Depending on the number of checkins, your SU level, and other factors, your flag will need to be reviewed by Foursquare Superusers before it is accepted.

        +
        + +
        What do the numbers and colors mean in venue listings?
        +
        This is the ratio of checkins to unique users. A high number means relatively few people have checked in there many times. This is often, but not always, an indicator of an illigitimate or duplicate venue. The number is in red if it is greater than 10 or if fewer than 5 people have checked in there, yellow if it is greater than 3 or if fewer than 15 people have checked in there, and green if less than 3 or if more than 50 people have checked in.
        + +
        How do I cancel a flag once I've submitted it?
        +
        The Foursquare API doesn't allow that yet — once you've submitted a flag, another SU will review it. Look at Foursquare's Check Yo Flags to see its status. There's currently no way to remove a flag from the 4sweep list after you submit it, but you can hide it from your submitted list.
        + +
        How do I schedule a venue close?
        +
        + If you know a venue will close in the future (say, a conference or a restaurant that announced its last day), you can select it, then choose the close icon, 'Schedule Date', and choose the date that the venue will close. 4sweep send your close flag to Foursquare at 4AM the next morning, in your time zone. Please include a comment explaining why you think the venue is closing – a URL can be really useful to the reviewer! Use this with the "Recently Created" search intent to close conference and event venues. +
        + +
        Why do I have to connect to the Foursquare API?
        +
        +

        4sweep is a tool for Foursquare Superusers. When you submit a flag through 4sweep, it is sent to Foursquare just as if you flagged it on the Foursquare site itself, using your login credentials.

        +

        4sweep will only use your API token to search venues, submit flags, and for related tasks. To help give you a better interface, it also uses your last check in (and centers the map there) and your superuser level, hometown, name, and profile photo. It will never check in for you, post tips or photos, or otherwise modify your account. 4sweep can add venues to a list if you request it to.

        +
        +
        + +
        +
        What do the symbols next to venue names mean?
        +
        +

        – A verified venue. A manager has claimed the venue on Foursquare. It's not a guarantee that the venue is legitimate or up-to-date, and some venues are improperly claimed, but it often means the venue is higher quality than venues without check marks.

        +

        – Locked venues. An SU3 or Foursquare staff member will have to approve your change to this venue.

        +

        – Private venue. These venues are generally not shown in search results, except to the venue creator and their friends.

        +
        + +
        What are the red and yellow circles displayed around a venue?
        +
        These are indicators of the venue's rough size and concentration of checkins. In general, 50% of check ins at a given venue occur within the red circle and 90% occur within the yellow circle. The circles may not appear for venues that have few checkins, are private homes, or have incomplete data. The circles may appear in the wrong place or be the wrong size for venues that have had their pin manually relocated or have been merged.
        + +
        How does "Load More" work?
        +
        Load more divides your search area into smaller areas and performs search again within those areas. If Foursquare returns more than 50 venues for any of the new search areas, you can click on "Load More" again and it will further divide those areas and search within them. You can continue clicking this button to load more venues until it becomes unavailable, which means that there are no more subareas to search.
        + + +
        What does "Alternate Resolution (Changed) mean?"
        +
        You may see this status on Edit Venue flags. It means that 4sweep can't be certain that your flag was accepted exactly as you submitted it, probably because Foursquare normalized some field for you. For example, Foursquare automatically expands or shortens certain abbreviations, adds country codes to phone numbers, and performs many other automatic cleanups. When this happens, we resolve the flag as "Alternate Resolution (Changed)" if any of the fields you edited changed from their previous values. The vast majority of the time this means that your flag was accepted.
        + +
        Any legal or other disclosures you need to make?
        +
        (Okay, nobody actually asks this one, but here is what Foursquare asks me to write):

        This service uses the Foursquare® application programming interface but is not endorsed or certified by Foursquare Labs, Inc. All of the Foursquare® logos (including all badges) and trademarks displayed on this service are the property of Foursquare Labs, Inc.

        +
        + +
        + +


        diff --git a/app/views/static_pages/suggestion.html.erb b/app/views/static_pages/suggestion.html.erb new file mode 100644 index 0000000..62e2af2 --- /dev/null +++ b/app/views/static_pages/suggestion.html.erb @@ -0,0 +1,17 @@ +<% content_for :javascripts do %> + +<% end %> + +

        Make suggestions and vote on ideas (Open in new window)


        + +
        +
        +
        + + diff --git a/app/views/stats/category_changes.html.erb b/app/views/stats/category_changes.html.erb new file mode 100644 index 0000000..77da3c7 --- /dev/null +++ b/app/views/stats/category_changes.html.erb @@ -0,0 +1,55 @@ +<% content_for :flagcount do %> +<%= @flagcount == 0 ? "" : @flagcount %> +<% end %> + + +
        + +

        Category Changes

        +
        +
        +
        + Want more category goodness? Check out: + +
        +
        + +<% @diffs.each do |diff| %> + +<% next if diff[:added].size == 0 && diff[:removed].size == 0 %> +
        + +

        Noticed at: <%= diff[:created_at].strftime("%F %H:%M") %>

        + +
        +<% if diff[:added].size > 0 %> + +

        Added

        + +
        +<%= diff[:added].join("\n") %>
        +  
        +<% else %> +  +<% end %> + +
        + +
        +<% if diff[:removed].size > 0 %> +

        Removed

        + +
        +<%= diff[:removed].join("\n") %>
        +  
        +<% end %> + +
        +
        +<% end %> + +
        diff --git a/app/views/stats/stats.html.erb b/app/views/stats/stats.html.erb new file mode 100644 index 0000000..287ab07 --- /dev/null +++ b/app/views/stats/stats.html.erb @@ -0,0 +1,96 @@ +<% content_for :flagcount do %> +<%= @flagcount == 0 ? "" : @flagcount %> +<% end %> + +

        4sweep Flag Stats for <%= @foruser ? @foruser.name : "All Users" %>

        + +<% if @foruser %> +
        +
        +
        + SU Level: <% if @foruser.level && @foruser.level != '' %> SU<%=@foruser.level%> <% else %> None <% end %>    + Home City: <%= @foruser.hometown %>    + Foursquare ID: <%= @foruser.uid %> +
        +
        +<% end %> + +
        + +<% unless @foruser %> +

        By User

        + + + + + + <% @user_counts.each do |statrow| %> + + + + + <% end %> +
        UserCount
        <%= statrow.name.truncate(30) %> <% if @current_user.level && @current_user.level != '' %> (SU<%=statrow.level%>) <% end %> [<%=statrow.hometown%>]<%= statrow.flag_count %>
        +<% end %> + +

        By Problem

        + + + + + + <% @problem_counts.each do |statrow| %> + + + + + <% end %> +
        ProblemCount
        <%= statrow.problem %><%= statrow.flag_count %>
        + +

        By Date

        + + + + + + <% @day_counts.each do |statrow| %> + + + + + <% end %> +
        DateCount
        <%= statrow.date %><%= statrow.flag_count %>
        +
        + +
        +

        By Status

        + + + + + + + <% @status_counts.each do |statrow| %> + + + + + <% end %> +
        StatusCount
        <%= statrow.status %><%= statrow.flag_count %>
        + +

        By Type

        + + + + + + + + <% @type_counts.each do |statrow| %> + + + + + <% end %> +
        TypeCount
        <%= statrow.type %><%= statrow.flag_count %>
        +
        diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..e004721 --- /dev/null +++ b/config.ru @@ -0,0 +1,4 @@ +# This file is used by Rack-based servers to start the application. + +require ::File.expand_path('../config/environment', __FILE__) +run Foursweep::Application diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 0000000..468e677 --- /dev/null +++ b/config/application.rb @@ -0,0 +1,73 @@ +require File.expand_path('../boot', __FILE__) + +require 'rails/all' + +if defined?(Bundler) + # If you precompile assets before deploying to production, use this line + Bundler.require(*Rails.groups(:assets => %w(development test))) + # If you want your assets lazily compiled in production, use this line + # Bundler.require(:default, :assets, Rails.env) +end + +module AssetsInitializers + class Railtie < Rails::Railtie + initializer "assets_initializers.initialize_rails", + :group => :assets do |app| + require "#{Rails.root}/config/initializers/register_pegjs.rb" + end + end +end + +module Foursweep + class Application < Rails::Application + # Settings in config/environments/* take precedence over those specified here. + # Application configuration should go into files in config/initializers + # -- all .rb files in that directory are automatically loaded. + + # Custom directories with classes and modules you want to be autoloadable. + # config.autoload_paths += %W(#{config.root}/extras) + + # Only load the plugins named here, in the order given (default is alphabetical). + # :all can be used as a placeholder for all plugins not explicitly named. + # config.plugins = [ :exception_notification, :ssl_requirement, :all ] + + # Activate observers that should always be running. + # config.active_record.observers = :cacher, :garbage_collector, :forum_observer + + # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. + # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. + # config.time_zone = 'Central Time (US & Canada)' + + # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. + # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] + # config.i18n.default_locale = :de + + # Configure the default encoding used in templates for Ruby 1.9. + config.encoding = "utf-8" + + # Configure sensitive parameters which will be filtered from the log file. + config.filter_parameters += [:password] + + # Enable escaping HTML in JSON. + config.active_support.escape_html_entities_in_json = true + + # Use SQL instead of Active Record's schema dumper when creating the database. + # This is necessary if your schema can't be completely dumped by the schema dumper, + # like if you have constraints or database-specific column types + # config.active_record.schema_format = :sql + + # Enforce whitelist mode for mass assignment. + # This will create an empty whitelist of attributes available for mass-assignment for all models + # in your app. As such, your models will need to explicitly whitelist or blacklist accessible + # parameters by using an attr_accessible or attr_protected declaration. + config.active_record.whitelist_attributes = true + + # Enable the asset pipeline + config.assets.enabled = true + + # Version of your assets, change this if you want to expire all your assets + config.assets.version = '1.0' + config.assets.initialize_on_precompile = false + + end +end diff --git a/config/application.yml b/config/application.yml new file mode 100644 index 0000000..0db5934 --- /dev/null +++ b/config/application.yml @@ -0,0 +1,23 @@ +defaults: &defaults + +development: + app_id: "REPLACE_ME" + app_secret: "REPLACE_ME" + callback_url: "http://localhost:3000/session/callback" + aws_key: "REPLACE_ME" + aws_secret: "REPLACE_ME" + s3_bucket: "REPLACE_ME" + cloudwatch_key: "REPLACE_ME" + cloudwatch_secret: "REPLACE_ME" + +test: + +production: + app_id: "REPLACE_ME" + app_secret: "REPLACE_ME" + callback_url: "REPLACE_ME" + aws_key: "REPLACE_ME" + aws_secret: "REPLACE_ME" + s3_bucket: "REPLACE_ME" + cloudwatch_key: "REPLACE_ME" + cloudwatch_secret: "REPLACE_ME" diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 0000000..cd944cb --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,8 @@ +require 'rubygems' + +require 'yaml' +#YAML::ENGINE.yamler= 'psych' +# Set up gems listed in the Gemfile. +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) + +require 'bundler/setup' if File.exists?(ENV['BUNDLE_GEMFILE']) diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 0000000..6431285 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,34 @@ +# SQLite version 3.x +# gem install sqlite3 +# +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem 'sqlite3' +development: + adapter: mysql2 + database: REPLACE_ME + username: REPLACE_ME + password: REPLACE_ME + host: localhost + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +# test: + # adapter: sqlite3 + # database: db/test.sqlite3 + # pool: 5 + # timeout: 5000 + +production: + adapter: mysql2 + database: REPLACE_ME + username: REPLACE_ME + password: REPLACE_ME + host: localhost + +beta: + adapter: mysql2 + database: REPLACE_ME + username: REPLACE_ME + password: REPLACE_ME + host: localhost diff --git a/config/deploy.rb b/config/deploy.rb new file mode 100644 index 0000000..de8202c --- /dev/null +++ b/config/deploy.rb @@ -0,0 +1,73 @@ +# config valid only for Capistrano 3.1 +lock '3.4.0' + +set :application, 'foursweep' +set :repo_url, 'REPLACE_ME' +# +# Default branch is :master +ask :branch, proc { `git rev-parse --abbrev-ref HEAD`.chomp } + +# Default deploy_to directory is /var/www/my_app +# set :deploy_to, '/var/www/my_app' +# +# Default value for :scm is :git +set :scm, :git + +# Default value for :format is :pretty +# set :format, :pretty + +# Default value for :log_level is :debug +set :log_level, :debug + +# Default value for :pty is false +# set :pty, true + +set :tmp_dir, 'REPLACE_ME' + +# Default value for :linked_files is [] +# set :linked_files, %w{config/database.yml} + +# Default value for linked_dirs is [] +# set :linked_dirs, %w{bin log tmp/pids tmp/cache tmp/sockets vendor/bundle public/system} + +# Default value for default_env is {} +# set :default_env, { path: "/opt/ruby/bin:$PATH" } + +# Default value for keep_releases is 5 +# set :keep_releases, 5 + +namespace :deploy do + + desc "Restart nginx" + task :restart do + on roles(:all) do + execute "#{deploy_to}/bin/restart" + end + end + + after :publishing, :restart + + after :restart, :clear_cache do + on roles(:web), in: :groups, limit: 3, wait: 10 do + # Here we can do anything such as: + # within release_path do + # execute :rake, 'cache:clear' + # end + end + end + +end + +task :notify_rollbar do + on roles(:app) do |h| + revision = `git log -n 1 --pretty=format:"%H"` + local_user = `whoami` + rollbar_token = 'REPLACE_ME' + rails_env = fetch(:rails_env, 'production') + # execute "curl -s https://api.rollbar.com/api/1/deploy/ -F access_token=#{rollbar_token} -F environment=#{rails_env} -F revision=#{revision} -F local_username=#{local_user} >/dev/null 2>&1", :once => true + end +end + +# after :deploy, 'notify_rollbar' + + diff --git a/config/deploy/production.rb b/config/deploy/production.rb new file mode 100644 index 0000000..3379c73 --- /dev/null +++ b/config/deploy/production.rb @@ -0,0 +1,74 @@ +# Simple Role Syntax +# ================== +# Supports bulk-adding hosts to roles, the primary +# server in each group is considered to be the first +# unless any hosts have the primary property set. +# Don't declare `role :all`, it's a meta role +role :app, %w{REPLACE_ME} +role :web, %w{REPLACE_ME} +role :db, %w{REPLACE_ME} + + +set :deploy_to, "REPLACE_ME" +set :linked_dirs, %w{log tmp} +set :delayed_job_args, "-n 2" +set :branch, "master" +set :rails_env, "production" + +set :default_env, { + 'GEM_PATH' => 'REPLACE_ME/gems/', + 'GEM_HOME' => 'REPLACE_ME/gems/', +} + +namespace :delayed_job do + + def args + fetch(:delayed_job_args, "") + end + + def delayed_job_roles + fetch(:delayed_job_server_role, :app) + end + + desc 'Stop the delayed_job process' + task :stop do + on roles(delayed_job_roles) do + within release_path do + with rails_env: fetch(:rails_env) do + execute :bundle, :exec, :'script/delayed_job', :stop + end + end + end + end + + desc 'Start the delayed_job process' + task :start do + on roles(delayed_job_roles) do + within release_path do + with rails_env: fetch(:rails_env) do + execute :bundle, :exec, :'script/delayed_job', args, :start + end + end + end + end + + desc 'Restart the delayed_job process' + task :restart do + on roles(delayed_job_roles) do + within release_path do + with rails_env: fetch(:rails_env) do + execute :bundle, :exec, :'script/delayed_job', :stop + execute :bundle, :exec, :'script/delayed_job', args, :restart + end + end + end + end + +end + +after 'deploy:publishing', 'deploy:restart' +namespace :deploy do + task :restart do + invoke 'delayed_job:restart' + end +end diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 0000000..281b8ea --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the rails application +require File.expand_path('../application', __FILE__) + +# Initialize the rails application +Foursweep::Application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 0000000..5559a2b --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,37 @@ +Foursweep::Application.configure do + # Settings specified here will take precedence over those in config/application.rb + + # In the development environment your application's code is reloaded on + # every request. This slows down response time but is perfect for development + # since you don't have to restart the web server when you make code changes. + config.cache_classes = false + + # Log error messages when you accidentally call methods on nil. + config.whiny_nils = true + + # Show full error reports and disable caching + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + + # Don't care if the mailer can't send + config.action_mailer.raise_delivery_errors = false + + # Print deprecation notices to the Rails logger + config.active_support.deprecation = :log + + # Only use best-standards-support built into browsers + config.action_dispatch.best_standards_support = :builtin + + # Raise exception on mass assignment protection for Active Record models + config.active_record.mass_assignment_sanitizer = :strict + + # Log the query plan for queries taking more than this (works + # with SQLite, MySQL, and PostgreSQL) + config.active_record.auto_explain_threshold_in_seconds = 0.5 + + # Do not compress assets + config.assets.compress = false + + # Expands the lines which load the assets + config.assets.debug = true +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 0000000..7365e35 --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,70 @@ +Foursweep::Application.configure do + # Settings specified here will take precedence over those in config/application.rb + + # Code is not reloaded between requests + config.cache_classes = true + + # Full error reports are disabled and caching is turned on + config.consider_all_requests_local = false + config.action_controller.perform_caching = true + + # Disable Rails's static asset server (Apache or nginx will already do this) + config.serve_static_assets = false + + # Compress JavaScripts and CSS + config.assets.compress = true + + # Don't fallback to assets pipeline if a precompiled asset is missed + config.assets.compile = false + + # Generate digests for assets URLs + config.assets.digest = true + + # Defaults to nil and saved in location specified by config.assets.prefix + # config.assets.manifest = YOUR_PATH + + # Specifies the header that your server uses for sending files + # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache + # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # See everything in the log (default is :info) + config.log_level = :info. # REPLACE_ME + + # Prepend all log lines with the following tags + # config.log_tags = [ :subdomain, :uuid ] + + # Use a different logger for distributed setups + # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) + config.logger = Logger.new(config.paths['log'].first, 'daily') + + # Use a different cache store in production + # config.cache_store = :mem_cache_store + + # Enable serving of images, stylesheets, and JavaScripts from an asset server + # config.action_controller.asset_host = "http://assets.example.com" + + # Precompile additional assets (application.js, application.css, and all non-JS/CSS are already added) + # config.assets.precompile += %w( search.js ) + config.assets.precompile += %w( explorer.js flags.js advancedsearch.js filter.js items.js ) + + # Disable delivery errors, bad email addresses will be ignored + # config.action_mailer.raise_delivery_errors = false + + # Enable threaded mode + # config.threadsafe! + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation can not be found) + config.i18n.fallbacks = true + + # Send deprecation notices to registered listeners + config.active_support.deprecation = :notify + + # Log the query plan for queries taking more than this (works + # with SQLite, MySQL, and PostgreSQL) + config.active_record.auto_explain_threshold_in_seconds = 0.5 + +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 0000000..723d5cf --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,37 @@ +Foursweep::Application.configure do + # Settings specified here will take precedence over those in config/application.rb + + # The test environment is used exclusively to run your application's + # test suite. You never need to work with it otherwise. Remember that + # your test database is "scratch space" for the test suite and is wiped + # and recreated between test runs. Don't rely on the data there! + config.cache_classes = true + + # Configure static asset server for tests with Cache-Control for performance + config.serve_static_assets = true + config.static_cache_control = "public, max-age=3600" + + # Log error messages when you accidentally call methods on nil + config.whiny_nils = true + + # Show full error reports and disable caching + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + + # Raise exceptions instead of rendering exception templates + config.action_dispatch.show_exceptions = false + + # Disable request forgery protection in test environment + config.action_controller.allow_forgery_protection = false + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Raise exception on mass assignment protection for Active Record models + config.active_record.mass_assignment_sanitizer = :strict + + # Print deprecation notices to the stderr + config.active_support.deprecation = :stderr +end diff --git a/config/initializers/backtrace_silencers.rb b/config/initializers/backtrace_silencers.rb new file mode 100644 index 0000000..59385cd --- /dev/null +++ b/config/initializers/backtrace_silencers.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. +# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } + +# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. +# Rails.backtrace_cleaner.remove_silencers! diff --git a/config/initializers/delayed_job.rb b/config/initializers/delayed_job.rb new file mode 100644 index 0000000..afe3f55 --- /dev/null +++ b/config/initializers/delayed_job.rb @@ -0,0 +1,5 @@ +Delayed::Worker.logger = Rails.logger +Delayed::Worker.max_attempts = 10 +Delayed::Worker.max_run_time = 2.minutes +Delayed::Worker.destroy_failed_jobs = false +Delayed::Worker.sleep_delay = 30 diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 0000000..5d8d9be --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,15 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format +# (all these examples are active by default): +# ActiveSupport::Inflector.inflections do |inflect| +# inflect.plural /^(ox)$/i, '\1en' +# inflect.singular /^(ox)en/i, '\1' +# inflect.irregular 'person', 'people' +# inflect.uncountable %w( fish sheep ) +# end +# +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections do |inflect| +# inflect.acronym 'RESTful' +# end diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb new file mode 100644 index 0000000..72aca7e --- /dev/null +++ b/config/initializers/mime_types.rb @@ -0,0 +1,5 @@ +# 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 diff --git a/config/initializers/notifications.rb b/config/initializers/notifications.rb new file mode 100644 index 0000000..5a48866 --- /dev/null +++ b/config/initializers/notifications.rb @@ -0,0 +1,6 @@ +# ActiveSupport::Notifications.subscribe('request.faraday') do |name, starts, ends, _, env| +# url = env[:url] +# http_method = env[:method].to_s.upcase +# duration = ends - starts +# Rails.logger.info " API REQUEST TO: #{url.host}, #{http_method}, #{url.request_uri}, takes #{duration} seconds, rate-limit: #{env[:response_headers]['x-ratelimit-limit']}, rate-limit-remaining: #{env[:response_headers]['x-ratelimit-remaining']}, status: #{env[:status]}" +# end diff --git a/config/initializers/register_pegjs.rb b/config/initializers/register_pegjs.rb new file mode 100644 index 0000000..cefbfb3 --- /dev/null +++ b/config/initializers/register_pegjs.rb @@ -0,0 +1,29 @@ +require 'rails/engine' + +module Pegjs + class Template < ::Tilt::Template + def prepare + # Do any initialization here + end + + def evaluate(scope, locals, &block) + exportvar = scope.logical_path.gsub(".js$", '') + + # Hack Alert -- allowedStartRules expected as a comment in pegjs file + if (data.match("^// *allowedStartRules *= *(.*)$")) + allowedStartRules = $1.strip() + else + allowedStartRules = "" + end + return Pegjs.parse(data, :exportvar => exportvar, :allowedStartRules => allowedStartRules) + end + end + + module Rails + class Engine < ::Rails::Engine + config.app_generators.javascript_engine :pegjs + end + end +end + +Rails.application.assets.register_engine '.pegjs', Pegjs::Template diff --git a/config/initializers/rollbar.rb b/config/initializers/rollbar.rb new file mode 100644 index 0000000..e1c4da4 --- /dev/null +++ b/config/initializers/rollbar.rb @@ -0,0 +1,12 @@ +require 'rollbar/rails' + +Rollbar.configure do |config| + config.enabled = false + config.access_token = 'REPLACE_ME' + config.exception_level_filters.merge!('ActionController::RoutingError' => 'ignore') + config.dj_threshold = 5 + + if Rails.env.development? + config.enabled = false + end +end diff --git a/config/initializers/secret_token.rb b/config/initializers/secret_token.rb new file mode 100644 index 0000000..7bb23b9 --- /dev/null +++ b/config/initializers/secret_token.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# Your secret key for verifying the integrity of signed cookies. +# If you change this key, all old signed cookies will become invalid! +# Make sure the secret is at least 30 characters and all random, +# no regular words or you'll be exposed to dictionary attacks. +Foursweep::Application.config.secret_token = 'REPLACE_ME' diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb new file mode 100644 index 0000000..8d13466 --- /dev/null +++ b/config/initializers/session_store.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +Foursweep::Application.config.session_store :cookie_store, :key => '_foursweep_session' + +# Use the database for sessions instead of the cookie-based default, +# which shouldn't be used to store highly confidential information +# (create the session table with "rails generate session_migration") +# Foursweep::Application.config.session_store :active_record_store diff --git a/config/initializers/wrap_parameters.rb b/config/initializers/wrap_parameters.rb new file mode 100644 index 0000000..da4fb07 --- /dev/null +++ b/config/initializers/wrap_parameters.rb @@ -0,0 +1,14 @@ +# Be sure to restart your server when you modify this file. +# +# This file contains settings for ActionController::ParamsWrapper which +# is enabled by default. + +# Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. +ActiveSupport.on_load(:action_controller) do + wrap_parameters :format => [:json] +end + +# Disable root element in JSON by default. +ActiveSupport.on_load(:active_record) do + self.include_root_in_json = false +end diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 0000000..179c14c --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,5 @@ +# Sample localization file for English. Add more files in this directory for other locales. +# See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points. + +en: + hello: "Hello world" diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..8ba4828 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,99 @@ +Foursweep::Application.routes.draw do + constraints(:host => /www.4sweep.com/) do + root :to => redirect("https://4sweep.com") + match '/*path', :to => redirect {|params| "https://4sweep.com/#{params[:path]}"} + end + + match "changes" => 'changelog#changes' + + match "stats/" => 'stats#stats' + + match "stats/:user_id" => 'stats#stats' + match "category_changes" => 'stats#category_changes' + + match "about" => 'static_pages#about' + match "about/faq" => 'static_pages#faq' + match "about/changelog" => 'static_pages#changelog' + match "about/contact" => 'static_pages#contact' + match "about/suggestion" => 'static_pages#suggestion' + + match 'heartbeat' => 'heartbeat#heartbeat' + get "flags/list" + get "flags/check" + get "flags/newcount" + match "flags/statuses" + match 'flags/run' => 'flags#run', :via=>:post + match 'flags/resubmit' => 'flags#resubmit', :via=>:post + match 'flags/hide' => 'flags#hide', :via=>:post + match 'flags/check' => 'flags#check' + match 'flags/cancel' => 'flags#cancel' + + get "explorer/explore" + + get "session/callback" + get "session/error" + get "session/new" + get "session/logout" + get "session/not_allowed" + + resources :flags + + root :to => 'explorer#explore' + + # The priority is based upon order of creation: + # first created -> highest priority. + + # Sample of regular route: + # match 'products/:id' => 'catalog#view' + # Keep in mind you can assign values other than :controller and :action + + # Sample of named route: + # match 'products/:id/purchase' => 'catalog#purchase', :as => :purchase + # This route can be invoked with purchase_url(:id => product.id) + + # Sample resource route (maps HTTP verbs to controller actions automatically): + # resources :products + + # Sample resource route with options: + # resources :products do + # member do + # get 'short' + # post 'toggle' + # end + # + # collection do + # get 'sold' + # end + # end + + # Sample resource route with sub-resources: + # resources :products do + # resources :comments, :sales + # resource :seller + # end + + # Sample resource route with more complex sub-resources + # resources :products do + # resources :comments + # resources :sales do + # get 'recent', :on => :collection + # end + # end + + # Sample resource route within a namespace: + # namespace :admin do + # # Directs /admin/products/* to Admin::ProductsController + # # (app/controllers/admin/products_controller.rb) + # resources :products + # end + + # You can have the root of your site routed with "root" + # just remember to delete public/index.html. + # root :to => 'welcome#index' + + # See how all your routes lay out with "rake routes" + + # This is a legacy wild controller route that's not recommended for RESTful applications. + # Note: This route will make all actions in every controller accessible via GET requests. + # match ':controller(/:action(/:id))(.:format)' +end diff --git a/db/migrate/20120805050614_create_users.rb b/db/migrate/20120805050614_create_users.rb new file mode 100644 index 0000000..8ec5066 --- /dev/null +++ b/db/migrate/20120805050614_create_users.rb @@ -0,0 +1,12 @@ +class CreateUsers < ActiveRecord::Migration + def change + create_table :users do |t| + t.string :name + t.string :level + t.string :token + t.boolean :enabled + + t.timestamps + end + end +end diff --git a/db/migrate/20120807070620_add_uid_to_users.rb b/db/migrate/20120807070620_add_uid_to_users.rb new file mode 100644 index 0000000..fbb21ba --- /dev/null +++ b/db/migrate/20120807070620_add_uid_to_users.rb @@ -0,0 +1,5 @@ +class AddUidToUsers < ActiveRecord::Migration + def change + add_column :users, :uid, :string + end +end diff --git a/db/migrate/20120812003642_create_flags.rb b/db/migrate/20120812003642_create_flags.rb new file mode 100644 index 0000000..198e60a --- /dev/null +++ b/db/migrate/20120812003642_create_flags.rb @@ -0,0 +1,18 @@ +class CreateFlags < ActiveRecord::Migration + def change + create_table :flags do |t| + t.string :type + t.string :status + t.string :venueId + t.references :user + t.string :secondaryVenueId + t.string :primaryName + t.string :secondaryName + t.text :primaryJSON + t.text :secondaryJSON + + t.timestamps + end + add_index :flags, :user_id + end +end diff --git a/db/migrate/20120812072203_add_problem_to_flag.rb b/db/migrate/20120812072203_add_problem_to_flag.rb new file mode 100644 index 0000000..bf3626e --- /dev/null +++ b/db/migrate/20120812072203_add_problem_to_flag.rb @@ -0,0 +1,5 @@ +class AddProblemToFlag < ActiveRecord::Migration + def change + add_column :flags, :problem, :string + end +end diff --git a/db/migrate/20120812080604_set_default_status_on_flag.rb b/db/migrate/20120812080604_set_default_status_on_flag.rb new file mode 100644 index 0000000..e3a7b2b --- /dev/null +++ b/db/migrate/20120812080604_set_default_status_on_flag.rb @@ -0,0 +1,10 @@ +class SetDefaultStatusOnFlag < ActiveRecord::Migration + def up + change_column :flags, :status, :string, :default => 'new' + end + + def down + # You can't currently remove default values in Rails + raise ActiveRecord::IrreversibleMigration, "Can't remove the default" + end +end diff --git a/db/migrate/20120812100523_add_submitted_to_flag.rb b/db/migrate/20120812100523_add_submitted_to_flag.rb new file mode 100644 index 0000000..da9891d --- /dev/null +++ b/db/migrate/20120812100523_add_submitted_to_flag.rb @@ -0,0 +1,5 @@ +class AddSubmittedToFlag < ActiveRecord::Migration + def change + add_column :flags, :submitted_at, :timestamp + end +end diff --git a/db/migrate/20120814101841_create_categories_caches.rb b/db/migrate/20120814101841_create_categories_caches.rb new file mode 100644 index 0000000..0430717 --- /dev/null +++ b/db/migrate/20120814101841_create_categories_caches.rb @@ -0,0 +1,9 @@ +class CreateCategoriesCaches < ActiveRecord::Migration + def change + create_table :categories_caches do |t| + t.text :categories + + t.timestamps + end + end +end diff --git a/db/migrate/20120815062628_add_cache_to_user.rb b/db/migrate/20120815062628_add_cache_to_user.rb new file mode 100644 index 0000000..29f9adc --- /dev/null +++ b/db/migrate/20120815062628_add_cache_to_user.rb @@ -0,0 +1,6 @@ +class AddCacheToUser < ActiveRecord::Migration + def change + add_column :users, :user_cache, :text + add_column :users, :cached_at, :timestamp + end +end diff --git a/db/migrate/20120815074024_add_index_to_flags.rb b/db/migrate/20120815074024_add_index_to_flags.rb new file mode 100644 index 0000000..bed124a --- /dev/null +++ b/db/migrate/20120815074024_add_index_to_flags.rb @@ -0,0 +1,5 @@ +class AddIndexToFlags < ActiveRecord::Migration + def change + add_index :flags, :venueId + end +end diff --git a/db/migrate/20120821081413_add_hash_and_verified_date_to_categories_cache.rb b/db/migrate/20120821081413_add_hash_and_verified_date_to_categories_cache.rb new file mode 100644 index 0000000..a2a94f4 --- /dev/null +++ b/db/migrate/20120821081413_add_hash_and_verified_date_to_categories_cache.rb @@ -0,0 +1,11 @@ +class AddHashAndVerifiedDateToCategoriesCache < ActiveRecord::Migration + def change + add_column :categories_caches, :last_verified, :timestamp + add_column :categories_caches, :digest, :string + + CategoriesCache.all.each do |c| + c.digest = Digest::SHA1.hexdigest(c.aslist.join("\n")) + c.save + end + end +end diff --git a/db/migrate/20120824033631_remove_json_fields_from_flags.rb b/db/migrate/20120824033631_remove_json_fields_from_flags.rb new file mode 100644 index 0000000..48b4327 --- /dev/null +++ b/db/migrate/20120824033631_remove_json_fields_from_flags.rb @@ -0,0 +1,11 @@ +class RemoveJsonFieldsFromFlags < ActiveRecord::Migration + def up + remove_column :flags, :primaryJSON + remove_column :flags, :secondaryJSON + end + + def down + add_column :flags, :secondaryJSON, :text + add_column :flags, :primaryJSON, :text + end +end diff --git a/db/migrate/20120824072809_add_secondary_index_to_flags.rb b/db/migrate/20120824072809_add_secondary_index_to_flags.rb new file mode 100644 index 0000000..fcb7287 --- /dev/null +++ b/db/migrate/20120824072809_add_secondary_index_to_flags.rb @@ -0,0 +1,5 @@ +class AddSecondaryIndexToFlags < ActiveRecord::Migration + def change + add_index :flags, :secondaryVenueId + end +end diff --git a/db/migrate/20120824073208_add_status_index_to_flags.rb b/db/migrate/20120824073208_add_status_index_to_flags.rb new file mode 100644 index 0000000..8c3c3cf --- /dev/null +++ b/db/migrate/20120824073208_add_status_index_to_flags.rb @@ -0,0 +1,5 @@ +class AddStatusIndexToFlags < ActiveRecord::Migration + def change + add_index :flags, :status + end +end diff --git a/db/migrate/20120824185705_make_digests_unique.rb b/db/migrate/20120824185705_make_digests_unique.rb new file mode 100644 index 0000000..d7864fa --- /dev/null +++ b/db/migrate/20120824185705_make_digests_unique.rb @@ -0,0 +1,19 @@ +class MakeDigestsUnique < ActiveRecord::Migration + def up + digests = CategoriesCache.all.map {|e| e.digest}.uniq + digests.each do |d| + earliest = CategoriesCache.find_all_by_digest(d).sort_by {|e| e.created_at}.first + latest = CategoriesCache.find_all_by_digest(d).sort_by {|e| e.created_at}.last + if earliest.last_verified == nil + earliest.last_verified = latest.created_at + earliest.save + end + end + CategoriesCache.where('"last_verified" is null').each do |c| + c.delete + end + end + + def down + end +end diff --git a/db/migrate/20120827005837_add_resolution_details_to_flags.rb b/db/migrate/20120827005837_add_resolution_details_to_flags.rb new file mode 100644 index 0000000..a076ce0 --- /dev/null +++ b/db/migrate/20120827005837_add_resolution_details_to_flags.rb @@ -0,0 +1,5 @@ +class AddResolutionDetailsToFlags < ActiveRecord::Migration + def change + add_column :flags, :resolved_details, :string + end +end diff --git a/db/migrate/20120830081544_make_categories_cache_larger.rb b/db/migrate/20120830081544_make_categories_cache_larger.rb new file mode 100644 index 0000000..12f85ed --- /dev/null +++ b/db/migrate/20120830081544_make_categories_cache_larger.rb @@ -0,0 +1,8 @@ +class MakeCategoriesCacheLarger < ActiveRecord::Migration + def up + change_column :categories_caches, :categories, :text, :limit => 16777215 + end + + def down + end +end diff --git a/db/migrate/20120831200852_make_user_cache_larger.rb b/db/migrate/20120831200852_make_user_cache_larger.rb new file mode 100644 index 0000000..e6e77bf --- /dev/null +++ b/db/migrate/20120831200852_make_user_cache_larger.rb @@ -0,0 +1,8 @@ +class MakeUserCacheLarger < ActiveRecord::Migration + def up + change_column :users, :user_cache, :text, :limit => 16777215 + end + + def down + end +end diff --git a/db/migrate/20120912040953_add_last_checked_to_flags.rb b/db/migrate/20120912040953_add_last_checked_to_flags.rb new file mode 100644 index 0000000..c709a73 --- /dev/null +++ b/db/migrate/20120912040953_add_last_checked_to_flags.rb @@ -0,0 +1,5 @@ +class AddLastCheckedToFlags < ActiveRecord::Migration + def change + add_column :flags, :last_checked, :timestamp + end +end diff --git a/db/migrate/20120914213202_add_hidden_to_flags.rb b/db/migrate/20120914213202_add_hidden_to_flags.rb new file mode 100644 index 0000000..a8a1dfd --- /dev/null +++ b/db/migrate/20120914213202_add_hidden_to_flags.rb @@ -0,0 +1,5 @@ +class AddHiddenToFlags < ActiveRecord::Migration + def change + add_column :flags, :hidden, :boolean, :null => false, :default => false + end +end diff --git a/db/migrate/20120923035358_add_has_home_category_to_flags.rb b/db/migrate/20120923035358_add_has_home_category_to_flags.rb new file mode 100644 index 0000000..718c568 --- /dev/null +++ b/db/migrate/20120923035358_add_has_home_category_to_flags.rb @@ -0,0 +1,6 @@ +class AddHasHomeCategoryToFlags < ActiveRecord::Migration + def change + add_column :flags, :primaryHasHome, :boolean + add_column :flags, :secondaryHasHome, :boolean + end +end diff --git a/db/migrate/20130317092353_add_hometown_to_users.rb b/db/migrate/20130317092353_add_hometown_to_users.rb new file mode 100644 index 0000000..0540dd0 --- /dev/null +++ b/db/migrate/20130317092353_add_hometown_to_users.rb @@ -0,0 +1,5 @@ +class AddHometownToUsers < ActiveRecord::Migration + def change + add_column :users, :hometown, :string + end +end diff --git a/db/migrate/20131028062937_add_indexes_to_users.rb b/db/migrate/20131028062937_add_indexes_to_users.rb new file mode 100644 index 0000000..5e4ff24 --- /dev/null +++ b/db/migrate/20131028062937_add_indexes_to_users.rb @@ -0,0 +1,6 @@ +class AddIndexesToUsers < ActiveRecord::Migration + def change + add_index :users, :token + add_index :users, :uid + end +end diff --git a/db/migrate/20131028225611_add_flags_multicolumn_index.rb b/db/migrate/20131028225611_add_flags_multicolumn_index.rb new file mode 100644 index 0000000..0c0369d --- /dev/null +++ b/db/migrate/20131028225611_add_flags_multicolumn_index.rb @@ -0,0 +1,9 @@ +class AddFlagsMulticolumnIndex < ActiveRecord::Migration + def up + add_index :flags, [:user_id, :status, :created_at] + end + + def down + remove_index :flags, [:user_id, :status, :created_at] + end +end diff --git a/db/migrate/20140206053602_add_category_fields_to_flags.rb b/db/migrate/20140206053602_add_category_fields_to_flags.rb new file mode 100644 index 0000000..9fc627f --- /dev/null +++ b/db/migrate/20140206053602_add_category_fields_to_flags.rb @@ -0,0 +1,6 @@ +class AddCategoryFieldsToFlags < ActiveRecord::Migration + def change + add_column :flags, :categoryId, :string + add_column :flags, :categoryName, :string + end +end diff --git a/db/migrate/20140216234613_add_comment_to_flags.rb b/db/migrate/20140216234613_add_comment_to_flags.rb new file mode 100644 index 0000000..1d3de2b --- /dev/null +++ b/db/migrate/20140216234613_add_comment_to_flags.rb @@ -0,0 +1,5 @@ +class AddCommentToFlags < ActiveRecord::Migration + def change + add_column :flags, :comment, :string + end +end diff --git a/db/migrate/20140218090306_create_delayed_jobs.rb b/db/migrate/20140218090306_create_delayed_jobs.rb new file mode 100644 index 0000000..ec0dd93 --- /dev/null +++ b/db/migrate/20140218090306_create_delayed_jobs.rb @@ -0,0 +1,22 @@ +class CreateDelayedJobs < ActiveRecord::Migration + def self.up + create_table :delayed_jobs, :force => true do |table| + table.integer :priority, :default => 0, :null => false # Allows some jobs to jump to the front of the queue + table.integer :attempts, :default => 0, :null => false # Provides for retries, but still fail eventually. + table.text :handler, :null => false # YAML-encoded string of the object that will do work + table.text :last_error # reason for last failure (See Note below) + table.datetime :run_at # When to run. Could be Time.zone.now for immediately, or sometime in the future. + table.datetime :locked_at # Set when a client is working on this object + table.datetime :failed_at # Set when all retries have failed (actually, by default, the record is deleted instead) + table.string :locked_by # Who is working on this object (if locked) + table.string :queue # The name of the queue this job is in + table.timestamps + end + + add_index :delayed_jobs, [:priority, :run_at], :name => 'delayed_jobs_priority' + end + + def self.down + drop_table :delayed_jobs + end +end diff --git a/db/migrate/20140218232301_add_job_id_to_flags.rb b/db/migrate/20140218232301_add_job_id_to_flags.rb new file mode 100644 index 0000000..a5884bd --- /dev/null +++ b/db/migrate/20140218232301_add_job_id_to_flags.rb @@ -0,0 +1,5 @@ +class AddJobIdToFlags < ActiveRecord::Migration + def change + add_column :flags, :job_id, :integer + end +end diff --git a/db/migrate/20140219235704_add_scheduled_to_flags.rb b/db/migrate/20140219235704_add_scheduled_to_flags.rb new file mode 100644 index 0000000..0958a2b --- /dev/null +++ b/db/migrate/20140219235704_add_scheduled_to_flags.rb @@ -0,0 +1,5 @@ +class AddScheduledToFlags < ActiveRecord::Migration + def change + add_column :flags, :scheduled_at, :datetime + end +end diff --git a/db/migrate/20140227063428_add_edits_to_flags.rb b/db/migrate/20140227063428_add_edits_to_flags.rb new file mode 100644 index 0000000..d34140b --- /dev/null +++ b/db/migrate/20140227063428_add_edits_to_flags.rb @@ -0,0 +1,5 @@ +class AddEditsToFlags < ActiveRecord::Migration + def change + add_column :flags, :edits, :text + end +end diff --git a/db/migrate/20140301224435_add_index_to_category_caches.rb b/db/migrate/20140301224435_add_index_to_category_caches.rb new file mode 100644 index 0000000..6fa1ec2 --- /dev/null +++ b/db/migrate/20140301224435_add_index_to_category_caches.rb @@ -0,0 +1,5 @@ +class AddIndexToCategoryCaches < ActiveRecord::Migration + def change + add_index :categories_caches, :created_at + end +end diff --git a/db/migrate/20140305211423_fix_indexes_on_flags.rb b/db/migrate/20140305211423_fix_indexes_on_flags.rb new file mode 100644 index 0000000..f7ef15c --- /dev/null +++ b/db/migrate/20140305211423_fix_indexes_on_flags.rb @@ -0,0 +1,13 @@ +class FixIndexesOnFlags < ActiveRecord::Migration + def up + remove_index :flags, :user_id + add_index :flags, [:user_id, :status, :venueId] + add_index :flags, [:user_id, :status, :secondaryVenueId] + end + + def down + add_index :flags, :user_id + remove_index :flags, [:user_id, :status, :venueId] + remove_index :flags, [:user_id, :status, :secondaryVenueId] + end +end diff --git a/db/migrate/20140312030725_change_edit_flags_format.rb b/db/migrate/20140312030725_change_edit_flags_format.rb new file mode 100644 index 0000000..573b1ef --- /dev/null +++ b/db/migrate/20140312030725_change_edit_flags_format.rb @@ -0,0 +1,17 @@ +class ChangeEditFlagsFormat < ActiveRecord::Migration + def up + EditVenueFlag.all.each do |flag| + newvalues = flag.edits + flag.edits = {'oldvalues' => newvalues, 'newvalues' => newvalues} + if flag.status != 'resolved' + flag.status = "alternate resolution" + flag.resolved_details = "(beta legacy)" + end + flag.save + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20140407001025_clear_user_caches.rb b/db/migrate/20140407001025_clear_user_caches.rb new file mode 100644 index 0000000..6de0410 --- /dev/null +++ b/db/migrate/20140407001025_clear_user_caches.rb @@ -0,0 +1,9 @@ +class ClearUserCaches < ActiveRecord::Migration + def up + ActiveRecord::Base.connection.execute("update users set user_cache = null, cached_at = null") + end + + def down + + end +end diff --git a/db/migrate/20140413021349_add_creator_id_to_flags.rb b/db/migrate/20140413021349_add_creator_id_to_flags.rb new file mode 100644 index 0000000..7212ef1 --- /dev/null +++ b/db/migrate/20140413021349_add_creator_id_to_flags.rb @@ -0,0 +1,5 @@ +class AddCreatorIdToFlags < ActiveRecord::Migration + def change + add_column :flags, :creator_id, :string + end +end diff --git a/db/migrate/20140505231533_add_photo_fields_to_flags.rb b/db/migrate/20140505231533_add_photo_fields_to_flags.rb new file mode 100644 index 0000000..eb3090e --- /dev/null +++ b/db/migrate/20140505231533_add_photo_fields_to_flags.rb @@ -0,0 +1,7 @@ +class AddPhotoFieldsToFlags < ActiveRecord::Migration + def change + add_column :flags, :creatorName, :string + rename_column :flags, :categoryId, :itemId + rename_column :flags, :categoryName, :itemName + end +end diff --git a/db/migrate/20140505232604_flags_table_cleanup.rb b/db/migrate/20140505232604_flags_table_cleanup.rb new file mode 100644 index 0000000..bca9fb9 --- /dev/null +++ b/db/migrate/20140505232604_flags_table_cleanup.rb @@ -0,0 +1,10 @@ +class FlagsTableCleanup < ActiveRecord::Migration + def up + remove_column :flags, :primaryHasHome + remove_column :flags, :secondaryHasHome + remove_column :flags, :hidden + end + + def down + end +end diff --git a/db/migrate/20140505233603_rename_creator_id.rb b/db/migrate/20140505233603_rename_creator_id.rb new file mode 100644 index 0000000..1d90d89 --- /dev/null +++ b/db/migrate/20140505233603_rename_creator_id.rb @@ -0,0 +1,5 @@ +class RenameCreatorId < ActiveRecord::Migration + def change + rename_column :flags, :creator_id, :creatorId + end +end diff --git a/db/migrate/20140522234829_add_index_on_creator_id.rb b/db/migrate/20140522234829_add_index_on_creator_id.rb new file mode 100644 index 0000000..ae4093c --- /dev/null +++ b/db/migrate/20140522234829_add_index_on_creator_id.rb @@ -0,0 +1,11 @@ +class AddIndexOnCreatorId < ActiveRecord::Migration + def change + add_index :flags, [:user_id, :status, :creatorId, :type] + + remove_index :flags, [:user_id, :status, :venueId] + remove_index :flags, [:user_id, :status, :secondaryVenueId] + + add_index :flags, [:user_id, :status, :venueId, :type] + add_index :flags, [:user_id, :status, :secondaryVenueId, :type] + end +end diff --git a/db/migrate/20140705230607_add_venue_details_to_flags.rb b/db/migrate/20140705230607_add_venue_details_to_flags.rb new file mode 100644 index 0000000..1ef9c25 --- /dev/null +++ b/db/migrate/20140705230607_add_venue_details_to_flags.rb @@ -0,0 +1,5 @@ +class AddVenueDetailsToFlags < ActiveRecord::Migration + def change + add_column :flags, :venues_details, :text + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..93320e4 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,91 @@ +# encoding: UTF-8 +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# Note that this schema.rb definition is the authoritative source for your +# database schema. If you need to create the application database on another +# system, you should be using db:schema:load, not running all the migrations +# from scratch. The latter is a flawed and unsustainable approach (the more migrations +# you'll amass, the slower it'll run and the greater likelihood for issues). +# +# It's strongly recommended to check this file into your version control system. + +ActiveRecord::Schema.define(:version => 20140705230607) do + + create_table "categories_caches", :force => true do |t| + t.text "categories", :limit => 16777215 + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.string "digest" + t.datetime "last_verified" + end + + add_index "categories_caches", ["created_at"], :name => "index_categories_caches_on_created_at" + + create_table "delayed_jobs", :force => true do |t| + t.integer "priority", :default => 0, :null => false + t.integer "attempts", :default => 0, :null => false + t.text "handler", :null => false + t.text "last_error" + t.datetime "run_at" + t.datetime "locked_at" + t.datetime "failed_at" + t.string "locked_by" + t.string "queue" + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + end + + add_index "delayed_jobs", ["priority", "run_at"], :name => "delayed_jobs_priority" + + create_table "flags", :force => true do |t| + t.string "type" + t.string "status", :default => "new" + t.string "venueId" + t.integer "user_id" + t.string "secondaryVenueId" + t.string "primaryName" + t.string "secondaryName" + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.string "problem" + t.datetime "submitted_at" + t.string "resolved_details" + t.datetime "last_checked" + t.string "itemId" + t.string "itemName" + t.string "comment" + t.integer "job_id" + t.datetime "scheduled_at" + t.text "edits" + t.string "creatorId" + t.string "creatorName" + t.text "venues_details" + end + + add_index "flags", ["secondaryVenueId"], :name => "index_flags_on_secondaryVenueId" + add_index "flags", ["status"], :name => "index_flags_on_status" + add_index "flags", ["user_id", "status", "created_at"], :name => "index_flags_on_user_id_and_status_and_created_at" + add_index "flags", ["user_id", "status", "creatorId", "type"], :name => "index_flags_on_user_id_and_status_and_creatorId_and_type" + add_index "flags", ["user_id", "status", "secondaryVenueId", "type"], :name => "index_flags_on_user_id_and_status_and_secondaryVenueId_and_type" + add_index "flags", ["user_id", "status", "venueId", "type"], :name => "index_flags_on_user_id_and_status_and_venueId_and_type" + add_index "flags", ["venueId"], :name => "index_flags_on_venueId" + + create_table "users", :force => true do |t| + t.string "name" + t.string "level" + t.string "token" + t.boolean "enabled" + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.string "uid" + t.text "user_cache", :limit => 16777215 + t.datetime "cached_at" + t.string "hometown" + end + + add_index "users", ["token"], :name => "index_users_on_token" + add_index "users", ["uid"], :name => "index_users_on_uid" + +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 0000000..d34dfa0 --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,7 @@ +# This file should contain all the record creation needed to seed the database with its default values. +# The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). +# +# Examples: +# +# cities = City.create([{ :name => 'Chicago' }, { :name => 'Copenhagen' }]) +# Mayor.create(:name => 'Emanuel', :city => cities.first) diff --git a/doc/README_FOR_APP b/doc/README_FOR_APP new file mode 100644 index 0000000..fe41f5c --- /dev/null +++ b/doc/README_FOR_APP @@ -0,0 +1,2 @@ +Use this README file to introduce your application and point to useful places in the API for learning more. +Run "rake doc:app" to generate API documentation for your models, controllers, helpers, and libraries. diff --git a/doc/filters_bnf_notes.md b/doc/filters_bnf_notes.md new file mode 100644 index 0000000..bb16881 --- /dev/null +++ b/doc/filters_bnf_notes.md @@ -0,0 +1,85 @@ +Filter BNF +=== + +``` +filters := filter* + +filter := positive_filter | negative_filter + +positive_filter := textvalued | boolvalued | numbervalued | anyfield + +negative_filter := negation_atom positive_filter + +negation_atom := + "-" | "NOT" + + +// ANYFIELD +anyfield := textoperands // contains search of any of name, address, crossStreet, postalCode, category, twitter, phone + +// TEXT +textvalued_binary := textfields binary_text_operators textoperands +textvalued_unary := textfields unary_text_operators + +textfields := + | "name" + | "address" + | "crossStreet" + | "postalCode" | "zip" // same meaning + | "city" + | "twitter" + | "phone" + | "url" + | "category" | "cat" // only first category + | "location" // location means any of name, address, crossStreet, postalCode, city + | "ratio" // good,maybe,bad + +binary_text_operators := + ":" // any of textfield matches any of textoperands + | "=" // any of textfield exactly matches textoperands + +unary_text_operators := + "[:|=|IS] *empty" // any of textfield is empty or blank + | "[:|=|IS] *missing" // same as empty + | "[:|=|IS] *blank" // same as empty + +textoperands := + STRING* // list of bare, single quoted, or double quoted strings + +// NUMBER + +numbervalued_binary := numberfields binary_number_operators numberoperand + +numberfields := + "users" + | "checkins" + | "herenow" + | "tips" + +binary_number_operators := + "=" + | "<" + | "<=" + | ">" + | ">=" + +numberoperand := + INTEGER // no non-int fields yet + +// DATE VALUED + +datevalued := datefields binary_number_operators dateoperand + +datefields := + created_at + +dateoperand := + string // parsed by moment.js, parse failure there means parse failure here + +// BOOLEAN + +boolvalued := + "private" + | "verified" | "claimed" // same meaning + | "home" | "homes" +``` diff --git a/doc/possible_keyboard_shortcuts.txt b/doc/possible_keyboard_shortcuts.txt new file mode 100644 index 0000000..43c91fa --- /dev/null +++ b/doc/possible_keyboard_shortcuts.txt @@ -0,0 +1,41 @@ +If popover open: +=== + + + close it + submit flag + comment + + +Normal: +=== + + / up/down + zoom + select/deselect + search + mark private popup + make home + change category on selected + select category + close selected: + event over + closed + remove selected: + inappropriate + nonexistent + edit current +

        pin/unpin + help / keyboard shortcuts + actions on current: + p + h + b? + o + e + g + m + f + +<,> nav cat left +<.> nav cat right diff --git a/doc/undocumented endpoints.txt b/doc/undocumented endpoints.txt new file mode 100644 index 0000000..c14972b --- /dev/null +++ b/doc/undocumented endpoints.txt @@ -0,0 +1,92 @@ +Documentation for Foursquare Queues +---- + +Endpoints: + + +/venues/flagstats +==== + +parameters: + explict-lang + near + +response: + flagstats: [FlagStat] + leaderboard: [LeaderboardItem] + + +/venues/flagged +==== +parameters: + near + type: one of ["attribute", "category", "duplicate", "explorespam", "info", "missingaddress", "missingphone", "partnerMatch", "photo", "private", "remove", "svd", "tip", "uncategorized", "manualDuplicate"] + limit + ll + (no radius or bounding box as far as I can tell) + mode: [history, all] (history = place you've been) +response: + count: integer + venues.items:[Woe] + + + +/users/XXX/flaggedvenues +==== +parameters: + options to it + reporter = true -- user reported it + reporter = false -- user voted on it + limit/offset + resolved: true/false/missing (missing means both, or at least, will after today) + decision: accepted/rejected + woeType: info/duplicate/etc (only one at a time) +response: + [Flag?] + +==== + +Other endpoints: + +/venues/XXX/edits +- Shows historical edits +parameters: + ??? +response: + +/venues/XXX/flags + +/venueedits/YYYY + edits: + count: integer + items:Edit + +POST /venueedits/YYY/rollback + + +==== + +/venues/XXX/attributes + +response: +attributeSections: [ + {section: String + displayType: ("standard" is only value seen) + machineName: ('experience', 'foodAndDrink', 'features') + items: [ + name: + displayName: + lineItems: [ + {name: + availability: 'unknown'/'yes'/'no' + displayName:}, .... + ] + ] + }, ... +] + + +POST /venues/XXX/validatehours +/venues/XXX/likes + +/users/XXX/venuelikes diff --git a/doc/undocumented response types.txt b/doc/undocumented response types.txt new file mode 100644 index 0000000..c65643d --- /dev/null +++ b/doc/undocumented response types.txt @@ -0,0 +1,239 @@ +Foursquare undocumented response types: + +FlagTypes: + one of ["attribute", "category", "duplicate", "explorespam", "info", "missingaddress", "missingphone", "partnerMatch", "photo", "private", "remove", "svd", "tip", "uncategorized"] + +FlagStat + type: FlagType + count: integer + +LeaderboardItem + 'count': integer + 'user': CompactUser + +Woe + CompactVenue + new fields: + flags: + items: [Flag] + count + creator: CompactUser + editingMetadata: EditingMetadata + +Flag + ID + type: ['at' (attribute), 'info', 'category', 'primarycategory', 'removecategory', 'duplicate', 'missingaddress', 'missingphone', 'tip', 'privatevenue', 'remove', 'uncategorized', "suspicious", "price", "svd"] + [ "at" "category" "hours" "info" "mislocated" "missingaddress" "missingphone" "price" "primarycategory" "remove" "suspicious" "svd" "tip" "uncategorized"] + field: + [address city crossstreet description facebookId gs gm hours nh phone twitter_name url venuename zip] + ["americanExpress", "atm", "barService", "beer", "byo", "coatCheck", "cocktails", "creditCards", "discover", "driveThrough", "fullBar", "groupsOnlyReservations", "happyHour", "hasParking", "masterCard", "outdoorSeating", "privateLot", "publicLot", "reservations", "restroom", "sitDownDining", "smoking", "streetParking", "takeout", "takesDinersClub", "tvs", "valetParking", "visa", "wheelchairAccessible", "wifi", "wine"] + currentValue: String + value: FlagValue + displayValue: ? + displayName: String (human readable of field) + comments: [String?] + reporters: [User] + notes: [Note] + resolvedTime: ?? + resolvedUsers: ?? + canonicalPath: + reason: ["no_longer_relevant_tip", "offensive_tip", "spam_tip"] + + +FlagValue: + +String, for string valued +Hours +CategoryValue: + action: ['removeCategory', 'primaryCategory', 'addCategory'] + category: CompactCategory +DuplicateValue: + CompactVenue + + + editingMetadata: EditingMetadata + + preview: CompactVenue +TipValue: + CompactTip w/ CompactUser +RemoveValue: + "reason": one of ['closed'] + +Note: + type: one of ['recent', 'popular', 'robot', 'claimed', 'private'] + text + +EditingMetadata + humanHours: {} ?? + machineHours: {} ?? + coordinates: [ [Lat, Lng]* ] + daysSinceLastCheckin: integer + checkinCounts: { + "60": integer + } + + +Edit: + id + venueId + editType: one of ['rollback', 'merge', 'edit', 'create'] + deltas: [ Delta ] + reportingUsers: [CompactUser* ] + approvingUsers: [CompactUser* ] + isAutomatedEdit: Boolean + createdAt: timestamp + comment: + oldVenue: CompactVenue + mergeInfo: Text + mergeAttemps: [{ + status: ["Success"] + attemptCount: 1 + }*] + + +Delta: + op: ['modify', 'remove', 'add', 'change_head'] + name: one of: + "address (Address)" + "attributes.atm (ATM)" + "attributes.barService (Bar Service)" + "attributes.beer (Beer)" + "attributes.byo (BYO)" + "attributes.coatCheck (Coat Check)" + "attributes.cocktails (Cocktails)" + "attributes.delivery (Delivery)" + "attributes.driveThrough (Drive-through)" + "attributes.essentialReservations (Essential)" + "attributes.fullBar (Full Bar)" + "attributes.groupsOnlyReservations (Groups Only)" + "attributes.hasMusic (Music)" + "attributes.liveMusic (Live Music)" + "attributes.outdoorSeating (Outdoor Seating)" + "attributes.price (Price)" + "attributes.privateRoom (Private Room)" + "attributes.reservations (Reservations)" + "attributes.restroom (Restroom)" + "attributes.servesBarSnacks (Bar Snacks)" + "attributes.servesBreakfast (Breakfast)" + "attributes.servesBrunch (Brunch)" + "attributes.servesDessert (Dessert)" + "attributes.servesDinner (Dinner)" + "attributes.servesHappyHour (Happy Hour)" + "attributes.servesLunch (Lunch)" + "attributes.servesTastingMenu (Tasting Menu)" + "attributes.sitDownDining (Table Service)" + "attributes.smoking (Smoking)" + "attributes.takeout (Take-out)" + "attributes.takesAmex (American Express)" + "attributes.takesCreditCards (Credit Cards)" + "attributes.takesDinersClub (Diners Club)" + "attributes.takesDiscover (Discover)" + "attributes.takesMasterCard (MasterCard)" + "attributes.takesUnionPay (Union Pay)" + "attributes.takesVisa (Visa)" + "attributes.tvs (TVs)" + "attributes.wifi (Wi-Fi)" + "badgeTags ()" + "categories (Category)" + "categories (Primary Category)" + "chainUrl ()" + "city (City)" + "cityGeoId (City)" + "countrycode (Country)" + "countyGeoId (County)" + "crossstreet (Cross street)" + "description (Description)" + "fbId (Facebook ID)" + "fbName ()" + "fbUsername (Facebook Username)" + "flags (flags)" + "geomobile ()" + "georadius ()" + "hours (Hours)" + "latlng (Lat/Lng)" + "macrohoodGeoId (Neighborhood)" + "neighborhoodGeoId (Neighborhood)" + "phone (Phone)" + "state (State)" + "stateGeoId (State)" + "subhoodGeoId (Neighborhood)" + "twitterName (Twitter)" + "tz ()" + "url (Url)" + "userId ()" + "venuename (Name)" + "zip (Zip code)" + old: { + value: String + } + new: { + value: String + } + listObj: { + + } + + displayName: String + +QUEUES TO BUILD OUT: + "attribute", +1 "category", + "duplicate", +NO "explorespam", + "info", + "missingaddress", + "missingphone", +NO "partnerMatch", + "photo", + "private", + "remove", +NO "svd", + "tip", + "uncategorized" + +COULD NOT GET VALUES FOR: + duplicate + photo + partnerMatch + + + +Known Flag Values +1 PhoneNA +2 AddressNA +4 +8 CrossNA +16 CityNA +32 +64 ZipNA +128 TwitterNA +256 +512 PriceNA +1024 PrivateVenue +2048 +4096 CountryCodeOverridden +8192 DontCanonicalizeAddress +16384 UserEnteredNeighborhoodAsCity +32768 +65536 UserEnteredMacrohoodAsCity +131072 IsCityFromRevGeo +262144 IsCountyFromRevGeo +524288 IsStateFromRevGeo +1048576 UserEnteredNeighborhood +2097152 +4194304 UserEnteredMacrohood +8388608 UserEnteredCountyAsCity +16777216 + + + +--- + +/venues/XXX/validatehours responses + +status: "ERROR" +message: "Improper hours format" + + +status: "POPULARHOURSWARNING" +message: "Hours don't cover times when location is popular" + +status: "OK" +hours: [HUMAN READABLE HOURS FORMAT] + diff --git a/lib/assets/.gitkeep b/lib/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lib/tasks/.gitkeep b/lib/tasks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lib/tasks/category_icons.rake b/lib/tasks/category_icons.rake new file mode 100644 index 0000000..059a866 --- /dev/null +++ b/lib/tasks/category_icons.rake @@ -0,0 +1,73 @@ +require 'open-uri' + +def cat_icon_urls + client = Foursquare2::Client.new(:client_id => Settings.app_id, :client_secret => Settings.app_secret, :api_version => '20121015') + all_cats = client.venue_categories + + urls = sub_prefixes(all_cats) + urls << "https://ss3.4sqi.net/img/categories_v2/none_" + urls.uniq +end + +def sub_prefixes(cats) + urls = [] + cats.each do |cat| + urls << cat.icon.prefix + if cat.categories + urls += sub_prefixes(cat.categories) + end + end + urls +end + +task :upload_cat_icons => :environment do + urls = cat_icon_urls + + s3 = AWS::S3.new( + :access_key_id => Settings.aws_key, + :secret_access_key => Settings.aws_secret + ) + + urls.each do |url| + puts "Attempting: #{url}" + begin + blob = open(url + "32.png").read + rescue OpenURI::HTTPError => e + puts "HTTP Error fetching #{url}: #{e.message}" + next + end + icon = Magick::Image.from_blob(blob).first + + background_gray = Magick::Image.new(icon.columns, icon.rows) { self.background_color='#cccccc'} + background_orange = Magick::Image.new(icon.columns, icon.rows) { self.background_color='#FFAF7A'} + background_green = Magick::Image.new(icon.columns, icon.rows) { self.background_color='#b7cda9'} + background_faded = Magick::Image.new(icon.columns, icon.rows) { self.background_color='#eeeeee'} + + filename = url.gsub("https://ss1.4sqi.net/img/categories_v2/", "") + bordered = background_gray.composite(icon, 0, 0, Magick::AtopCompositeOp).border(2,2,'#888888') + orange = background_orange.composite(icon, 0, 0, Magick::AtopCompositeOp).border(2,2, "#888888") + green = background_green.composite(icon, 0, 0, Magick::AtopCompositeOp).border(2,2, "#888888") + faded = background_faded.composite(icon, 0, 0, Magick::AtopCompositeOp).border(2,2, "#aaaaaa") + + files = { + :bordered => bordered, + :green => green, + :orange => orange, + :faded => faded + } + + files.each_pair do |name, image| + destination = filename + "32_#{name.to_s}.png" + + obj = s3.buckets[Settings.s3_bucket].objects[destination] + obj.write(image.to_blob {self.format = 'png'}, :mime_type => "image/png") + # s3.store( + # destination, + # image.to_blob {self.format = 'png'}, + # Settings.s3_bucket, + # :mime_type => "image/png" + # ) + puts "Put #{destination}" + end + end +end diff --git a/log/.gitkeep b/log/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/422.html b/public/422.html new file mode 100644 index 0000000..83660ab --- /dev/null +++ b/public/422.html @@ -0,0 +1,26 @@ + + + + The change you wanted was rejected (422) + + + + + +

        + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 0000000..e195e28 --- /dev/null +++ b/public/500.html @@ -0,0 +1,24 @@ + + + + We're sorry, but something went wrong (500) + + + + +
        +

        We're sorry, but something went wrong. If the problem persists, please tweet us at @4sweep or email 4sweep@4sweep.com for help. Thanks!

        +
        + + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/public/fontello-demo.html b/public/fontello-demo.html new file mode 100644 index 0000000..ea7281b --- /dev/null +++ b/public/fontello-demo.html @@ -0,0 +1,415 @@ + + + + + + + + + +
        +

        + 4sweep_fontello + font demo +

        + +
        +
        +
        +
        i-crown0xe800
        +
        i-chat0xe801
        +
        i-sliders0xe802
        +
        i-lock-open-alt0xe803
        +
        +
        +
        i-lock0xe804
        +
        i-lock-open0xe805
        +
        i-th0xe806
        +
        i-users0xe807
        +
        +
        +
        i-zoom-in0xe808
        +
        i-list-numbered0xe809
        +
        i-star0xe80a
        +
        i-star-empty0xe80b
        +
        +
        +
        i-search0xe80c
        +
        i-quote-left0xe80d
        +
        i-quote-right0xe80e
        +
        i-tags0xe80f
        +
        +
        +
        i-tag0xe810
        +
        i-attention-alt0xe811
        +
        i-chat-empty0xe812
        +
        i-comment-empty0xe813
        +
        +
        +
        i-comment0xe814
        +
        i-cog-alt0xe815
        +
        i-cog0xe816
        +
        i-sun0xe817
        +
        +
        +
        i-facebook0xe818
        +
        i-facebook-squared0xe819
        +
        i-certificate0xe81a
        +
        i-spoon0xe81b
        +
        +
        +
        i-twitter0xe81c
        +
        i-attention-circled0xe81d
        +
        i-eye-off0xe81e
        +
        i-calendar0xe81f
        +
        +
        +
        i-bookmark-empty0xe820
        +
        i-sort-alt-up0xe821
        +
        i-sort-alt-down0xe822
        +
        i-user-add0xe823
        +
        +
        +
        i-thumbs-down-alt0xe824
        +
        i-flag0xe825
        +
        i-thumbs-up-alt0xe826
        +
        i-thumbs-up0xe827
        +
        +
        +
        i-ok-circled0xe828
        +
        i-clock0xe829
        +
        i-group0xe82a
        +
        i-group-circled0xe82b
        +
        +
        +
        i-ellipsis0xe82c
        +
        i-ellipsis-vert0xe82d
        +
        i-food0xe82e
        +
        i-flow-merge0xe82f
        +
        +
        +
        i-fork0xe830
        +
        i-th-list-outline0xe831
        +
        i-sort-number-up0xe832
        +
        i-sort-number-down0xe833
        +
        +
        +
        i-sort-name-down0xe834
        +
        i-sort-name-up0xe835
        +
        i-sort-up0xe836
        +
        i-address0xe837
        +
        +
        +
        i-sort-down0xe838
        +
        i-tasks0xe839
        +
        i-location0xe83a
        +
        i-resize-horizontal0xe83b
        +
        +
        +
        i-attach0xe83c
        +
        i-attach-10xe83d
        +
        i-pin0xe83e
        +
        i-pin-10xe83f
        +
        +
        +
        i-attach-20xe840
        +
        i-arrows-cw0xe841
        +
        i-arrows-cw-10xe842
        +
        i-loop0xe843
        +
        +
        +
        i-question0xe844
        +
        i-help-circled0xe845
        +
        i-help-circled-alt0xe846
        +
        i-help-circled-10xe847
        +
        +
        +
        i-help-circled-20xe848
        +
        i-switch0xe849
        +
        i-plus-squared0xe84a
        +
        i-minus-squared0xe84b
        +
        +
        +
        i-arrows-cw-20xe84c
        +
        i-spin30xe84d
        +
        i-location-circled0xe84e
        +
        i-link0xe84f
        +
        +
        +
        i-flow-tree0xe850
        +
        i-right0xe851
        +
        i-up0xe852
        +
        i-left0xe853
        +
        +
        +
        i-down0xe854
        +
        i-ok0xe855
        +
        i-cancel0xe856
        +
        i-fast-food0xe857
        +
        +
        +
        i-chat-10xe858
        +
        i-twitter-squared0xe859
        +
        i-phone-squared0xe85a
        +
        i-heart0xe85b
        +
        +
        +
        i-heart-broken0xe85c
        +
        i-link-ext0xe85d
        +
        i-right-open0xe85e
        +
        i-left-open0xe85f
        +
        +
        +
        i-plus0xe860
        +
        i-list-add0xe861
        +
        i-spread0xe862
        +
        i-export-alt0xe863
        +
        +
        +
        i-foursquare0xe864
        +
        i-foursquare-old0xe865
        +
        i-link-ext-alt0xe866
        +
        +
        +
        + + \ No newline at end of file diff --git a/public/img/checkmark.png b/public/img/checkmark.png new file mode 100644 index 0000000..0527b28 Binary files /dev/null and b/public/img/checkmark.png differ diff --git a/public/img/congruent_outline.png b/public/img/congruent_outline.png new file mode 100644 index 0000000..441ce0a Binary files /dev/null and b/public/img/congruent_outline.png differ diff --git a/public/img/dot.png b/public/img/dot.png new file mode 100644 index 0000000..5eddf87 Binary files /dev/null and b/public/img/dot.png differ diff --git a/public/img/flagdup.png b/public/img/flagdup.png new file mode 100644 index 0000000..666a5cc Binary files /dev/null and b/public/img/flagdup.png differ diff --git a/public/img/gray-mapicon.png b/public/img/gray-mapicon.png new file mode 100644 index 0000000..4b9d0ac Binary files /dev/null and b/public/img/gray-mapicon.png differ diff --git a/public/img/greendot.png b/public/img/greendot.png new file mode 100644 index 0000000..135f0f5 Binary files /dev/null and b/public/img/greendot.png differ diff --git a/public/img/greentrans.png b/public/img/greentrans.png new file mode 100644 index 0000000..9fca2f4 Binary files /dev/null and b/public/img/greentrans.png differ diff --git a/public/img/hastip.png b/public/img/hastip.png new file mode 100644 index 0000000..e97b279 Binary files /dev/null and b/public/img/hastip.png differ diff --git a/public/img/orangetrans.png b/public/img/orangetrans.png new file mode 100644 index 0000000..4f82fc7 Binary files /dev/null and b/public/img/orangetrans.png differ diff --git a/public/img/star.png b/public/img/star.png new file mode 100644 index 0000000..4bae360 Binary files /dev/null and b/public/img/star.png differ diff --git a/public/img/zoom_in.png b/public/img/zoom_in.png new file mode 100644 index 0000000..1bd2d6a Binary files /dev/null and b/public/img/zoom_in.png differ diff --git a/public/maintenance.html b/public/maintenance.html new file mode 100644 index 0000000..17c984a --- /dev/null +++ b/public/maintenance.html @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + 4sweep Downtime + + + + + + + + + + +
        + +
        +

        4sweep

        +
        +
        +

        4sweep is Temporarily Down

        +

        + Sorry! +

        +

        4sweep is performing maintenance work and will be back up shortly.

        + +

        + You can check our Twitter for updates and more information. +

        +
        + + +
        + + + + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..085187f --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,5 @@ +# See http://www.robotstxt.org/wc/norobots.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-Agent: * +# Disallow: / diff --git a/script/delayed_job b/script/delayed_job new file mode 100755 index 0000000..edf1959 --- /dev/null +++ b/script/delayed_job @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby + +require File.expand_path(File.join(File.dirname(__FILE__), '..', 'config', 'environment')) +require 'delayed/command' +Delayed::Command.new(ARGV).daemonize diff --git a/script/rails b/script/rails new file mode 100755 index 0000000..f8da2cf --- /dev/null +++ b/script/rails @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application. + +APP_PATH = File.expand_path('../../config/application', __FILE__) +require File.expand_path('../../config/boot', __FILE__) +require 'rails/commands' diff --git a/test/fixtures/.gitkeep b/test/fixtures/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/categories_caches.yml b/test/fixtures/categories_caches.yml new file mode 100644 index 0000000..1e38897 --- /dev/null +++ b/test/fixtures/categories_caches.yml @@ -0,0 +1,7 @@ +# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/Fixtures.html + +one: + categories: MyText + +two: + categories: MyText diff --git a/test/fixtures/flags.yml b/test/fixtures/flags.yml new file mode 100644 index 0000000..eb74e22 --- /dev/null +++ b/test/fixtures/flags.yml @@ -0,0 +1,25 @@ +# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/Fixtures.html + +one: + type: + status: MyString + venueId: MyString + user_id: + secondaryVenueId: MyString + primaryName: MyString + secondaryName: MyString + created_at: 2012-08-11 19:36:42 + primaryJSON: MyText + secondaryJSON: MyText + +two: + type: + status: MyString + venueId: MyString + user_id: + secondaryVenueId: MyString + primaryName: MyString + secondaryName: MyString + created_at: 2012-08-11 19:36:42 + primaryJSON: MyText + secondaryJSON: MyText diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..2ee31e3 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,13 @@ +# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/Fixtures.html + +one: + name: MyString + level: MyString + token: MyString + enabled: false + +two: + name: MyString + level: MyString + token: MyString + enabled: false diff --git a/test/functional/.gitkeep b/test/functional/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/functional/explorer_controller_test.rb b/test/functional/explorer_controller_test.rb new file mode 100644 index 0000000..d4e25c5 --- /dev/null +++ b/test/functional/explorer_controller_test.rb @@ -0,0 +1,9 @@ +require 'test_helper' + +class ExplorerControllerTest < ActionController::TestCase + test "should get explore" do + get :explore + assert_response :success + end + +end diff --git a/test/functional/flags_controller_test.rb b/test/functional/flags_controller_test.rb new file mode 100644 index 0000000..1860a4d --- /dev/null +++ b/test/functional/flags_controller_test.rb @@ -0,0 +1,19 @@ +require 'test_helper' + +class FlagsControllerTest < ActionController::TestCase + test "should get list" do + get :list + assert_response :success + end + + test "should get submit" do + get :submit + assert_response :success + end + + test "should get check" do + get :check + assert_response :success + end + +end diff --git a/test/functional/session_controller_test.rb b/test/functional/session_controller_test.rb new file mode 100644 index 0000000..8e6453d --- /dev/null +++ b/test/functional/session_controller_test.rb @@ -0,0 +1,14 @@ +require 'test_helper' + +class SessionControllerTest < ActionController::TestCase + test "should get callback" do + get :callback + assert_response :success + end + + test "should get new" do + get :new + assert_response :success + end + +end diff --git a/test/functional/static_pages_controller_test.rb b/test/functional/static_pages_controller_test.rb new file mode 100644 index 0000000..95160fa --- /dev/null +++ b/test/functional/static_pages_controller_test.rb @@ -0,0 +1,14 @@ +require 'test_helper' + +class StaticPagesControllerTest < ActionController::TestCase + test "should get about" do + get :about + assert_response :success + end + + test "should get faq" do + get :faq + assert_response :success + end + +end diff --git a/test/functional/stats_controller_test.rb b/test/functional/stats_controller_test.rb new file mode 100644 index 0000000..b4c50d9 --- /dev/null +++ b/test/functional/stats_controller_test.rb @@ -0,0 +1,9 @@ +require 'test_helper' + +class StatsControllerTest < ActionController::TestCase + test "should get stats" do + get :stats + assert_response :success + end + +end diff --git a/test/integration/.gitkeep b/test/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/performance/browsing_test.rb b/test/performance/browsing_test.rb new file mode 100644 index 0000000..3fea27b --- /dev/null +++ b/test/performance/browsing_test.rb @@ -0,0 +1,12 @@ +require 'test_helper' +require 'rails/performance_test_help' + +class BrowsingTest < ActionDispatch::PerformanceTest + # Refer to the documentation for all available options + # self.profile_options = { :runs => 5, :metrics => [:wall_time, :memory] + # :output => 'tmp/performance', :formats => [:flat] } + + def test_homepage + get '/' + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..8bf1192 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,13 @@ +ENV["RAILS_ENV"] = "test" +require File.expand_path('../../config/environment', __FILE__) +require 'rails/test_help' + +class ActiveSupport::TestCase + # Setup all fixtures in test/fixtures/*.(yml|csv) for all tests in alphabetical order. + # + # Note: You'll currently still have to declare fixtures explicitly in integration tests + # -- they do not yet inherit this setting + fixtures :all + + # Add more helper methods to be used by all tests here... +end diff --git a/test/unit/.gitkeep b/test/unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/categories_cache_test.rb b/test/unit/categories_cache_test.rb new file mode 100644 index 0000000..7e2fa66 --- /dev/null +++ b/test/unit/categories_cache_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class CategoriesCacheTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/unit/flags_test.rb b/test/unit/flags_test.rb new file mode 100644 index 0000000..99c0592 --- /dev/null +++ b/test/unit/flags_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class FlagsTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/unit/helpers/explorer_helper_test.rb b/test/unit/helpers/explorer_helper_test.rb new file mode 100644 index 0000000..bf408a3 --- /dev/null +++ b/test/unit/helpers/explorer_helper_test.rb @@ -0,0 +1,4 @@ +require 'test_helper' + +class ExplorerHelperTest < ActionView::TestCase +end diff --git a/test/unit/helpers/flags_helper_test.rb b/test/unit/helpers/flags_helper_test.rb new file mode 100644 index 0000000..92da4fc --- /dev/null +++ b/test/unit/helpers/flags_helper_test.rb @@ -0,0 +1,4 @@ +require 'test_helper' + +class FlagsHelperTest < ActionView::TestCase +end diff --git a/test/unit/helpers/session_helper_test.rb b/test/unit/helpers/session_helper_test.rb new file mode 100644 index 0000000..2824733 --- /dev/null +++ b/test/unit/helpers/session_helper_test.rb @@ -0,0 +1,4 @@ +require 'test_helper' + +class SessionHelperTest < ActionView::TestCase +end diff --git a/test/unit/helpers/static_pages_helper_test.rb b/test/unit/helpers/static_pages_helper_test.rb new file mode 100644 index 0000000..a1f06a2 --- /dev/null +++ b/test/unit/helpers/static_pages_helper_test.rb @@ -0,0 +1,4 @@ +require 'test_helper' + +class StaticPagesHelperTest < ActionView::TestCase +end diff --git a/test/unit/helpers/stats_helper_test.rb b/test/unit/helpers/stats_helper_test.rb new file mode 100644 index 0000000..3f0b9aa --- /dev/null +++ b/test/unit/helpers/stats_helper_test.rb @@ -0,0 +1,4 @@ +require 'test_helper' + +class StatsHelperTest < ActionView::TestCase +end diff --git a/test/unit/helpers/users_helper_test.rb b/test/unit/helpers/users_helper_test.rb new file mode 100644 index 0000000..96af37a --- /dev/null +++ b/test/unit/helpers/users_helper_test.rb @@ -0,0 +1,4 @@ +require 'test_helper' + +class UsersHelperTest < ActionView::TestCase +end diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb new file mode 100644 index 0000000..82f61e0 --- /dev/null +++ b/test/unit/user_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class UserTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/vendor/assets/fonts/4sweep_fontello.eot b/vendor/assets/fonts/4sweep_fontello.eot new file mode 100644 index 0000000..07e3d60 Binary files /dev/null and b/vendor/assets/fonts/4sweep_fontello.eot differ diff --git a/vendor/assets/fonts/4sweep_fontello.svg b/vendor/assets/fonts/4sweep_fontello.svg new file mode 100644 index 0000000..b4e4cfc --- /dev/null +++ b/vendor/assets/fonts/4sweep_fontello.svg @@ -0,0 +1,114 @@ + + + +Copyright (C) 2015 by original authors @ fontello.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vendor/assets/fonts/4sweep_fontello.ttf b/vendor/assets/fonts/4sweep_fontello.ttf new file mode 100644 index 0000000..1b870f9 Binary files /dev/null and b/vendor/assets/fonts/4sweep_fontello.ttf differ diff --git a/vendor/assets/fonts/4sweep_fontello.woff b/vendor/assets/fonts/4sweep_fontello.woff new file mode 100644 index 0000000..ca23d31 Binary files /dev/null and b/vendor/assets/fonts/4sweep_fontello.woff differ diff --git a/vendor/assets/fonts/config.json b/vendor/assets/fonts/config.json new file mode 100644 index 0000000..2a64d83 --- /dev/null +++ b/vendor/assets/fonts/config.json @@ -0,0 +1,628 @@ +{ + "name": "4sweep_fontello", + "css_prefix_text": "i-", + "css_use_suffix": false, + "hinting": true, + "units_per_em": 1000, + "ascent": 850, + "glyphs": [ + { + "uid": "2a6740fc2f9d0edea54205963f662594", + "css": "spin3", + "code": 59469, + "src": "fontelico" + }, + { + "uid": "186dec7a13156bbe2550790c158fb85d", + "css": "crown", + "code": 59392, + "src": "fontelico" + }, + { + "uid": "9dd9e835aebe1060ba7190ad2b2ed951", + "css": "search", + "code": 59404, + "src": "fontawesome" + }, + { + "uid": "474656633f79ea2f1dad59ff63f6bf07", + "css": "star", + "code": 59402, + "src": "fontawesome" + }, + { + "uid": "d17030afaecc1e1c22349b99f3c4992a", + "css": "star-empty", + "code": 59403, + "src": "fontawesome" + }, + { + "uid": "31972e4e9d080eaa796290349ae6c1fd", + "css": "users", + "code": 59399, + "src": "fontawesome" + }, + { + "uid": "b1887b423d2fd15c345e090320c91ca0", + "css": "th", + "code": 59398, + "src": "fontawesome" + }, + { + "uid": "43ab845088317bd348dee1d975700c48", + "css": "ok-circled", + "code": 59432, + "src": "fontawesome" + }, + { + "uid": "44e04715aecbca7f266a17d5a7863c68", + "css": "plus", + "code": 59488, + "src": "fontawesome" + }, + { + "uid": "1a5cfa186647e8c929c2b17b9fc4dac1", + "css": "plus-squared", + "code": 59466, + "src": "fontawesome" + }, + { + "uid": "f755a58fb985eeb70bd47d9b31892a34", + "css": "minus-squared", + "code": 59467, + "src": "fontawesome" + }, + { + "uid": "17ebadd1e3f274ff0205601eef7b9cc4", + "css": "help-circled-1", + "code": 59463, + "src": "fontawesome" + }, + { + "uid": "e15f0d620a7897e2035c18c80142f6d9", + "css": "link-ext", + "code": 59485, + "src": "fontawesome" + }, + { + "uid": "e35de5ea31cd56970498e33efbcb8e36", + "css": "link-ext-alt", + "code": 59494, + "src": "fontawesome" + }, + { + "uid": "c1f1975c885aa9f3dad7810c53b82074", + "css": "lock", + "code": 59396, + "src": "fontawesome" + }, + { + "uid": "657ab647f6248a6b57a5b893beaf35a9", + "css": "lock-open", + "code": 59397, + "src": "fontawesome" + }, + { + "uid": "05376be04a27d5a46e855a233d6e8508", + "css": "lock-open-alt", + "code": 59395, + "src": "fontawesome" + }, + { + "uid": "5b0772e9484a1a11646793a82edd622a", + "css": "pin-1", + "code": 59455, + "src": "fontawesome" + }, + { + "uid": "7fd683b2c518ceb9e5fa6757f2276faa", + "css": "eye-off", + "code": 59422, + "src": "fontawesome" + }, + { + "uid": "3db5347bd219f3bce6025780f5d9ef45", + "css": "tag", + "code": 59408, + "src": "fontawesome" + }, + { + "uid": "a3f89e106175a5c5c4e9738870b12e55", + "css": "tags", + "code": 59407, + "src": "fontawesome" + }, + { + "uid": "2f5ef6f6b7aaebc56458ab4e865beff5", + "css": "bookmark-empty", + "code": 59424, + "src": "fontawesome" + }, + { + "uid": "57a0ac800df728aad61a7cf9e12f5fef", + "css": "flag", + "code": 59429, + "src": "fontawesome" + }, + { + "uid": "acf41aa4018e58d49525665469e35665", + "css": "thumbs-up", + "code": 59431, + "src": "fontawesome" + }, + { + "uid": "5e2ab018e3044337bcef5f7e94098ea1", + "css": "thumbs-up-alt", + "code": 59430, + "src": "fontawesome" + }, + { + "uid": "ddcd918b502642705838815d40aea9e3", + "css": "thumbs-down-alt", + "code": 59428, + "src": "fontawesome" + }, + { + "uid": "ab95e1351ebaec5850101097cbf7097f", + "css": "quote-left", + "code": 59405, + "src": "fontawesome" + }, + { + "uid": "d745d7c05b94e609decabade2cae12cb", + "css": "quote-right", + "code": 59406, + "src": "fontawesome" + }, + { + "uid": "13b9eebfea581ad8e756ee7a18a7cba8", + "css": "export-alt", + "code": 59491, + "src": "fontawesome" + }, + { + "uid": "85528017f1e6053b2253785c31047f44", + "css": "comment", + "code": 59412, + "src": "fontawesome" + }, + { + "uid": "dcedf50ab1ede3283d7a6c70e2fe32f3", + "css": "chat", + "code": 59393, + "src": "fontawesome" + }, + { + "uid": "9c1376672bb4f1ed616fdd78a23667e9", + "css": "comment-empty", + "code": 59411, + "src": "fontawesome" + }, + { + "uid": "31951fbb9820ed0690f675b3d495c8da", + "css": "chat-empty", + "code": 59410, + "src": "fontawesome" + }, + { + "uid": "00391fac5d419345ffcccd95b6f76263", + "css": "attention-alt", + "code": 59409, + "src": "fontawesome" + }, + { + "uid": "b035c28eba2b35c6ffe92aee8b0df507", + "css": "attention-circled", + "code": 59421, + "src": "fontawesome" + }, + { + "uid": "ec488dfd1f548948c09671ca5a60ec92", + "css": "phone-squared", + "code": 59482, + "src": "fontawesome" + }, + { + "uid": "e99461abfef3923546da8d745372c995", + "css": "cog", + "code": 59414, + "src": "fontawesome" + }, + { + "uid": "98687378abd1faf8f6af97c254eb6cd6", + "css": "cog-alt", + "code": 59413, + "src": "fontawesome" + }, + { + "uid": "21b42d3c3e6be44c3cc3d73042faa216", + "css": "sliders", + "code": 59394, + "src": "fontawesome" + }, + { + "uid": "531bc468eecbb8867d822f1c11f1e039", + "css": "calendar", + "code": 59423, + "src": "fontawesome" + }, + { + "uid": "598a5f2bcf3521d1615de8e1881ccd17", + "css": "clock", + "code": 59433, + "src": "fontawesome" + }, + { + "uid": "3c73d058e4589b65a8d959c0fc8f153d", + "css": "resize-horizontal", + "code": 59451, + "src": "fontawesome" + }, + { + "uid": "0b2b66e526028a6972d51a6f10281b4b", + "css": "zoom-in", + "code": 59400, + "src": "fontawesome" + }, + { + "uid": "d870630ff8f81e6de3958ecaeac532f2", + "css": "left-open", + "code": 59487, + "src": "fontawesome" + }, + { + "uid": "399ef63b1e23ab1b761dfbb5591fa4da", + "css": "right-open", + "code": 59486, + "src": "fontawesome" + }, + { + "uid": "a73c5deb486c8d66249811642e5d719a", + "css": "arrows-cw-2", + "code": 59468, + "src": "fontawesome" + }, + { + "uid": "aa035df0908c4665c269b7b09a5596f3", + "css": "sun", + "code": 59415, + "src": "fontawesome" + }, + { + "uid": "f6766a8b042c2453a4e153af03294383", + "css": "list-numbered", + "code": 59401, + "src": "fontawesome" + }, + { + "uid": "107ce08c7231097c7447d8f4d059b55f", + "css": "ellipsis", + "code": 59436, + "src": "fontawesome" + }, + { + "uid": "750058837a91edae64b03d60fc7e81a7", + "css": "ellipsis-vert", + "code": 59437, + "src": "fontawesome" + }, + { + "uid": "bc4b94dd7a9a1dd2e02f9e4648062596", + "css": "fork", + "code": 59440, + "src": "fontawesome" + }, + { + "uid": "d61be837c725a299b432dcbee2ecdae6", + "css": "certificate", + "code": 59418, + "src": "fontawesome" + }, + { + "uid": "9396b2d8849e0213a0f11c5fd7fcc522", + "css": "tasks", + "code": 59449, + "src": "fontawesome" + }, + { + "uid": "94103e1b3f1e8cf514178ec5912b4469", + "css": "sort-down", + "code": 59448, + "src": "fontawesome" + }, + { + "uid": "65b3ce930627cabfb6ac81ac60ec5ae4", + "css": "sort-up", + "code": 59446, + "src": "fontawesome" + }, + { + "uid": "0cd2582b8c93719d066ee0affd02ac78", + "css": "sort-alt-up", + "code": 59425, + "src": "fontawesome" + }, + { + "uid": "27b13eff5eb0ca15e01a6e65ffe6eeec", + "css": "sort-alt-down", + "code": 59426, + "src": "fontawesome" + }, + { + "uid": "3ed68ae14e9cde775121954242a412b2", + "css": "sort-name-up", + "code": 59445, + "src": "fontawesome" + }, + { + "uid": "6586267200a42008a9fc0a1bf7ac06c7", + "css": "sort-name-down", + "code": 59444, + "src": "fontawesome" + }, + { + "uid": "3a7b6876c1817ce3b801b86c04a9d0af", + "css": "sort-number-up", + "code": 59442, + "src": "fontawesome" + }, + { + "uid": "b04fc30546f597a7e0a14715e6fc81ff", + "css": "sort-number-down", + "code": 59443, + "src": "fontawesome" + }, + { + "uid": "30b79160618d99ce798e4bd11cafe3fe", + "css": "food", + "code": 59438, + "src": "fontawesome" + }, + { + "uid": "3964e28e6bdf85b3b70df3533db69867", + "css": "spoon", + "code": 59419, + "src": "fontawesome" + }, + { + "uid": "8e04c98c8f5ca0a035776e3001ad2638", + "css": "facebook", + "code": 59416, + "src": "fontawesome" + }, + { + "uid": "4743b088aa95d6f3b6b990e770d3b647", + "css": "facebook-squared", + "code": 59417, + "src": "fontawesome" + }, + { + "uid": "a32d12927584e3c8a3dff23eb816d360", + "css": "foursquare", + "code": 59492, + "src": "fontawesome" + }, + { + "uid": "906348dc798a0d42715cc97c875e3ac6", + "css": "twitter-squared", + "code": 59481, + "src": "fontawesome" + }, + { + "uid": "627abcdb627cb1789e009c08e2678ef9", + "css": "twitter", + "code": 59420, + "src": "fontawesome" + }, + { + "uid": "6274e0601f2feef7eced89146e708de0", + "css": "user-add", + "code": 59427, + "src": "entypo" + }, + { + "uid": "de9a631a7d18106aea1c89ba51b1990a", + "css": "help-circled-2", + "code": 59464, + "src": "entypo" + }, + { + "uid": "44b9e75612c5fad5505edd70d071651f", + "css": "attach-2", + "code": 59456, + "src": "entypo" + }, + { + "uid": "540b6a4262be769515c79700618b4aea", + "css": "address", + "code": 59447, + "src": "entypo" + }, + { + "uid": "f0eac0958921fe45b85d01b79d76e86b", + "css": "switch", + "code": 59465, + "src": "entypo" + }, + { + "uid": "97bd5542ed3e143d2ee9b60e14487615", + "css": "list-add", + "code": 59489, + "src": "entypo" + }, + { + "uid": "8a1d446e5555e76f82ddb1c8b526f579", + "css": "flow-tree", + "code": 59472, + "src": "entypo" + }, + { + "uid": "43855c51ebf847e8d581b794e4126dfe", + "css": "th-list-outline", + "code": 59441, + "src": "typicons" + }, + { + "uid": "c1bea2b4c01d1d4bd1e4e1f79e51cdd2", + "css": "flow-merge", + "code": 59439, + "src": "typicons" + }, + { + "uid": "794d73c3a5fcf710265679700e470578", + "css": "pin", + "code": 59454, + "src": "iconic" + }, + { + "uid": "5d3ef4b7c90d2931e641b840ee42f694", + "css": "loop", + "code": 59459, + "src": "iconic" + }, + { + "uid": "23d1c53bc15ca452df9453450e94a19c", + "css": "question", + "code": 59460, + "src": "modernpics" + }, + { + "uid": "d02a803276d9e1f42b7393964aa22ce9", + "css": "heart", + "code": 59483, + "src": "mfglabs" + }, + { + "uid": "862129f833b09f3d34ae39acf8484a7b", + "css": "heart-broken", + "code": 59484, + "src": "mfglabs" + }, + { + "uid": "a3d734a5b4bec33fc3aa459d82092b23", + "css": "help-circled", + "code": 59461, + "src": "mfglabs" + }, + { + "uid": "3e02a8849305ac80a0e36302f461f265", + "css": "help-circled-alt", + "code": 59462, + "src": "mfglabs" + }, + { + "uid": "1fb8776fe6f1d3bbf970996fdfcf0f94", + "css": "link", + "code": 59471, + "src": "mfglabs" + }, + { + "uid": "a8ed7903f8f548da5a8084e1773f0bbb", + "css": "chat-1", + "code": 59480, + "src": "mfglabs" + }, + { + "uid": "500cde26773d15aaac1bfaa2a33cc5a9", + "css": "spread", + "code": 59490, + "src": "mfglabs" + }, + { + "uid": "d27bcf5c8638e4078aaadae1d49b6909", + "css": "fast-food", + "code": 59479, + "src": "maki" + }, + { + "uid": "ffecc77dcd9b9dff653ff88b508220d4", + "css": "foursquare-old", + "code": 59493, + "src": "zocial" + }, + { + "uid": "e36d581e4f2844db345bddc205d15dda", + "css": "group", + "code": 59434, + "src": "elusive" + }, + { + "uid": "8d40bca7a7f11091ca865e07535fcc47", + "css": "group-circled", + "code": 59435, + "src": "elusive" + }, + { + "uid": "ce7452abce8b55ded1c393997a51e6b3", + "css": "ok", + "code": 59477, + "src": "elusive" + }, + { + "uid": "499b745a2e2485bdd059c3a53d048e5f", + "css": "cancel", + "code": 59478, + "src": "elusive" + }, + { + "uid": "22cfc7af2c4f158b37317c65c92b48c2", + "css": "location", + "code": 59450, + "src": "elusive" + }, + { + "uid": "c54c00a5b7fba94b9fbc940de38a7beb", + "css": "location-circled", + "code": 59470, + "src": "elusive" + }, + { + "uid": "a79ef2d6f102af86440aa80238d5f4b0", + "css": "down", + "code": 59476, + "src": "elusive" + }, + { + "uid": "1b2ef17b42012a1e46743f9be8384f83", + "css": "left", + "code": 59475, + "src": "elusive" + }, + { + "uid": "23012c4e68769e1f133bba1f93a734d1", + "css": "right", + "code": 59473, + "src": "elusive" + }, + { + "uid": "cbb11c546600a92fde108476faf5d337", + "css": "up", + "code": 59474, + "src": "elusive" + }, + { + "uid": "d5fabfa46384953ae055fceacb2229a7", + "css": "arrows-cw", + "code": 59457, + "src": "elusive" + }, + { + "uid": "359f380b2113cb40259269aed843e33d", + "css": "attach", + "code": 59452, + "src": "linecons" + }, + { + "uid": "21115bb09fa242341cb91ed34710aa13", + "css": "attach-1", + "code": 59453, + "src": "websymbols" + }, + { + "uid": "41eeea14413f2539da21b4d1754bf4be", + "css": "arrows-cw-1", + "code": 59458, + "src": "websymbols" + } + ] +} \ No newline at end of file diff --git a/vendor/assets/javascripts/.gitkeep b/vendor/assets/javascripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/assets/javascripts/HoursParser.js b/vendor/assets/javascripts/HoursParser.js new file mode 100644 index 0000000..0cb8901 --- /dev/null +++ b/vendor/assets/javascripts/HoursParser.js @@ -0,0 +1,274 @@ +// Copyright 2014 Foursquare Labs Inc. All Rights Reserved. + +var fourSq = fourSq || {}; +fourSq.util = fourSq.util || {} + +fourSq.util.Hours = { + /** + * Pads times to be HHMM + * @param {string} text + * @return {string} + */ + padTimes: function(text) { + // Add leading/trailing zeros to times so it's always 4 digits, like 0800 + // Have to run each twice because they're pivoting around the separator + // i.e. x10-12x first matches "x10-" and doesn't match the rest + text = text.replace(/([^0-9]|^)([0-9]{3})([^0-9]|$)/g, '$10$2$3'); + text = text.replace(/([^0-9]|^)([0-9]{3})([^0-9]|$)/g, '$10$2$3'); + text = text.replace(/([^0-9]|^)([0-9]{2})([^0-9]|$)/g, '$1$200$3'); + text = text.replace(/([^0-9]|^)([0-9]{2})([^0-9]|$)/g, '$1$200$3'); + text = text.replace(/([^0-9]|^)([0-9])([^0-9]|$)/g, '$10$200$3'); + text = text.replace(/([^0-9]|^)([0-9])([^0-9]|$)/g, '$10$200$3'); + return text; + }, + + /** + * @param {Array.} days + * @param {number} startMinutes + * @param {number} endMinutes + */ + toTimeframe: function(days, startMinutes, endMinutes) { + // If we've day wrapped and end before 4am, push the ending value up 24 hours. + if (startMinutes >= endMinutes && endMinutes <= 240) { + endMinutes += 1440; + } + var startFormatted = fourSq.util.Hours.formatMinutes(startMinutes); + var endFormatted = fourSq.util.Hours.formatMinutes(endMinutes); + + return /** @type {fourSq.api.models.hours.MachineTimeframe} */ (({ + days: days, + open: [(/** @type {fourSq.api.models.hours.MachineSegment} */({ + start: startFormatted, + end: endFormatted + }))] + })); + }, + + /** + * @param {number} minutes after minute + * @return {string} the hhmm format that API takes for the input hours + */ + formatMinutes: function(minutes) { + var hh = Math.floor(minutes / 60); + var mm = minutes % 60; + var intoNextDay = ((hh % 24) !== hh); + hh = (hh % 24); + if (hh % 10 === hh) { + hh = '0' + hh; + } + if (intoNextDay) { + hh = '+' + hh; + } + if (mm % 10 === mm) { + mm = '0' + mm; + } + return hh + '' + mm; + }, + + /** + * @param {string} hoursText + * @param {(string|undefined)} minutesText + * @param {(string|undefined)} meridiem + * @return {number} + */ + minutesAfterMidnight: function(hoursText, minutesText, meridiem) { + var hours = parseInt(hoursText, 10); + var minutes = (minutesText !== undefined) ? parseInt(minutesText, 10) : 0; + if (hours === 12 && meridiem) { + hours -= 12; + } + if (meridiem && meridiem[0] === 'p') { + hours += 12; + } + + return (hours * 60) + minutes; + } +} + +fourSq.util.HoursParser = { + + /** + * @return {fourSq.api.models.hours.MachineHours} + */ + parse: function(text) { + text = text.toLowerCase(); + + // Normalize new lines to ';' + text = text.replace(/\n/g, ' ; '); + + // Massage times + // TODO(ss): translate and do weekend/weekday subs + text = text.replace(/(12|12:00)?midnight/g, '1200a'); + text = text.replace(/(12|12:00)?noon/g, '1200p'); + text = text.replace(/(open)?\s*24\s*hours?/g, '1200a-1200a'); + + // Standardize conjunctions to '&' + text = text.replace(/\s*(and|,|\+|&)\s*/g, '&'); + + // Standardize range tokens to '-' + text = text.replace(/\s*(-|to|thru|through|till?|'till?|until)\s*/g, '-'); + + // Standardize am/pm + text = text.replace(/\s*a\.?m?\.?/g, 'a'); + text = text.replace(/\s*p\.?m?\.?/g, 'p'); + + // Not sure this happens, but add trailing zeros to things like 5:3pm + text = text.replace(/([0-9])(h|:|\.)([0-9])([^0-9]|$)/g, '$1$2$30$4'); + + // Remove separators from times (e.g. ':')... + // if they both have separators + text = text.replace(/([0-9]+)\s*[^0-9]\s*([0-9]{2})([^0-9]+?)([0-9]+)\s*[^0-9]\s*([0-9]{2})/g, '$1$2$3$4$5'); + // if only the start time has a separator + text = text.replace(/([0-9]+)\s*(h|:|\.)\s*([0-9]{2})/g, '$1$3'); + // if only the end time has a separator + //text = text.replace(/([0-9]+)([^0-9ap]+?)([0-9]+)\s*(h|:|\.)\s*([0-9]{2})/g, '$1$2$3$5'); + + text = fourSq.util.Hours.padTimes(text); + + // Massage days + var dayCanonicals = _.map(_.range(1, 8), function(dayI) { + var allNames = fourSq.util.HoursParser.dayAliases(dayI); + var canonical = _.head(allNames); // Shortest is at the front + var aliases = _.tail(allNames); + aliases.reverse(); // Need to have the largest alias first for replacing + if (canonical && aliases) { + _.each(aliases, function(alias) { + text = text.replace(new RegExp(alias, 'g'), canonical); + }); + } + return canonical; + }); + + var dayPattern = '(' + dayCanonicals.join('|') + ')'; + var timePattern = '([0-9][0-9])([0-9][0-9])\\s*([ap])?'; + var globTimePattern = '[0-9]{4}\\s*[ap]?'; + var globTimeRangePattern = '(' + globTimePattern + '[^0-9]+' + globTimePattern + ')'; + + // Need to establish whether days come before times (forward) or not (backward) + var forwardTimeframePattern = dayPattern + '.*?' + globTimeRangePattern; + var backwardTimeframePattern = globTimeRangePattern + '.*?' + dayPattern; + + var forwardPosition = text.search(new RegExp(forwardTimeframePattern)); + var backwardPosition = text.search(new RegExp(backwardTimeframePattern)); + + // If a forward pattern is found first, consider it a forward facing text + var isForward = (forwardPosition !== -1 && forwardPosition <= backwardPosition) || backwardPosition === -1; + // TODO(ss): may be better to normalize the string to be forward facing at this point + // so the rest of the method would be easier to grok + + // Separate out something like Mon-Thu, Sat, Sun + if (isForward) { + var ungroupedPattern = dayPattern + '&' + dayPattern + '[^&]*?' + globTimeRangePattern; + var ungroupedRegex = new RegExp(ungroupedPattern, 'g'); + for (var i = 0; i < dayCanonicals.length; ++i) { + text = text.replace(ungroupedRegex, '$1 $3; $2 $3; '); + } + } else { + var ungroupedPattern = globTimeRangePattern + '([^0-9]*?)' + dayPattern + '&' + dayPattern; + var ungroupedRegex = new RegExp(ungroupedPattern, 'g'); + for (var i = 0; i < dayCanonicals.length; ++i) { + text = text.replace(ungroupedRegex, '$1 $2 $3; $1 $4; '); + } + } + + var dayRangePattern = dayPattern + '[^a-z0-9]*' + dayPattern + '?'; + var timeRangePattern = timePattern + '[^0-9]*' + timePattern; + var timeframePattern = isForward ? ( + dayRangePattern + '.*?' + timeRangePattern + ) : ( + timeRangePattern + '.*?' + dayRangePattern + ); + var dayTimeMatcher = new RegExp(timeframePattern, 'g'); + + var matches = []; + do { + var dayTimeMatch = dayTimeMatcher.exec(text); + if (dayTimeMatch) { + matches.push(dayTimeMatch); + } + } while (dayTimeMatch) + + if (matches.length <= 0) { + // Try to find just a time range, and then we'll assume it's all days later on. + // First two groups are strings that won't match, to get undefined values + // in those slots in the regex match array. + var timeRangeMatcher = new RegExp('(@!ZfW#)?(@!ZfW#)?' + timeRangePattern); + var timeRangeMatch = timeRangeMatcher.exec(text); + if (timeRangeMatch) { + matches.push(timeRangeMatch); + } + } + + var timeframes = _.map(matches, function(match) { + // day slots in the regex match array + var day1 = isForward ? match[1] : match[7]; + var day2 = isForward ? match[2] : match[8]; + var startDay = (day1 !== undefined) ? dayCanonicals.indexOf(day1) : 0; + + var endDay = null; + if (day2 !== undefined) { + if (day1 === undefined) { + startDay = dayCanonicals.indexOf(day2); + } else { + endDay = dayCanonicals.indexOf(day2); + } + } else if (day1 === undefined) { + // If start and end days were undefined, assume 7 days a week + endDay = 7; + } + if (endDay === null) { + endDay = startDay; + } + + if (endDay < startDay) { + // For case where: Sun-Tue (we start on Monday) + endDay += 7; + } + var days = _.map(_.range(startDay, endDay + 1), function(day) { + // Days start at 1 for Monday + return (day % 7) + 1; + }); + + // time slots in the regex match array + var startHour = isForward ? match[3] : match[1]; + var startMinute = isForward ? match[4] : match[2]; + var startMeridiem = isForward ? match[5] : match[3]; + var endHour = isForward ? match[6] : match[4]; + var endMinute = isForward ? match[7] : match[5]; + var endMeridiem = isForward ? match[8] : match[6]; + // TODO(ss): hint the meridiem based on endHour < startHour and > 4 + var startTime = fourSq.util.Hours.minutesAfterMidnight(startHour, startMinute, startMeridiem); + var endTime = fourSq.util.Hours.minutesAfterMidnight(endHour, endMinute, endMeridiem); + return fourSq.util.Hours.toTimeframe(days, startTime, endTime); + }); + + if (timeframes.length) { + return /** @type {fourSq.api.models.hours.MachineHours} */ (({ + timeframes: timeframes + })); + } else { + return null; + } + }, + + /** + * @param {number} day starting at 1 for monday + * @return {Array.} all aliases of the day, sorted by length + */ + dayAliases: function(day) { + var text = ''; + switch(day) { + case 1: aliases = ['mondays','monday','monda','mond','mon','mo','m']; break; + case 2: aliases = ['tuesdays','tuesday','tuesd','tues','tue','tu']; break; + case 3: aliases = ['wednesdays','wednesday','wednes','wedne','wedn','wed','we','w']; break; + case 4: aliases = ['thursdays','thursday','thurs','thur','thu','th']; break; + case 5: aliases = ['fridays','friday','frida','frid','fri','fr','f']; break; + case 6: aliases = ['saturdays','saturday','satur','satu','sat','sa']; break; + case 7: aliases = ['sundays','sunday','sunda','sund','sun','su']; break; + default: return []; + } + return _.sortBy(aliases, function(alias) { + return alias.length; + }); + } +} diff --git a/vendor/assets/javascripts/handlebars_customhelpers.js.coffee b/vendor/assets/javascripts/handlebars_customhelpers.js.coffee new file mode 100644 index 0000000..49d13f6 --- /dev/null +++ b/vendor/assets/javascripts/handlebars_customhelpers.js.coffee @@ -0,0 +1,151 @@ +Handlebars.registerHelper 'categoryIconUrl', (categories, size) -> + if categories.length > 0 + "#{categories[0].icon.prefix}#{size}#{categories[0].icon.suffix}" + else + "https://foursquare.com/img/categories_v2/none_#{size}.png" + +Handlebars.registerHelper 'categoryTitle', (categories) -> + if categories.length > 0 + categories[0].name + else + "No Category" + +Handlebars.registerHelper 'ratioText', () -> + if @venue.stats.usersCount == 0 + " - " + else + (@venue.stats.checkinsCount / (@venue.stats.usersCount)).toFixed(1) + +Handlebars.registerHelper 'ratioClass', () -> + if @venue.stats.usersCount == 0 + ratio = " - " + else + ratio = (@venue.stats.checkinsCount / (@venue.stats.usersCount)).toFixed(1) + + ratioClass = 'label-success' + ratioClass = 'label-warning' if ratio > 3 or @venue.stats.usersCount < 15 + ratioClass = 'label-important' if ratio > 10 or @venue.stats.usersCount < 5 + ratioClass = 'label-success' if @venue.stats.usersCount > 50 + ratioClass + +Handlebars.registerHelper 'timeFromMongoId', (oid) -> + timestamp = parseInt(oid.slice(0,8), 16) + moment(timestamp * 1000).calendar() + " (" + moment(new Date(timestamp*1000)).fromNow() + ")" + +Handlebars.registerHelper 'moment', (timeVal) -> + time = if timeVal * 1000 then timeVal * 1000 else timeVal + moment(time).calendar() + +Handlebars.registerHelper 'moment-ago', (timeVal) -> + time = if timeVal * 1000 then timeVal * 1000 else timeVal + moment(time).fromNow() + +Handlebars.registerHelper 'count', (items, options) -> + items.length + +Handlebars.registerHelper 'location', (location, options) -> + if location.city or location.state or location.country + if location.state + ((location.city || "") + " " + location.state).trim() + else + ((location.city || "") + " " + (location.country || "")).trim() + else + "Unknown Location" + +Handlebars.registerHelper 'replace', (subject, from, to) -> + subject.split(from).join(to) + +Handlebars.registerHelper 'plus', (op1, op2) -> + op1 + op2 + +Handlebars.registerHelper 'stringify', (obj) -> + JSON.stringify obj + +Handlebars.registerHelper 'ifany', (objs..., content) -> + success = false + success = (success || val) for val in objs + if success then content.fn(this) else content.inverse(this) + +Handlebars.registerHelper 'ifall', (objs..., content) -> + success = true + success = (success && val) for val in objs + if success then content.fn(this) else content.inverse(this) + +Handlebars.registerHelper 'isin', (needle, objs..., content) -> + success = false + success = (success || needle == val) for val in objs + if success then content.fn(this) else content.inverse(this) + +Handlebars.registerHelper 'nl2separator', (content, separator) -> + content.replace("\n", separator) + +Handlebars.registerHelper 'formatFacebookHours', (hours, day) -> + return "Closed" unless hours["#{day}_open"] + toAmPm = (time) -> + [hour, min] = time.split(/:/) + conv = ((parseInt(hour) + 11) % 12 + 1) + "#{conv}:#{min} " + if hour < 12 then "am" else "pm" + + span1 = toAmPm(hours["#{day}_open"]) + " – " + toAmPm(hours["#{day}_close"]) + +Handlebars.registerHelper 'pointDistance', (location1, location2) -> + if google.maps.geometry.spherical.computeDistanceBetween + meters = Math.round(google.maps.geometry.spherical.computeDistanceBetween( + new google.maps.LatLng(location1.lat, location1.lng), + new google.maps.LatLng(location2.lat, location2.lng) + )) + if meters < 1000 + meters + " m" + else + (meters/1000).toFixed(1) + " km" + else + return "Unknown" + +Handlebars.registerHelper 'pointsDirection', (location1, location2) -> + if google.maps.geometry.spherical.computeHeading + degrees = google.maps.geometry.spherical.computeHeading( + new google.maps.LatLng(location1.lat, location1.lng), + new google.maps.LatLng(location2.lat, location2.lng) + ) + dir = switch + when degrees < -90 then "SW" + when degrees < 0 then "NW" + when degrees < 90 then "NE" + when degrees >= 90 then "SE" + else "Unknown" + dir + else + "Unknown" + +Handlebars.registerHelper 'round', (number) -> + Math.round(number).toLocaleString() + +Handlebars.registerHelper 'truncate', (str, len, separator = " ", continuation = "…", nl2br = false) -> + filter = if nl2br then ((x) -> Handlebars.helpers['nl2br'](x)) else (x) -> x + if (str && str.length > len && str.length > 0) + new_str = str + separator + new_str = str.substr(0, len) + new_str = str.substr(0, new_str.lastIndexOf(separator)) + new_str = if (new_str.length > 0) then new_str else str.substr(0, len) + + filter(new_str + continuation) + else + filter(str) + +Handlebars.registerHelper 'ifIsModPlus1', (op1, op2, options) -> + if (op1 + 1) % op2 == 0 + options.fn(this) + else + options.inverse(this) + +Handlebars.registerHelper 'uc', (str) -> + if str + encodeURIComponent(str) + else + "" + +Handlebars.registerHelper "num", (val) -> + if val?.toLocaleString + val.toLocaleString() + else + val diff --git a/vendor/assets/javascripts/handlebars_helpers.js b/vendor/assets/javascripts/handlebars_helpers.js new file mode 100644 index 0000000..08866d1 --- /dev/null +++ b/vendor/assets/javascripts/handlebars_helpers.js @@ -0,0 +1,110 @@ +(function (root, factory) { + if (typeof exports === 'object') { + module.exports = factory(require('handlebars')); + } else if (typeof define === 'function' && define.amd) { + define(['handlebars'], factory); + } else { + root.HandlebarsHelpersRegistry = factory(root.Handlebars); + } +}(this, function (Handlebars) { + + var isArray = function(value) { + return Object.prototype.toString.call(value) === '[object Array]'; + } + + var ExpressionRegistry = function() { + this.expressions = []; + }; + + ExpressionRegistry.prototype.add = function (operator, method) { + this.expressions[operator] = method; + }; + + ExpressionRegistry.prototype.call = function (operator, left, right) { + if ( ! this.expressions.hasOwnProperty(operator)) { + throw new Error('Unknown operator "'+operator+'"'); + } + + return this.expressions[operator](left, right); + }; + + var eR = new ExpressionRegistry; + eR.add('not', function(left, right) { + return left != right; + }); + eR.add('>', function(left, right) { + return left > right; + }); + eR.add('<', function(left, right) { + return left < right; + }); + eR.add('>=', function(left, right) { + return left >= right; + }); + eR.add('<=', function(left, right) { + return left <= right; + }); + eR.add('===', function(left, right) { + return left === right; + }); + eR.add('!==', function(left, right) { + return left !== right; + }); + eR.add('in', function(left, right) { + if ( ! isArray(right)) { + right = right.split(','); + } + return right.indexOf(left) !== -1; + }); + + var isHelper = function() { + var args = arguments + , left = args[0] + , operator = args[1] + , right = args[2] + , options = args[3] + ; + + if (args.length == 2) { + options = args[1]; + if (left) return options.fn(this); + return options.inverse(this); + } + + if (args.length == 3) { + right = args[1]; + options = args[2]; + if (left == right) return options.fn(this); + return options.inverse(this); + } + + if (eR.call(operator, left, right)) { + return options.fn(this); + } + return options.inverse(this); + }; + + Handlebars.registerHelper('is', isHelper); + + Handlebars.registerHelper('nl2br', function(text) { + var nl2br = (text + '').replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, + Handlebars.Utils.escapeExpression('$1') + '
        ' + Handlebars.Utils.escapeExpression('$2')); + return new Handlebars.SafeString(nl2br); + }); + + Handlebars.registerHelper('log', function() { + console.log(['Values:'].concat( + Array.prototype.slice.call(arguments, 0, -1) + )); + }); + + Handlebars.registerHelper('debug', function() { + console.log('Context:', this); + console.log(['Values:'].concat( + Array.prototype.slice.call(arguments, 0, -1) + )); + }); + + return eR; + +})); diff --git a/vendor/assets/javascripts/imagesloaded.pkgd.js b/vendor/assets/javascripts/imagesloaded.pkgd.js new file mode 100644 index 0000000..f12805d --- /dev/null +++ b/vendor/assets/javascripts/imagesloaded.pkgd.js @@ -0,0 +1,893 @@ +/*! + * imagesLoaded PACKAGED v3.1.7 + * JavaScript is all like "You images are done yet or what?" + * MIT License + */ + + +/*! + * EventEmitter v4.2.6 - git.io/ee + * Oliver Caldwell + * MIT license + * @preserve + */ + +(function () { + + + /** + * Class for managing events. + * Can be extended to provide event functionality in other classes. + * + * @class EventEmitter Manages event registering and emitting. + */ + function EventEmitter() {} + + // Shortcuts to improve speed and size + var proto = EventEmitter.prototype; + var exports = this; + var originalGlobalValue = exports.EventEmitter; + + /** + * Finds the index of the listener for the event in it's storage array. + * + * @param {Function[]} listeners Array of listeners to search through. + * @param {Function} listener Method to look for. + * @return {Number} Index of the specified listener, -1 if not found + * @api private + */ + function indexOfListener(listeners, listener) { + var i = listeners.length; + while (i--) { + if (listeners[i].listener === listener) { + return i; + } + } + + return -1; + } + + /** + * Alias a method while keeping the context correct, to allow for overwriting of target method. + * + * @param {String} name The name of the target method. + * @return {Function} The aliased method + * @api private + */ + function alias(name) { + return function aliasClosure() { + return this[name].apply(this, arguments); + }; + } + + /** + * Returns the listener array for the specified event. + * Will initialise the event object and listener arrays if required. + * Will return an object if you use a regex search. The object contains keys for each matched event. So /ba[rz]/ might return an object containing bar and baz. But only if you have either defined them with defineEvent or added some listeners to them. + * Each property in the object response is an array of listener functions. + * + * @param {String|RegExp} evt Name of the event to return the listeners from. + * @return {Function[]|Object} All listener functions for the event. + */ + proto.getListeners = function getListeners(evt) { + var events = this._getEvents(); + var response; + var key; + + // Return a concatenated array of all matching events if + // the selector is a regular expression. + if (typeof evt === 'object') { + response = {}; + for (key in events) { + if (events.hasOwnProperty(key) && evt.test(key)) { + response[key] = events[key]; + } + } + } + else { + response = events[evt] || (events[evt] = []); + } + + return response; + }; + + /** + * Takes a list of listener objects and flattens it into a list of listener functions. + * + * @param {Object[]} listeners Raw listener objects. + * @return {Function[]} Just the listener functions. + */ + proto.flattenListeners = function flattenListeners(listeners) { + var flatListeners = []; + var i; + + for (i = 0; i < listeners.length; i += 1) { + flatListeners.push(listeners[i].listener); + } + + return flatListeners; + }; + + /** + * Fetches the requested listeners via getListeners but will always return the results inside an object. This is mainly for internal use but others may find it useful. + * + * @param {String|RegExp} evt Name of the event to return the listeners from. + * @return {Object} All listener functions for an event in an object. + */ + proto.getListenersAsObject = function getListenersAsObject(evt) { + var listeners = this.getListeners(evt); + var response; + + if (listeners instanceof Array) { + response = {}; + response[evt] = listeners; + } + + return response || listeners; + }; + + /** + * Adds a listener function to the specified event. + * The listener will not be added if it is a duplicate. + * If the listener returns true then it will be removed after it is called. + * If you pass a regular expression as the event name then the listener will be added to all events that match it. + * + * @param {String|RegExp} evt Name of the event to attach the listener to. + * @param {Function} listener Method to be called when the event is emitted. If the function returns true then it will be removed after calling. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.addListener = function addListener(evt, listener) { + var listeners = this.getListenersAsObject(evt); + var listenerIsWrapped = typeof listener === 'object'; + var key; + + for (key in listeners) { + if (listeners.hasOwnProperty(key) && indexOfListener(listeners[key], listener) === -1) { + listeners[key].push(listenerIsWrapped ? listener : { + listener: listener, + once: false + }); + } + } + + return this; + }; + + /** + * Alias of addListener + */ + proto.on = alias('addListener'); + + /** + * Semi-alias of addListener. It will add a listener that will be + * automatically removed after it's first execution. + * + * @param {String|RegExp} evt Name of the event to attach the listener to. + * @param {Function} listener Method to be called when the event is emitted. If the function returns true then it will be removed after calling. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.addOnceListener = function addOnceListener(evt, listener) { + return this.addListener(evt, { + listener: listener, + once: true + }); + }; + + /** + * Alias of addOnceListener. + */ + proto.once = alias('addOnceListener'); + + /** + * Defines an event name. This is required if you want to use a regex to add a listener to multiple events at once. If you don't do this then how do you expect it to know what event to add to? Should it just add to every possible match for a regex? No. That is scary and bad. + * You need to tell it what event names should be matched by a regex. + * + * @param {String} evt Name of the event to create. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.defineEvent = function defineEvent(evt) { + this.getListeners(evt); + return this; + }; + + /** + * Uses defineEvent to define multiple events. + * + * @param {String[]} evts An array of event names to define. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.defineEvents = function defineEvents(evts) { + for (var i = 0; i < evts.length; i += 1) { + this.defineEvent(evts[i]); + } + return this; + }; + + /** + * Removes a listener function from the specified event. + * When passed a regular expression as the event name, it will remove the listener from all events that match it. + * + * @param {String|RegExp} evt Name of the event to remove the listener from. + * @param {Function} listener Method to remove from the event. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.removeListener = function removeListener(evt, listener) { + var listeners = this.getListenersAsObject(evt); + var index; + var key; + + for (key in listeners) { + if (listeners.hasOwnProperty(key)) { + index = indexOfListener(listeners[key], listener); + + if (index !== -1) { + listeners[key].splice(index, 1); + } + } + } + + return this; + }; + + /** + * Alias of removeListener + */ + proto.off = alias('removeListener'); + + /** + * Adds listeners in bulk using the manipulateListeners method. + * If you pass an object as the second argument you can add to multiple events at once. The object should contain key value pairs of events and listeners or listener arrays. You can also pass it an event name and an array of listeners to be added. + * You can also pass it a regular expression to add the array of listeners to all events that match it. + * Yeah, this function does quite a bit. That's probably a bad thing. + * + * @param {String|Object|RegExp} evt An event name if you will pass an array of listeners next. An object if you wish to add to multiple events at once. + * @param {Function[]} [listeners] An optional array of listener functions to add. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.addListeners = function addListeners(evt, listeners) { + // Pass through to manipulateListeners + return this.manipulateListeners(false, evt, listeners); + }; + + /** + * Removes listeners in bulk using the manipulateListeners method. + * If you pass an object as the second argument you can remove from multiple events at once. The object should contain key value pairs of events and listeners or listener arrays. + * You can also pass it an event name and an array of listeners to be removed. + * You can also pass it a regular expression to remove the listeners from all events that match it. + * + * @param {String|Object|RegExp} evt An event name if you will pass an array of listeners next. An object if you wish to remove from multiple events at once. + * @param {Function[]} [listeners] An optional array of listener functions to remove. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.removeListeners = function removeListeners(evt, listeners) { + // Pass through to manipulateListeners + return this.manipulateListeners(true, evt, listeners); + }; + + /** + * Edits listeners in bulk. The addListeners and removeListeners methods both use this to do their job. You should really use those instead, this is a little lower level. + * The first argument will determine if the listeners are removed (true) or added (false). + * If you pass an object as the second argument you can add/remove from multiple events at once. The object should contain key value pairs of events and listeners or listener arrays. + * You can also pass it an event name and an array of listeners to be added/removed. + * You can also pass it a regular expression to manipulate the listeners of all events that match it. + * + * @param {Boolean} remove True if you want to remove listeners, false if you want to add. + * @param {String|Object|RegExp} evt An event name if you will pass an array of listeners next. An object if you wish to add/remove from multiple events at once. + * @param {Function[]} [listeners] An optional array of listener functions to add/remove. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.manipulateListeners = function manipulateListeners(remove, evt, listeners) { + var i; + var value; + var single = remove ? this.removeListener : this.addListener; + var multiple = remove ? this.removeListeners : this.addListeners; + + // If evt is an object then pass each of it's properties to this method + if (typeof evt === 'object' && !(evt instanceof RegExp)) { + for (i in evt) { + if (evt.hasOwnProperty(i) && (value = evt[i])) { + // Pass the single listener straight through to the singular method + if (typeof value === 'function') { + single.call(this, i, value); + } + else { + // Otherwise pass back to the multiple function + multiple.call(this, i, value); + } + } + } + } + else { + // So evt must be a string + // And listeners must be an array of listeners + // Loop over it and pass each one to the multiple method + i = listeners.length; + while (i--) { + single.call(this, evt, listeners[i]); + } + } + + return this; + }; + + /** + * Removes all listeners from a specified event. + * If you do not specify an event then all listeners will be removed. + * That means every event will be emptied. + * You can also pass a regex to remove all events that match it. + * + * @param {String|RegExp} [evt] Optional name of the event to remove all listeners for. Will remove from every event if not passed. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.removeEvent = function removeEvent(evt) { + var type = typeof evt; + var events = this._getEvents(); + var key; + + // Remove different things depending on the state of evt + if (type === 'string') { + // Remove all listeners for the specified event + delete events[evt]; + } + else if (type === 'object') { + // Remove all events matching the regex. + for (key in events) { + if (events.hasOwnProperty(key) && evt.test(key)) { + delete events[key]; + } + } + } + else { + // Remove all listeners in all events + delete this._events; + } + + return this; + }; + + /** + * Alias of removeEvent. + * + * Added to mirror the node API. + */ + proto.removeAllListeners = alias('removeEvent'); + + /** + * Emits an event of your choice. + * When emitted, every listener attached to that event will be executed. + * If you pass the optional argument array then those arguments will be passed to every listener upon execution. + * Because it uses `apply`, your array of arguments will be passed as if you wrote them out separately. + * So they will not arrive within the array on the other side, they will be separate. + * You can also pass a regular expression to emit to all events that match it. + * + * @param {String|RegExp} evt Name of the event to emit and execute listeners for. + * @param {Array} [args] Optional array of arguments to be passed to each listener. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.emitEvent = function emitEvent(evt, args) { + var listeners = this.getListenersAsObject(evt); + var listener; + var i; + var key; + var response; + + for (key in listeners) { + if (listeners.hasOwnProperty(key)) { + i = listeners[key].length; + + while (i--) { + // If the listener returns true then it shall be removed from the event + // The function is executed either with a basic call or an apply if there is an args array + listener = listeners[key][i]; + + if (listener.once === true) { + this.removeListener(evt, listener.listener); + } + + response = listener.listener.apply(this, args || []); + + if (response === this._getOnceReturnValue()) { + this.removeListener(evt, listener.listener); + } + } + } + } + + return this; + }; + + /** + * Alias of emitEvent + */ + proto.trigger = alias('emitEvent'); + + /** + * Subtly different from emitEvent in that it will pass its arguments on to the listeners, as opposed to taking a single array of arguments to pass on. + * As with emitEvent, you can pass a regex in place of the event name to emit to all events that match it. + * + * @param {String|RegExp} evt Name of the event to emit and execute listeners for. + * @param {...*} Optional additional arguments to be passed to each listener. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.emit = function emit(evt) { + var args = Array.prototype.slice.call(arguments, 1); + return this.emitEvent(evt, args); + }; + + /** + * Sets the current value to check against when executing listeners. If a + * listeners return value matches the one set here then it will be removed + * after execution. This value defaults to true. + * + * @param {*} value The new value to check for when executing listeners. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.setOnceReturnValue = function setOnceReturnValue(value) { + this._onceReturnValue = value; + return this; + }; + + /** + * Fetches the current value to check against when executing listeners. If + * the listeners return value matches this one then it should be removed + * automatically. It will return true by default. + * + * @return {*|Boolean} The current value to check for or the default, true. + * @api private + */ + proto._getOnceReturnValue = function _getOnceReturnValue() { + if (this.hasOwnProperty('_onceReturnValue')) { + return this._onceReturnValue; + } + else { + return true; + } + }; + + /** + * Fetches the events object and creates one if required. + * + * @return {Object} The events storage object. + * @api private + */ + proto._getEvents = function _getEvents() { + return this._events || (this._events = {}); + }; + + /** + * Reverts the global {@link EventEmitter} to its previous value and returns a reference to this version. + * + * @return {Function} Non conflicting EventEmitter class. + */ + EventEmitter.noConflict = function noConflict() { + exports.EventEmitter = originalGlobalValue; + return EventEmitter; + }; + + // Expose the class either via AMD, CommonJS or the global object + if (typeof define === 'function' && define.amd) { + define('eventEmitter/EventEmitter',[],function () { + return EventEmitter; + }); + } + else if (typeof module === 'object' && module.exports){ + module.exports = EventEmitter; + } + else { + this.EventEmitter = EventEmitter; + } +}.call(this)); + +/*! + * eventie v1.0.4 + * event binding helper + * eventie.bind( elem, 'click', myFn ) + * eventie.unbind( elem, 'click', myFn ) + */ + +/*jshint browser: true, undef: true, unused: true */ +/*global define: false */ + +( function( window ) { + + + +var docElem = document.documentElement; + +var bind = function() {}; + +function getIEEvent( obj ) { + var event = window.event; + // add event.target + event.target = event.target || event.srcElement || obj; + return event; +} + +if ( docElem.addEventListener ) { + bind = function( obj, type, fn ) { + obj.addEventListener( type, fn, false ); + }; +} else if ( docElem.attachEvent ) { + bind = function( obj, type, fn ) { + obj[ type + fn ] = fn.handleEvent ? + function() { + var event = getIEEvent( obj ); + fn.handleEvent.call( fn, event ); + } : + function() { + var event = getIEEvent( obj ); + fn.call( obj, event ); + }; + obj.attachEvent( "on" + type, obj[ type + fn ] ); + }; +} + +var unbind = function() {}; + +if ( docElem.removeEventListener ) { + unbind = function( obj, type, fn ) { + obj.removeEventListener( type, fn, false ); + }; +} else if ( docElem.detachEvent ) { + unbind = function( obj, type, fn ) { + obj.detachEvent( "on" + type, obj[ type + fn ] ); + try { + delete obj[ type + fn ]; + } catch ( err ) { + // can't delete window object properties + obj[ type + fn ] = undefined; + } + }; +} + +var eventie = { + bind: bind, + unbind: unbind +}; + +// transport +if ( typeof define === 'function' && define.amd ) { + // AMD + define( 'eventie/eventie',eventie ); +} else { + // browser global + window.eventie = eventie; +} + +})( this ); + +/*! + * imagesLoaded v3.1.7 + * JavaScript is all like "You images are done yet or what?" + * MIT License + */ + +( function( window, factory ) { + // universal module definition + + /*global define: false, module: false, require: false */ + + if ( typeof define === 'function' && define.amd ) { + // AMD + define( [ + 'eventEmitter/EventEmitter', + 'eventie/eventie' + ], function( EventEmitter, eventie ) { + return factory( window, EventEmitter, eventie ); + }); + } else if ( typeof exports === 'object' ) { + // CommonJS + module.exports = factory( + window, + require('eventEmitter'), + require('eventie') + ); + } else { + // browser global + window.imagesLoaded = factory( + window, + window.EventEmitter, + window.eventie + ); + } + +})( window, + +// -------------------------- factory -------------------------- // + +function factory( window, EventEmitter, eventie ) { + + + +var $ = window.jQuery; +var console = window.console; +var hasConsole = typeof console !== 'undefined'; + +// -------------------------- helpers -------------------------- // + +// extend objects +function extend( a, b ) { + for ( var prop in b ) { + a[ prop ] = b[ prop ]; + } + return a; +} + +var objToString = Object.prototype.toString; +function isArray( obj ) { + return objToString.call( obj ) === '[object Array]'; +} + +// turn element or nodeList into an array +function makeArray( obj ) { + var ary = []; + if ( isArray( obj ) ) { + // use object if already an array + ary = obj; + } else if ( typeof obj.length === 'number' ) { + // convert nodeList to array + for ( var i=0, len = obj.length; i < len; i++ ) { + ary.push( obj[i] ); + } + } else { + // array of single index + ary.push( obj ); + } + return ary; +} + + // -------------------------- imagesLoaded -------------------------- // + + /** + * @param {Array, Element, NodeList, String} elem + * @param {Object or Function} options - if function, use as callback + * @param {Function} onAlways - callback function + */ + function ImagesLoaded( elem, options, onAlways ) { + // coerce ImagesLoaded() without new, to be new ImagesLoaded() + if ( !( this instanceof ImagesLoaded ) ) { + return new ImagesLoaded( elem, options ); + } + // use elem as selector string + if ( typeof elem === 'string' ) { + elem = document.querySelectorAll( elem ); + } + + this.elements = makeArray( elem ); + this.options = extend( {}, this.options ); + + if ( typeof options === 'function' ) { + onAlways = options; + } else { + extend( this.options, options ); + } + + if ( onAlways ) { + this.on( 'always', onAlways ); + } + + this.getImages(); + + if ( $ ) { + // add jQuery Deferred object + this.jqDeferred = new $.Deferred(); + } + + // HACK check async to allow time to bind listeners + var _this = this; + setTimeout( function() { + _this.check(); + }); + } + + ImagesLoaded.prototype = new EventEmitter(); + + ImagesLoaded.prototype.options = {}; + + ImagesLoaded.prototype.getImages = function() { + this.images = []; + + // filter & find items if we have an item selector + for ( var i=0, len = this.elements.length; i < len; i++ ) { + var elem = this.elements[i]; + // filter siblings + if ( elem.nodeName === 'IMG' ) { + this.addImage( elem ); + } + // find children + // no non-element nodes, #143 + var nodeType = elem.nodeType; + if ( !nodeType || !( nodeType === 1 || nodeType === 9 || nodeType === 11 ) ) { + continue; + } + var childElems = elem.querySelectorAll('img'); + // concat childElems to filterFound array + for ( var j=0, jLen = childElems.length; j < jLen; j++ ) { + var img = childElems[j]; + this.addImage( img ); + } + } + }; + + /** + * @param {Image} img + */ + ImagesLoaded.prototype.addImage = function( img ) { + var loadingImage = new LoadingImage( img ); + this.images.push( loadingImage ); + }; + + ImagesLoaded.prototype.check = function() { + var _this = this; + var checkedCount = 0; + var length = this.images.length; + this.hasAnyBroken = false; + // complete if no images + if ( !length ) { + this.complete(); + return; + } + + function onConfirm( image, message ) { + if ( _this.options.debug && hasConsole ) { + console.log( 'confirm', image, message ); + } + + _this.progress( image ); + checkedCount++; + if ( checkedCount === length ) { + _this.complete(); + } + return true; // bind once + } + + for ( var i=0; i < length; i++ ) { + var loadingImage = this.images[i]; + loadingImage.on( 'confirm', onConfirm ); + loadingImage.check(); + } + }; + + ImagesLoaded.prototype.progress = function( image ) { + this.hasAnyBroken = this.hasAnyBroken || !image.isLoaded; + // HACK - Chrome triggers event before object properties have changed. #83 + var _this = this; + setTimeout( function() { + _this.emit( 'progress', _this, image ); + if ( _this.jqDeferred && _this.jqDeferred.notify ) { + _this.jqDeferred.notify( _this, image ); + } + }); + }; + + ImagesLoaded.prototype.complete = function() { + var eventName = this.hasAnyBroken ? 'fail' : 'done'; + this.isComplete = true; + var _this = this; + // HACK - another setTimeout so that confirm happens after progress + setTimeout( function() { + _this.emit( eventName, _this ); + _this.emit( 'always', _this ); + if ( _this.jqDeferred ) { + var jqMethod = _this.hasAnyBroken ? 'reject' : 'resolve'; + _this.jqDeferred[ jqMethod ]( _this ); + } + }); + }; + + // -------------------------- jquery -------------------------- // + + if ( $ ) { + $.fn.imagesLoaded = function( options, callback ) { + var instance = new ImagesLoaded( this, options, callback ); + return instance.jqDeferred.promise( $(this) ); + }; + } + + + // -------------------------- -------------------------- // + + function LoadingImage( img ) { + this.img = img; + } + + LoadingImage.prototype = new EventEmitter(); + + LoadingImage.prototype.check = function() { + // first check cached any previous images that have same src + var resource = cache[ this.img.src ] || new Resource( this.img.src ); + if ( resource.isConfirmed ) { + this.confirm( resource.isLoaded, 'cached was confirmed' ); + return; + } + + // If complete is true and browser supports natural sizes, + // try to check for image status manually. + if ( this.img.complete && this.img.naturalWidth !== undefined ) { + // report based on naturalWidth + this.confirm( this.img.naturalWidth !== 0, 'naturalWidth' ); + return; + } + + // If none of the checks above matched, simulate loading on detached element. + var _this = this; + resource.on( 'confirm', function( resrc, message ) { + _this.confirm( resrc.isLoaded, message ); + return true; + }); + + resource.check(); + }; + + LoadingImage.prototype.confirm = function( isLoaded, message ) { + this.isLoaded = isLoaded; + this.emit( 'confirm', this, message ); + }; + + // -------------------------- Resource -------------------------- // + + // Resource checks each src, only once + // separate class from LoadingImage to prevent memory leaks. See #115 + + var cache = {}; + + function Resource( src ) { + this.src = src; + // add to cache + cache[ src ] = this; + } + + Resource.prototype = new EventEmitter(); + + Resource.prototype.check = function() { + // only trigger checking once + if ( this.isChecked ) { + return; + } + // simulate loading on detached element + var proxyImage = new Image(); + eventie.bind( proxyImage, 'load', this ); + eventie.bind( proxyImage, 'error', this ); + proxyImage.src = this.src; + // set flag + this.isChecked = true; + }; + + // ----- events ----- // + + // trigger specified handler for event type + Resource.prototype.handleEvent = function( event ) { + var method = 'on' + event.type; + if ( this[ method ] ) { + this[ method ]( event ); + } + }; + + Resource.prototype.onload = function( event ) { + this.confirm( true, 'onload' ); + this.unbindProxyEvents( event ); + }; + + Resource.prototype.onerror = function( event ) { + this.confirm( false, 'onerror' ); + this.unbindProxyEvents( event ); + }; + + // ----- confirm ----- // + + Resource.prototype.confirm = function( isLoaded, message ) { + this.isConfirmed = true; + this.isLoaded = isLoaded; + this.emit( 'confirm', this, message ); + }; + + Resource.prototype.unbindProxyEvents = function( event ) { + eventie.unbind( event.target, 'load', this ); + eventie.unbind( event.target, 'error', this ); + }; + + // ----- ----- // + + return ImagesLoaded; + +}); diff --git a/vendor/assets/javascripts/jquery.hoverIntent.minified.js b/vendor/assets/javascripts/jquery.hoverIntent.minified.js new file mode 100644 index 0000000..75c22ca --- /dev/null +++ b/vendor/assets/javascripts/jquery.hoverIntent.minified.js @@ -0,0 +1,9 @@ +/** +* hoverIntent r6 // 2011.02.26 // jQuery 1.5.1+ +* +* +* @param f onMouseOver function || An object with configuration options +* @param g onMouseOut function || Nothing (use configuration options object) +* @author Brian Cherne brian(at)cherne(dot)net +*/ +(function($){$.fn.hoverIntent=function(f,g){var cfg={sensitivity:7,interval:100,timeout:0};cfg=$.extend(cfg,g?{over:f,out:g}:f);var cX,cY,pX,pY;var track=function(ev){cX=ev.pageX;cY=ev.pageY};var compare=function(ev,ob){ob.hoverIntent_t=clearTimeout(ob.hoverIntent_t);if((Math.abs(pX-cX)+Math.abs(pY-cY))} days + * @param {number} startMinutes + * @param {number} endMinutes + */ + toTimeframe: function(days, startMinutes, endMinutes) { + // If we've day wrapped and end before 4am, push the ending value up 24 hours. + if (startMinutes >= endMinutes && endMinutes <= 240) { + endMinutes += 1440; + } + var startFormatted = fourSq.util.Hours.formatMinutes(startMinutes); + var endFormatted = fourSq.util.Hours.formatMinutes(endMinutes); + + return /** @type {fourSq.api.models.hours.MachineTimeframe} */ (({ + days: days, + open: [(/** @type {fourSq.api.models.hours.MachineSegment} */({ + start: startFormatted, + end: endFormatted + }))] + })); + }, + + /** + * @param {number} minutes after minute + * @return {string} the hhmm format that API takes for the input hours + */ + formatMinutes: function(minutes) { + var hh = Math.floor(minutes / 60); + var mm = minutes % 60; + var intoNextDay = ((hh % 24) !== hh); + hh = (hh % 24); + if (hh % 10 === hh) { + hh = '0' + hh; + } + if (intoNextDay) { + hh = '+' + hh; + } + if (mm % 10 === mm) { + mm = '0' + mm; + } + return hh + '' + mm; + }, + + /** + * @param {string} hoursText + * @param {(string|undefined)} minutesText + * @param {(string|undefined)} meridiem + * @return {number} + */ + minutesAfterMidnight: function(hoursText, minutesText, meridiem) { + var hours = parseInt(hoursText, 10); + var minutes = (minutesText !== undefined) ? parseInt(minutesText, 10) : 0; + if (hours === 12 && meridiem) { + hours -= 12; + } + if (meridiem && meridiem[0] === 'p') { + hours += 12; + } + + return (hours * 60) + minutes; + } +} + +fourSq.util.HoursParser = { + + /** + * @return {fourSq.api.models.hours.MachineHours} + */ + parse: function(text) { + text = text.toLowerCase(); + + // Normalize new lines to ';' + text = text.replace(/\n/g, ' ; '); + + // Massage times + // TODO(ss): translate and do weekend/weekday subs + text = text.replace(/(12|12:00)?midnight/g, '1200a'); + text = text.replace(/(12|12:00)?noon/g, '1200p'); + text = text.replace(/(open)?\s*24\s*hours?/g, '1200a-1200a'); + + // Standardize conjunctions to '&' + text = text.replace(/\s*(and|,|\+|&)\s*/g, '&'); + + // Standardize range tokens to '-' + text = text.replace(/\s*(-|to|thru|through|till?|'till?|until)\s*/g, '-'); + + // Standardize am/pm + text = text.replace(/\s*a\.?m?\.?/g, 'a'); + text = text.replace(/\s*p\.?m?\.?/g, 'p'); + + // Not sure this happens, but add trailing zeros to things like 5:3pm + text = text.replace(/([0-9])(h|:|\.)([0-9])([^0-9]|$)/g, '$1$2$30$4'); + + // Remove separators from times (e.g. ':')... + // if they both have separators + text = text.replace(/([0-9]+)\s*[^0-9]\s*([0-9]{2})([^0-9]+?)([0-9]+)\s*[^0-9]\s*([0-9]{2})/g, '$1$2$3$4$5'); + // if only the start time has a separator + text = text.replace(/([0-9]+)\s*(h|:|\.)\s*([0-9]{2})/g, '$1$3'); + // if only the end time has a separator + //text = text.replace(/([0-9]+)([^0-9ap]+?)([0-9]+)\s*(h|:|\.)\s*([0-9]{2})/g, '$1$2$3$5'); + + text = fourSq.util.Hours.padTimes(text); + + // Massage days + var dayCanonicals = _.map(_.range(1, 8), function(dayI) { + var allNames = fourSq.util.HoursParser.dayAliases(dayI); + var canonical = _.head(allNames); // Shortest is at the front + var aliases = _.tail(allNames); + aliases.reverse(); // Need to have the largest alias first for replacing + if (canonical && aliases) { + _.each(aliases, function(alias) { + text = text.replace(new RegExp(alias, 'g'), canonical); + }); + } + return canonical; + }); + + var dayPattern = '(' + dayCanonicals.join('|') + ')'; + var timePattern = '([0-9][0-9])([0-9][0-9])\\s*([ap])?'; + var globTimePattern = '[0-9]{4}\\s*[ap]?'; + var globTimeRangePattern = '(' + globTimePattern + '[^0-9]+' + globTimePattern + ')'; + + // Need to establish whether days come before times (forward) or not (backward) + var forwardTimeframePattern = dayPattern + '.*?' + globTimeRangePattern; + var backwardTimeframePattern = globTimeRangePattern + '.*?' + dayPattern; + + var forwardPosition = text.search(new RegExp(forwardTimeframePattern)); + var backwardPosition = text.search(new RegExp(backwardTimeframePattern)); + + // If a forward pattern is found first, consider it a forward facing text + var isForward = (forwardPosition !== -1 && forwardPosition <= backwardPosition) || backwardPosition === -1; + // TODO(ss): may be better to normalize the string to be forward facing at this point + // so the rest of the method would be easier to grok + + // Separate out something like Mon-Thu, Sat, Sun + if (isForward) { + var ungroupedPattern = dayPattern + '&' + dayPattern + '[^&]*?' + globTimeRangePattern; + var ungroupedRegex = new RegExp(ungroupedPattern, 'g'); + for (var i = 0; i < dayCanonicals.length; ++i) { + text = text.replace(ungroupedRegex, '$1 $3; $2 $3; '); + } + } else { + var ungroupedPattern = globTimeRangePattern + '([^0-9]*?)' + dayPattern + '&' + dayPattern; + var ungroupedRegex = new RegExp(ungroupedPattern, 'g'); + for (var i = 0; i < dayCanonicals.length; ++i) { + text = text.replace(ungroupedRegex, '$1 $2 $3; $1 $4; '); + } + } + + var dayRangePattern = dayPattern + '[^a-z0-9]*' + dayPattern + '?'; + var timeRangePattern = timePattern + '[^0-9]*' + timePattern; + var timeframePattern = isForward ? ( + dayRangePattern + '.*?' + timeRangePattern + ) : ( + timeRangePattern + '.*?' + dayRangePattern + ); + var dayTimeMatcher = new RegExp(timeframePattern, 'g'); + + var matches = []; + do { + var dayTimeMatch = dayTimeMatcher.exec(text); + if (dayTimeMatch) { + matches.push(dayTimeMatch); + } + } while (dayTimeMatch) + + if (matches.length <= 0) { + // Try to find just a time range, and then we'll assume it's all days later on. + // First two groups are strings that won't match, to get undefined values + // in those slots in the regex match array. + var timeRangeMatcher = new RegExp('(@!ZfW#)?(@!ZfW#)?' + timeRangePattern); + var timeRangeMatch = timeRangeMatcher.exec(text); + if (timeRangeMatch) { + matches.push(timeRangeMatch); + } + } + + var timeframes = _.map(matches, function(match) { + // day slots in the regex match array + var day1 = isForward ? match[1] : match[7]; + var day2 = isForward ? match[2] : match[8]; + var startDay = (day1 !== undefined) ? dayCanonicals.indexOf(day1) : 0; + + var endDay = null; + if (day2 !== undefined) { + if (day1 === undefined) { + startDay = dayCanonicals.indexOf(day2); + } else { + endDay = dayCanonicals.indexOf(day2); + } + } else if (day1 === undefined) { + // If start and end days were undefined, assume 7 days a week + endDay = 7; + } + if (endDay === null) { + endDay = startDay; + } + + if (endDay < startDay) { + // For case where: Sun-Tue (we start on Monday) + endDay += 7; + } + var days = _.map(_.range(startDay, endDay + 1), function(day) { + // Days start at 1 for Monday + return (day % 7) + 1; + }); + + // time slots in the regex match array + var startHour = isForward ? match[3] : match[1]; + var startMinute = isForward ? match[4] : match[2]; + var startMeridiem = isForward ? match[5] : match[3]; + var endHour = isForward ? match[6] : match[4]; + var endMinute = isForward ? match[7] : match[5]; + var endMeridiem = isForward ? match[8] : match[6]; + // TODO(ss): hint the meridiem based on endHour < startHour and > 4 + var startTime = fourSq.util.Hours.minutesAfterMidnight(startHour, startMinute, startMeridiem); + var endTime = fourSq.util.Hours.minutesAfterMidnight(endHour, endMinute, endMeridiem); + return fourSq.util.Hours.toTimeframe(days, startTime, endTime); + }); + + if (timeframes.length) { + return /** @type {fourSq.api.models.hours.MachineHours} */ (({ + timeframes: timeframes + })); + } else { + return null; + } + }, + + /** + * @param {number} day starting at 1 for monday + * @return {Array.} all aliases of the day, sorted by length + */ + dayAliases: function(day) { + var text = ''; + switch(day) { + case 1: aliases = ['mondays','monday','monda','mond','mon','mo','m']; break; + case 2: aliases = ['tuesdays','tuesday','tuesd','tues','tue','tu']; break; + case 3: aliases = ['wednesdays','wednesday','wednes','wedne','wedn','wed','we','w']; break; + case 4: aliases = ['thursdays','thursday','thurs','thur','thu','th']; break; + case 5: aliases = ['fridays','friday','frida','frid','fri','fr','f']; break; + case 6: aliases = ['saturdays','saturday','satur','satu','sat','sa']; break; + case 7: aliases = ['sundays','sunday','sunda','sund','sun','su']; break; + default: return []; + } + return _.sortBy(aliases, function(alias) { + return alias.length; + }); + } +} diff --git a/vendor/hoursparser.js/LICENSE b/vendor/hoursparser.js/LICENSE new file mode 100644 index 0000000..5c304d1 --- /dev/null +++ b/vendor/hoursparser.js/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/hoursparser.js/README.md b/vendor/hoursparser.js/README.md new file mode 100644 index 0000000..7737cb1 --- /dev/null +++ b/vendor/hoursparser.js/README.md @@ -0,0 +1,53 @@ +hoursparser.js +============== + +dumb but useful hours extractor from free-text entry + +demo @ http://foursquare.github.io/hoursparser.js/ + +some examples of the inputs and outputs: + + ([{ + input: 'm-w 10:15am-2am; fri 12pm-11pm;', + output: /** @type {fourSq.api.models.hours.MachineHours} */ ({ + timeframes: /** @type {Array.} */ ([ + { days: [1, 2, 3], open: [ { start: '1015', end: '+0200' } ] }, + { days: [5], open: [ { start: '1200', end: '2300' } ] } + ]) + }) + }, { + input: 'mon, tues, wednesday 1015-2; f 12:00p until 2300;', + output: /** @type {fourSq.api.models.hours.MachineHours} */ ({ + timeframes: /** @type {Array.} */ ([ + { days: [1], open: [ { start: '1015', end: '+0200' } ] }, + { days: [2], open: [ { start: '1015', end: '+0200' } ] }, + { days: [3], open: [ { start: '1015', end: '+0200' } ] }, + { days: [5], open: [ { start: '1200', end: '2300' } ] } + ]) + }) + }, { + input: 'mon-tu, w 10:15 A.M.-02h00; f 12:00 until 23:00;', + output: /** @type {fourSq.api.models.hours.MachineHours} */ ({ + timeframes: /** @type {Array.} */ ([ + { days: [1, 2], open: [ { start: '1015', end: '+0200' } ] }, + { days: [3], open: [ { start: '1015', end: '+0200' } ] }, + { days: [5], open: [ { start: '1200', end: '2300' } ] } + ]) + }) + }, { + input: 'm-f 10-12', + output: /** @type {fourSq.api.models.hours.MachineHours} */ ({ + timeframes: /** @type {Array.} */ ([ + { days: [1, 2, 3, 4, 5], open: [ { start: '1000', end: '1200' } ] } + ]) + }) + }, { + input: '10:15am-2am m-w; 12pm-11pm fri,su', + output: /** @type {fourSq.api.models.hours.MachineHours} */ ({ + timeframes: /** @type {Array.} */ ([ + { days: [1, 2, 3], open: [ { start: '1015', end: '+0200' } ] }, + { days: [5], open: [ { start: '1200', end: '2300' } ] }, + { days: [7], open: [ { start: '1200', end: '2300' } ] } + ]) + }) + }]) diff --git a/vendor/hoursparser.js/index.html b/vendor/hoursparser.js/index.html new file mode 100644 index 0000000..52905c4 --- /dev/null +++ b/vendor/hoursparser.js/index.html @@ -0,0 +1,63 @@ + + + + + + + + + + enter hours:
        + +

        + parsed output: +

        + + + +

        Tests/Examples

        +
        + + diff --git a/vendor/hoursparser.js/testcases.js b/vendor/hoursparser.js/testcases.js new file mode 100644 index 0000000..4259852 --- /dev/null +++ b/vendor/hoursparser.js/testcases.js @@ -0,0 +1,44 @@ +var testcases = [{ + input: 'm-w 10:15am-2am; fri 12pm-11pm;', + output: /** @type {fourSq.api.models.hours.MachineHours} */ ({ + timeframes: /** @type {Array.} */ ([ + { days: [1, 2, 3], open: [ { start: '1015', end: '+0200' } ] }, + { days: [5], open: [ { start: '1200', end: '2300' } ] } + ]) + }) +}, { + input: 'mon, tues, wednesday 1015-2; f 12:00p until 2300;', + output: /** @type {fourSq.api.models.hours.MachineHours} */ ({ + timeframes: /** @type {Array.} */ ([ + { days: [1], open: [ { start: '1015', end: '+0200' } ] }, + { days: [2], open: [ { start: '1015', end: '+0200' } ] }, + { days: [3], open: [ { start: '1015', end: '+0200' } ] }, + { days: [5], open: [ { start: '1200', end: '2300' } ] } + ]) + }) +}, { + input: 'mon-tu, w 10:15 A.M.-02h00; f 12:00 until 23:00;', + output: /** @type {fourSq.api.models.hours.MachineHours} */ ({ + timeframes: /** @type {Array.} */ ([ + { days: [1, 2], open: [ { start: '1015', end: '+0200' } ] }, + { days: [3], open: [ { start: '1015', end: '+0200' } ] }, + { days: [5], open: [ { start: '1200', end: '2300' } ] } + ]) + }) +}, { + input: 'm-f 10-12', + output: /** @type {fourSq.api.models.hours.MachineHours} */ ({ + timeframes: /** @type {Array.} */ ([ + { days: [1, 2, 3, 4, 5], open: [ { start: '1000', end: '1200' } ] } + ]) + }) +}, { + input: '10:15am-2am m-w; 12pm-11pm fri,su', + output: /** @type {fourSq.api.models.hours.MachineHours} */ ({ + timeframes: /** @type {Array.} */ ([ + { days: [1, 2, 3], open: [ { start: '1015', end: '+0200' } ] }, + { days: [5], open: [ { start: '1200', end: '2300' } ] }, + { days: [7], open: [ { start: '1200', end: '2300' } ] } + ]) + }) +}]; diff --git a/vendor/ruby-pegjs/.gitignore b/vendor/ruby-pegjs/.gitignore new file mode 100644 index 0000000..a7d417c --- /dev/null +++ b/vendor/ruby-pegjs/.gitignore @@ -0,0 +1,7 @@ +* +!*/ +!/.gitignore +!/MIT-LICENSE +!/README.md +!/pegjs.gemspec +!/lib/**/*.rb diff --git a/vendor/ruby-pegjs/MIT-LICENSE b/vendor/ruby-pegjs/MIT-LICENSE new file mode 100644 index 0000000..4a18a95 --- /dev/null +++ b/vendor/ruby-pegjs/MIT-LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Dylon Edwards + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/ruby-pegjs/README.md b/vendor/ruby-pegjs/README.md new file mode 100644 index 0000000..cf9e57c --- /dev/null +++ b/vendor/ruby-pegjs/README.md @@ -0,0 +1,93 @@ +ruby-jison +========== + +Ruby, Jison Compiler + +[Jison is Your friendly JavaScript parser generator!](http://zaach.github.io/jison/) + +```Ruby +require 'jison' +javascript_text = Jison.parse File.read('/path/to/grammar.js.jison') +``` + +Prerequesites +------------- +1. You must have the [jison, npm module](https://npmjs.org/package/jison "jison") +installed and on your `$PATH`, and it must be executable by your application. + +```Shell +npm install jison +``` + +To accomplish this brutal task, you will probably need to add +[npm](https://github.com/isaacs/npm "npm") to your `$PATH`. To execute it, you +may even need [node.js](http://nodejs.org/ "node.js"), but that's not for me to +judge -- I'll let every man decide for himself. + +Note that if you receive an exception, like + +``` +Errno::ENOENT: No such file or directory - jison + from /usr/lib/ruby/2.0.0/open3.rb:211:in `spawn' + from /usr/lib/ruby/2.0.0/open3.rb:211:in `popen_run' + from /usr/lib/ruby/2.0.0/open3.rb:99:in `popen3' + from /usr/lib/ruby/2.0.0/open3.rb:279:in `capture3' + ... +``` + +then you probably do not have the [jison, npm module](https://npmjs.org/package/jison "jison") +installed or it is not on your `$PATH`. + +Operations +---------- + +### Jison.parse + +Accepts a string representing a Jison grammar, and returns another string +representing its JavaScript equivalent. + +```Ruby +require 'jison' + +begin + # `grammar` is a string consisting of a Jison grammar + javascript_text = Jison.parse(grammar) + + # do something with javascript_text +rescue Jison::ExecutionError => error + $stderr.puts "jison command terminated with exit code #{error.exit_code}" + $stderr.puts "#{error.message}\n #{error.backtrace.join("\n ")}" +rescue Errno::ENOENT => error + $stderr.puts "#{error.message}\n #{error.backtrace.join("\n ")}" + cmd = error.message[/\b\w+$/, 0] + $stdout.puts "Please be sure #{cmd} is installed and on your $PATH" +end +``` + +### Jison.version + +Returns an instance of `Jison::Version` containing the major, minor and micro +versions of the jison on your `$PATH`. `Jison::Version` implements `Comparable` +and may be compared against other `Jison::Version`s, `String`s and `Fixnum`s. + +```Ruby +require 'jison' + +version = Jison.version +version.class #-> Jison::Version +version.to_s #-> "0.4.13" + +version.major #-> 0 +version.minor #-> 4 +version.micro #-> 13 + +version == version #-> true + +version < Jison::Version.new(1,0,0) #-> true +version > Jison::Version.from_string '0.1.0' #-> true +version > Jison::Version.new(1) #-> false + +version.between?(0,1) #-> true +version.between?(1,2) #-> false +version.between?('0.1', '0.5') #-> true +``` diff --git a/vendor/ruby-pegjs/lib/pegjs.rb b/vendor/ruby-pegjs/lib/pegjs.rb new file mode 100644 index 0000000..a2226d4 --- /dev/null +++ b/vendor/ruby-pegjs/lib/pegjs.rb @@ -0,0 +1,23 @@ +require 'open3' +require 'pegjs/execution_error' +require 'pegjs/version' + +module Pegjs + class << self + def version + Version.from_string `pegjs --version` + end + + def parse(grammar, opts = {}) + defaultOpts = {:exportvar => 'module.exports', :allowedStartRules => ""} + options = defaultOpts.merge(opts) + if (options[:allowedStartRules].size > 0) + allowedStartRules = "--allowed-start-rules " + options[:allowedStartRules] + end + stdout, stderr, status = Open3.capture3("pegjs -e #{options[:exportvar]} #{allowedStartRules}", :stdin_data => grammar) + throw stderr unless status.exitstatus.zero? + return stdout if status.exitstatus.zero? + raise ExecutionError.new(stderr, status.exitstatus) + end + end +end diff --git a/vendor/ruby-pegjs/lib/pegjs/execution_error.rb b/vendor/ruby-pegjs/lib/pegjs/execution_error.rb new file mode 100644 index 0000000..f1d09db --- /dev/null +++ b/vendor/ruby-pegjs/lib/pegjs/execution_error.rb @@ -0,0 +1,10 @@ +module Pegjs + class ExecutionError < RuntimeError + attr_reader :exit_code + + def initialize(message, exit_code) + super(message) + @exit_code = exit_code + end + end +end diff --git a/vendor/ruby-pegjs/lib/pegjs/version.rb b/vendor/ruby-pegjs/lib/pegjs/version.rb new file mode 100644 index 0000000..eeebe8f --- /dev/null +++ b/vendor/ruby-pegjs/lib/pegjs/version.rb @@ -0,0 +1,43 @@ +module Jison + class Version + include Comparable + + attr_reader :major, :minor, :micro + + def self.from_string(string) + version = string.gsub(/^\s+|\s+$/, '').split('.').map(&:to_i) + new(*version) + end + + def initialize(major, minor=0, micro=0) + @major, @minor, @micro = major, minor, micro + end + + def ==(other) + major == other.major \ + && minor == other.minor \ + && micro == other.micro + end + + def <=>(other) + case other + when Version + cmp = major - other.major + return cmp unless cmp.zero? + cmp = minor - other.minor + return cmp unless cmp.zero? + micro - other.micro + when String + self <=> Version.from_string(other) + when Fixnum + major - other + else + raise RuntimeError.new("Cannot compare against #{other.class}: #{other.inspect}") + end + end + + def to_s + "#{major}.#{minor}.#{micro}" + end + end +end diff --git a/vendor/ruby-pegjs/pegjs.gemspec b/vendor/ruby-pegjs/pegjs.gemspec new file mode 100644 index 0000000..d4a6811 --- /dev/null +++ b/vendor/ruby-pegjs/pegjs.gemspec @@ -0,0 +1,24 @@ +Gem::Specification.new do |s| + s.name = 'pegjs' + s.version = '0.0.1' + s.date = '2014-04-08' + + s.homepage = '' + s.summary = 'Ruby, peg.js compiler' + s.description = <<-EOS + Wrapper around the pegjs npm module that compiles PEG.js grammars and + returns the corresponding JavaScript text. + + PEG.js is a parser generator for JavaScript with a simple syntax and good + error reporting. + + Entirely derived from Dylon Edward's ruby-jison gem, see: + https://github.com/dylon/ruby-jison + EOS + + s.files = Dir.glob('lib/**/*.rb') + + s.authors = ['4sweep'] + s.email = '4sweep@4sweep.com' + s.license = 'MIT' +end