umlaut 3.0.0alpha1

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 (293) hide show
  1. data/LICENSE +7 -0
  2. data/README.md +49 -0
  3. data/Rakefile +37 -0
  4. data/app/assets/images/error.gif +0 -0
  5. data/app/assets/images/export_bg_bot.gif +0 -0
  6. data/app/assets/images/export_bg_mid.gif +0 -0
  7. data/app/assets/images/export_bg_top.gif +0 -0
  8. data/app/assets/images/famfamfam/book_open.png +0 -0
  9. data/app/assets/images/famfamfam/cross.png +0 -0
  10. data/app/assets/images/famfamfam/page_sound.gif +0 -0
  11. data/app/assets/images/famfamfam/page_text.gif +0 -0
  12. data/app/assets/images/famfamfam/page_up.gif +0 -0
  13. data/app/assets/images/famfamfam/page_white.png +0 -0
  14. data/app/assets/images/famfamfam/readme.html +1495 -0
  15. data/app/assets/images/famfamfam/tiny_cross.png +0 -0
  16. data/app/assets/images/frame_remove.gif +0 -0
  17. data/app/assets/images/ico_go.gif +0 -0
  18. data/app/assets/images/jhu_findit.gif +0 -0
  19. data/app/assets/images/list_closed.png +0 -0
  20. data/app/assets/images/list_open.png +0 -0
  21. data/app/assets/images/more_info.gif +0 -0
  22. data/app/assets/images/rails.png +0 -0
  23. data/app/assets/images/request.gif +0 -0
  24. data/app/assets/images/spinner.gif +0 -0
  25. data/app/assets/javascripts/umlaut/ajax_windows.js +35 -0
  26. data/app/assets/javascripts/umlaut/ensure_window_size.js.erb +34 -0
  27. data/app/assets/javascripts/umlaut/expand_contract_toggle.js +25 -0
  28. data/app/assets/javascripts/umlaut/search_autocomplete.js +46 -0
  29. data/app/assets/javascripts/umlaut/simple_visible_toggle.js +8 -0
  30. data/app/assets/javascripts/umlaut/update_html.js +152 -0
  31. data/app/assets/javascripts/umlaut.js +17 -0
  32. data/app/assets/stylesheets/umlaut.css +857 -0
  33. data/app/controllers/application_controller.rb +14 -0
  34. data/app/controllers/export_email_controller.rb +123 -0
  35. data/app/controllers/js_helper_controller.rb +10 -0
  36. data/app/controllers/link_router_controller.rb +87 -0
  37. data/app/controllers/open_search_controller.rb +9 -0
  38. data/app/controllers/resolve_controller.rb +288 -0
  39. data/app/controllers/resource_controller.rb +83 -0
  40. data/app/controllers/search_controller.rb +328 -0
  41. data/app/controllers/search_methods/sfx3.rb +148 -0
  42. data/app/controllers/search_methods/sfx4.rb +257 -0
  43. data/app/controllers/search_methods/sfx_api.rb +47 -0
  44. data/app/controllers/store_controller.rb +64 -0
  45. data/app/controllers/umlaut/controller_behavior.rb +20 -0
  46. data/app/controllers/umlaut/controller_logic.rb +96 -0
  47. data/app/controllers/umlaut/error_handling.rb +48 -0
  48. data/app/controllers/umlaut_controller.rb +112 -0
  49. data/app/helpers/application_helper.rb +4 -0
  50. data/app/helpers/emailer_helper.rb +43 -0
  51. data/app/helpers/export_email_helper.rb +34 -0
  52. data/app/helpers/open_search_helper.rb +7 -0
  53. data/app/helpers/resolve_helper.rb +225 -0
  54. data/app/helpers/search_helper.rb +50 -0
  55. data/app/helpers/umlaut/footer_helper.rb +64 -0
  56. data/app/helpers/umlaut/helper.rb +62 -0
  57. data/app/helpers/umlaut/html_head_helper.rb +37 -0
  58. data/app/helpers/umlaut/url_generation.rb +77 -0
  59. data/app/mailers/emailer.rb +48 -0
  60. data/app/models/clickthrough.rb +2 -0
  61. data/app/models/collection.rb +259 -0
  62. data/app/models/crossref_lookup.rb +2 -0
  63. data/app/models/dispatched_service.rb +58 -0
  64. data/app/models/permalink.rb +29 -0
  65. data/app/models/referent.rb +473 -0
  66. data/app/models/referent_value.rb +14 -0
  67. data/app/models/request.rb +449 -0
  68. data/app/models/service_response.rb +179 -0
  69. data/app/models/service_store.rb +59 -0
  70. data/app/models/service_type_value.rb +58 -0
  71. data/app/models/service_wave.rb +150 -0
  72. data/app/models/sfx_db/az_additional_title.rb +11 -0
  73. data/app/models/sfx_db/az_letter_group.rb +11 -0
  74. data/app/models/sfx_db/az_title.rb +38 -0
  75. data/app/models/sfx_db/az_title_v2.rb +34 -0
  76. data/app/models/sfx_db/isbn.rb +12 -0
  77. data/app/models/sfx_db/issn.rb +12 -0
  78. data/app/models/sfx_db/object.rb +35 -0
  79. data/app/models/sfx_db/object_portfolio.rb +6 -0
  80. data/app/models/sfx_db/publisher.rb +10 -0
  81. data/app/models/sfx_db/sfx_db_base.rb +54 -0
  82. data/app/models/sfx_db/target.rb +9 -0
  83. data/app/models/sfx_db/target_service.rb +10 -0
  84. data/app/models/sfx_db/title.rb +10 -0
  85. data/app/models/sfx_db.rb +10 -0
  86. data/app/models/sfx_url.rb +35 -0
  87. data/app/views/emailer/citation.text.erb +28 -0
  88. data/app/views/emailer/short_citation.text.erb +8 -0
  89. data/app/views/export_email/_email.html.erb +25 -0
  90. data/app/views/export_email/_send_email.html.erb +3 -0
  91. data/app/views/export_email/_send_txt.html.erb +3 -0
  92. data/app/views/export_email/_txt.html.erb +62 -0
  93. data/app/views/export_email/email.html.erb +3 -0
  94. data/app/views/export_email/send_email.html.erb +1 -0
  95. data/app/views/export_email/send_txt.html.erb +1 -0
  96. data/app/views/export_email/txt.html.erb +3 -0
  97. data/app/views/js_helper/loader.erb.js +13 -0
  98. data/app/views/layouts/umlaut.html.erb +52 -0
  99. data/app/views/open_search/index.html.erb +9 -0
  100. data/app/views/resolve/_api_in_progress.xml.erb +21 -0
  101. data/app/views/resolve/_background_progress.html.erb +51 -0
  102. data/app/views/resolve/_background_updater.html.erb +38 -0
  103. data/app/views/resolve/_citation.html.erb +87 -0
  104. data/app/views/resolve/_coins.html.erb +1 -0
  105. data/app/views/resolve/_compact_citation.html.erb +33 -0
  106. data/app/views/resolve/_cover_image.html.erb +35 -0
  107. data/app/views/resolve/_fulltext.html.erb +55 -0
  108. data/app/views/resolve/_help.html.erb +17 -0
  109. data/app/views/resolve/_holding.html.erb +91 -0
  110. data/app/views/resolve/_related_items.html.erb +35 -0
  111. data/app/views/resolve/_search_inside.html.erb +62 -0
  112. data/app/views/resolve/_section_display.html.erb +49 -0
  113. data/app/views/resolve/_service_errors.html.erb +29 -0
  114. data/app/views/resolve/_standard_response_item.html.erb +89 -0
  115. data/app/views/resolve/api.xml.builder +72 -0
  116. data/app/views/resolve/background_status.html.erb +26 -0
  117. data/app/views/resolve/index.html.erb +73 -0
  118. data/app/views/resolve/partial_html_sections.xml.erb +30 -0
  119. data/app/views/search/_a_to_z.html.erb +6 -0
  120. data/app/views/search/_citation.html.erb +94 -0
  121. data/app/views/search/_pager.html.erb +60 -0
  122. data/app/views/search/books.html.erb +103 -0
  123. data/app/views/search/journal_search.html.erb +90 -0
  124. data/app/views/search/journals.html.erb +167 -0
  125. data/app/views/search/opensearch_description.rxml +10 -0
  126. data/app/views/testing/index.html.erb +1 -0
  127. data/app/views/umlaut/README +5 -0
  128. data/app/views/umlaut/error.html.erb +45 -0
  129. data/db/migrate/01_umlaut_init.rb +113 -0
  130. data/db/orig_fixed_data/service_type_values.yml +120 -0
  131. data/db/seeds.rb +7 -0
  132. data/lib/CronTab.rb +192 -0
  133. data/lib/aws_product_sign.rb +146 -0
  134. data/lib/exlibris/aleph/patron.rb +64 -0
  135. data/lib/exlibris/aleph/record.rb +54 -0
  136. data/lib/exlibris/aleph/rest_api.rb +29 -0
  137. data/lib/exlibris/primo/holding.rb +192 -0
  138. data/lib/exlibris/primo/rsrc.rb +17 -0
  139. data/lib/exlibris/primo/searcher.rb +276 -0
  140. data/lib/exlibris/primo/source/aleph.rb +46 -0
  141. data/lib/exlibris/primo/source/distribution/nyu_aleph.rb +323 -0
  142. data/lib/exlibris/primo/toc.rb +17 -0
  143. data/lib/exlibris/primo_ws.rb +140 -0
  144. data/lib/generators/templates/umlaut_services.yml +237 -0
  145. data/lib/generators/umlaut/asset_hooks_generator.rb +44 -0
  146. data/lib/generators/umlaut/install_generator.rb +110 -0
  147. data/lib/hip3/bib.rb +291 -0
  148. data/lib/hip3/bib_searcher.rb +302 -0
  149. data/lib/hip3/custom_field_lookup.rb +44 -0
  150. data/lib/hip3/holding.rb +50 -0
  151. data/lib/hip3/item.rb +65 -0
  152. data/lib/hip3/receipt.rb +7 -0
  153. data/lib/hip3/serial_copy.rb +82 -0
  154. data/lib/holding.rb +32 -0
  155. data/lib/marc_helper.rb +254 -0
  156. data/lib/metadata_helper.rb +312 -0
  157. data/lib/opensearch_feed.rb +398 -0
  158. data/lib/opensearch_query.rb +98 -0
  159. data/lib/referent_filter.rb +16 -0
  160. data/lib/referent_filters/dissertation_catch.rb +45 -0
  161. data/lib/section_renderer.rb +503 -0
  162. data/lib/service.rb +336 -0
  163. data/lib/service_adaptors/ajax_export.rb +37 -0
  164. data/lib/service_adaptors/amazon.rb +412 -0
  165. data/lib/service_adaptors/blacklight.rb +327 -0
  166. data/lib/service_adaptors/book_finder.rb +40 -0
  167. data/lib/service_adaptors/bx.rb +51 -0
  168. data/lib/service_adaptors/cover_thing.rb +73 -0
  169. data/lib/service_adaptors/elsevier_cover.rb +57 -0
  170. data/lib/service_adaptors/email_export.rb +10 -0
  171. data/lib/service_adaptors/ezproxy.rb +171 -0
  172. data/lib/service_adaptors/google_book_search.rb +442 -0
  173. data/lib/service_adaptors/gpo.rb +124 -0
  174. data/lib/service_adaptors/hathi_trust.rb +308 -0
  175. data/lib/service_adaptors/hip3_service.rb +150 -0
  176. data/lib/service_adaptors/hip_holding_search.rb +237 -0
  177. data/lib/service_adaptors/internet_archive.rb +488 -0
  178. data/lib/service_adaptors/isbn_db.rb +86 -0
  179. data/lib/service_adaptors/isi.rb +258 -0
  180. data/lib/service_adaptors/jcr.rb +146 -0
  181. data/lib/service_adaptors/opac.rb +351 -0
  182. data/lib/service_adaptors/open_library.rb +316 -0
  183. data/lib/service_adaptors/open_library_cover.rb +73 -0
  184. data/lib/service_adaptors/primo_service.rb +392 -0
  185. data/lib/service_adaptors/primo_source.rb +78 -0
  186. data/lib/service_adaptors/pubmed.rb +133 -0
  187. data/lib/service_adaptors/request_to_fixture.rb +68 -0
  188. data/lib/service_adaptors/scopus.rb +295 -0
  189. data/lib/service_adaptors/sfx-new.rb +557 -0
  190. data/lib/service_adaptors/sfx.rb +566 -0
  191. data/lib/service_adaptors/sfx_backchannel_record.rb +69 -0
  192. data/lib/service_adaptors/txt_holding_export.rb +32 -0
  193. data/lib/service_adaptors/ulrichs_cover.rb +57 -0
  194. data/lib/service_adaptors/ulrichs_link.rb +47 -0
  195. data/lib/service_adaptors/worldcat.rb +116 -0
  196. data/lib/service_adaptors/worldcat_identities.rb +591 -0
  197. data/lib/tasks/umlaut.rake +134 -0
  198. data/lib/umlaut/default_configuration.rb +5 -0
  199. data/lib/umlaut/routes.rb +136 -0
  200. data/lib/umlaut/version.rb +3 -0
  201. data/lib/umlaut.rb +37 -0
  202. data/lib/umlaut_configurable.rb +343 -0
  203. data/lib/umlaut_http.rb +100 -0
  204. data/lib/xml_schema_helper.rb +109 -0
  205. data/test/dummy/Rakefile +7 -0
  206. data/test/dummy/app/assets/javascripts/application.js +13 -0
  207. data/test/dummy/app/assets/stylesheets/application.css +15 -0
  208. data/test/dummy/app/controllers/application_controller.rb +3 -0
  209. data/test/dummy/app/controllers/umlaut_controller.rb +112 -0
  210. data/test/dummy/app/helpers/application_helper.rb +2 -0
  211. data/test/dummy/app/views/layouts/application.html.erb +14 -0
  212. data/test/dummy/config/application.rb +45 -0
  213. data/test/dummy/config/boot.rb +10 -0
  214. data/test/dummy/config/database-jhu.yml +44 -0
  215. data/test/dummy/config/database.yml +25 -0
  216. data/test/dummy/config/environment.rb +5 -0
  217. data/test/dummy/config/environments/development.rb +34 -0
  218. data/test/dummy/config/environments/production.rb +60 -0
  219. data/test/dummy/config/environments/test.rb +39 -0
  220. data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
  221. data/test/dummy/config/initializers/inflections.rb +10 -0
  222. data/test/dummy/config/initializers/mime_types.rb +5 -0
  223. data/test/dummy/config/initializers/secret_token.rb +7 -0
  224. data/test/dummy/config/initializers/session_store.rb +8 -0
  225. data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
  226. data/test/dummy/config/locales/en.yml +5 -0
  227. data/test/dummy/config/routes.rb +61 -0
  228. data/test/dummy/config/umlaut_services.yml +237 -0
  229. data/test/dummy/config.ru +4 -0
  230. data/test/dummy/db/migrate/20111228211210_umlaut_init.rb +113 -0
  231. data/test/dummy/db/schema.rb +124 -0
  232. data/test/dummy/log/development.log +12981 -0
  233. data/test/dummy/log/production.log +0 -0
  234. data/test/dummy/public/404.html +26 -0
  235. data/test/dummy/public/422.html +26 -0
  236. data/test/dummy/public/500.html +26 -0
  237. data/test/dummy/public/favicon.ico +0 -0
  238. data/test/dummy/script/rails +6 -0
  239. data/test/dummy/tmp/cache/assets/C5F/340/sprockets%2F99692920160b7a279b86a80415b79db7 +0 -0
  240. data/test/dummy/tmp/cache/assets/C70/4D0/sprockets%2F034ad2036e623081bd352800786dfe80 +0 -0
  241. data/test/dummy/tmp/cache/assets/C73/920/sprockets%2Fd371318f22900492fd180f17c5e2a504 +9268 -0
  242. data/test/dummy/tmp/cache/assets/C80/980/sprockets%2Fc94807409c1523d43e18d25f35d93c41 +0 -0
  243. data/test/dummy/tmp/cache/assets/C8F/780/sprockets%2Fe47e28558116fb5f8038754e60d1961d +11769 -0
  244. data/test/dummy/tmp/cache/assets/CAA/EB0/sprockets%2F1d179210e8b76f1ea63c802688a015e4 +9271 -0
  245. data/test/dummy/tmp/cache/assets/CBB/9C0/sprockets%2F706f28923fb754cad04b9107c89986a1 +0 -0
  246. data/test/dummy/tmp/cache/assets/CBF/B60/sprockets%2F08ca89671549936265dcb673bf02e36f +0 -0
  247. data/test/dummy/tmp/cache/assets/CC9/9F0/sprockets%2F306166316e2cafd13c15e62b51a2339d +0 -0
  248. data/test/dummy/tmp/cache/assets/CF6/F20/sprockets%2F5b2ffa1103079dfd555197838f87a99f +0 -0
  249. data/test/dummy/tmp/cache/assets/CF7/2B0/sprockets%2F25a7c73655bd3598173b39d9f98bcd46 +862 -0
  250. data/test/dummy/tmp/cache/assets/CFE/080/sprockets%2F37fe9f4255baddbd549a659914929398 +0 -0
  251. data/test/dummy/tmp/cache/assets/D22/060/sprockets%2F9aec77b768e91a802d284271c58e2f7e +21357 -0
  252. data/test/dummy/tmp/cache/assets/D32/A10/sprockets%2F13fe41fee1fe35b49d145bcc06610705 +0 -0
  253. data/test/dummy/tmp/cache/assets/D33/6D0/sprockets%2F500129c57f1146e556ec3aacd6cd38c1 +0 -0
  254. data/test/dummy/tmp/cache/assets/D33/FD0/sprockets%2F2ba0b4e6334a77b923e5f770381bb2bf +0 -0
  255. data/test/dummy/tmp/cache/assets/D42/C20/sprockets%2Fbcf14e437b1582bf93b77670acf8e090 +21353 -0
  256. data/test/dummy/tmp/cache/assets/D50/A30/sprockets%2F7d8b294ac433db5d056538f8cf7c66b9 +0 -0
  257. data/test/dummy/tmp/cache/assets/D54/ED0/sprockets%2F71c9fa01091d432b131da3bb73faf3d4 +872 -0
  258. data/test/dummy/tmp/cache/assets/D65/590/sprockets%2Fc1bb92fc3406a126b7dd302edc96d629 +0 -0
  259. data/test/dummy/tmp/cache/assets/D71/6B0/sprockets%2Fde558b71b494cf09b1bf055c8dff0353 +0 -0
  260. data/test/dummy/tmp/cache/assets/D72/610/sprockets%2Fa8c708eeb30ef93de34d755d4f45d023 +859 -0
  261. data/test/dummy/tmp/cache/assets/D76/AD0/sprockets%2Fe2158cde93188cf5ab6457bc6d6602ec +0 -0
  262. data/test/dummy/tmp/cache/assets/D7A/E40/sprockets%2F9622ffcc499a57627cd1bb18fe31b8e4 +11772 -0
  263. data/test/dummy/tmp/cache/assets/D84/210/sprockets%2Fabd0103ccec2b428ac62c94e4c40b384 +0 -0
  264. data/test/dummy/tmp/cache/assets/D9B/770/sprockets%2F8aacf02eb7dbb0949704b28f27b87e0b +0 -0
  265. data/test/dummy/tmp/cache/assets/DA6/A80/sprockets%2F92e26d8e58d5bcc8b8f6c25d1b05b9c1 +0 -0
  266. data/test/dummy/tmp/cache/assets/DE8/790/sprockets%2Fd1333bde2b9aafcc712d11dd09ab35d8 +0 -0
  267. data/test/dummy/tmp/cache/assets/DF7/F30/sprockets%2F7bc16c4109b17fabe29f8ddbbf732d1c +374 -0
  268. data/test/dummy/tmp/cache/assets/E03/570/sprockets%2F493bdc0ac14cd4f57fdfe4253f992bde +0 -0
  269. data/test/dummy/tmp/cache/assets/E04/890/sprockets%2F2f5173deea6c795b8fdde723bb4b63af +0 -0
  270. data/test/dummy/tmp/cache/assets/E0B/4B0/sprockets%2F7988df51a61c81ce6ede4a2d4c8cce4f +377 -0
  271. data/test/dummy/tmp/cache/assets/E5F/960/sprockets%2Fdc007b6cad5c7ef08e33ec28cfff0ef6 +0 -0
  272. data/test/fixtures/dispatched_services.yml +5 -0
  273. data/test/fixtures/permalinks.yml +5 -0
  274. data/test/fixtures/referent_values.yml +1734 -0
  275. data/test/fixtures/referents.yml +156 -0
  276. data/test/fixtures/requests.yml +284 -0
  277. data/test/fixtures/service_responses.yml +5 -0
  278. data/test/fixtures/sfx_urls.yml +4 -0
  279. data/test/performance/browsing_test.rb +9 -0
  280. data/test/test_helper.rb +10 -0
  281. data/test/umlaut_test.rb +7 -0
  282. data/test/unit/aleph_patron_test.rb +39 -0
  283. data/test/unit/aleph_record_benchmarks.rb +28 -0
  284. data/test/unit/aleph_record_test.rb +30 -0
  285. data/test/unit/aws_product_sign_test.rb +93 -0
  286. data/test/unit/collection_test.rb +76 -0
  287. data/test/unit/google_book_search_test.rb +101 -0
  288. data/test/unit/primo_searcher_test.rb +403 -0
  289. data/test/unit/primo_service_test.rb +939 -0
  290. data/test/unit/primo_ws_test.rb +131 -0
  291. data/test/unit/service_response_test.rb +9 -0
  292. data/test/unit/service_test.rb +33 -0
  293. metadata +580 -0
@@ -0,0 +1,57 @@
1
+ # Elsevier provides publically available and linkable sample cover images
2
+ # for journals they publish. Thanks Elsevier! This service does nothing
3
+ # more than take an ISSN and look for a match from Elsevier.
4
+ class ElsevierCover < Service
5
+ require 'open-uri'
6
+
7
+ def service_types_generated
8
+ return [ServiceTypeValue[:cover_image]]
9
+ end
10
+
11
+ def initialize(config)
12
+ #@base_url = "http://www1.elsevier.com/inca/covers/store/issn/"
13
+ @base_url = "http://www.extranet.elsevier.com/inca_covers_store/issn/"
14
+
15
+ super(config)
16
+ end
17
+
18
+ def handle(request)
19
+ issn = request.referent.issn
20
+
21
+ # We need an ISSN
22
+ return request.dispatched(self, true) unless issn
23
+
24
+ # No hyphens please
25
+ issn = issn.gsub(/[^0-9X]/, '')
26
+
27
+ check_url = @base_url + issn + '.gif'
28
+
29
+ # does it exist?
30
+ if ( url_resolves(check_url) )
31
+ request.add_service_response(:service => self,
32
+ :service_type_value => ServiceTypeValue[:cover_image] ,
33
+ :url => check_url,
34
+ :size => "medium" )
35
+ end
36
+
37
+ return request.dispatched(self, true)
38
+ end
39
+
40
+ def url_resolves(url)
41
+ uri_obj = URI.parse(url)
42
+ response = Net::HTTP.start(uri_obj.host, uri_obj.port) {|http|
43
+ http.head(uri_obj.request_uri)
44
+ }
45
+ if (response.kind_of?( Net::HTTPSuccess ))
46
+ return true
47
+ elsif ( response.kind_of?(Net::HTTPNotFound))
48
+ return false
49
+ else
50
+ # unexpected condition, raise
51
+ response.value
52
+ end
53
+
54
+
55
+ end
56
+
57
+ end
@@ -0,0 +1,10 @@
1
+ class EmailExport < AjaxExport
2
+
3
+ def initialize(config)
4
+ @display_text ||= "Email"
5
+ @form_controller ||= "export_email"
6
+ @form_action ||= "email"
7
+ super(config)
8
+ end
9
+
10
+ end
@@ -0,0 +1,171 @@
1
+ # By default, proxies a URL after checking the EZProxy API to see if
2
+ # it's proxy-able. But you can set the config param precheck_with_api
3
+ # to false, and then this will simply automatically proxy all links
4
+ # from umlaut responses. That is useful if you have your EZProxy
5
+ # server set to automatically redirect non-proxyable URLs to the original
6
+ # non-proxied version, the API check may not be neccesary.
7
+
8
+ # Required parameters:
9
+ # proxy_server: hostname of EZProxy instance (no "http://", just hostname)
10
+ #
11
+ # optional params:
12
+ # proxy_password (the ProxyURLPassword parameter in ezproxy.cfg; must be set
13
+ # to turn on proxy url api feature ).
14
+ # proxy_url_path: defaults to /proxy_url, the default ezproxy path to call api
15
+ # exclude_hosts: array of hosts to exclude from proxying _even if_ found in
16
+ # ezproxy config. Each entry can be a string, in which
17
+ # case it must match host portion of url exactly. Or it can
18
+ # be a regexp, which will be tested against entire url.
19
+ # (supply a string inside // markers. eg '/regexp/' ).
20
+ #
21
+ # This service is a link_out_filter service, it must be setup in your
22
+ # services.yml with "task: link_out_filter ".
23
+
24
+
25
+ class Ezproxy < Service
26
+ required_config_params :proxy_server
27
+
28
+ require 'rexml/document'
29
+ require 'uri'
30
+ require 'net/http'
31
+ require 'cgi'
32
+
33
+ def initialize(config)
34
+ @precheck_with_api = true
35
+ @display_name = "EZProxy"
36
+ @proxy_login_path = "/login"
37
+
38
+ super(config)
39
+
40
+
41
+ @proxy_url_path ||= "/proxy_url"
42
+ @proxy_url_path = "/" + @proxy_url_path unless @proxy_url_path[0,1] = '/'
43
+
44
+ @exclude ||= []
45
+ end
46
+
47
+ # This is meant to be called as task:link_out_filter, it doesn't have an
48
+ # implementation for handle, it implements link_out_filter() instead.
49
+ def handle(request)
50
+ raise "Not implemented."
51
+ end
52
+
53
+ # Hook method called by Umlaut.
54
+ # Returns a proxied url if it should be proxied, or nil if the url
55
+ # can not or does not need to be proxied.
56
+ def link_out_filter(orig_url, service_response, other_args = {})
57
+ # remove trailing or leading whitespace from url, it makes it
58
+ # an illegal URL anyway, but maybe we can rescue it? Marc 856's
59
+ # sometimes have accidental trailing whitespace.
60
+ orig_url = orig_url.strip
61
+
62
+ # bad uri? Forget it.
63
+ return nil unless valid_url?( orig_url )
64
+
65
+ # If it's already proxied, leave it alone.
66
+ return nil if already_proxied(orig_url)
67
+
68
+ return nil if excluded?(orig_url)
69
+
70
+ new_url = nil
71
+ if @precheck_with_api
72
+ new_url = check_proxy_urls( [orig_url] ).values[0]
73
+ else
74
+ new_url = auto_proxy_url(orig_url)
75
+ end
76
+
77
+ return new_url
78
+ end
79
+
80
+ def valid_url?(url)
81
+ begin
82
+ raise Exception.new("Empty url!") if url.blank?
83
+ URI.parse( url )
84
+ return true
85
+ rescue Exception => e
86
+ Rails.logger.error("Bad uri sent to ezproxy service. Can not parse. url: <#{url}>")
87
+ return false
88
+ end
89
+ end
90
+
91
+ # see @exclude config parameter.
92
+ def excluded?(url)
93
+ return false if @exclude.blank?
94
+
95
+ @exclude.each do |entry|
96
+ if ((entry[0,1] == '/') && (entry[entry.length()-1 ,1 ] == '/'))
97
+ # regexp. Match against entire url.
98
+ re = Regexp.new( entry )
99
+ return true if re =~ url
100
+ elsif (entry.kind_of? Regexp)
101
+ return true if entry =~ url
102
+ else
103
+ # ordinary string. Just match against host.
104
+ host = URI.parse(url).host
105
+ return true if host == entry
106
+ end
107
+ end
108
+ # looped through them all, no match?
109
+ return false
110
+ end
111
+
112
+ # pass in a url, this just mindlessly sends it through your
113
+ # ezproxy instance.
114
+ def auto_proxy_url(url)
115
+ return "http://" + @proxy_server + @proxy_login_path + "?qurl=" + CGI.escape(url)
116
+ end
117
+
118
+ # Pass in an array of URLs. Will determine if they are proxyable by EZProxy.
119
+ # Returns a hash, where the key is the original URL, and the value is the
120
+ # proxied url---or nil if could not be proxied.
121
+ def check_proxy_urls(urls)
122
+ url_doc = REXML::Document.new
123
+ doc_root = url_doc.add_element "proxy_url_request", {"password"=>@proxy_password}
124
+ urls_elem = doc_root.add_element "urls"
125
+ urls.each { | link |
126
+ url_elem = urls_elem.add_element "url"
127
+ url_elem.text = link
128
+ }
129
+ begin
130
+ resp = Net::HTTP.post_form(URI.parse('http://' + @proxy_server+@proxy_url_path), {"xml"=>url_doc.to_s})
131
+ proxy_doc = REXML::Document.new resp.body
132
+ rescue Timeout::Error
133
+ Rails.logger.error "Timed out connecting to EZProxy"
134
+ return proxy_links
135
+ rescue Exception => e
136
+ Rails.logger.error "EZProxy error, NOT proxying URL + #{e}"
137
+ end
138
+
139
+ return_hash = {}
140
+ REXML::XPath.each(proxy_doc, "/proxy_url_response/proxy_urls/url") { | u |
141
+ unless (u && u.get_text) # if u is empty... weird, but skip it.
142
+ Rails.logger.error "EZProxy response seems to be missing some pieces.\n Urls requested: #{urls.join(',')}\n EZProxy api request xml: #{url_doc.to_s}\n EZProxy response: #{proxy_doc.to_s}"
143
+ end
144
+ orig_url = u.get_text.value
145
+ return_hash[orig_url] = nil
146
+
147
+ if u.attributes["proxy"] == "true"
148
+ proxied_url = u.attributes["scheme"]+"://"+u.attributes["hostname"]+":"+u.attributes["port"]+u.attributes["login_path"]
149
+ if u.attributes["encode"] == "true"
150
+ proxied_url += CGI::escape(u.get_text.value)
151
+ else
152
+ proxied_url += u.get_text.value
153
+ end
154
+
155
+ return_hash[orig_url] = proxied_url
156
+
157
+ end
158
+ }
159
+ return return_hash
160
+ end
161
+
162
+ # pass in url as a string. Return true if the
163
+ # url is already pointing to the proxy server
164
+ # configured.
165
+ def already_proxied(url)
166
+ uri_obj = URI.parse(url)
167
+
168
+ return uri_obj.host == @proxy_server && uri_obj.path == @proxy_login_path
169
+ end
170
+
171
+ end
@@ -0,0 +1,442 @@
1
+ # Service that searches Google Book Search to determine viewability.
2
+ # It searches by ISBN, OCLCNUM and/or LCCN.
3
+ #
4
+ # Uses Google Books API, http://code.google.com/apis/books/docs/v1/getting_started.html
5
+ # http://code.google.com/apis/books/docs/v1/using.html
6
+ #
7
+ # If a full view is available it returns a fulltext service response.
8
+ # If partial view is available, return as "limited experts".
9
+ # If no view at all, still includes a link in highlighted_links, to pay
10
+ # lip service to google branding requirements.
11
+ # Unfortunately there is no way tell which of the noview
12
+ # books provide search, although some do -- search is advertised if full or
13
+ # partial view is available.
14
+ #
15
+ # If a thumbnail_url is returned in the responses, a cover image is displayed.
16
+ #
17
+ # = Google API Key
18
+ #
19
+ # Setting an api key in :api_key STRONGLY recommended, or you'll
20
+ # probably get rate limited (not clear what the limit is with no api
21
+ # key supplied). You may have to ask for higher rate limit for your api
22
+ # key than the default 1000/day, which you can do through the google
23
+ # api console:
24
+ # https://code.google.com/apis/console
25
+ #
26
+ # I requested 50k with this message, and was quickly approved with no questions
27
+ # "Services for academic library (Johns Hopkins Libraries) web applications to match Google Books availability to items presented by our catalog, OpenURL link resolver, and other software. "
28
+ #
29
+ # Recommend setting your 'per user limit' to something crazy high, as well
30
+ # as requesting more quota.
31
+ class GoogleBookSearch < Service
32
+ require 'multi_json'
33
+
34
+
35
+ # Identifiers used in API response to indicate viewability level
36
+ ViewFullValue = 'ALL_PAGES'
37
+ ViewPartialValue = 'PARTIAL'
38
+ # None might also be 'snippet', but Google doesn't want to distinguish
39
+ ViewNoneValue = 'NO_PAGES'
40
+ ViewUnknownValue = 'UNKNOWN'
41
+
42
+
43
+
44
+ include MetadataHelper
45
+ include UmlautHttp
46
+
47
+ # required params
48
+
49
+ # attr_reader is important for tests
50
+ attr_reader :url, :display_name, :num_full_views
51
+
52
+ def service_types_generated
53
+ types= [
54
+ ServiceTypeValue[:fulltext],
55
+ ServiceTypeValue[:cover_image],
56
+ ServiceTypeValue[:highlighted_link],
57
+ ServiceTypeValue[:search_inside],
58
+ ServiceTypeValue[:excerpts]]
59
+ types.push(ServiceTypeValue[:referent_enhance]) if @referent_enhance
60
+ return types
61
+ end
62
+
63
+ def initialize(config)
64
+ @url = 'https://www.googleapis.com/books/v1/volumes?q='
65
+
66
+ @display_name = 'Google Books'
67
+ # number of full views to show
68
+ @num_full_views = 1
69
+ # default on, to enhance our metadata with stuff from google
70
+ @referent_enhance = true
71
+ # google api key strongly recommended, otherwise you'll
72
+ # probably get rate limited.
73
+ @api_key = nil
74
+
75
+ @credits = {
76
+ "Google Books" => "http://books.google.com/"
77
+ }
78
+
79
+ super(config)
80
+ end
81
+
82
+ def handle(request)
83
+
84
+ bibkeys = get_bibkeys(request.referent)
85
+ return request.dispatched(self, true) if bibkeys.nil?
86
+ data = do_query(bibkeys, request)
87
+
88
+
89
+ if data.blank? || data["error"]
90
+ # fail fatal
91
+ return request.dispatched(self, false)
92
+ end
93
+
94
+ # 0 hits, return.
95
+ return request.dispatched(self, true) if data["totalItems"] == 0
96
+
97
+ enhance_referent(request, data) if @referent_enhance
98
+
99
+ #return full views first
100
+ full_views_shown = create_fulltext_service_response(request, data)
101
+
102
+ # Add search_inside link if appropriate
103
+ add_search_inside(request, data)
104
+
105
+ # only if no full view is shown, add links for partial view or noview
106
+ unless full_views_shown
107
+ do_web_links(request, data)
108
+ end
109
+
110
+ thumbnail_url = find_thumbnail_url(data)
111
+ if thumbnail_url
112
+ add_cover_image(request, thumbnail_url)
113
+ end
114
+
115
+ return request.dispatched(self, true)
116
+ end
117
+
118
+ # Take the FIRST hit from google, and use it's values to enhance
119
+ # our metadata. Will NOT overwrite existing data.
120
+ def enhance_referent(request, data)
121
+
122
+ entry = data["items"].first
123
+
124
+
125
+ if (volumeInfo = entry["volumeInfo"])
126
+
127
+ title = volumeInfo["title"]
128
+ title += ": #{volumeInfo["subtitle"]}" if (title && volumeInfo["subtitle"])
129
+
130
+ element_enhance(request, "title", title)
131
+ element_enhance(request, "au", volumeInfo["authors"].first) if volumeInfo["authors"]
132
+ element_enhance(request, "pub", volumeInfo["publisher"])
133
+
134
+ element_enhance(request, "tpages", volumeInfo["pageCount"])
135
+
136
+ if (date = volumeInfo["publishedDate"] && date =~ /^(\d\d\d\d)/)
137
+ element_enhance(request, "date", $1)
138
+ end
139
+
140
+ # LCCN is only rarely included, but is sometimes, eg:
141
+ # "industryIdentifiers"=>[{"type"=>"OTHER", "identifier"=>"LCCN:72627172"}],
142
+ # Also "LCCN:76630875"
143
+ #
144
+ # And sometimes OCLC number like:
145
+ # "industryIdentifiers"=>[{"type"=>"OTHER", "identifier"=>"OCLC:12345678"}],
146
+ #
147
+ (volumeInfo["industryIdentifiers"] || []).each do |hash|
148
+
149
+ if hash["type"] == "ISBN_13"
150
+ element_enhance(request, "isbn", hash["identifier"])
151
+
152
+ elsif hash["type"] == "OTHER" && hash["identifier"].starts_with?("LCCN:")
153
+ lccn = normalize_lccn( hash["identifier"].slice(5, hash["identifier"].length) )
154
+ request.referent.add_identifier("info:lccn/#{lccn}")
155
+
156
+ elsif hash["type"] == "OTHER" && hash["identifier"].starts_with?("OCLC:")
157
+ oclcnum = normalize_lccn( hash["identifier"].slice(5, hash["identifier"].length) )
158
+ request.referent.add_identifier("info:oclcnum/#{oclcnum}")
159
+ end
160
+
161
+ end
162
+ end
163
+ end
164
+
165
+ # Will not over-write existing referent values.
166
+ def element_enhance(request, rft_key, value)
167
+ if (value)
168
+ request.referent.enhance_referent(rft_key, value.to_s, true, false, :overwrite => false)
169
+ end
170
+ end
171
+
172
+
173
+ # returns nil or escaped string of bibkeys
174
+ # to increase the chances of good hit, we send all available bibkeys
175
+ # and later dedupe by id.
176
+ # FIXME Assumes we only have one of each kind of identifier.
177
+ def get_bibkeys(rft)
178
+ isbn = get_identifier(:urn, "isbn", rft)
179
+ oclcnum = get_identifier(:info, "oclcnum", rft)
180
+ lccn = get_lccn(rft)
181
+
182
+ # Google doesn't officially support oclc/lccn search, but does
183
+ # index as token with prefix smashed up right with identifier
184
+ # eg http://books.google.com/books/feeds/volumes?q=OCLC32012617
185
+ #
186
+ # Except turns out doing it as a phrase search is important! Or
187
+ # google's normalization/tokenization does odd things.
188
+ keys = []
189
+ keys << ('isbn:' + isbn) if isbn
190
+ keys << ('"' + "OCLC" + oclcnum + '"') if oclcnum
191
+ # Only use LCCN if we've got nothing else, it returns many
192
+ # false positives.
193
+ keys << ('"' + 'LCCN' + lccn + '"') if lccn && keys.length == 0
194
+
195
+ return nil if keys.empty?
196
+ keys = CGI.escape( keys.join(' OR ') )
197
+ return keys
198
+ end
199
+
200
+ def do_query(bibkeys, request)
201
+ headers = build_headers(request)
202
+ link = @url + bibkeys
203
+ if @api_key
204
+ link += "&key=#{@api_key}"
205
+ end
206
+
207
+ # Add on limit to only request books, not magazines.
208
+ link += "&printType=books"
209
+
210
+ Rails.logger.debug("GoogleBookSearch requesting: #{link}")
211
+ response = http_fetch(link, :headers => headers, :raise_on_http_error_code => false)
212
+ data = MultiJson.decode(response.body)
213
+
214
+ # If Google gives us an error cause it says it can't geo-locate,
215
+ # remove the IP, log warning, and try again.
216
+
217
+ if (data["error"] && data["error"]["errors"] &&
218
+ data["error"]["errors"].find {|h| h["reason"] == "unknownLocation"} )
219
+ Rails.logger.warn("GoogleBookSearch: geo-locate error, retrying without X-Forwarded-For: '#{link}' headers: #{headers.inspect} #{response.inspect}\n #{data.inspect}")
220
+
221
+ response = http_fetch(link, :raise_on_http_error_code => false)
222
+ data = MultiJson.decode(response.body)
223
+
224
+ end
225
+
226
+
227
+ if (! response.kind_of?(Net::HTTPSuccess)) || data["error"]
228
+ Rails.logger.error("GoogleBookSearch error: '#{link}' headers: #{headers.inspect} #{response.inspect}\n #{data.inspect}")
229
+ end
230
+
231
+ return data
232
+ end
233
+
234
+ # We don't need to fake a proxy request anymore, but we still
235
+ # include X-Forwarded-For so google can return location-appropriate
236
+ # availability. If there's an existing X-Forwarded-For, we respect
237
+ # it and add on to it.
238
+ def build_headers(request)
239
+ original_forwarded_for = nil
240
+ if (request.http_env && request.http_env['HTTP_X_FORWARDED_FOR'])
241
+ original_forwarded_for = request.http_env['HTTP_X_FORWARDED_FOR']
242
+ end
243
+
244
+ # we used to prepare a comma seperated list in x-forwarded-for if
245
+ # we had multiple requests, as per the x-forwarded-for spec, but I
246
+ # think Google doesn't like it.
247
+
248
+ ip_address = (original_forwarded_for ?
249
+ original_forwarded_for :
250
+ request.client_ip_addr.to_s)
251
+
252
+ return {} if ip_address.blank?
253
+
254
+ # If we've got a comma-seperated list from an X-Forwarded-For, we
255
+ # can't send it on to google, google won't accept that, just take
256
+ # the first one in the list, which is actually the ultimate client
257
+ # IP. split returns the whole string if seperator isn't found, convenient.
258
+ ip_address = ip_address.split(",").first
259
+
260
+ # If all we have is an internal/private IP from the internal network,
261
+ # do NOT send that to Google, or Google will give you a 503 error
262
+ # and refuse to process your request, as of 7 sep 2011. sigh.
263
+ # Also if it doesn't look like an IP at all, forget it, don't send it.
264
+ if ((! ip_address =~ /^\d+\.\d+\.\d+\/\d$/) ||
265
+ ip_address.start_with?("10.") ||
266
+ ip_address.start_with?("172.16") ||
267
+ ip_address.start_with?("192.168"))
268
+ return {}
269
+ else
270
+ return {'X-Forwarded-For' => ip_address }
271
+ end
272
+ end
273
+
274
+ def find_entries(gbs_response, viewabilities)
275
+ unless (viewabilities.kind_of?(Array))
276
+ viewabilities = [viewabilities]
277
+ end
278
+
279
+ entries = gbs_response["items"].find_all do |entry|
280
+ viewability = entry["accessInfo"]["viewability"]
281
+ (viewability && viewabilities.include?(viewability))
282
+ end
283
+
284
+ return entries
285
+ end
286
+
287
+
288
+ # We only create a fulltext service response if we have a full view.
289
+ # We create only as many full views as are specified in config.
290
+ def create_fulltext_service_response(request, data)
291
+ display_name = @display_name
292
+
293
+ full_views = find_entries(data, ViewFullValue)
294
+ return nil if full_views.empty?
295
+
296
+ count = 0
297
+ full_views.each do |fv|
298
+
299
+ uri = fv["volumeInfo"]["previewLink"]
300
+
301
+ request.add_service_response(
302
+ :service => self,
303
+ :display_text => display_name,
304
+ :url => remove_query_context(uri),
305
+ :service_type_value => :fulltext
306
+ )
307
+ count += 1
308
+ break if count == @num_full_views
309
+ end
310
+ return true
311
+ end
312
+
313
+ def add_search_inside(request, data)
314
+ # Just take the first one we find, if multiple
315
+ searchable_view = find_entries(data, [ViewFullValue, ViewPartialValue])[0]
316
+
317
+ if ( searchable_view )
318
+ url = searchable_view["volumeInfo"]["infoLink"]
319
+
320
+ request.add_service_response(
321
+ :service => self,
322
+ :display_text=>@display_name,
323
+ :url=> remove_query_context(url),
324
+ :service_type_value => :search_inside
325
+ )
326
+ end
327
+
328
+ end
329
+
330
+ # create highlighted_link service response for partial and noview
331
+ # Only show one web link. prefer a partial view over a noview.
332
+ # Some noviews have a snippet/search, but we have no way to tell.
333
+ def do_web_links(request, data)
334
+
335
+ # some noview items will have a snippet view, but we have no way to tell
336
+ info_views = find_entries(data, ViewPartialValue)
337
+ viewability = ViewPartialValue
338
+
339
+ if info_views.blank?
340
+ info_views = find_entries(data, ViewNoneValue)
341
+ viewability = ViewNoneValue
342
+ end
343
+
344
+ # Shouldn't ever get to this point, but just in case
345
+ return nil if info_views.blank?
346
+
347
+ url = ''
348
+ iv = info_views.first
349
+ type = nil
350
+ if (viewability == ViewPartialValue &&
351
+ url = iv["volumeInfo"]["previewLink"])
352
+ display_text = @display_name
353
+ type = ServiceTypeValue[:excerpts]
354
+ else
355
+ url = url = iv["volumeInfo"]["infoLink"]
356
+ display_text = "Book Information"
357
+ type = ServiceTypeValue[:highlighted_link]
358
+ end
359
+ request.add_service_response(
360
+ :service=>self,
361
+ :url=> remove_query_context(url),
362
+ :display_text=>display_text,
363
+ :service_type_value => type
364
+ )
365
+ end
366
+
367
+
368
+
369
+
370
+ # Not all responses have a thumbnail_url. We look for them and return the 1st.
371
+ def find_thumbnail_url(data)
372
+ entries = data["items"].collect do |entry|
373
+ entry["volumeInfo"]["imageLinks"]["thumbnail"] if entry["volumeInfo"] && entry["volumeInfo"]["imageLinks"]
374
+ end
375
+
376
+ # removenill values
377
+ entries.compact!
378
+
379
+ # pick the first of the available thumbnails, or nil
380
+ return entries[0]
381
+ end
382
+
383
+
384
+ def add_cover_image(request, url)
385
+ zoom_url = url.clone
386
+
387
+ # if we're sent to a page other than the frontcover then strip out the
388
+ # page number and insert front cover
389
+ zoom_url.sub!(/&pg=.*?&/, '&printsec=frontcover&')
390
+
391
+ # hack out the 'curl' if we can
392
+ zoom_url.sub!('&edge=curl', '')
393
+
394
+ request.add_service_response(
395
+ :service=>self,
396
+ :display_text => 'Cover Image',
397
+ :url => zoom_url,
398
+ :size => "medium",
399
+ :service_type_value => :cover_image
400
+ )
401
+ end
402
+
403
+ # Google gives us URL to the book that contains a 'dq' param
404
+ # with the original query, which for us is an ISSN/LCCN/OCLCnum query,
405
+ # which we don't actually want to leave in there.
406
+ def remove_query_context(url)
407
+ url.sub(/&dq=[^&]+/, '')
408
+ end
409
+
410
+ # Catch url_for call for search_inside, because we're going to redirect
411
+ def response_url(service_response, submitted_params)
412
+ if ( ! (service_response.service_type_value.name == "search_inside" ))
413
+ return super(service_response, submitted_params)
414
+ else
415
+ # search inside!
416
+ base = service_response[:url]
417
+ query = CGI.escape(submitted_params["query"] || "")
418
+ # attempting to reverse engineer a bit to get 'snippet'
419
+ # style results instead of 'onepage' style results.
420
+ # snippet seem more user friendly, and are what google's own
421
+ # interface seems to give you by default. but 'onepage' is the
422
+ # default from our deep link, but if we copy the JS hash data,
423
+ # it looks like we can get Google to 'snippet'.
424
+ url = base + "&q=#{query}#v=snippet&q=#{query}&f=false"
425
+ return url
426
+ end
427
+ end
428
+
429
+ end
430
+
431
+ # Important to quote search, see: "OCLC1246014"
432
+
433
+ # Test WorldCat links
434
+ # FIXME: This produces two 'noview' links because the ids don't match.
435
+ # This might be as good as we can do though, unless we want to only ever show
436
+ # one 'noview' link. Notice that the metadata does differ between the two.
437
+ # http://localhost:3000/resolve?url_ver=Z39.88-2004&rfr_id=info%3Asid%2Fworldcat.org%3Aworldcat&rft_val_fmt=info%3Aofi%2Ffmt%3Akev%3Amtx%3Abook&req_dat=%3Csessionid%3E&rft_id=info%3Aoclcnum%2F34576818&rft_id=urn%3AISBN%3A9780195101386&rft_id=urn%3AISSN%3A&rft.aulast=Twain&rft.aufirst=Mark&rft.auinitm=&rft.btitle=The+prince+and+the+pauper&rft.atitle=&rft.date=1996&rft.tpages=&rft.isbn=9780195101386&rft.aucorp=&rft.place=New+York&rft.pub=Oxford+University+Press&rft.edition=&rft.series=&rft.genre=book&url_ver=Z39.88-2004
438
+ #
439
+ # Snippet view returns noview through the API
440
+ # http://localhost:3000/resolve?rft.isbn=0155374656
441
+ #
442
+ # full view example, LCCN 07020699 ; OCLC: 1246014