calagator 0.0.1.pre1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (384) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +23 -0
  3. data/README.md +75 -0
  4. data/Rakefile +12 -0
  5. data/app/assets/images/confused-alligator-sm.png +0 -0
  6. data/app/assets/images/disk.png +0 -0
  7. data/app/assets/images/edit.png +0 -0
  8. data/app/assets/images/external_sites/epdx.png +0 -0
  9. data/app/assets/images/external_sites/external.gif +0 -0
  10. data/app/assets/images/external_sites/facebook.png +0 -0
  11. data/app/assets/images/external_sites/foursquare.png +0 -0
  12. data/app/assets/images/external_sites/gowalla.png +0 -0
  13. data/app/assets/images/external_sites/lanyrd.png +0 -0
  14. data/app/assets/images/external_sites/meetup.png +0 -0
  15. data/app/assets/images/external_sites/plancast.png +0 -0
  16. data/app/assets/images/external_sites/shizzow.png +0 -0
  17. data/app/assets/images/external_sites/upcoming.png +0 -0
  18. data/app/assets/images/external_sites/yelp.png +0 -0
  19. data/app/assets/images/feed.png +0 -0
  20. data/app/assets/images/heart.png +0 -0
  21. data/app/assets/images/icon_ical.gif +0 -0
  22. data/app/assets/images/information.png +0 -0
  23. data/app/assets/images/nav_marker.gif +0 -0
  24. data/app/assets/images/nav_marker.png +0 -0
  25. data/app/assets/images/plus.png +0 -0
  26. data/app/assets/images/redx.png +0 -0
  27. data/app/assets/images/site-icon.png +0 -0
  28. data/app/assets/images/spinner.gif +0 -0
  29. data/app/assets/images/star.png +0 -0
  30. data/app/assets/images/subnav_marker.gif +0 -0
  31. data/app/assets/images/subnav_marker.png +0 -0
  32. data/app/assets/images/tag_blue.png +0 -0
  33. data/app/assets/images/tag_icons/angular.png +0 -0
  34. data/app/assets/images/tag_icons/beer.png +0 -0
  35. data/app/assets/images/tag_icons/bitcoin.png +0 -0
  36. data/app/assets/images/tag_icons/free.png +0 -0
  37. data/app/assets/images/tag_icons/golang.png +0 -0
  38. data/app/assets/images/tag_icons/html.png +0 -0
  39. data/app/assets/images/tag_icons/javascript.png +0 -0
  40. data/app/assets/images/tag_icons/linux.png +0 -0
  41. data/app/assets/images/tag_icons/php.png +0 -0
  42. data/app/assets/images/tag_icons/pizza.png +0 -0
  43. data/app/assets/images/tag_icons/python.png +0 -0
  44. data/app/assets/images/tag_icons/rails.png +0 -0
  45. data/app/assets/images/tag_icons/ruby.png +0 -0
  46. data/app/assets/images/tag_icons/sass.png +0 -0
  47. data/app/assets/images/tag_icons/swift.png +0 -0
  48. data/app/assets/images/transmit_blue.png +0 -0
  49. data/app/assets/images/weekday_background.gif +0 -0
  50. data/app/assets/javascripts/calagator.js +18 -0
  51. data/app/assets/javascripts/calagator/forms.js +60 -0
  52. data/app/assets/stylesheets/calagator.scss +8 -0
  53. data/app/assets/stylesheets/calagator/common.scss +14 -0
  54. data/app/assets/stylesheets/calagator/datepicker.scss +177 -0
  55. data/app/assets/stylesheets/calagator/errors.css +110 -0
  56. data/app/assets/stylesheets/calagator/forms.scss +4 -0
  57. data/app/assets/stylesheets/calagator/reset.css +40 -0
  58. data/app/assets/stylesheets/calagator/theme.css +0 -0
  59. data/app/controllers/calagator/admin_controller.rb +27 -0
  60. data/app/controllers/calagator/application_controller.rb +82 -0
  61. data/app/controllers/calagator/events_controller.rb +171 -0
  62. data/app/controllers/calagator/site_controller.rb +35 -0
  63. data/app/controllers/calagator/sources_controller.rb +94 -0
  64. data/app/controllers/calagator/venues_controller.rb +133 -0
  65. data/app/controllers/calagator/versions_controller.rb +20 -0
  66. data/app/helpers/calagator/application_helper.rb +128 -0
  67. data/app/helpers/calagator/events_helper.rb +184 -0
  68. data/app/helpers/calagator/google_event_export_helper.rb +67 -0
  69. data/app/helpers/calagator/mapping_helper.rb +103 -0
  70. data/app/helpers/calagator/sources_helper.rb +10 -0
  71. data/app/helpers/calagator/tags_helper.rb +42 -0
  72. data/app/helpers/calagator/time_range_helper.rb +166 -0
  73. data/app/models/calagator/event.rb +246 -0
  74. data/app/models/calagator/event/cloner.rb +39 -0
  75. data/app/models/calagator/event/ical_renderer.rb +155 -0
  76. data/app/models/calagator/event/overview.rb +45 -0
  77. data/app/models/calagator/event/saver.rb +58 -0
  78. data/app/models/calagator/event/search.rb +72 -0
  79. data/app/models/calagator/event/search_engine.rb +28 -0
  80. data/app/models/calagator/event/search_engine/apache_sunspot.rb +106 -0
  81. data/app/models/calagator/event/search_engine/sql.rb +107 -0
  82. data/app/models/calagator/source.rb +115 -0
  83. data/app/models/calagator/source/importer.rb +46 -0
  84. data/app/models/calagator/source/parser.rb +128 -0
  85. data/app/models/calagator/source/parser/facebook.rb +67 -0
  86. data/app/models/calagator/source/parser/hcal.rb +108 -0
  87. data/app/models/calagator/source/parser/http_authentication_required_error.rb +6 -0
  88. data/app/models/calagator/source/parser/ical.rb +186 -0
  89. data/app/models/calagator/source/parser/meetup.rb +72 -0
  90. data/app/models/calagator/source/parser/not_found.rb +9 -0
  91. data/app/models/calagator/source/parser/plancast.rb +59 -0
  92. data/app/models/calagator/venue.rb +161 -0
  93. data/app/models/calagator/venue/geocoder.rb +51 -0
  94. data/app/models/calagator/venue/search.rb +63 -0
  95. data/app/models/calagator/venue/search_engine.rb +24 -0
  96. data/app/models/calagator/venue/search_engine/apache_sunspot.rb +85 -0
  97. data/app/models/calagator/venue/search_engine/sql.rb +68 -0
  98. data/app/models/event/search_engine/base.rb +0 -0
  99. data/app/observers/calagator/cache_observer.rb +37 -0
  100. data/app/views/calagator/admin/events.html.erb +28 -0
  101. data/app/views/calagator/admin/index.html.erb +8 -0
  102. data/app/views/calagator/events/_feed_item.html.erb +62 -0
  103. data/app/views/calagator/events/_form.html.erb +63 -0
  104. data/app/views/calagator/events/_gcal_reminder.html.erb +1 -0
  105. data/app/views/calagator/events/_hcal.html.erb +41 -0
  106. data/app/views/calagator/events/_item.html.erb +120 -0
  107. data/app/views/calagator/events/_list.html.erb +30 -0
  108. data/app/views/calagator/events/_list_item.html.erb +37 -0
  109. data/app/views/calagator/events/_search_section.html.erb +15 -0
  110. data/app/views/calagator/events/_subnav.html.erb +13 -0
  111. data/app/views/calagator/events/_table.html.erb +74 -0
  112. data/app/views/calagator/events/duplicates.html.erb +58 -0
  113. data/app/views/calagator/events/edit.html.erb +3 -0
  114. data/app/views/calagator/events/index.atom.builder +32 -0
  115. data/app/views/calagator/events/index.html.erb +51 -0
  116. data/app/views/calagator/events/index.kml.erb +20 -0
  117. data/app/views/calagator/events/new.html.erb +7 -0
  118. data/app/views/calagator/events/search.html.erb +25 -0
  119. data/app/views/calagator/events/show.html.erb +81 -0
  120. data/app/views/calagator/site/_appropriateness_message.html.erb +15 -0
  121. data/app/views/calagator/site/_description.html.erb +3 -0
  122. data/app/views/calagator/site/about.html.erb +7 -0
  123. data/app/views/calagator/site/defunct.html.erb +15 -0
  124. data/app/views/calagator/site/export.html.erb +4 -0
  125. data/app/views/calagator/site/index.html.erb +40 -0
  126. data/app/views/calagator/site/opensearch.xml.builder +8 -0
  127. data/app/views/calagator/sources/_subnav.html.erb +0 -0
  128. data/app/views/calagator/sources/edit.html.erb +12 -0
  129. data/app/views/calagator/sources/import.html.erb +10 -0
  130. data/app/views/calagator/sources/index.html.erb +22 -0
  131. data/app/views/calagator/sources/new.html.erb +32 -0
  132. data/app/views/calagator/sources/show.html.erb +16 -0
  133. data/app/views/calagator/venues/_form.html.erb +57 -0
  134. data/app/views/calagator/venues/_subnav.html.erb +10 -0
  135. data/app/views/calagator/venues/duplicates.html.erb +65 -0
  136. data/app/views/calagator/venues/edit.html.erb +3 -0
  137. data/app/views/calagator/venues/index.html.erb +93 -0
  138. data/app/views/calagator/venues/index.kml.erb +15 -0
  139. data/app/views/calagator/venues/map.html.erb +4 -0
  140. data/app/views/calagator/venues/new.html.erb +5 -0
  141. data/app/views/calagator/venues/show.html.erb +113 -0
  142. data/app/views/calagator/versions/_chooser.html.erb +28 -0
  143. data/app/views/calagator/versions/_edit_with_chooser.html.erb +16 -0
  144. data/app/views/layouts/calagator/application.html.erb +110 -0
  145. data/config/deploy/local.rb +37 -0
  146. data/config/deploy/lucca.rb +33 -0
  147. data/config/initializers/dates.rb +7 -0
  148. data/config/initializers/formtastic.rb +82 -0
  149. data/config/initializers/geokit.rb +74 -0
  150. data/config/initializers/ics_renderer.rb +6 -0
  151. data/config/initializers/load_tag_model_extensions.rb +3 -0
  152. data/config/initializers/mime_types.rb +9 -0
  153. data/config/initializers/observers.rb +1 -0
  154. data/config/initializers/search_engine.rb +4 -0
  155. data/config/initializers/set_default_url_host.rb +5 -0
  156. data/config/initializers/time_get_zone.rb +8 -0
  157. data/config/locales/en.yml +5 -0
  158. data/config/routes.rb +55 -0
  159. data/config/secrets.yml.blag +75 -0
  160. data/config/sunspot.yml +23 -0
  161. data/db/development.sqlite3 +0 -0
  162. data/db/development.sqlite3.bak +0 -0
  163. data/db/development.sqlite3.old +0 -0
  164. data/db/development~20111112@110950.sqlite3 +0 -0
  165. data/db/migrate/001_create_events.rb +17 -0
  166. data/db/migrate/002_create_venues.rb +17 -0
  167. data/db/migrate/003_create_sources.rb +16 -0
  168. data/db/migrate/004_add_detailed_fields_to_venue.rb +19 -0
  169. data/db/migrate/005_add_end_time_to_events.rb +9 -0
  170. data/db/migrate/006_add_source_id_to_events.rb +9 -0
  171. data/db/migrate/008_add_source_id_to_venues.rb +10 -0
  172. data/db/migrate/009_add_duplicate_of_column_to_venues.rb +9 -0
  173. data/db/migrate/010_add_duplicate_of_column_to_events.rb +9 -0
  174. data/db/migrate/011_change_lat_long_type.rb +12 -0
  175. data/db/migrate/012_add_source_reimport.rb +9 -0
  176. data/db/migrate/013_change_end_time_to_duration.rb +11 -0
  177. data/db/migrate/014_remove_format_type_from_source.rb +9 -0
  178. data/db/migrate/015_create_updates.rb +15 -0
  179. data/db/migrate/016_remove_next_update_from_source.rb +9 -0
  180. data/db/migrate/20080705163959_change_duration_to_end_time.rb +11 -0
  181. data/db/migrate/20080705164959_create_tags_and_taggings.rb +28 -0
  182. data/db/migrate/20081011181519_create_versioned_events.rb +25 -0
  183. data/db/migrate/20081011193124_create_versioned_venues.rb +32 -0
  184. data/db/migrate/20081115190515_add_rrule_to_events.rb +15 -0
  185. data/db/migrate/20090912082129_create_versions.rb +18 -0
  186. data/db/migrate/20110219205156_add_closed_flag_to_venues.rb +9 -0
  187. data/db/migrate/20110220001008_add_wifi_flag_to_venues.rb +9 -0
  188. data/db/migrate/20110220011427_add_access_notes_to_venues.rb +9 -0
  189. data/db/migrate/20110220031117_add_events_count_to_venues.rb +8 -0
  190. data/db/migrate/20110604174521_add_venue_details_to_events.rb +9 -0
  191. data/db/migrate/20110717231316_acts_as_taggable_on_migration.rb +50 -0
  192. data/db/migrate/20120709092821_cleanup.rb +14 -0
  193. data/db/migrate/20120831234448_specify_venues_latitude_and_longitude_precision.rb +11 -0
  194. data/db/migrate/20150206085809_remove_updates.rb +13 -0
  195. data/db/migrate/20150207231355_add_locked_status_to_events.rb +5 -0
  196. data/db/production.sqlite3 +0 -0
  197. data/db/schema.rb +102 -0
  198. data/db/seeds.rb +16 -0
  199. data/db/test.sqlite3 +0 -0
  200. data/db/test2.sqlite3 +0 -0
  201. data/lib/calagator.rb +30 -0
  202. data/lib/calagator/blacklist_validator.rb +69 -0
  203. data/lib/calagator/decode_html_entities_hack.rb +33 -0
  204. data/lib/calagator/duplicate_checking.rb +133 -0
  205. data/lib/calagator/duplicate_checking/controller_actions.rb +38 -0
  206. data/lib/calagator/duplicate_checking/duplicate_finder.rb +83 -0
  207. data/lib/calagator/duplicate_checking/duplicate_squasher.rb +74 -0
  208. data/lib/calagator/engine.rb +30 -0
  209. data/lib/calagator/strip_whitespace.rb +19 -0
  210. data/lib/calagator/tag_model_extensions.rb +104 -0
  211. data/lib/calagator/url_prefixer.rb +9 -0
  212. data/lib/calagator/version.rb +3 -0
  213. data/lib/generators/calagator/install_generator.rb +39 -0
  214. data/lib/generators/calagator/templates/config/calagator.rb +26 -0
  215. data/lib/generators/calagator/templates/config/secrets.yml.sample +83 -0
  216. data/lib/secrets_reader.rb +76 -0
  217. data/lib/tasks/spec_db.rake +53 -0
  218. data/lib/tasks/sunspot_reindex_calagator.rake +6 -0
  219. data/lib/tasks/sunspot_solr_restart_enhancements.rake +19 -0
  220. data/lib/tasks/update_counter_caches.rake +13 -0
  221. data/lib/templates/erb/scaffold/_form.html.erb +11 -0
  222. data/lib/theme_reader.rb +17 -0
  223. data/lib/wait_for_solr.rb +25 -0
  224. data/spec/controllers/calagator/application_controller_spec.rb +47 -0
  225. data/spec/controllers/calagator/events_controller_spec.rb +794 -0
  226. data/spec/controllers/calagator/site_controller_spec.rb +59 -0
  227. data/spec/controllers/calagator/sources_controller_spec.rb +439 -0
  228. data/spec/controllers/calagator/venues_controller_spec.rb +319 -0
  229. data/spec/controllers/calagator/versions_controller_spec.rb +82 -0
  230. data/spec/controllers/squash_many_duplicates_examples.rb +49 -0
  231. data/spec/dummy/Gemfile +39 -0
  232. data/spec/dummy/Gemfile.lock +195 -0
  233. data/spec/dummy/README.rdoc +261 -0
  234. data/spec/dummy/Rakefile +7 -0
  235. data/spec/dummy/app/assets/images/rails.png +0 -0
  236. data/spec/dummy/app/assets/javascripts/application.js +16 -0
  237. data/spec/dummy/app/assets/stylesheets/application.css +14 -0
  238. data/spec/dummy/app/controllers/application_controller.rb +3 -0
  239. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  240. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  241. data/spec/dummy/config.ru +4 -0
  242. data/spec/dummy/config/application.rb +62 -0
  243. data/spec/dummy/config/boot.rb +6 -0
  244. data/spec/dummy/config/database.yml +25 -0
  245. data/spec/dummy/config/environment.rb +5 -0
  246. data/spec/dummy/config/environments/development.rb +37 -0
  247. data/spec/dummy/config/environments/production.rb +67 -0
  248. data/spec/dummy/config/environments/test.rb +37 -0
  249. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  250. data/spec/dummy/config/initializers/calagator.rb +26 -0
  251. data/spec/dummy/config/initializers/inflections.rb +15 -0
  252. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  253. data/spec/dummy/config/initializers/secret_token.rb +7 -0
  254. data/spec/dummy/config/initializers/session_store.rb +8 -0
  255. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  256. data/spec/dummy/config/locales/en.yml +5 -0
  257. data/spec/dummy/config/routes.rb +59 -0
  258. data/spec/dummy/config/secrets.yml +83 -0
  259. data/spec/dummy/db/development.sqlite3 +0 -0
  260. data/spec/dummy/db/migrate/20150309023304_create_events.calagator.rb +18 -0
  261. data/spec/dummy/db/migrate/20150309023305_create_venues.calagator.rb +18 -0
  262. data/spec/dummy/db/migrate/20150309023306_create_sources.calagator.rb +17 -0
  263. data/spec/dummy/db/migrate/20150309023307_add_detailed_fields_to_venue.calagator.rb +20 -0
  264. data/spec/dummy/db/migrate/20150309023308_add_end_time_to_events.calagator.rb +10 -0
  265. data/spec/dummy/db/migrate/20150309023309_add_source_id_to_events.calagator.rb +10 -0
  266. data/spec/dummy/db/migrate/20150309023310_add_source_id_to_venues.calagator.rb +11 -0
  267. data/spec/dummy/db/migrate/20150309023311_add_duplicate_of_column_to_venues.calagator.rb +10 -0
  268. data/spec/dummy/db/migrate/20150309023312_add_duplicate_of_column_to_events.calagator.rb +10 -0
  269. data/spec/dummy/db/migrate/20150309023313_change_lat_long_type.calagator.rb +13 -0
  270. data/spec/dummy/db/migrate/20150309023314_add_source_reimport.calagator.rb +10 -0
  271. data/spec/dummy/db/migrate/20150309023315_change_end_time_to_duration.calagator.rb +12 -0
  272. data/spec/dummy/db/migrate/20150309023316_remove_format_type_from_source.calagator.rb +10 -0
  273. data/spec/dummy/db/migrate/20150309023317_create_updates.calagator.rb +16 -0
  274. data/spec/dummy/db/migrate/20150309023318_remove_next_update_from_source.calagator.rb +10 -0
  275. data/spec/dummy/db/migrate/20150309023319_change_duration_to_end_time.calagator.rb +12 -0
  276. data/spec/dummy/db/migrate/20150309023320_create_tags_and_taggings.calagator.rb +29 -0
  277. data/spec/dummy/db/migrate/20150309023321_create_versioned_events.calagator.rb +26 -0
  278. data/spec/dummy/db/migrate/20150309023322_create_versioned_venues.calagator.rb +33 -0
  279. data/spec/dummy/db/migrate/20150309023323_add_rrule_to_events.calagator.rb +16 -0
  280. data/spec/dummy/db/migrate/20150309023324_create_versions.calagator.rb +19 -0
  281. data/spec/dummy/db/migrate/20150309023325_add_closed_flag_to_venues.calagator.rb +10 -0
  282. data/spec/dummy/db/migrate/20150309023326_add_wifi_flag_to_venues.calagator.rb +10 -0
  283. data/spec/dummy/db/migrate/20150309023327_add_access_notes_to_venues.calagator.rb +10 -0
  284. data/spec/dummy/db/migrate/20150309023328_add_events_count_to_venues.calagator.rb +9 -0
  285. data/spec/dummy/db/migrate/20150309023329_add_venue_details_to_events.calagator.rb +10 -0
  286. data/spec/dummy/db/migrate/20150309023330_acts_as_taggable_on_migration.calagator.rb +51 -0
  287. data/spec/dummy/db/migrate/20150309023331_cleanup.calagator.rb +15 -0
  288. data/spec/dummy/db/migrate/20150309023332_specify_venues_latitude_and_longitude_precision.calagator.rb +12 -0
  289. data/spec/dummy/db/migrate/20150309023333_remove_updates.calagator.rb +14 -0
  290. data/spec/dummy/db/migrate/20150309023334_add_locked_status_to_events.calagator.rb +6 -0
  291. data/spec/dummy/db/schema.rb +95 -0
  292. data/spec/dummy/db/seeds.rb +7 -0
  293. data/spec/dummy/db/test.sqlite3 +0 -0
  294. data/spec/dummy/doc/README_FOR_APP +2 -0
  295. data/spec/dummy/log/development.log +273 -0
  296. data/spec/dummy/public/404.html +26 -0
  297. data/spec/dummy/public/422.html +26 -0
  298. data/spec/dummy/public/500.html +25 -0
  299. data/spec/dummy/public/favicon.ico +0 -0
  300. data/spec/dummy/public/robots.txt +5 -0
  301. data/spec/dummy/script/rails +6 -0
  302. data/spec/dummy/test/performance/browsing_test.rb +12 -0
  303. data/spec/dummy/test/test_helper.rb +13 -0
  304. data/spec/factories.rb +93 -0
  305. data/spec/features/add_event_spec.rb +99 -0
  306. data/spec/features/add_venue_spec.rb +34 -0
  307. data/spec/features/admin_auth_spec.rb +22 -0
  308. data/spec/features/admin_lock_event_spec.rb +41 -0
  309. data/spec/features/import_events_from_feed_spec.rb +43 -0
  310. data/spec/features/managing_event_spec.rb +111 -0
  311. data/spec/features/managing_venue_spec.rb +71 -0
  312. data/spec/features/search_event_spec.rb +27 -0
  313. data/spec/helpers/calagator/application_helper_spec.rb +82 -0
  314. data/spec/helpers/calagator/events_helper_spec.rb +172 -0
  315. data/spec/helpers/calagator/google_event_export_helper_spec.rb +70 -0
  316. data/spec/helpers/calagator/sources_helper_spec.rb +12 -0
  317. data/spec/helpers/calagator/tags_helper_spec.rb +85 -0
  318. data/spec/helpers/calagator/time_range_helper_spec.rb +59 -0
  319. data/spec/lib/calagator/blacklist_validator_spec.rb +65 -0
  320. data/spec/lib/calagator/decode_html_entities_hack_spec.rb +54 -0
  321. data/spec/lib/calagator/settings_spec.rb +20 -0
  322. data/spec/lib/calagator/url_prefixer_spec.rb +33 -0
  323. data/spec/lib/secrets_reader_spec.rb +65 -0
  324. data/spec/models/calagator/event/cloner_spec.rb +43 -0
  325. data/spec/models/calagator/event/overview_spec.rb +79 -0
  326. data/spec/models/calagator/event/search_spec.rb +103 -0
  327. data/spec/models/calagator/event_search_spec.rb +149 -0
  328. data/spec/models/calagator/event_spec.rb +859 -0
  329. data/spec/models/calagator/source/parser_facebook_spec.rb +73 -0
  330. data/spec/models/calagator/source/parser_hcal_spec.rb +69 -0
  331. data/spec/models/calagator/source/parser_ical_non_standard_spec.rb +91 -0
  332. data/spec/models/calagator/source/parser_ical_spec.rb +322 -0
  333. data/spec/models/calagator/source/parser_meetup_spec.rb +69 -0
  334. data/spec/models/calagator/source/parser_plancast_spec.rb +53 -0
  335. data/spec/models/calagator/source/parser_spec.rb +238 -0
  336. data/spec/models/calagator/source_spec.rb +135 -0
  337. data/spec/models/calagator/venue/search_spec.rb +92 -0
  338. data/spec/models/calagator/venue_search_spec.rb +124 -0
  339. data/spec/models/calagator/venue_spec.rb +346 -0
  340. data/spec/models/tag_spec.rb +35 -0
  341. data/spec/rails_helper.rb +39 -0
  342. data/spec/spec_helper.rb +140 -0
  343. data/spec/support/disable_geocoding.rb +1 -0
  344. data/spec/support/http_samples.rb +5 -0
  345. data/spec/support/samples/facebook.json +23 -0
  346. data/spec/support/samples/hcal_basic.xml +6 -0
  347. data/spec/support/samples/hcal_dup_event_dup_venue.xml +13 -0
  348. data/spec/support/samples/hcal_event_duplicates_fixture.xml +13 -0
  349. data/spec/support/samples/hcal_event_wo_lat_and_long.xml +14 -0
  350. data/spec/support/samples/hcal_multiple.xml +16 -0
  351. data/spec/support/samples/hcal_same_event_twice_with_different_venues.xml +12 -0
  352. data/spec/support/samples/hcal_single.xml +8 -0
  353. data/spec/support/samples/hcal_two_identical_events.xml +14 -0
  354. data/spec/support/samples/hcal_upcoming_v1.html +412 -0
  355. data/spec/support/samples/hcal_upcoming_v2.html +749 -0
  356. data/spec/support/samples/hcal_upcoming_v3.html +685 -0
  357. data/spec/support/samples/hcal_upcoming_v4.html +761 -0
  358. data/spec/support/samples/ical_apple.ics +22 -0
  359. data/spec/support/samples/ical_apple_v3.ics +37 -0
  360. data/spec/support/samples/ical_basic.ics +15 -0
  361. data/spec/support/samples/ical_basic_with_duration.ics +16 -0
  362. data/spec/support/samples/ical_event_with_squashed_venue.ics +12 -0
  363. data/spec/support/samples/ical_eventful_many.ics +504 -0
  364. data/spec/support/samples/ical_gmt.ics +12 -0
  365. data/spec/support/samples/ical_google.ics +786 -0
  366. data/spec/support/samples/ical_multiple_calendars.ics +111 -0
  367. data/spec/support/samples/ical_upcoming.ics +36 -0
  368. data/spec/support/samples/ical_upcoming_many.ics +682 -0
  369. data/spec/support/samples/ical_upcoming_v2.ics +43 -0
  370. data/spec/support/samples/ical_z.ics +10 -0
  371. data/spec/support/samples/meetup.ics +16 -0
  372. data/spec/support/samples/meetup.json +31 -0
  373. data/spec/support/samples/plancast.ics +59 -0
  374. data/spec/support/samples/plancast.json +51 -0
  375. data/spec/support/samples/plancast_with_missing_venue.json +39 -0
  376. data/spec/support/samples/upcoming_v1.xml +8 -0
  377. data/spec/support/samples/upcoming_v2_with_invalid_utc_dates.xml +8 -0
  378. data/spec/support/time_convenience_methods.rb +8 -0
  379. data/spec/support/time_zones.rb +12 -0
  380. data/spec/support/url_helpers.rb +5 -0
  381. data/spec/support/wait_for_ajax.rb +15 -0
  382. data/spec/support/webmock.rb +8 -0
  383. data/spec/travis_spec.rb +7 -0
  384. metadata +1194 -0
@@ -0,0 +1,108 @@
1
+ # == Source::Parser::Hcal
2
+ #
3
+ # Reads hCalendar events.
4
+
5
+ vendored_mofo_dir = File.expand_path("../../../../../vendor/gems/mofo-0.2.8/lib", File.dirname(__FILE__))
6
+ $: << vendored_mofo_dir
7
+ require "mofo"
8
+
9
+ module Calagator
10
+
11
+ class Source::Parser::Hcal < Source::Parser
12
+ self.label = :hCalendar
13
+
14
+ EVENT_TO_HCALENDAR_FIELD_MAP = {
15
+ :title => :summary,
16
+ :description => :description,
17
+ :start_time => :dtstart,
18
+ :end_time => :dtend,
19
+ :url => :url,
20
+ :venue => :location,
21
+ }
22
+
23
+ def to_events
24
+ hcals.map do |hcal|
25
+ event = Event.new
26
+ event.source = opts[:source]
27
+ EVENT_TO_HCALENDAR_FIELD_MAP.each do |field, mofo_field|
28
+ next unless hcal.respond_to?(mofo_field)
29
+ next unless value = decoded_field(hcal, mofo_field)
30
+ event.send "#{field}=", value
31
+ end
32
+ event_or_duplicate(event)
33
+ end.uniq do |event|
34
+ [event.attributes, event.venue.try(:attributes)]
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def decoded_field(hcal, mofo_field)
41
+ return unless raw_field = hcal.send(mofo_field)
42
+ decoded_field = case mofo_field
43
+ when :dtstart
44
+ HTMLEntities.new.decode(raw_field)
45
+ when :location
46
+ to_venue(opts.merge(:value => raw_field))
47
+ else
48
+ raw_field
49
+ end
50
+ end
51
+
52
+ VENUE_TO_HCARD_FIELD_MAP = {
53
+ :title => :fn,
54
+ :telephone => :tel,
55
+ :email => :email,
56
+ :description => :note,
57
+ }
58
+
59
+ # Return a Venue.
60
+ #
61
+ # Options:
62
+ # * :value -- hCard or string location
63
+ def to_venue(opts)
64
+ venue = Venue.new
65
+ venue.source = opts[:source]
66
+ case raw = opts[:value]
67
+ when String
68
+ venue.title = raw
69
+ when HCard
70
+ assign_fields(venue, raw)
71
+ assign_geo(venue, raw) if raw.respond_to?(:geo)
72
+ assign_address(venue, raw) if raw.respond_to?(:adr)
73
+ end
74
+ venue.geocode!
75
+ venue_or_duplicate(venue)
76
+ end
77
+
78
+ def assign_fields(venue, raw)
79
+ VENUE_TO_HCARD_FIELD_MAP.each do |field, mofo_field|
80
+ venue[field] = raw.send(mofo_field).try(:strip_html) if raw.respond_to?(mofo_field)
81
+ end
82
+ end
83
+
84
+ def assign_geo(venue, raw)
85
+ %w(latitude longitude).each do |field|
86
+ venue[field] = raw.geo.send(field) if raw.geo.respond_to?(field)
87
+ end
88
+ end
89
+
90
+ def assign_address(venue, raw)
91
+ attributes = %w(street_address locality region country_name postal_code).reduce({}) do |attributes, field|
92
+ attributes[field] = raw.adr.send(field) if raw.adr.respond_to?(field)
93
+ attributes
94
+ end
95
+
96
+ attributes["country"] = attributes.delete("country_name")
97
+ attributes["postal_code"] = attributes["postal_code"].to_s if attributes["postal_code"]
98
+ venue.attributes = attributes
99
+ end
100
+
101
+ def hcals
102
+ content = self.class.read_url(opts[:url])
103
+ something = hCalendar.find(:text => content)
104
+ something.is_a?(hCalendar) ? [something] : something
105
+ end
106
+ end
107
+
108
+ end
@@ -0,0 +1,6 @@
1
+ # Exception raised if user requests parsing of a URL that requires
2
+ # authentication but none was provided.
3
+ class Calagator::Source::Parser
4
+ class HttpAuthenticationRequiredError < Exception
5
+ end
6
+ end
@@ -0,0 +1,186 @@
1
+ # == Source::Parser::Ical
2
+ #
3
+ # Reads iCalendar events.
4
+ #
5
+ # Example:
6
+ # events = Source::Parser::Ical.to_events('http://appendix.23ae.com/calendars/AlternateHolidays.ics')
7
+ #
8
+ # Sample sources:
9
+ # webcal://appendix.23ae.com/calendars/AlternateHolidays.ics
10
+ # http://appendix.23ae.com/calendars/AlternateHolidays.ics
11
+ module Calagator
12
+
13
+ class Source::Parser::Ical < Source::Parser
14
+ self.label = :iCalendar
15
+
16
+ VENUE_CONTENT_RE = /^BEGIN:VVENUE$.*?^END:VVENUE$/m
17
+
18
+ # Override Base::read_url to handle "webcal" scheme addresses.
19
+ def self.read_url(url)
20
+ super(url.gsub(/^webcal:/, 'http:'))
21
+ end
22
+
23
+ def to_events
24
+ return false unless calendars = content_calendars
25
+
26
+ events = calendars.flat_map do |calendar|
27
+ calendar.events.map do |component|
28
+ next if skip_old? and old?(component)
29
+ component_to_event(component, calendar)
30
+ end
31
+ end
32
+
33
+ events.compact.uniq do |event|
34
+ [event.attributes, event.venue.try(:attributes)]
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def skip_old?
41
+ # Skip old events by default
42
+ true unless opts[:skip_old] == false
43
+ end
44
+
45
+ def old?(component)
46
+ cutoff = Time.now.yesterday
47
+ (component.dtend || component.dtstart).to_time < cutoff
48
+ end
49
+
50
+ def content_calendars
51
+ content = self.class.read_url(opts[:url]).gsub(/\r\n/, "\n")
52
+ content = munge_gmt_dates(content)
53
+ RiCal.parse_string(content)
54
+ rescue Exception => e
55
+ if e.message =~ /Invalid icalendar file/
56
+ false # Invalid data, give up.
57
+ else
58
+ raise e # Unknown error, we should care.
59
+ end
60
+ end
61
+
62
+ def component_to_event(component, calendar)
63
+ event = Event.new({
64
+ source: opts[:source],
65
+ title: component.summary,
66
+ description: component.description,
67
+ url: component.url,
68
+ })
69
+
70
+ dates_for_tz(component, event)
71
+
72
+ event.venue = to_venue(content_venue(component, calendar), opts.merge(fallback: component.location))
73
+ event_or_duplicate(event)
74
+ end
75
+
76
+ def content_venue(component, calendar)
77
+ content_venues = calendar.to_s.scan(VENUE_CONTENT_RE)
78
+
79
+ # finding the event venue id - VVENUE=V0-001-001423875-1@eventful.com
80
+ venue_uid = component.location_property.params["VVENUE"]
81
+ # finding in the content_venues array an item matching the uid
82
+ venue_uid ? content_venues.find{|content_venue| content_venue.match(/^UID:#{venue_uid}$/m)} : nil
83
+ rescue Exception => e
84
+ # Ignore
85
+ Rails.logger.info("Source::Parser::Ical.to_events : Failed to parse content_venue for event -- #{e}")
86
+ nil
87
+ end
88
+
89
+ # Helper to set the start and end dates correctly depending on whether it's a floating or fixed timezone
90
+ def dates_for_tz(component, event)
91
+ if component.dtstart_property.tzid
92
+ event.start_time = component.dtstart
93
+ event.end_time = component.dtend
94
+ else
95
+ event.start_time = Time.zone.parse(component.dtstart_property.value)
96
+ if component.dtend_property
97
+ event.end_time = Time.zone.parse(component.dtend_property.value)
98
+ else
99
+ if component.duration
100
+ event.end_time = component.duration_property.add_to_date_time_value(event.start_time)
101
+ else
102
+ event.end_time = event.start_time
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ def munge_gmt_dates(content)
109
+ content.gsub(/;TZID=GMT:(.*)/, ':\1Z')
110
+ end
111
+
112
+ # Return an Venue extracted from an iCalendar input.
113
+ #
114
+ # Arguments:
115
+ # * value - String with iCalendar data to parse which contains a VVENUE item.
116
+ #
117
+ # Options:
118
+ # * :fallback - String to use as the title for the location if the +value+ doesn't contain a VVENUE.
119
+ def to_venue(value, opts={})
120
+ venue = Venue.new
121
+
122
+ # VVENUE entries are considered just Vcards,
123
+ # treating them as such.
124
+ if vcard_hash = vcard_hash_from_value(value)
125
+ venue.attributes = {
126
+ title: vcard_hash['NAME'],
127
+ street_address: vcard_hash['ADDRESS'],
128
+ locality: vcard_hash['CITY'],
129
+ region: vcard_hash['REGION'],
130
+ postal_code: vcard_hash['POSTALCODE'],
131
+ country: vcard_hash['COUNTRY'],
132
+ }
133
+ venue.latitude, venue.longitude = vcard_hash['GEO'].split(/;/).map(&:to_f)
134
+
135
+ elsif opts[:fallback].present?
136
+ venue.title = opts[:fallback]
137
+ else
138
+ return nil
139
+ end
140
+
141
+ venue.geocode!
142
+ venue_or_duplicate(venue)
143
+ end
144
+
145
+ def vcard_hash_from_value(value)
146
+ value ||= ""
147
+ return unless data = value.scan(VENUE_CONTENT_RE).first
148
+
149
+ # Only use first vcard of a VVENUE
150
+ vcard = RiCal.parse_string(data).first
151
+
152
+ # Extract all properties, including non-standard ones, into an array of "KEY;meta-qualifier:value" strings
153
+ vcard_lines = vcard.export_properties_to(StringIO.new(''))
154
+
155
+ # Turn a String-like object into an Enumerable.
156
+ vcard_lines = vcard_lines.respond_to?(:lines) ? vcard_lines.lines : vcard_lines
157
+
158
+ hash_from_vcard_lines(vcard_lines)
159
+ end
160
+
161
+ # Return hash parsed from VCARD lines.
162
+ #
163
+ # Arguments:
164
+ # * vcard_lines - Array of "KEY;meta-qualifier:value" strings.
165
+ def hash_from_vcard_lines(vcard_lines)
166
+ vcard_lines.reduce({}) do |vcard_hash, vcard_line|
167
+ if matcher = vcard_line.match(/^([^;]+?)(;[^:]*?)?:(.*)$/)
168
+ _, key, qualifier, value = *matcher
169
+
170
+ if qualifier
171
+ # Add entry for a key and its meta-qualifier
172
+ vcard_hash["#{key}#{qualifier}"] = value
173
+
174
+ # Add fallback entry for a key from the matching meta-qualifier, e.g. create key "foo" from contents of key with meta-qualifier "foo;bar".
175
+ vcard_hash[key] ||= value
176
+ else
177
+ # Add entry for a key without a meta-qualifier.
178
+ vcard_hash[key] = value
179
+ end
180
+ end
181
+ vcard_hash
182
+ end
183
+ end
184
+ end
185
+
186
+ end
@@ -0,0 +1,72 @@
1
+ module Calagator
2
+
3
+ class Source::Parser::Meetup < Source::Parser
4
+ self.label = :Meetup
5
+ self.url_pattern = %r{^http://(?:www\.)?meetup\.com/[^/]+/events/([^/]+)/?}
6
+
7
+ def to_events
8
+ return fallback unless SECRETS.meetup_api_key.present?
9
+ return unless data = get_data
10
+ event = Event.new({
11
+ source: opts[:source],
12
+ title: data['name'],
13
+ description: data['description'],
14
+ url: data['event_url'],
15
+ venue: to_venue(data['venue']),
16
+ tag_list: "meetup:event=#{data['event_id']}, meetup:group=#{data['group']['urlname']}",
17
+ # Meetup sends us milliseconds since the epoch in UTC
18
+ start_time: Time.at(data['time']/1000).utc,
19
+ })
20
+
21
+ [event_or_duplicate(event)]
22
+ end
23
+
24
+ private
25
+
26
+ def fallback
27
+ to_events_wrapper(
28
+ Source::Parser::Ical,
29
+ %r{^http://(?:www\.)?meetup\.com/([^/]+)/events/([^/]+)/?},
30
+ lambda { |matcher| "http://www.meetup.com/#{matcher[1]}/events/#{matcher[2]}/ical" }
31
+ )
32
+ end
33
+
34
+ def get_data
35
+ to_events_api_helper(opts[:url], "problem") do |event_id|
36
+ [
37
+ "https://api.meetup.com/2/event/#{event_id}",
38
+ {
39
+ key: SECRETS.meetup_api_key,
40
+ sign: 'true'
41
+ }
42
+ ]
43
+ end
44
+ end
45
+
46
+ def to_venue(value)
47
+ return if value.blank?
48
+ venue = Venue.new({
49
+ source: opts[:source],
50
+ title: value['name'],
51
+ street_address: [value['address_1'], value['address_2'], value['address_3']].compact.join(", "),
52
+ locality: value['city'],
53
+ region: value['state'],
54
+ postal_code: value['zip'],
55
+ country: value['country'],
56
+ telephone: value['phone'],
57
+ tag_list: "meetup:venue=#{value['id']}",
58
+ })
59
+ venue.geocode!
60
+ venue_or_duplicate(venue)
61
+ end
62
+
63
+ def to_events_wrapper(driver, source, target)
64
+ if matcher = opts[:url].try(:match, source)
65
+ url = target.call(matcher)
66
+ opts[:content] = self.class.read_url(url)
67
+ driver.new(opts).to_events
68
+ end
69
+ end
70
+ end
71
+
72
+ end
@@ -0,0 +1,9 @@
1
+ class Calagator::Source::Parser
2
+ # == Source::Parser::NotFound
3
+ #
4
+ # Exception thrown to indicate that the source isn't found and no other parsers should be tried.
5
+ #
6
+ # This exception should only be thrown by the canonical handler that can definitively tell if the source doesn't exist. For example, the Source::Parser::Facebook parser can throw this exception when given a http://facebook.com/ URLs because it can be sure whether these exist.
7
+ class NotFound < StandardError
8
+ end
9
+ end
@@ -0,0 +1,59 @@
1
+ module Calagator
2
+
3
+ class Source::Parser::Plancast < Source::Parser
4
+ self.label = :Plancast
5
+ self.url_pattern = %r{^http://(?:www\.)?plancast\.com/p/([^/]+)/?}
6
+
7
+ def to_events
8
+ return unless data = get_data
9
+ event = Event.new({
10
+ source: opts[:source],
11
+ title: data['what'],
12
+ description: data['description'],
13
+
14
+ # Plancast is sending floating times as Unix timestamps, which is hard to parse
15
+ start_time: ActiveSupport::TimeWithZone.new(nil, Time.zone, Time.at(data['start'].to_i).utc),
16
+ end_time: ActiveSupport::TimeWithZone.new(nil, Time.zone, Time.at(data['stop'].to_i).utc),
17
+
18
+ url: (data['external_url'] || data['plan_url']),
19
+ tag_list: "plancast:plan=#{data['event_id']}",
20
+
21
+ venue: to_venue(data['place'], data['where']),
22
+ })
23
+
24
+ [event_or_duplicate(event)]
25
+ end
26
+
27
+ private
28
+
29
+ def get_data
30
+ to_events_api_helper(opts[:url]) do |event_id|
31
+ [
32
+ 'http://api.plancast.com/02/plans/show.json',
33
+ {
34
+ plan_id: event_id,
35
+ extensions: 'place'
36
+ }
37
+ ]
38
+ end
39
+ end
40
+
41
+ def to_venue(value, fallback=nil)
42
+ value = "" if value.nil?
43
+ if value.present?
44
+ venue = Venue.new({
45
+ source: opts[:source],
46
+ title: value['name'],
47
+ address: value['address'],
48
+ tag_list: "plancast:place=#{value['id']}",
49
+ })
50
+ venue.geocode!
51
+ venue_or_duplicate(venue)
52
+ elsif fallback.present?
53
+ venue = Venue.new(title: fallback)
54
+ venue_or_duplicate(venue)
55
+ end
56
+ end
57
+ end
58
+
59
+ end
@@ -0,0 +1,161 @@
1
+ # == Schema Information
2
+ # Schema version: 20110604174521
3
+ #
4
+ # Table name: venues
5
+ #
6
+ # id :integer not null, primary key
7
+ # title :string(255)
8
+ # description :text
9
+ # address :string(255)
10
+ # url :string(255)
11
+ # created_at :datetime
12
+ # updated_at :datetime
13
+ # street_address :string(255)
14
+ # locality :string(255)
15
+ # region :string(255)
16
+ # postal_code :string(255)
17
+ # country :string(255)
18
+ # latitude :decimal(, )
19
+ # longitude :decimal(, )
20
+ # email :string(255)
21
+ # telephone :string(255)
22
+ # source_id :integer
23
+ # duplicate_of_id :integer
24
+ # version :integer
25
+ # closed :boolean
26
+ # wifi :boolean
27
+ # access_notes :text
28
+ # events_count :integer
29
+ #
30
+ require "calagator/decode_html_entities_hack"
31
+ require "calagator/strip_whitespace"
32
+ require "calagator/url_prefixer"
33
+ require "paper_trail"
34
+ require "loofah-activerecord"
35
+ require "loofah/activerecord/xss_foliate"
36
+
37
+ module Calagator
38
+
39
+ class Venue < ActiveRecord::Base
40
+ self.table_name = "venues"
41
+
42
+ attr_protected
43
+
44
+ include StripWhitespace
45
+
46
+ has_paper_trail
47
+ acts_as_taggable
48
+
49
+ xss_foliate :sanitize => [:description, :access_notes]
50
+ include DecodeHtmlEntitiesHack
51
+
52
+ # Associations
53
+ has_many :events, dependent: :nullify
54
+ def future_events; events.future_with_venue; end
55
+ def past_events; events.past_with_venue; end
56
+ belongs_to :source
57
+
58
+ # Triggers
59
+ strip_whitespace! :title, :description, :address, :url, :street_address, :locality, :region, :postal_code, :country, :email, :telephone
60
+ before_save :geocode!
61
+
62
+ # Validations
63
+ validates_presence_of :title
64
+ validates_format_of :url,
65
+ :with => /(http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/,
66
+ :allow_blank => true,
67
+ :allow_nil => true
68
+ validates_inclusion_of :latitude,
69
+ :in => -90..90,
70
+ :allow_nil => true,
71
+ :message => "must be between -90 and 90"
72
+ validates_inclusion_of :longitude,
73
+ :in => -180..180,
74
+ :allow_nil => true,
75
+ :message => "must be between -180 and 180"
76
+
77
+ validates :title, :description, :address, :url, :street_address, :locality, :region, :postal_code, :country, :email, :telephone, blacklist: true
78
+
79
+ # Duplicates
80
+ include DuplicateChecking
81
+ duplicate_checking_ignores_attributes :source_id, :version, :closed, :wifi, :access_notes
82
+ duplicate_squashing_ignores_associations :tags, :base_tags, :taggings
83
+
84
+ # Named scopes
85
+ scope :masters, -> { where(duplicate_of_id: nil).includes(:source, :events, :tags, :taggings) }
86
+ scope :with_public_wifi, -> { where(wifi: true) }
87
+ scope :in_business, -> { where(closed: false) }
88
+ scope :out_of_business, -> { where(closed: true) }
89
+
90
+ #===[ Finders ]=========================================================
91
+
92
+ # Return Hash of Venues grouped by the +type+, e.g., a 'title'. Each Venue
93
+ # record will include an <tt>events_count</tt> field containing the number of
94
+ # events at the venue, which improves performance for displaying these.
95
+ def self.find_duplicates_by_type(type='na')
96
+ case type
97
+ when 'na', nil, ''
98
+ # The LEFT OUTER JOIN makes sure that venues without any events are also returned.
99
+ return { [] => \
100
+ self.where('venues.duplicate_of_id IS NULL').order('LOWER(venues.title)')
101
+ }
102
+ else
103
+ kind = %w[all any].include?(type) ? type.to_sym : type.split(',').map(&:to_sym)
104
+
105
+ return self.find_duplicates_by(kind,
106
+ :grouped => true,
107
+ :where => 'a.duplicate_of_id IS NULL AND b.duplicate_of_id IS NULL'
108
+ )
109
+ end
110
+ end
111
+
112
+ #===[ Search ]==========================================================
113
+
114
+ def self.search(query, opts={})
115
+ SearchEngine.search(query, opts)
116
+ end
117
+
118
+ #===[ Overrides ]=======================================================
119
+
120
+ def url=(value)
121
+ super UrlPrefixer.prefix(value)
122
+ end
123
+
124
+ #===[ Address helpers ]=================================================
125
+
126
+ # Does this venue have any address information?
127
+ def has_full_address?
128
+ [street_address, locality, region, postal_code, country].any?(&:present?)
129
+ end
130
+
131
+ # Display a single line address.
132
+ def full_address
133
+ if has_full_address?
134
+ "#{street_address}, #{locality} #{region} #{postal_code} #{country}"
135
+ end
136
+ end
137
+
138
+ #===[ Geocoding helpers ]===============================================
139
+
140
+ # Get an address we can use for geocoding
141
+ def geocode_address
142
+ full_address or address
143
+ end
144
+
145
+ # Return this venue's latitude/longitude location,
146
+ # or nil if it doesn't have one.
147
+ def location
148
+ if [latitude, longitude].all?(&:present?)
149
+ [latitude, longitude]
150
+ end
151
+ end
152
+
153
+ attr_accessor :force_geocoding
154
+
155
+ def geocode!
156
+ Geocoder.geocode(self)
157
+ true # Try to geocode, but don't complain if we can't.
158
+ end
159
+ end
160
+
161
+ end