diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ba13bf1f29..536a268e55 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1 +1 @@ - + diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8a94b40452..511d6a5481 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -111,7 +111,7 @@ jobs: - name: Core RSpec if: matrix.build_type == 'backend' && matrix.target == 'core' - run: bin/turbo_rspec + run: bin/turbo_rspec --verbose - name: Plugin RSpec if: matrix.build_type == 'backend' && matrix.target == 'plugins' diff --git a/COPYRIGHT.md b/COPYRIGHT.md new file mode 100644 index 0000000000..80c989e96b --- /dev/null +++ b/COPYRIGHT.md @@ -0,0 +1,59 @@ +# Legal notice + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or (at +your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with this program as the file LICENSE.txt; if not, please see +http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + +## Trademark + +Discourse is a registered trademark of Civilized Discourse Construction Kit. + +## Other copyright notices + +Discourse includes works under other copyright notices and distributed +according to the terms of the GNU General Public License or a compatible +license (where indicated), including: + +- Ember.js - Copyright (c) 2020 Yehuda Katz, Tom Dale and Ember.js contributors + MIT License + +- jQuery - Copyright OpenJS Foundation and other contributors, https://openjsf.org/ + MIT License + +- Rails - Copyright (c) 2005-2021 David Heinemeier Hansson + MIT License + +- Onebox - Copyright (c) 2013 jzeta + MIT License + +MIT License: + +``` +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/COPYRIGHT.txt b/COPYRIGHT.txt deleted file mode 100644 index cb867e5604..0000000000 --- a/COPYRIGHT.txt +++ /dev/null @@ -1,31 +0,0 @@ -All Discourse code is Copyright 2013 by Civilized Discourse Construction Kit, Inc. - -This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or (at -your option) any later version. - -This program is distributed in the hope that it will be useful, but -WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY -or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License -for more details. - -You should have received a copy of the GNU General Public License -along with this program as the file LICENSE.txt; if not, please see -http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - -Discourse is a registered trademark of Civilized Discourse Construction Kit. - -Discourse includes works under other copyright notices and distributed -according to the terms of the GNU General Public License or a compatible -license (where indicated), including: - -Javascript - - Ember.js - Copyright (c) 2012-2013 Yehuda Katz, Tom Dale, Charles Jolley and Ember.js contributors - - jQuery - Copyright (c) 2010-2013 John Resig - -Ruby - - Rails - Copyright (c) 2005-2013 David Heinemeier Hansson, Rails Core Team contributors (MIT) diff --git a/Gemfile b/Gemfile index 71ee3f910c..703c5da332 100644 --- a/Gemfile +++ b/Gemfile @@ -60,8 +60,6 @@ gem 'redis-namespace' # better maintained living fork gem 'active_model_serializers', '~> 0.8.3' -gem 'onebox' - gem 'http_accept_language', require: false # Ember related gems need to be pinned cause they control client side @@ -90,9 +88,7 @@ gem 'unf', require: false gem 'email_reply_trimmer' -# Forked until https://github.com/toy/image_optim/pull/162 is merged -# https://github.com/discourse/image_optim -gem 'discourse_image_optim', require: 'image_optim' +gem 'image_optim' gem 'multi_json' gem 'mustache' gem 'nokogiri' @@ -231,6 +227,8 @@ gem 'sshkey', require: false gem 'rchardet', require: false gem 'lz4-ruby', require: false, platform: :ruby +gem 'sanitize' + if ENV["IMPORT"] == "1" gem 'mysql2' gem 'redcarpet' diff --git a/Gemfile.lock b/Gemfile.lock index 2bbc42e7d2..e8ae9ce763 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,7 +92,7 @@ GEM chunky_png (1.4.0) coderay (1.1.3) colored2 (3.1.2) - concurrent-ruby (1.1.8) + concurrent-ruby (1.1.9) connection_pool (2.2.5) cose (1.2.0) cbor (~> 0.5.9) @@ -117,12 +117,6 @@ GEM discourse-fonts (0.0.8) discourse_dev (0.2.1) faker (~> 2.16) - discourse_image_optim (0.26.2) - exifr (~> 1.2, >= 1.2.2) - fspath (~> 3.0) - image_size (~> 1.5) - in_threads (~> 1.3) - progress (~> 3.0, >= 3.0.1) docile (1.4.0) ecma-re-validator (0.3.0) regexp_parser (~> 2.0) @@ -134,26 +128,30 @@ GEM sprockets (>= 3.3, < 4.1) ember-source (2.18.2) erubi (1.10.0) - excon (0.81.0) + excon (0.82.0) execjs (2.8.1) exifr (1.3.9) fabrication (2.22.0) - faker (2.17.0) + faker (2.18.0) i18n (>= 1.6, < 2) fakeweb (1.3.0) - faraday (1.4.1) + faraday (1.4.2) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) faraday-net_http (~> 1.0) faraday-net_http_persistent (~> 1.1) multipart-post (>= 1.2, < 3) ruby2_keywords (>= 0.0.4) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) faraday-excon (1.1.0) faraday-net_http (1.0.1) faraday-net_http_persistent (1.1.0) fast_blank (1.0.0) fast_xs (0.8.0) - fastimage (2.2.3) - ffi (1.15.0) + fastimage (2.2.4) + ffi (1.15.1) fspath (3.1.2) gc_tracer (1.5.1) globalid (0.4.2) @@ -168,7 +166,13 @@ GEM http_accept_language (2.1.1) i18n (1.8.10) concurrent-ruby (~> 1.0) - image_size (1.5.0) + image_optim (0.30.0) + exifr (~> 1.2, >= 1.2.2) + fspath (~> 3.0) + image_size (>= 1.5, < 3) + in_threads (~> 1.3) + progress (~> 3.0, >= 3.0.1) + image_size (2.1.0) in_threads (1.5.4) jmespath (1.4.0) jquery-rails (4.4.0) @@ -184,7 +188,7 @@ GEM regexp_parser (~> 2.0) uri_template (~> 0.7) jwt (2.2.3) - kgio (2.11.3) + kgio (2.11.4) libv8-node (15.14.0.1) libv8-node (15.14.0.1-arm64-darwin-20) libv8-node (15.14.0.1-x86_64-darwin-18) @@ -203,18 +207,18 @@ GEM logstash-logger (0.26.1) logstash-event (~> 1.2) logster (2.9.6) - loofah (2.9.1) + loofah (2.10.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) lru_redux (1.1.0) lz4-ruby (0.3.3) maxminddb (0.1.22) memory_profiler (1.0.0) - message_bus (3.3.5) + message_bus (3.3.6) rack (>= 1.1.3) method_source (1.0.0) mini_mime (1.1.0) - mini_portile2 (2.5.1) + mini_portile2 (2.5.3) mini_racer (0.4.0) libv8-node (~> 15.14.0.0) mini_scheduler (0.13.0) @@ -232,14 +236,14 @@ GEM multipart-post (2.1.1) mustache (1.1.1) nio4r (2.5.7) - nokogiri (1.11.3) + nokogiri (1.11.7) mini_portile2 (~> 2.5.0) racc (~> 1.4) - nokogiri (1.11.3-arm64-darwin) + nokogiri (1.11.7-arm64-darwin) racc (~> 1.4) - nokogiri (1.11.3-x86_64-darwin) + nokogiri (1.11.7-x86_64-darwin) racc (~> 1.4) - nokogiri (1.11.3-x86_64-linux) + nokogiri (1.11.7-x86_64-linux) racc (~> 1.4) nokogumbo (2.0.5) nokogiri (~> 1.8, >= 1.8.4) @@ -273,13 +277,6 @@ GEM omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) rack - onebox (2.2.15) - addressable (~> 2.7.0) - htmlentities (~> 4.3) - multi_json (~> 1.11) - mustache - nokogiri (~> 1.7) - sanitize openssl (2.2.0) openssl-signature_algorithm (1.1.1) openssl (~> 2.0) @@ -300,7 +297,7 @@ GEM pry-rails (0.3.9) pry (>= 0.10.4) public_suffix (4.0.6) - puma (5.3.1) + puma (5.3.2) nio4r (~> 2.0) r2 (0.2.7) racc (1.5.2) @@ -330,7 +327,7 @@ GEM rake (>= 0.8.7) thor (~> 1.0) rainbow (3.0.0) - raindrops (0.19.1) + raindrops (0.19.2) rake (13.0.3) rb-fsevent (0.11.0) rb-inotify (0.10.1) @@ -382,18 +379,18 @@ GEM json-schema (~> 2.2) railties (>= 3.1, < 7.0) rtlit (0.0.5) - rubocop (1.14.0) + rubocop (1.16.0) parallel (~> 1.10) parser (>= 3.0.0.0) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml - rubocop-ast (>= 1.5.0, < 2.0) + rubocop-ast (>= 1.7.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.5.0) + rubocop-ast (1.7.0) parser (>= 3.0.1.1) - rubocop-discourse (2.4.1) + rubocop-discourse (2.4.2) rubocop (>= 1.1.0) rubocop-rspec (>= 2.0.0) rubocop-rspec (2.3.0) @@ -459,7 +456,7 @@ GEM raindrops (~> 0.7) uniform_notifier (1.14.2) uri_template (0.7.0) - webmock (3.12.2) + webmock (3.13.0) addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -508,7 +505,6 @@ DEPENDENCIES discourse-ember-source (~> 3.12.2) discourse-fonts discourse_dev - discourse_image_optim email_reply_trimmer ember-handlebars-template (= 0.8.0) excon @@ -522,6 +518,7 @@ DEPENDENCIES highline htmlentities http_accept_language + image_optim json json_schemer listen @@ -554,7 +551,6 @@ DEPENDENCIES omniauth-google-oauth2 omniauth-oauth2 omniauth-twitter - onebox parallel_tests pg pry-byebug @@ -585,6 +581,7 @@ DEPENDENCIES ruby-prof ruby-readability rubyzip + sanitize sassc (= 2.0.1) sassc-rails seed-fu @@ -606,4 +603,4 @@ DEPENDENCIES yaml-lint BUNDLED WITH - 2.2.16 + 2.2.19 diff --git a/LICENSE.txt b/LICENSE.txt index 94fb84639c..d159169d10 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,12 +1,12 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - Preamble + Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public @@ -56,7 +56,7 @@ patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. - GNU GENERAL PUBLIC LICENSE + GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains @@ -255,7 +255,7 @@ make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - NO WARRANTY + NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN @@ -277,9 +277,9 @@ YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - END OF TERMS AND CONDITIONS + END OF TERMS AND CONDITIONS - How to Apply These Terms to Your New Programs + How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it diff --git a/app/assets/javascripts/admin/addon/components/admin-report.js b/app/assets/javascripts/admin/addon/components/admin-report.js index 3ecf00451d..2fe0ab68d8 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report.js +++ b/app/assets/javascripts/admin/addon/components/admin-report.js @@ -366,7 +366,7 @@ export default Component.extend({ }, _buildPayload(facets) { - let payload = { data: { cache: true, facets } }; + let payload = { data: { facets } }; if (this.startDate) { payload.data.start_date = moment(this.startDate) diff --git a/app/assets/javascripts/admin/addon/components/admin-watched-word.js b/app/assets/javascripts/admin/addon/components/admin-watched-word.js index fb24a6f388..8c5fd51496 100644 --- a/app/assets/javascripts/admin/addon/components/admin-watched-word.js +++ b/app/assets/javascripts/admin/addon/components/admin-watched-word.js @@ -1,10 +1,21 @@ import Component from "@ember/component"; -import I18n from "I18n"; +import { equal } from "@ember/object/computed"; import bootbox from "bootbox"; +import discourseComputed from "discourse-common/utils/decorators"; +import I18n from "I18n"; export default Component.extend({ classNames: ["watched-word"], + isReplace: equal("actionKey", "replace"), + isTag: equal("actionKey", "tag"), + isLink: equal("actionKey", "link"), + + @discourseComputed("word.replacement") + tags(replacement) { + return replacement.split(","); + }, + click() { this.word .destroy() diff --git a/app/assets/javascripts/admin/addon/components/suspension-details.js b/app/assets/javascripts/admin/addon/components/suspension-details.js index e99a7e1c5b..fe931ae12e 100644 --- a/app/assets/javascripts/admin/addon/components/suspension-details.js +++ b/app/assets/javascripts/admin/addon/components/suspension-details.js @@ -13,7 +13,7 @@ export default Component.extend({ reasonKeys: [ "not_listening_to_staff", "consuming_staff_time", - "combatative", + "combative", "in_wrong_place", "no_constructive_purpose", CUSTOM_REASON_KEY, diff --git a/app/assets/javascripts/admin/addon/components/watched-word-form.js b/app/assets/javascripts/admin/addon/components/watched-word-form.js index cf957148e2..89eb6bfe95 100644 --- a/app/assets/javascripts/admin/addon/components/watched-word-form.js +++ b/app/assets/javascripts/admin/addon/components/watched-word-form.js @@ -15,16 +15,24 @@ export default Component.extend({ formSubmitted: false, actionKey: null, showMessage: false, + selectedTags: null, canReplace: equal("actionKey", "replace"), canTag: equal("actionKey", "tag"), + canLink: equal("actionKey", "link"), - @discourseComputed("regularExpressions") - placeholderKey(regularExpressions) { - return ( - "admin.watched_words.form.placeholder" + - (regularExpressions ? "_regexp" : "") - ); + didInsertElement() { + this._super(...arguments); + this.set("selectedTags", []); + }, + + @discourseComputed("siteSettings.watched_words_regular_expressions") + placeholderKey(watchedWordsRegularExpressions) { + if (watchedWordsRegularExpressions) { + return "admin.watched_words.form.placeholder_regexp"; + } else { + return "admin.watched_words.form.placeholder"; + } }, @observes("word") @@ -46,6 +54,13 @@ export default Component.extend({ }, actions: { + changeSelectedTags(tags) { + this.setProperties({ + selectedTags: tags, + replacement: tags.join(","), + }); + }, + submit() { if (!this.isUniqueWord) { this.setProperties({ @@ -60,7 +75,10 @@ export default Component.extend({ const watchedWord = WatchedWord.create({ word: this.word, - replacement: this.canReplace || this.canTag ? this.replacement : null, + replacement: + this.canReplace || this.canTag || this.canLink + ? this.replacement + : null, action: this.actionKey, }); diff --git a/app/assets/javascripts/admin/addon/controllers/admin-dashboard-general.js b/app/assets/javascripts/admin/addon/controllers/admin-dashboard-general.js index a831b686bc..bde2cf574b 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-dashboard-general.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-dashboard-general.js @@ -1,4 +1,4 @@ -import Controller, { inject } from "@ember/controller"; +import Controller, { inject as controller } from "@ember/controller"; import AdminDashboard from "admin/models/admin-dashboard"; import I18n from "I18n"; import PeriodComputationMixin from "admin/mixins/period-computation"; @@ -18,7 +18,7 @@ function staticReport(reportType) { export default Controller.extend(PeriodComputationMixin, { isLoading: false, dashboardFetchedAt: null, - exceptionController: inject("exception"), + exceptionController: controller("exception"), logSearchQueriesEnabled: setting("log_search_queries"), @discourseComputed("siteSettings.dashboard_general_tab_activity_metrics") diff --git a/app/assets/javascripts/admin/addon/controllers/admin-dashboard.js b/app/assets/javascripts/admin/addon/controllers/admin-dashboard.js index 89955953fb..c7291f1a76 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-dashboard.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-dashboard.js @@ -1,4 +1,4 @@ -import Controller, { inject } from "@ember/controller"; +import Controller, { inject as controller } from "@ember/controller"; import AdminDashboard from "admin/models/admin-dashboard"; import VersionCheck from "admin/models/version-check"; import { computed } from "@ember/object"; @@ -10,7 +10,7 @@ const PROBLEMS_CHECK_MINUTES = 1; export default Controller.extend({ isLoading: false, dashboardFetchedAt: null, - exceptionController: inject("exception"), + exceptionController: controller("exception"), showVersionChecks: setting("version_checks"), @discourseComputed("problems.length") diff --git a/app/assets/javascripts/admin/addon/controllers/admin-watched-words-action.js b/app/assets/javascripts/admin/addon/controllers/admin-watched-words-action.js index 1265e3d028..d84d80615d 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-watched-words-action.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-watched-words-action.js @@ -12,20 +12,14 @@ import showModal from "discourse/lib/show-modal"; export default Controller.extend({ adminWatchedWords: controller(), actionNameKey: null, - showWordsList: or( - "adminWatchedWords.filtered", - "adminWatchedWords.showWords" - ), downloadLink: fmt( "actionNameKey", "/admin/customize/watched_words/action/%@/download" ), + showWordsList: or("adminWatchedWords.showWords", "adminWatchedWords.filter"), findAction(actionName) { - return (this.get("adminWatchedWords.model") || []).findBy( - "nameKey", - actionName - ); + return (this.adminWatchedWords.model || []).findBy("nameKey", actionName); }, @discourseComputed("actionNameKey", "adminWatchedWords.model") @@ -33,9 +27,15 @@ export default Controller.extend({ return this.findAction(actionName); }, - @discourseComputed("currentAction.words.[]", "adminWatchedWords.model") - filteredContent(words) { - return words || []; + @discourseComputed("currentAction.words.[]") + regexpError(words) { + for (const { regexp, word } of words) { + try { + RegExp(regexp); + } catch { + return I18n.t("admin.watched_words.invalid_regex", { word }); + } + } }, @discourseComputed("actionNameKey") @@ -43,47 +43,51 @@ export default Controller.extend({ return I18n.t("admin.watched_words.action_descriptions." + actionNameKey); }, - @discourseComputed("currentAction.count") - wordCount(count) { - return count || 0; - }, - actions: { recordAdded(arg) { - const a = this.findAction(this.actionNameKey); - if (a) { - a.words.unshiftObject(arg); - a.incrementProperty("count"); - schedule("afterRender", () => { - // remove from other actions lists - let match = null; - this.get("adminWatchedWords.model").forEach((action) => { - if (match) { - return; - } - - if (action.nameKey !== this.actionNameKey) { - match = action.words.findBy("id", arg.id); - if (match) { - action.words.removeObject(match); - action.decrementProperty("count"); - } - } - }); - }); + const action = this.findAction(this.actionNameKey); + if (!action) { + return; } + + action.words.unshiftObject(arg); + schedule("afterRender", () => { + // remove from other actions lists + let match = null; + this.adminWatchedWords.model.forEach((otherAction) => { + if (match) { + return; + } + + if (otherAction.nameKey !== this.actionNameKey) { + match = otherAction.words.findBy("id", arg.id); + if (match) { + otherAction.words.removeObject(match); + } + } + }); + }); }, recordRemoved(arg) { if (this.currentAction) { this.currentAction.words.removeObject(arg); - this.currentAction.decrementProperty("count"); } }, uploadComplete() { WatchedWord.findAll().then((data) => { - this.set("adminWatchedWords.model", data); + this.adminWatchedWords.set("model", data); + }); + }, + + test() { + WatchedWord.findAll().then((data) => { + this.adminWatchedWords.set("model", data); + showModal("admin-watched-word-test", { + admin: true, + model: this.currentAction, + }); }); }, @@ -102,25 +106,12 @@ export default Controller.extend({ }).then(() => { const action = this.findAction(actionKey); if (action) { - action.setProperties({ - words: [], - count: 0, - }); + action.set("words", []); } }); } } ); }, - - test() { - WatchedWord.findAll().then((data) => { - this.set("adminWatchedWords.model", data); - showModal("admin-watched-word-test", { - admin: true, - model: this.currentAction, - }); - }); - }, }, }); diff --git a/app/assets/javascripts/admin/addon/controllers/admin-watched-words.js b/app/assets/javascripts/admin/addon/controllers/admin-watched-words.js index cfd86c6c22..ce41e741fd 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-watched-words.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-watched-words.js @@ -1,71 +1,56 @@ import Controller from "@ember/controller"; -import EmberObject from "@ember/object"; +import EmberObject, { action } from "@ember/object"; import { INPUT_DELAY } from "discourse-common/config/environment"; -import { alias } from "@ember/object/computed"; import discourseDebounce from "discourse-common/lib/debounce"; import { isEmpty } from "@ember/utils"; import { observes } from "discourse-common/utils/decorators"; export default Controller.extend({ filter: null, - filtered: false, showWords: false, - disableShowWords: alias("filtered"), - regularExpressions: null, - filterContentNow() { - if (!!isEmpty(this.allWatchedWords)) { + _filterContent() { + if (isEmpty(this.allWatchedWords)) { return; } - let filter; - if (this.filter) { - filter = this.filter.toLowerCase(); - } - - if (filter === undefined || filter.length < 1) { + if (!this.filter) { this.set("model", this.allWatchedWords); return; } - const matchesByAction = []; + const filter = this.filter.toLowerCase(); + const model = []; this.allWatchedWords.forEach((wordsForAction) => { const wordRecords = wordsForAction.words.filter((wordRecord) => { return wordRecord.word.indexOf(filter) > -1; }); - matchesByAction.pushObject( + + model.pushObject( EmberObject.create({ nameKey: wordsForAction.nameKey, name: wordsForAction.name, words: wordRecords, - count: wordRecords.length, }) ); }); - this.set("model", matchesByAction); + this.set("model", model); }, @observes("filter") filterContent() { - discourseDebounce( - this, - function () { - this.filterContentNow(); - this.set("filtered", !isEmpty(this.filter)); - }, - INPUT_DELAY - ); + discourseDebounce(this, this._filterContent, INPUT_DELAY); }, - actions: { - clearFilter() { - this.setProperties({ filter: "" }); - }, + @action + clearFilter() { + this.set("filter", ""); + }, - toggleMenu() { - $(".admin-detail").toggleClass("mobile-closed mobile-open"); - }, + @action + toggleMenu() { + $(".admin-detail").toggleClass("mobile-closed mobile-open"); }, }); diff --git a/app/assets/javascripts/admin/addon/controllers/modals/admin-watched-word-test.js b/app/assets/javascripts/admin/addon/controllers/modals/admin-watched-word-test.js index 215cc08a6d..3ce120419c 100644 --- a/app/assets/javascripts/admin/addon/controllers/modals/admin-watched-word-test.js +++ b/app/assets/javascripts/admin/addon/controllers/modals/admin-watched-word-test.js @@ -1,14 +1,50 @@ import Controller from "@ember/controller"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import discourseComputed from "discourse-common/utils/decorators"; +import { equal } from "@ember/object/computed"; export default Controller.extend(ModalFunctionality, { - @discourseComputed("value", "model.compiledRegularExpression") - matches(value, regexpString) { + isReplace: equal("model.nameKey", "replace"), + isTag: equal("model.nameKey", "tag"), + isLink: equal("model.nameKey", "link"), + + @discourseComputed( + "value", + "model.compiledRegularExpression", + "model.words", + "isReplace", + "isTag", + "isLink" + ) + matches(value, regexpString, words, isReplace, isTag, isLink) { if (!value || !regexpString) { return; } - let censorRegexp = new RegExp(regexpString, "ig"); - return value.match(censorRegexp); + + const regexp = new RegExp(regexpString, "ig"); + const matches = value.match(regexp) || []; + + if (isReplace || isLink) { + return matches.map((match) => ({ + match, + replacement: words.find((word) => + new RegExp(word.regexp, "ig").test(match) + ).replacement, + })); + } else if (isTag) { + return matches.map((match) => { + const tags = new Set(); + + words.forEach((word) => { + if (new RegExp(word.regexp, "ig").test(match)) { + word.replacement.split(",").forEach((tag) => tags.add(tag)); + } + }); + + return { match, tags: Array.from(tags) }; + }); + } + + return matches; }, }); diff --git a/app/assets/javascripts/admin/addon/models/watched-word.js b/app/assets/javascripts/admin/addon/models/watched-word.js index 9aebef18eb..54d20bffff 100644 --- a/app/assets/javascripts/admin/addon/models/watched-word.js +++ b/app/assets/javascripts/admin/addon/models/watched-word.js @@ -31,27 +31,21 @@ WatchedWord.reopenClass({ findAll() { return ajax("/admin/customize/watched_words.json").then((list) => { const actions = {}; - list.words.forEach((s) => { - if (!actions[s.action]) { - actions[s.action] = []; - } - actions[s.action].pushObject(WatchedWord.create(s)); + + list.actions.forEach((action) => { + actions[action] = []; }); - list.actions.forEach((a) => { - if (!actions[a]) { - actions[a] = []; - } + list.words.forEach((watchedWord) => { + actions[watchedWord.action].pushObject(WatchedWord.create(watchedWord)); }); - return Object.keys(actions).map((n) => { + return Object.keys(actions).map((nameKey) => { return EmberObject.create({ - nameKey: n, - name: I18n.t("admin.watched_words.actions." + n), - words: actions[n], - count: actions[n].length, - regularExpressions: list.regular_expressions, - compiledRegularExpression: list.compiled_regular_expressions[n], + nameKey, + name: I18n.t("admin.watched_words.actions." + nameKey), + words: actions[nameKey], + compiledRegularExpression: list.compiled_regular_expressions[nameKey], }); }); }); diff --git a/app/assets/javascripts/admin/addon/routes/admin-watched-words-action.js b/app/assets/javascripts/admin/addon/routes/admin-watched-words-action.js index fe1ce75ab7..5ba55219c7 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-watched-words-action.js +++ b/app/assets/javascripts/admin/addon/routes/admin-watched-words-action.js @@ -4,17 +4,12 @@ import I18n from "I18n"; export default DiscourseRoute.extend({ model(params) { - this.controllerFor("adminWatchedWordsAction").set( - "actionNameKey", - params.action_id - ); - let filteredContent = this.controllerFor("adminWatchedWordsAction").get( - "filteredContent" - ); + const controller = this.controllerFor("adminWatchedWordsAction"); + controller.set("actionNameKey", params.action_id); return EmberObject.create({ nameKey: params.action_id, name: I18n.t("admin.watched_words.actions." + params.action_id), - words: filteredContent, + words: controller.filteredContent, }); }, }); diff --git a/app/assets/javascripts/admin/addon/routes/admin-watched-words.js b/app/assets/javascripts/admin/addon/routes/admin-watched-words.js index d995d09d45..aa793646cb 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-watched-words.js +++ b/app/assets/javascripts/admin/addon/routes/admin-watched-words.js @@ -10,17 +10,8 @@ export default DiscourseRoute.extend({ return WatchedWord.findAll(); }, - setupController(controller, model) { - controller.set("model", model); - if (model && model.length) { - controller.set("regularExpressions", model[0].get("regularExpressions")); - } - }, - - afterModel(watchedWordsList) { - this.controllerFor("adminWatchedWords").set( - "allWatchedWords", - watchedWordsList - ); + afterModel(model) { + const controller = this.controllerFor("adminWatchedWords"); + controller.set("allWatchedWords", model); }, }); diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-watched-word.hbs b/app/assets/javascripts/admin/addon/templates/components/admin-watched-word.hbs index cc03496510..53d61a9b97 100644 --- a/app/assets/javascripts/admin/addon/templates/components/admin-watched-word.hbs +++ b/app/assets/javascripts/admin/addon/templates/components/admin-watched-word.hbs @@ -1 +1,9 @@ -{{d-icon "times"}} {{word.word}} {{#if word.replacement}}→ {{word.replacement}}{{/if}} +{{d-icon "times"}} {{word.word}} +{{#if (or isReplace isLink)}} + → {{word.replacement}} +{{else if isTag}} + → + {{#each tags as |tag|}} + {{tag}} + {{/each}} +{{/if}} diff --git a/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs b/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs index dbc7c8603f..be6727ca68 100644 --- a/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs +++ b/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs @@ -5,15 +5,29 @@ {{#if canReplace}}
- - {{text-field id="watched-replacement" value=replacement disabled=formSubmitted class="watched-word-input-replace" autocorrect="off" autocapitalize="off" placeholderKey="admin.watched_words.form.replacement_placeholder"}} + + {{text-field id="watched-replacement" value=replacement disabled=formSubmitted class="watched-word-input-field" autocorrect="off" autocapitalize="off" placeholderKey="admin.watched_words.form.replace_placeholder"}}
{{/if}} {{#if canTag}}
- {{text-field id="watched-tag" value=replacement disabled=formSubmitted class="watched-word-input" autocorrect="off" autocapitalize="off" placeholderKey="admin.watched_words.form.tag_placeholder"}} + {{tag-chooser + id="watched-tag" + class="watched-word-input-field" + allowCreate=true + disabled=formSubmitted + tags=selectedTags + onChange=(action "changeSelectedTags") + }} +
+{{/if}} + +{{#if canLink}} +
+ + {{text-field id="watched-replacement" value=replacement disabled=formSubmitted class="watched-word-input-field" autocorrect="off" autocapitalize="off" placeholderKey="admin.watched_words.form.link_placeholder"}}
{{/if}} diff --git a/app/assets/javascripts/admin/addon/templates/modal/admin-install-theme.hbs b/app/assets/javascripts/admin/addon/templates/modal/admin-install-theme.hbs index 2fda0a1b00..78baeab2e6 100644 --- a/app/assets/javascripts/admin/addon/templates/modal/admin-install-theme.hbs +++ b/app/assets/javascripts/admin/addon/templates/modal/admin-install-theme.hbs @@ -1,4 +1,4 @@ -{{#d-modal-body class="upload-selector install-theme" title="admin.customize.theme.install"}} +{{#d-modal-body class="install-theme" title="admin.customize.theme.install"}} {{#unless directRepoInstall}}
{{install-theme-item value="popular" selection=selection label="admin.customize.theme.install_popular"}} diff --git a/app/assets/javascripts/admin/addon/templates/modal/admin-watched-word-test.hbs b/app/assets/javascripts/admin/addon/templates/modal/admin-watched-word-test.hbs index ef56567cdd..92b6b22679 100644 --- a/app/assets/javascripts/admin/addon/templates/modal/admin-watched-word-test.hbs +++ b/app/assets/javascripts/admin/addon/templates/modal/admin-watched-word-test.hbs @@ -5,9 +5,29 @@

{{i18n "admin.watched_words.test.found_matches"}}

{{else}} diff --git a/app/assets/javascripts/admin/addon/templates/watched-words-action.hbs b/app/assets/javascripts/admin/addon/templates/watched-words-action.hbs index d17ce9e300..005e4b759b 100644 --- a/app/assets/javascripts/admin/addon/templates/watched-words-action.hbs +++ b/app/assets/javascripts/admin/addon/templates/watched-words-action.hbs @@ -1,3 +1,7 @@ +{{#if regexpError}} +
{{regexpError}}
+{{/if}} +
{{d-button class="btn-default download-link" @@ -8,6 +12,7 @@ {{watched-word-uploader uploading=uploading actionKey=actionNameKey done=(action "uploadComplete")}} {{d-button + class="watched-word-test" label="admin.watched_words.test.button_label" icon="far-eye" action=(action "test")}} @@ -24,20 +29,20 @@ {{watched-word-form actionKey=actionNameKey action=(action "recordAdded") - filteredContent=filteredContent - regularExpressions=adminWatchedWords.regularExpressions}} + filteredContent=currentAction.words +}} -{{#if wordCount}} +{{#if currentAction.words}} {{/if}} {{#if showWordsList}} -
- {{#each filteredContent as |word| }} -
{{admin-watched-word word=word action=(action "recordRemoved")}}
+
+ {{#each currentAction.words as |word| }} +
{{admin-watched-word actionKey=actionNameKey word=word action=(action "recordRemoved")}}
{{/each}}
{{/if}} diff --git a/app/assets/javascripts/admin/addon/templates/watched-words.hbs b/app/assets/javascripts/admin/addon/templates/watched-words.hbs index 02f81d7866..3342a22b94 100644 --- a/app/assets/javascripts/admin/addon/templates/watched-words.hbs +++ b/app/assets/javascripts/admin/addon/templates/watched-words.hbs @@ -12,7 +12,7 @@
  • {{#link-to "adminWatchedWords.action" action.nameKey}} {{action.name}} - {{#if action.count}}({{action.count}}){{/if}} + {{#if action.words}}({{action.words.length}}){{/if}} {{/link-to}}
  • {{/each}} diff --git a/app/assets/javascripts/admin/package.json b/app/assets/javascripts/admin/package.json index 7d6e02b5c9..56274a24a5 100644 --- a/app/assets/javascripts/admin/package.json +++ b/app/assets/javascripts/admin/package.json @@ -40,6 +40,7 @@ "ember-source": "~3.15.0", "ember-source-channel-url": "^2.0.1", "ember-try": "^1.4.0", + "eslint": "^7.27.0", "eslint-plugin-ember": "^7.7.1", "eslint-plugin-node": "^10.0.0", "loader.js": "^4.7.0" diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index a42435af3a..0e9654e0dd 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -22,6 +22,7 @@ //= require ./discourse/app/lib/offset-calculator //= require ./discourse/app/lib/lock-on //= require ./discourse/app/lib/url +//= require ./discourse/app/lib/email-provider-default-settings //= require ./discourse/app/lib/debounce //= require ./discourse/app/lib/quote //= require ./discourse/app/lib/key-value-store diff --git a/app/assets/javascripts/discourse-shims.js b/app/assets/javascripts/discourse-shims.js index bf4765a13f..b63d4a6fce 100644 --- a/app/assets/javascripts/discourse-shims.js +++ b/app/assets/javascripts/discourse-shims.js @@ -26,10 +26,6 @@ define("ember-buffered-proxy/proxy", ["exports"], function (__exports__) { __exports__.default = window.BufferedProxy; }); -define("ember-buffered-proxy/mixin", ["exports"], function (__exports__) { - __exports__.default = null; -}); - define("bootbox", ["exports"], function (__exports__) { __exports__.default = window.bootbox; }); diff --git a/app/assets/javascripts/discourse/app/components/badge-card.js b/app/assets/javascripts/discourse/app/components/badge-card.js index 6e2a862c38..d1da83e296 100644 --- a/app/assets/javascripts/discourse/app/components/badge-card.js +++ b/app/assets/javascripts/discourse/app/components/badge-card.js @@ -31,4 +31,9 @@ export default Component.extend({ } return sanitize(description); }, + + @discourseComputed("badge.id") + showFavorite(badgeId) { + return ![1, 2, 3, 4].includes(badgeId); + }, }); diff --git a/app/assets/javascripts/discourse/app/components/bookmark.js b/app/assets/javascripts/discourse/app/components/bookmark.js index ccd790e526..d51cb7baa9 100644 --- a/app/assets/javascripts/discourse/app/components/bookmark.js +++ b/app/assets/javascripts/discourse/app/components/bookmark.js @@ -308,7 +308,7 @@ export default Component.extend({ customOptions.push({ icon: "globe-americas", id: TIME_SHORTCUT_TYPES.POST_LOCAL_DATE, - label: "bookmarks.reminders.post_local_date", + label: "time_shortcut.post_local_date", time: this._postLocalDate(), timeFormatted: this._postLocalDate().format( I18n.t("dates.long_no_year") diff --git a/app/assets/javascripts/discourse/app/components/bread-crumbs.js b/app/assets/javascripts/discourse/app/components/bread-crumbs.js index a93c9e9a3b..4f0925706f 100644 --- a/app/assets/javascripts/discourse/app/components/bread-crumbs.js +++ b/app/assets/javascripts/discourse/app/components/bread-crumbs.js @@ -7,6 +7,8 @@ import { filter } from "@ember/object/computed"; export default Component.extend({ classNameBindings: ["hidden:hidden", ":category-breadcrumb"], tagName: "ol", + editingCategory: false, + editingCategoryTab: null, @discourseComputed("categories") filteredCategories(categories) { @@ -47,6 +49,11 @@ export default Component.extend({ }); }, + @discourseComputed("siteSettings.tagging_enabled", "editingCategory") + showTagsSection(taggingEnabled, editingCategory) { + return taggingEnabled && !editingCategory; + }, + @discourseComputed("category") parentCategory(category) { deprecated( diff --git a/app/assets/javascripts/discourse/app/components/bulk-select-button.js b/app/assets/javascripts/discourse/app/components/bulk-select-button.js index 9f46112d23..e3359e1f73 100644 --- a/app/assets/javascripts/discourse/app/components/bulk-select-button.js +++ b/app/assets/javascripts/discourse/app/components/bulk-select-button.js @@ -1,5 +1,6 @@ import Component from "@ember/component"; import { schedule } from "@ember/runloop"; +import { reads } from "@ember/object/computed"; import showModal from "discourse/lib/show-modal"; export default Component.extend({ @@ -17,6 +18,8 @@ export default Component.extend({ }); }, + canDoBulkActions: reads("currentUser.staff"), + actions: { showBulkActions() { const controller = showModal("topic-bulk-actions", { diff --git a/app/assets/javascripts/discourse/app/components/category-title-link.js b/app/assets/javascripts/discourse/app/components/category-title-link.js index afe11e3db4..1ac2a2fbab 100644 --- a/app/assets/javascripts/discourse/app/components/category-title-link.js +++ b/app/assets/javascripts/discourse/app/components/category-title-link.js @@ -1,6 +1,6 @@ import Component from "@ember/component"; export default Component.extend({ tagName: "h3", - // icon name defined here so it can be easily overriden in theme components + // icon name defined here so it can be easily overridden in theme components lockIcon: "lock", }); diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js index c1be68eda5..d4bf5097e5 100644 --- a/app/assets/javascripts/discourse/app/components/composer-editor.js +++ b/app/assets/javascripts/discourse/app/components/composer-editor.js @@ -269,7 +269,11 @@ export default Component.extend({ if (tl === 0 || tl === 1) { reason += "
    " + - I18n.t("composer.error.try_like", { heart: iconHTML("heart") }); + I18n.t("composer.error.try_like", { + heart: iconHTML("heart", { + label: I18n.t("likes_lowercase", { count: 1 }), + }), + }); } } @@ -288,7 +292,7 @@ export default Component.extend({ // when adding two separate files with the same filename search for matching // placeholder already existing in the editor ie [Uploading: test.png...] - // and add order nr to the next one: [Uplodading: test.png(1)...] + // and add order nr to the next one: [Uploading: test.png(1)...] const escapedFilename = filename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regexString = `\\[${I18n.t("uploading_filename", { filename: escapedFilename + "(?:\\()?([0-9])?(?:\\))?", diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js index 345777759f..adcbc6b6d9 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.js +++ b/app/assets/javascripts/discourse/app/components/d-editor.js @@ -129,7 +129,7 @@ class Toolbar { this.addButton({ id: "code", group: "insertions", - shortcut: "Shift+C", + shortcut: "E", preventFocus: true, trimLeading: true, action: (...args) => this.context.send("formatCode", args), diff --git a/app/assets/javascripts/discourse/app/components/d-modal-body.js b/app/assets/javascripts/discourse/app/components/d-modal-body.js index fada62686d..27e092191b 100644 --- a/app/assets/javascripts/discourse/app/components/d-modal-body.js +++ b/app/assets/javascripts/discourse/app/components/d-modal-body.js @@ -4,6 +4,7 @@ export default Component.extend({ classNames: ["modal-body"], fixed: false, dismissable: true, + autoFocus: true, didInsertElement() { this._super(...arguments); @@ -28,14 +29,6 @@ export default Component.extend({ }, _afterFirstRender() { - if ( - !this.site.mobileView && - this.autoFocus !== "false" && - this.element.querySelector("input") - ) { - this.element.querySelector("input").focus(); - } - const maxHeight = this.maxHeight; if (maxHeight) { const maxHeightFloat = parseFloat(maxHeight) / 100.0; @@ -57,7 +50,8 @@ export default Component.extend({ "subtitle", "rawSubtitle", "dismissable", - "headerClass" + "headerClass", + "autoFocus" ) ); }, diff --git a/app/assets/javascripts/discourse/app/components/d-modal.js b/app/assets/javascripts/discourse/app/components/d-modal.js index 8bb6568242..533e03a29b 100644 --- a/app/assets/javascripts/discourse/app/components/d-modal.js +++ b/app/assets/javascripts/discourse/app/components/d-modal.js @@ -132,13 +132,22 @@ export default Component.extend({ this.set("headerClass", data.headerClass || null); - if (this.element) { - const autofocusInputs = this.element.querySelectorAll( + if (this.element && data.autoFocus) { + let focusTarget = this.element.querySelector( ".modal-body input[autofocus]" ); - if (autofocusInputs.length) { - afterTransition(() => autofocusInputs[0].focus()); + if (!focusTarget && !this.site.mobileView) { + focusTarget = this.element.querySelector( + ".modal-body input, .modal-body button, .modal-footer input, .modal-footer button" + ); + + if (!focusTarget) { + focusTarget = this.element.querySelector(".modal-header button"); + } + } + if (focusTarget) { + afterTransition(() => focusTarget.focus()); } } }, diff --git a/app/assets/javascripts/discourse/app/components/directory-item.js b/app/assets/javascripts/discourse/app/components/directory-item.js index 709ad7a856..0b557d6672 100644 --- a/app/assets/javascripts/discourse/app/components/directory-item.js +++ b/app/assets/javascripts/discourse/app/components/directory-item.js @@ -5,4 +5,5 @@ export default Component.extend({ tagName: "tr", classNameBindings: ["me"], me: propertyEqual("item.user.id", "currentUser.id"), + columns: null, }); diff --git a/app/assets/javascripts/discourse/app/components/directory-table.js b/app/assets/javascripts/discourse/app/components/directory-table.js new file mode 100644 index 0000000000..f1b819310e --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/directory-table.js @@ -0,0 +1,17 @@ +import Component from "@ember/component"; +import { action } from "@ember/object"; + +export default Component.extend({ + classNames: ["directory-table-container"], + + @action + setActiveHeader(header) { + // After render, scroll table left to ensure the order by column is visible + const scrollPixels = + header.offsetLeft + header.offsetWidth + 10 - this.element.offsetWidth; + + if (scrollPixels > 0) { + this.element.scrollLeft = scrollPixels; + } + }, +}); diff --git a/app/assets/javascripts/discourse/app/components/discovery-topics-list.js b/app/assets/javascripts/discourse/app/components/discovery-topics-list.js index 8665981311..5790316a18 100644 --- a/app/assets/javascripts/discourse/app/components/discovery-topics-list.js +++ b/app/assets/javascripts/discourse/app/components/discovery-topics-list.js @@ -21,8 +21,17 @@ const DiscoveryTopicsListComponent = Component.extend(UrlRefresh, LoadMore, { } }, - @observes("topicTrackingState.states") - _updateTopics() { + @on("didInsertElement") + _monitorTrackingState() { + this.topicTrackingState.onStateChange(() => this._updateTrackingTopics()); + }, + + @on("willDestroyElement") + _removeTrackingStateChangeMonitor() { + this.topicTrackingState.offStateChange(this.stateChangeCallbackId); + }, + + _updateTrackingTopics() { this.topicTrackingState.updateTopics(this.model.topics); }, diff --git a/app/assets/javascripts/discourse/app/components/footer-nav.js b/app/assets/javascripts/discourse/app/components/footer-nav.js index 9a99c1b4fc..449ab625e4 100644 --- a/app/assets/javascripts/discourse/app/components/footer-nav.js +++ b/app/assets/javascripts/discourse/app/components/footer-nav.js @@ -38,12 +38,13 @@ const FooterNavComponent = MountWidget.extend( } if (this.capabilities.isIpadOS) { - $("body").addClass("footer-nav-ipad"); + document.body.classList.add("footer-nav-ipad"); } else { this.bindScrolling({ name: "footer-nav" }); - $(window).on("resize.footer-nav-on-scroll", () => this.scrolled()); + window.addEventListener("resize", this.scrolled, false); this.appEvents.on("composer:opened", this, "_composerOpened"); this.appEvents.on("composer:closed", this, "_composerClosed"); + document.body.classList.add("footer-nav-visible"); } }, @@ -57,10 +58,10 @@ const FooterNavComponent = MountWidget.extend( } if (this.capabilities.isIpadOS) { - $("body").removeClass("footer-nav-ipad"); + document.body.classList.remove("footer-nav-ipad"); } else { this.unbindScrolling("footer-nav"); - $(window).unbind("resize.footer-nav-on-scroll"); + window.removeEventListener("resize", this.scrolled); this.appEvents.off("composer:opened", this, "_composerOpened"); this.appEvents.off("composer:closed", this, "_composerClosed"); } @@ -77,12 +78,10 @@ const FooterNavComponent = MountWidget.extend( return; } - const offset = window.pageYOffset || $("html").scrollTop(); - throttle( this, this.calculateDirection, - offset, + window.pageYOffset, MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE ); }, @@ -91,12 +90,11 @@ const FooterNavComponent = MountWidget.extend( // in the header, otherwise, we hide it. @observes("mobileScrollDirection") toggleMobileFooter() { - $(this.element).toggleClass( + this.element.classList.toggle( "visible", this.mobileScrollDirection === null ? true : false ); - // body class used to adjust positioning of #topic-progress-wrapper - $("body").toggleClass( + document.body.classList.toggle( "footer-nav-visible", this.mobileScrollDirection === null ? true : false ); @@ -126,14 +124,23 @@ const FooterNavComponent = MountWidget.extend( }, _modalOn() { - postRNWebviewMessage( - "headerBg", - $(".modal-backdrop").css("background-color") - ); + const backdrop = document.querySelector(".modal-backdrop"); + if (backdrop) { + postRNWebviewMessage( + "headerBg", + getComputedStyle(backdrop)["background-color"] + ); + } }, _modalOff() { - postRNWebviewMessage("headerBg", $(".d-header").css("background-color")); + const dheader = document.querySelector(".d-header"); + if (dheader) { + postRNWebviewMessage( + "headerBg", + getComputedStyle(dheader)["background-color"] + ); + } }, goBack() { diff --git a/app/assets/javascripts/discourse/app/components/future-date-input.js b/app/assets/javascripts/discourse/app/components/future-date-input.js index 5dfed3af56..b76c31d7eb 100644 --- a/app/assets/javascripts/discourse/app/components/future-date-input.js +++ b/app/assets/javascripts/discourse/app/components/future-date-input.js @@ -24,7 +24,6 @@ export default Component.extend({ date: datetime.format("YYYY-MM-DD"), time: datetime.format("HH:mm"), }); - this._updateInput(); } }, diff --git a/app/assets/javascripts/discourse/app/components/group-imap-email-settings.js b/app/assets/javascripts/discourse/app/components/group-imap-email-settings.js new file mode 100644 index 0000000000..012c117869 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/group-imap-email-settings.js @@ -0,0 +1,91 @@ +import Component from "@ember/component"; +import emailProviderDefaultSettings from "discourse/lib/email-provider-default-settings"; +import { isEmpty } from "@ember/utils"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import discourseComputed, { on } from "discourse-common/utils/decorators"; +import EmberObject, { action } from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; + +export default Component.extend({ + tagName: "", + form: null, + + @discourseComputed( + "group.email_username", + "group.email_password", + "form.imap_server", + "form.imap_port" + ) + missingSettings(email_username, email_password, imap_server, imap_port) { + return [ + email_username, + email_password, + imap_server, + imap_port, + ].some((value) => isEmpty(value)); + }, + + @discourseComputed("group.imap_mailboxes") + mailboxes(imapMailboxes) { + if (!imapMailboxes) { + return []; + } + return imapMailboxes.map((mailbox) => ({ name: mailbox, value: mailbox })); + }, + + @discourseComputed("group.imap_mailbox_name", "mailboxes.length") + mailboxSelected(mailboxName, mailboxesSize) { + return mailboxesSize === 0 || !isEmpty(mailboxName); + }, + + @action + resetSettingsValid() { + this.set("imapSettingsValid", false); + }, + + @on("init") + _fillForm() { + this.set( + "form", + EmberObject.create({ + imap_server: this.group.imap_server, + imap_port: (this.group.imap_port || "").toString(), + imap_ssl: this.group.imap_ssl, + }) + ); + }, + + @action + prefillSettings(provider) { + this.form.setProperties(emailProviderDefaultSettings(provider, "imap")); + }, + + @action + testImapSettings() { + const settings = { + host: this.form.imap_server, + port: this.form.imap_port, + ssl: this.form.imap_ssl, + username: this.group.email_username, + password: this.group.email_password, + }; + + this.set("testingSettings", true); + this.set("imapSettingsValid", false); + + return ajax(`/groups/${this.group.id}/test_email_settings`, { + type: "POST", + data: Object.assign(settings, { protocol: "imap" }), + }) + .then(() => { + this.set("imapSettingsValid", true); + this.group.setProperties({ + imap_server: this.form.imap_server, + imap_port: this.form.imap_port, + imap_ssl: this.form.imap_ssl, + }); + }) + .catch(popupAjaxError) + .finally(() => this.set("testingSettings", false)); + }, +}); diff --git a/app/assets/javascripts/discourse/app/components/group-manage-email-settings.js b/app/assets/javascripts/discourse/app/components/group-manage-email-settings.js new file mode 100644 index 0000000000..ffa586b32a --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/group-manage-email-settings.js @@ -0,0 +1,116 @@ +import Component from "@ember/component"; +import { isEmpty } from "@ember/utils"; +import discourseComputed, { on } from "discourse-common/utils/decorators"; +import I18n from "I18n"; +import bootbox from "bootbox"; +import { action } from "@ember/object"; + +export default Component.extend({ + tagName: "", + + imapSettingsValid: false, + smtpSettingsValid: false, + + @on("init") + _determineSettingsValid() { + this.set( + "imapSettingsValid", + this.group.imap_enabled && this.group.imap_server + ); + this.set( + "smtpSettingsValid", + this.group.smtp_enabled && this.group.smtp_server + ); + }, + + @discourseComputed( + "emailSettingsValid", + "group.smtp_enabled", + "group.imap_enabled" + ) + enableImapSettings(emailSettingsValid, smtpEnabled, imapEnabled) { + return smtpEnabled && (emailSettingsValid || imapEnabled); + }, + + @discourseComputed( + "smtpSettingsValid", + "imapSettingsValid", + "group.smtp_enabled", + "group.imap_enabled" + ) + emailSettingsValid( + smtpSettingsValid, + imapSettingsValid, + smtpEnabled, + imapEnabled + ) { + return ( + (!smtpEnabled || smtpSettingsValid) && (!imapEnabled || imapSettingsValid) + ); + }, + + _anySmtpFieldsFilled() { + return [ + this.group.smtp_server, + this.group.smtp_port, + this.group.email_username, + this.group.email_password, + ].some((value) => !isEmpty(value)); + }, + + _anyImapFieldsFilled() { + return [this.group.imap_server, this.group.imap_port].some( + (value) => !isEmpty(value) + ); + }, + + @action + smtpEnabledChange(event) { + if ( + !event.target.checked && + this.group.smtp_enabled && + this._anySmtpFieldsFilled() + ) { + bootbox.confirm( + I18n.t("groups.manage.email.smtp_disable_confirm"), + (result) => { + if (!result) { + this.group.set("smtp_enabled", true); + } else { + this.group.set("imap_enabled", false); + } + } + ); + } + + this.group.set("smtp_enabled", event.target.checked); + }, + + @action + imapEnabledChange(event) { + if ( + !event.target.checked && + this.group.imap_enabled && + this._anyImapFieldsFilled() + ) { + bootbox.confirm( + I18n.t("groups.manage.email.imap_disable_confirm"), + (result) => { + if (!result) { + this.group.set("imap_enabled", true); + } + } + ); + } + + this.group.set("imap_enabled", event.target.checked); + }, + + @action + afterSave() { + // reload the group to get the updated imap_mailboxes + this.store.find("group", this.group.name).then(() => { + this._determineSettingsValid(); + }); + }, +}); diff --git a/app/assets/javascripts/discourse/app/components/group-manage-save-button.js b/app/assets/javascripts/discourse/app/components/group-manage-save-button.js index 0a92d4bd84..82cc182b7d 100644 --- a/app/assets/javascripts/discourse/app/components/group-manage-save-button.js +++ b/app/assets/javascripts/discourse/app/components/group-manage-save-button.js @@ -7,6 +7,7 @@ import { popupAutomaticMembershipAlert } from "discourse/controllers/groups-new" export default Component.extend({ saving: null, + disabled: false, @discourseComputed("saving") savingText(saving) { @@ -15,6 +16,10 @@ export default Component.extend({ actions: { save() { + if (this.beforeSave) { + this.beforeSave(); + } + this.set("saving", true); const group = this.model; @@ -31,6 +36,10 @@ export default Component.extend({ } this.set("saved", true); + + if (this.afterSave) { + this.afterSave(); + } }) .catch(popupAjaxError) .finally(() => this.set("saving", false)); diff --git a/app/assets/javascripts/discourse/app/components/group-smtp-email-settings.js b/app/assets/javascripts/discourse/app/components/group-smtp-email-settings.js new file mode 100644 index 0000000000..d9758d2c46 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/group-smtp-email-settings.js @@ -0,0 +1,82 @@ +import Component from "@ember/component"; +import emailProviderDefaultSettings from "discourse/lib/email-provider-default-settings"; +import { isEmpty } from "@ember/utils"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import discourseComputed, { on } from "discourse-common/utils/decorators"; +import EmberObject, { action } from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; + +export default Component.extend({ + tagName: "", + form: null, + + @discourseComputed( + "form.email_username", + "form.email_password", + "form.smtp_server", + "form.smtp_port" + ) + missingSettings(email_username, email_password, smtp_server, smtp_port) { + return [ + email_username, + email_password, + smtp_server, + smtp_port, + ].some((value) => isEmpty(value)); + }, + + @action + resetSettingsValid() { + this.set("smtpSettingsValid", false); + }, + + @on("init") + _fillForm() { + this.set( + "form", + EmberObject.create({ + email_username: this.group.email_username, + email_password: this.group.email_password, + smtp_server: this.group.smtp_server, + smtp_port: (this.group.smtp_port || "").toString(), + smtp_ssl: this.group.smtp_ssl, + }) + ); + }, + + @action + prefillSettings(provider) { + this.form.setProperties(emailProviderDefaultSettings(provider, "smtp")); + }, + + @action + testSmtpSettings() { + const settings = { + host: this.form.smtp_server, + port: this.form.smtp_port, + ssl: this.form.smtp_ssl, + username: this.form.email_username, + password: this.form.email_password, + }; + + this.set("testingSettings", true); + this.set("smtpSettingsValid", false); + + return ajax(`/groups/${this.group.id}/test_email_settings`, { + type: "POST", + data: Object.assign(settings, { protocol: "smtp" }), + }) + .then(() => { + this.set("smtpSettingsValid", true); + this.group.setProperties({ + smtp_server: this.form.smtp_server, + smtp_port: this.form.smtp_port, + smtp_ssl: this.form.smtp_ssl, + email_username: this.form.email_username, + email_password: this.form.email_password, + }); + }) + .catch(popupAjaxError) + .finally(() => this.set("testingSettings", false)); + }, +}); diff --git a/app/assets/javascripts/discourse/app/components/navigation-bar.js b/app/assets/javascripts/discourse/app/components/navigation-bar.js index 5c354a80f5..546841c8a5 100644 --- a/app/assets/javascripts/discourse/app/components/navigation-bar.js +++ b/app/assets/javascripts/discourse/app/components/navigation-bar.js @@ -52,6 +52,10 @@ export default Component.extend(FilterModeMixin, { }, ensureDropClosed() { + if (!this.element || this.isDestroying || this.isDestroyed) { + return; + } + if (this.expanded) { this.set("expanded", false); } @@ -75,17 +79,13 @@ export default Component.extend(FilterModeMixin, { this.element.querySelector(".drop").style.display = "none"; next(() => { - if (!this.element || this.isDestroying || this.isDestroyed) { - return; - } - this.set("expanded", false); + this.ensureDropClosed(); }); - return true; }); $(window).on("click.navigation-bar", () => { - this.set("expanded", false); + this.ensureDropClosed(); return true; }); }); diff --git a/app/assets/javascripts/discourse/app/components/popup-input-tip.js b/app/assets/javascripts/discourse/app/components/popup-input-tip.js index 9d02aeff01..e94a2a8786 100644 --- a/app/assets/javascripts/discourse/app/components/popup-input-tip.js +++ b/app/assets/javascripts/discourse/app/components/popup-input-tip.js @@ -5,6 +5,7 @@ import { iconHTML } from "discourse-common/lib/icon-library"; export default Component.extend({ classNameBindings: [":popup-tip", "good", "bad", "lastShownAt::hide"], + attributeBindings: ["role"], animateAttribute: null, bouncePixels: 6, bounceDelay: 100, @@ -12,6 +13,13 @@ export default Component.extend({ closeIcon: `${iconHTML("times-circle")}`.htmlSafe(), tipReason: null, + @discourseComputed("bad") + role(bad) { + if (bad) { + return "alert"; + } + }, + click() { this.set("shownAt", null); this.set("validation.lastShownAt", null); diff --git a/app/assets/javascripts/discourse/app/components/reviewable-item.js b/app/assets/javascripts/discourse/app/components/reviewable-item.js index bf436f4a07..23071cac96 100644 --- a/app/assets/javascripts/discourse/app/components/reviewable-item.js +++ b/app/assets/javascripts/discourse/app/components/reviewable-item.js @@ -29,12 +29,17 @@ export default Component.extend({ @discourseComputed( "reviewable.type", + "reviewable.stale", "siteSettings.blur_tl0_flagged_posts_media", "reviewable.target_created_by_trust_level" ) - customClasses(type, blurEnabled, trustLevel) { + customClasses(type, stale, blurEnabled, trustLevel) { let classes = type.dasherize(); + if (stale) { + classes = `${classes} reviewable-stale`; + } + if (blurEnabled && trustLevel === 0) { classes = `${classes} blur-images`; } diff --git a/app/assets/javascripts/discourse/app/components/site-header.js b/app/assets/javascripts/discourse/app/components/site-header.js index eac53665d6..b037a30003 100644 --- a/app/assets/javascripts/discourse/app/components/site-header.js +++ b/app/assets/javascripts/discourse/app/components/site-header.js @@ -1,6 +1,5 @@ import PanEvents, { SWIPE_DISTANCE_THRESHOLD, - SWIPE_VELOCITY, SWIPE_VELOCITY_THRESHOLD, } from "discourse/mixins/pan-events"; import { cancel, later, schedule } from "@ember/runloop"; @@ -23,7 +22,6 @@ const SiteHeaderComponent = MountWidget.extend( _isPanning: false, _panMenuOrigin: "right", _panMenuOffset: 0, - _scheduledMovingAnimation: null, _scheduledRemoveAnimate: null, _topic: null, _mousetrap: null, @@ -37,26 +35,44 @@ const SiteHeaderComponent = MountWidget.extend( this.queueRerender(); }, - _animateOpening($panel) { - $panel.css({ right: "", left: "" }); + _animateOpening(panel) { + const headerCloak = document.querySelector(".header-cloak"); + panel.classList.add("animate"); + headerCloak.classList.add("animate"); + this._scheduledRemoveAnimate = later(() => { + panel.classList.remove("animate"); + headerCloak.classList.remove("animate"); + }, 200); + panel.style.setProperty("--offset", 0); + headerCloak.style.setProperty("--opacity", 0.5); this._panMenuOffset = 0; }, - _animateClosing($panel, menuOrigin, windowWidth) { - $panel.css(menuOrigin, -windowWidth); + _animateClosing(panel, menuOrigin) { + const windowWidth = document.body.offsetWidth; this._animate = true; - schedule("afterRender", () => { - this.eventDispatched("dom:clean", "header"); - this._panMenuOffset = 0; - }); + const headerCloak = document.querySelector(".header-cloak"); + panel.classList.add("animate"); + headerCloak.classList.add("animate"); + const offsetDirection = menuOrigin === "left" ? -1 : 1; + panel.style.setProperty("--offset", `${offsetDirection * windowWidth}px`); + headerCloak.style.setProperty("--opacity", 0); + this._scheduledRemoveAnimate = later(() => { + panel.classList.remove("animate"); + headerCloak.classList.remove("animate"); + schedule("afterRender", () => { + this.eventDispatched("dom:clean", "header"); + this._panMenuOffset = 0; + }); + }, 200); }, _isRTL() { - return $("html").css("direction") === "rtl"; + return document.querySelector("html").classList["direction"] === "rtl"; }, _leftMenuClass() { - return this._isRTL() ? ".user-menu" : ".hamburger-panel"; + return this._isRTL() ? "user-menu" : "hamburger-panel"; }, _leftMenuAction() { @@ -67,28 +83,14 @@ const SiteHeaderComponent = MountWidget.extend( return this._isRTL() ? "toggleHamburger" : "toggleUserMenu"; }, - _handlePanDone(offset, event) { - const $window = $(window); - const windowWidth = $window.width(); - const $menuPanels = $(".menu-panel"); + _handlePanDone(event) { + const menuPanels = document.querySelectorAll(".menu-panel"); const menuOrigin = this._panMenuOrigin; - this._shouldMenuClose(event, menuOrigin) - ? (offset += SWIPE_VELOCITY) - : (offset -= SWIPE_VELOCITY); - $menuPanels.each((idx, panel) => { - const $panel = $(panel); - const $headerCloak = $(".header-cloak"); - $panel.css(menuOrigin, -offset); - $headerCloak.css("opacity", Math.min(0.5, (300 - offset) / 600)); - if (offset > windowWidth) { - this._animateClosing($panel, menuOrigin, windowWidth); - } else if (offset <= 0) { - this._animateOpening($panel); + menuPanels.forEach((panel) => { + if (this._shouldMenuClose(event, menuOrigin)) { + this._animateClosing(panel, menuOrigin); } else { - //continue to open or close menu - this._scheduledMovingAnimation = window.requestAnimationFrame(() => - this._handlePanDone(offset, event) - ); + this._animateOpening(panel); } }); }, @@ -114,11 +116,15 @@ const SiteHeaderComponent = MountWidget.extend( panStart(e) { const center = e.center; - const $centeredElement = $(document.elementFromPoint(center.x, center.y)); + const panOverValidElement = document + .elementsFromPoint(center.x, center.y) + .some( + (ele) => + ele.classList.contains("panel-body") || + ele.classList.contains("header-cloak") + ); if ( - ($centeredElement.hasClass("panel-body") || - $centeredElement.hasClass("header-cloak") || - $centeredElement.parents(".panel-body").length) && + panOverValidElement && (e.direction === "left" || e.direction === "right") ) { e.originalEvent.preventDefault(); @@ -133,57 +139,51 @@ const SiteHeaderComponent = MountWidget.extend( return; } this._isPanning = false; - $(".menu-panel").each((idx, panel) => { - const $panel = $(panel); - let offset = $panel.css("right"); - if (this._panMenuOrigin === "left") { - offset = $panel.css("left"); - } - offset = Math.abs(parseInt(offset, 10)); - this._handlePanDone(offset, e); - }); + this._handlePanDone(e); }, panMove(e) { if (!this._isPanning) { return; } - const $menuPanels = $(".menu-panel"); - $menuPanels.each((idx, panel) => { - const $panel = $(panel); - const $headerCloak = $(".header-cloak"); - if (this._panMenuOrigin === "right") { - const pxClosed = Math.min(0, -e.deltaX + this._panMenuOffset); - $panel.css("right", pxClosed); - $headerCloak.css("opacity", Math.min(0.5, (300 + pxClosed) / 600)); - } else { - const pxClosed = Math.min(0, e.deltaX + this._panMenuOffset); - $panel.css("left", pxClosed); - $headerCloak.css("opacity", Math.min(0.5, (300 + pxClosed) / 600)); - } - }); + const panel = document.querySelector(".menu-panel"); + const headerCloak = document.querySelector(".header-cloak"); + if (this._panMenuOrigin === "right") { + const pxClosed = Math.min(0, -e.deltaX + this._panMenuOffset); + panel.style.setProperty("--offset", `${-pxClosed}px`); + headerCloak.style.setProperty( + "--opacity", + Math.min(0.5, (300 + pxClosed) / 600) + ); + } else { + const pxClosed = Math.min(0, e.deltaX + this._panMenuOffset); + panel.style.setProperty("--offset", `${pxClosed}px`); + headerCloak.style.setProperty( + "--opacity", + Math.min(0.5, (300 + pxClosed) / 600) + ); + } }, dockCheck(info) { - const $header = $("header.d-header"); + const header = document.querySelector("header.d-header"); if (this.docAt === null) { - if (!($header && $header.length === 1)) { + if (!header) { return; } - this.docAt = $header.offset().top; + this.docAt = header.offsetTop; } - const $body = $("body"); const offset = info.offset(); if (offset >= this.docAt) { if (!this.dockedHeader) { - $body.addClass("docked"); + document.body.classList.add("docked"); this.dockedHeader = true; } } else { if (this.dockedHeader) { - $body.removeClass("docked"); + document.body.classList.remove("docked"); this.dockedHeader = false; } } @@ -197,13 +197,14 @@ const SiteHeaderComponent = MountWidget.extend( willRender() { if (this.get("currentUser.staff")) { - $("body").addClass("staff"); + document.body.classList.add("staff"); } }, didInsertElement() { this._super(...arguments); - $(window).on("resize.discourse-menu-panel", () => this.afterRender()); + this._resizeDiscourseMenuPanel = () => this.afterRender(); + window.addEventListener("resize", this._resizeDiscourseMenuPanel); this.appEvents.on("header:show-topic", this, "setTopic"); this.appEvents.on("header:hide-topic", this, "setTopic"); @@ -279,14 +280,13 @@ const SiteHeaderComponent = MountWidget.extend( willDestroyElement() { this._super(...arguments); - $(window).off("resize.discourse-menu-panel"); + window.removeEventListener("resize", this._resizeDiscourseMenuPanel); this.appEvents.off("header:show-topic", this, "setTopic"); this.appEvents.off("header:hide-topic", this, "setTopic"); this.appEvents.off("dom:clean", this, "_cleanDom"); cancel(this._scheduledRemoveAnimate); - window.cancelAnimationFrame(this._scheduledMovingAnimation); this._mousetrap.reset(); @@ -308,25 +308,24 @@ const SiteHeaderComponent = MountWidget.extend( ); } - const $menuPanels = $(".menu-panel"); - if ($menuPanels.length === 0) { + const menuPanels = document.querySelectorAll(".menu-panel"); + if (menuPanels.length === 0) { if (this.site.mobileView) { this._animate = true; } return; } - const $window = $(window); - const windowWidth = $window.width(); - - const headerWidth = $("#main-outlet .container").width() || 1100; + const windowWidth = document.body.offsetWidth; + const headerWidth = + document.querySelector("#main-outlet .container").offsetWidth || 1100; const remaining = (windowWidth - headerWidth) / 2; - const viewMode = remaining < 50 ? "slide-in" : "drop-down"; + const viewMode = + this.site.mobileView || remaining < 50 ? "slide-in" : "drop-down"; - $menuPanels.each((idx, panel) => { - const $panel = $(panel); - const $headerCloak = $(".header-cloak"); - let width = parseInt($panel.attr("data-max-width"), 10) || 300; + menuPanels.forEach((panel) => { + const headerCloak = document.querySelector(".header-cloak"); + let width = parseInt(panel.getAttribute("data-max-width"), 10) || 300; if (windowWidth - width < 50) { width = windowWidth - 50; } @@ -334,51 +333,51 @@ const SiteHeaderComponent = MountWidget.extend( this._panMenuOffset = -width; } - $panel.removeClass("drop-down slide-in").addClass(viewMode); + panel.classList.remove("drop-down"); + panel.classList.remove("slide-in"); + panel.classList.add(viewMode); if (this._animate || this._panMenuOffset !== 0) { - $headerCloak.css("opacity", 0); if ( this.site.mobileView && - $panel.parent(this._leftMenuClass()).length > 0 + panel.parentElement.classList.contains(this._leftMenuClass()) ) { this._panMenuOrigin = "left"; - $panel.css("left", -windowWidth); + panel.style.setProperty("--offset", `${-windowWidth}px`); } else { this._panMenuOrigin = "right"; - $panel.css("right", -windowWidth); + panel.style.setProperty("--offset", `${windowWidth}px`); } + headerCloak.style.setProperty("--opacity", 0); } - const $panelBody = $(".panel-body", $panel); + const panelBody = panel.querySelector(".panel-body"); // We use a mutationObserver to check for style changes, so it's important - // we don't set it if it doesn't change. Same goes for the $panelBody! - const style = $panel.prop("style"); + // we don't set it if it doesn't change. Same goes for the panelBody! if (viewMode === "drop-down") { - const $buttonPanel = $("header ul.icons"); - if ($buttonPanel.length === 0) { + const buttonPanel = document.querySelectorAll("header ul.icons"); + if (buttonPanel.length === 0) { return; } // These values need to be set here, not in the css file - this is to deal with the // possibility of the window being resized and the menu changing from .slide-in to .drop-down. - if (style.top !== "100%" || style.height !== "auto") { - $panel.css({ top: "100%", height: "auto" }); + if (panel.style.top !== "100%" || panel.style.height !== "auto") { + panel.style.setProperty("top", "100%"); + panel.style.setProperty("height", "auto"); } - $("body").addClass("drop-down-mode"); + document.body.classList.add("drop-down-mode"); } else { if (this.site.mobileView) { - $headerCloak.show(); + headerCloak.style.display = "block"; } const menuTop = this.site.mobileView ? headerTop() : headerHeight(); const winHeightOffset = 16; - let initialWinHeight = window.innerHeight - ? window.innerHeight - : $(window).height(); + let initialWinHeight = window.innerHeight; const winHeight = initialWinHeight - winHeightOffset; let height; @@ -394,27 +393,26 @@ const SiteHeaderComponent = MountWidget.extend( height = winHeight - menuTop - iPadOffset; } - if ($panelBody.prop("style").height !== "100%") { - $panelBody.height("100%"); + if (panelBody.style.height !== "100%") { + panelBody.style.setProperty("height", "100%"); } - if (style.top !== menuTop + "px" || style[heightProp] !== height) { - $panel.css({ top: menuTop + "px", [heightProp]: height }); - $(".header-cloak").css({ top: menuTop + "px" }); + if ( + panel.style.top !== `${menuTop}px` || + panel.style[heightProp] !== `${height}px` + ) { + panel.style.top = `${menuTop}px`; + panel.style.setProperty(heightProp, `${height}px`); + if (headerCloak) { + headerCloak.style.top = `${menuTop}px`; + } } - $("body").removeClass("drop-down-mode"); + document.body.classList.remove("drop-down-mode"); } - $panel.width(width); + panel.style.setProperty("width", `${width}px`); if (this._animate) { - $panel.addClass("animate"); - $headerCloak.addClass("animate"); - this._scheduledRemoveAnimate = later(() => { - $panel.removeClass("animate"); - $headerCloak.removeClass("animate"); - }, 200); + this._animateOpening(panel); } - $panel.css({ right: "", left: "" }); - $headerCloak.css("opacity", 0.5); this._animate = false; }); }, @@ -426,21 +424,19 @@ export default SiteHeaderComponent.extend({ }); export function headerHeight() { - const $header = $("header.d-header"); + const header = document.querySelector("header.d-header"); // Header may not exist in tests (e.g. in the user menu component test). - if ($header.length === 0) { + if (!header) { return 0; } - const headerOffset = $header.offset(); - const headerOffsetTop = headerOffset ? headerOffset.top : 0; - return $header.outerHeight() + headerOffsetTop - $(window).scrollTop(); + const headerOffsetTop = header.offsetTop ? header.offsetTop : 0; + return header.offsetHeight + headerOffsetTop - document.body.scrollTop; } export function headerTop() { - const $header = $("header.d-header"); - const headerOffset = $header.offset(); - const headerOffsetTop = headerOffset ? headerOffset.top : 0; - return headerOffsetTop - $(window).scrollTop(); + const header = document.querySelector("header.d-header"); + const headerOffsetTop = header.offsetTop ? header.offsetTop : 0; + return headerOffsetTop - document.body.scrollTop; } diff --git a/app/assets/javascripts/discourse/app/components/software-update-prompt.js b/app/assets/javascripts/discourse/app/components/software-update-prompt.js index 73424b6513..b8a864f0db 100644 --- a/app/assets/javascripts/discourse/app/components/software-update-prompt.js +++ b/app/assets/javascripts/discourse/app/components/software-update-prompt.js @@ -9,6 +9,7 @@ export default Component.extend({ tagName: "", showPrompt: false, + animatePrompt: false, _timeoutHandler: null, @discourseComputed @@ -29,18 +30,34 @@ export default Component.extend({ if (!this._timeoutHandler && this.session.requiresRefresh) { if (isTesting()) { - this.set("showPrompt", true); + this.updatePromptState(true); } else { // Since we can do this transparently for people browsing the forum // hold back the message 24 hours. this._timeoutHandler = later(() => { - this.set("showPrompt", true); + this.updatePromptState(true); }, 1000 * 60 * 24 * 60); } } }); }, + updatePromptState(value) { + // when adding the message, we inject the HTML then add the animation + // when dismissing, things need to happen in the opposite order + const firstProp = value ? "showPrompt" : "animatePrompt", + secondProp = value ? "animatePrompt" : "showPrompt"; + + this.set(firstProp, value); + if (isTesting()) { + this.set(secondProp, value); + } else { + later(() => { + this.set(secondProp, value); + }, 500); + } + }, + @action refreshPage() { document.location.reload(); @@ -48,7 +65,7 @@ export default Component.extend({ @action dismiss() { - this.set("showPrompt", false); + this.updatePromptState(false); }, @on("willDestroyElement") diff --git a/app/assets/javascripts/discourse/app/components/table-header-toggle.js b/app/assets/javascripts/discourse/app/components/table-header-toggle.js index 0ab184a1e4..afe7ef32eb 100644 --- a/app/assets/javascripts/discourse/app/components/table-header-toggle.js +++ b/app/assets/javascripts/discourse/app/components/table-header-toggle.js @@ -1,6 +1,4 @@ import Component from "@ember/component"; -import I18n from "I18n"; -import discourseComputed from "discourse-common/utils/decorators"; import { iconHTML } from "discourse-common/lib/icon-library"; export default Component.extend({ @@ -10,15 +8,8 @@ export default Component.extend({ labelKey: null, chevronIcon: null, columnIcon: null, - - @discourseComputed("field", "labelKey") - title(field, labelKey) { - if (!labelKey) { - labelKey = `directory.${this.field}`; - } - - return I18n.t(labelKey + "_long", { defaultValue: I18n.t(labelKey) }); - }, + translated: false, + onActiveRender: null, toggleProperties() { if (this.order === this.field) { @@ -40,13 +31,12 @@ export default Component.extend({ }, didReceiveAttrs() { this._super(...arguments); + this.set("id", `table-header-toggle-${this.field.replace(/\s/g, "")}`); this.toggleChevron(); }, - init() { - this._super(...arguments); - if (this.icon) { - let columnIcon = iconHTML(this.icon); - this.set("columnIcon", `${columnIcon}`.htmlSafe()); + didRender() { + if (this.onActiveRender && this.chevronIcon) { + this.onActiveRender(this.element); } }, }); diff --git a/app/assets/javascripts/discourse/app/components/tag-groups-form.js b/app/assets/javascripts/discourse/app/components/tag-groups-form.js index 8bf218f1cb..dde6b73ef0 100644 --- a/app/assets/javascripts/discourse/app/components/tag-groups-form.js +++ b/app/assets/javascripts/discourse/app/components/tag-groups-form.js @@ -5,9 +5,11 @@ import PermissionType from "discourse/models/permission-type"; import bootbox from "bootbox"; import { bufferedProperty } from "discourse/mixins/buffered-content"; import discourseComputed from "discourse-common/utils/decorators"; +import { inject as service } from "@ember/service"; import { isEmpty } from "@ember/utils"; export default Component.extend(bufferedProperty("model"), { + router: service(), tagName: "", allGroups: null, @@ -36,15 +38,6 @@ export default Component.extend(bufferedProperty("model"), { ); }, - @discourseComputed("buffered.permissions") - showPrivateChooser(permissions) { - if (!permissions) { - return true; - } - - return permissions.everyone !== PermissionType.READONLY; - }, - @discourseComputed("buffered.permissions", "allGroups") selectedGroupIds(permissions, allGroups) { if (!permissions || !allGroups) { @@ -140,6 +133,8 @@ export default Component.extend(bufferedProperty("model"), { if (this.onSave) { this.onSave(); + } else { + this.router.transitionTo("tagGroups.index"); } }); }, diff --git a/app/assets/javascripts/discourse/app/components/tags-admin-dropdown.js b/app/assets/javascripts/discourse/app/components/tags-admin-dropdown.js index 1f1da87149..aaf32f8259 100644 --- a/app/assets/javascripts/discourse/app/components/tags-admin-dropdown.js +++ b/app/assets/javascripts/discourse/app/components/tags-admin-dropdown.js @@ -8,7 +8,7 @@ export default DropdownSelectBoxComponent.extend({ actionsMapping: null, selectKitOptions: { - icons: ["bars", "caret-down"], + icons: ["wrench", "caret-down"], showFullTitle: false, }, @@ -18,7 +18,7 @@ export default DropdownSelectBoxComponent.extend({ id: "manageGroups", name: I18n.t("tagging.manage_groups"), description: I18n.t("tagging.manage_groups_description"), - icon: "wrench", + icon: "tags", }, { id: "uploadTags", diff --git a/app/assets/javascripts/discourse/app/components/topic-dismiss-buttons.js b/app/assets/javascripts/discourse/app/components/topic-dismiss-buttons.js new file mode 100644 index 0000000000..5092bfe2df --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-dismiss-buttons.js @@ -0,0 +1,105 @@ +import { action } from "@ember/object"; +import showModal from "discourse/lib/show-modal"; +import { later } from "@ember/runloop"; +import isElementInViewport from "discourse/lib/is-element-in-viewport"; +import discourseComputed, { on } from "discourse-common/utils/decorators"; +import I18n from "I18n"; +import Component from "@ember/component"; + +export default Component.extend({ + tagName: "", + classNames: ["topic-dismiss-buttons"], + + position: null, + selectedTopics: null, + model: null, + + @discourseComputed("position") + containerClass(position) { + return `dismiss-container-${position}`; + }, + + @discourseComputed("position") + dismissReadId(position) { + return `dismiss-topics-${position}`; + }, + + @discourseComputed("position") + dismissNewId(position) { + return `dismiss-new-${position}`; + }, + + @discourseComputed( + "position", + "isOtherDismissUnreadButtonVisible", + "isOtherDismissNewButtonVisible" + ) + showBasedOnPosition( + position, + isOtherDismissUnreadButtonVisible, + isOtherDismissNewButtonVisible + ) { + if (position !== "top") { + return true; + } + + return !( + isOtherDismissUnreadButtonVisible || isOtherDismissNewButtonVisible + ); + }, + + @discourseComputed("selectedTopics.length") + dismissLabel(selectedTopicCount) { + if (selectedTopicCount === 0) { + return I18n.t("topics.bulk.dismiss_button"); + } + return I18n.t("topics.bulk.dismiss_button_with_selected", { + count: selectedTopicCount, + }); + }, + + @discourseComputed("selectedTopics.length") + dismissNewLabel(selectedTopicCount) { + if (selectedTopicCount === 0) { + return I18n.t("topics.bulk.dismiss_new"); + } + return I18n.t("topics.bulk.dismiss_new_with_selected", { + count: selectedTopicCount, + }); + }, + + // we want to only render the Dismiss... button at the top of the + // page if the user cannot see the bottom Dismiss... button based on their + // viewport, or if too many topics fill the page + @on("didInsertElement") + _determineOtherDismissVisibility() { + later(() => { + if (this.position === "top") { + this.set( + "isOtherDismissUnreadButtonVisible", + isElementInViewport(document.getElementById("dismiss-topics-bottom")) + ); + this.set( + "isOtherDismissNewButtonVisible", + isElementInViewport(document.getElementById("dismiss-new-bottom")) + ); + } else { + this.set("isOtherDismissUnreadButtonVisible", true); + this.set("isOtherDismissNewButtonVisible", true); + } + }); + }, + + @action + dismissReadPosts() { + let dismissTitle = "topics.bulk.dismiss_read"; + if (this.selectedTopics.length > 0) { + dismissTitle = "topics.bulk.dismiss_read_with_selected"; + } + showModal("dismiss-read", { + titleTranslated: I18n.t(dismissTitle, { + count: this.selectedTopics.length, + }), + }); + }, +}); diff --git a/app/assets/javascripts/discourse/app/components/topic-footer-buttons.js b/app/assets/javascripts/discourse/app/components/topic-footer-buttons.js index 270f488b6f..e0167ebf7d 100644 --- a/app/assets/javascripts/discourse/app/components/topic-footer-buttons.js +++ b/app/assets/javascripts/discourse/app/components/topic-footer-buttons.js @@ -6,6 +6,10 @@ import { getTopicFooterButtons } from "discourse/lib/register-topic-footer-butto export default Component.extend({ elementId: "topic-footer-buttons", + attributeBindings: ["role"], + + role: "region", + // Allow us to extend it layoutName: "components/topic-footer-buttons", diff --git a/app/assets/javascripts/discourse/app/components/topic-list-item.js b/app/assets/javascripts/discourse/app/components/topic-list-item.js index 3019433078..8b05472b1d 100644 --- a/app/assets/javascripts/discourse/app/components/topic-list-item.js +++ b/app/assets/javascripts/discourse/app/components/topic-list-item.js @@ -38,8 +38,10 @@ export function navigateToTopic(topic, href) { export default Component.extend({ tagName: "tr", classNameBindings: [":topic-list-item", "unboundClassNames", "topic.visited"], - attributeBindings: ["data-topic-id"], + attributeBindings: ["data-topic-id", "role", "ariaLevel:aria-level"], "data-topic-id": alias("topic.id"), + role: "heading", + ariaLevel: "2", didReceiveAttrs() { this._super(...arguments); diff --git a/app/assets/javascripts/discourse/app/components/topic-navigation.js b/app/assets/javascripts/discourse/app/components/topic-navigation.js index 5b58ff9147..93d39a2660 100644 --- a/app/assets/javascripts/discourse/app/components/topic-navigation.js +++ b/app/assets/javascripts/discourse/app/components/topic-navigation.js @@ -1,6 +1,5 @@ import PanEvents, { SWIPE_DISTANCE_THRESHOLD, - SWIPE_VELOCITY, SWIPE_VELOCITY_THRESHOLD, } from "discourse/mixins/pan-events"; import Component from "@ember/component"; @@ -127,17 +126,18 @@ export default Component.extend(PanEvents, { const $timelineContainer = $(".timeline-container"); const maxOffset = parseInt($timelineContainer.css("height"), 10); - this._shouldPanClose(event) - ? (offset += SWIPE_VELOCITY) - : (offset -= SWIPE_VELOCITY); - - $timelineContainer.css("bottom", -offset); - if (offset > maxOffset) { - this._collapseFullscreen(); - } else if (offset <= 0) { - $timelineContainer.css("bottom", ""); + $timelineContainer.addClass("animate"); + if (this._shouldPanClose(event)) { + $timelineContainer.css("--offset", `${maxOffset}px`); + later(() => { + this._collapseFullscreen(); + $timelineContainer.removeClass("animate"); + }, 200); } else { - later(() => this._handlePanDone(offset, event), 20); + $timelineContainer.css("--offset", 0); + later(() => { + $timelineContainer.removeClass("animate"); + }, 200); } }, @@ -174,7 +174,7 @@ export default Component.extend(PanEvents, { return; } e.originalEvent.preventDefault(); - $(".timeline-container").css("bottom", Math.min(0, -e.deltaY)); + $(".timeline-container").css("--offset", `${Math.max(0, e.deltaY)}px`); }, didInsertElement() { diff --git a/app/assets/javascripts/discourse/app/components/watch-read.js b/app/assets/javascripts/discourse/app/components/watch-read.js index 6603068980..e375f4e183 100644 --- a/app/assets/javascripts/discourse/app/components/watch-read.js +++ b/app/assets/javascripts/discourse/app/components/watch-read.js @@ -13,7 +13,7 @@ export default Component.extend({ if (path === "faq" || path === "guidelines") { $(window).on("load.faq resize.faq scroll.faq", () => { const faqUnread = !currentUser.get("read_faq"); - if (faqUnread && isElementInViewport($(".contents p").last())) { + if (faqUnread && isElementInViewport($(".contents p").last()[0])) { this.action(); } }); diff --git a/app/assets/javascripts/discourse/app/controllers/about.js b/app/assets/javascripts/discourse/app/controllers/about.js index 26f3761bfd..b22ce0d0cd 100644 --- a/app/assets/javascripts/discourse/app/controllers/about.js +++ b/app/assets/javascripts/discourse/app/controllers/about.js @@ -6,20 +6,15 @@ import { gt } from "@ember/object/computed"; export default Controller.extend({ faqOverriden: gt("siteSettings.faq_url.length", 0), - @discourseComputed - contactInfo() { - if (this.siteSettings.contact_url) { + @discourseComputed("model.contact_url", "model.contact_email") + contactInfo(url, email) { + if (url) { return I18n.t("about.contact_info", { - contact_info: - "" + - this.siteSettings.contact_url + - "", + contact_info: `${url}`, }); - } else if (this.siteSettings.contact_email) { + } else if (email) { return I18n.t("about.contact_info", { - contact_info: this.siteSettings.contact_email, + contact_info: email, }); } else { return null; diff --git a/app/assets/javascripts/discourse/app/controllers/change-owner.js b/app/assets/javascripts/discourse/app/controllers/change-owner.js index fe2a7c537c..96db63e3d4 100644 --- a/app/assets/javascripts/discourse/app/controllers/change-owner.js +++ b/app/assets/javascripts/discourse/app/controllers/change-owner.js @@ -1,4 +1,4 @@ -import Controller, { inject } from "@ember/controller"; +import Controller, { inject as controller } from "@ember/controller"; import DiscourseURL from "discourse/lib/url"; import I18n from "I18n"; import ModalFunctionality from "discourse/mixins/modal-functionality"; @@ -9,7 +9,7 @@ import { isEmpty } from "@ember/utils"; import { next } from "@ember/runloop"; export default Controller.extend(ModalFunctionality, { - topicController: inject("topic"), + topicController: controller("topic"), saving: false, newOwner: null, diff --git a/app/assets/javascripts/discourse/app/controllers/change-timestamp.js b/app/assets/javascripts/discourse/app/controllers/change-timestamp.js index 8e41d3aa04..07915407d5 100644 --- a/app/assets/javascripts/discourse/app/controllers/change-timestamp.js +++ b/app/assets/javascripts/discourse/app/controllers/change-timestamp.js @@ -1,4 +1,4 @@ -import Controller, { inject } from "@ember/controller"; +import Controller, { inject as controller } from "@ember/controller"; import DiscourseURL from "discourse/lib/url"; import I18n from "I18n"; import ModalFunctionality from "discourse/mixins/modal-functionality"; @@ -9,7 +9,7 @@ import { next } from "@ember/runloop"; // Modal related to changing the timestamp of posts export default Controller.extend(ModalFunctionality, { - topicController: inject("topic"), + topicController: controller("topic"), saving: false, date: "", time: "", diff --git a/app/assets/javascripts/discourse/app/controllers/composer.js b/app/assets/javascripts/discourse/app/controllers/composer.js index 626f339d14..0c4be8430c 100644 --- a/app/assets/javascripts/discourse/app/controllers/composer.js +++ b/app/assets/javascripts/discourse/app/controllers/composer.js @@ -1,5 +1,5 @@ import Composer, { SAVE_ICONS, SAVE_LABELS } from "discourse/models/composer"; -import Controller, { inject } from "@ember/controller"; +import Controller, { inject as controller } from "@ember/controller"; import EmberObject, { action, computed } from "@ember/object"; import { alias, and, or, reads } from "@ember/object/computed"; import { @@ -93,7 +93,7 @@ export function addPopupMenuOptionsCallback(callback) { } export default Controller.extend({ - topicController: inject("topic"), + topicController: controller("topic"), router: service(), checkedMessages: false, @@ -101,6 +101,7 @@ export default Controller.extend({ showEditReason: false, editReason: null, scopedCategoryId: null, + prioritizedCategoryId: null, lastValidatedAt: null, isUploading: false, topic: null, @@ -710,7 +711,10 @@ export default Controller.extend({ composer.set("disableDrafts", true); // for now handle a very narrow use case - // if we are replying to a topic AND not on the topic pop the window up + // if we are replying to a topic + // AND are on on a different topic + // AND topic is open (or we are staff) + // --> pop the window up if (!force && composer.replyingToTopic) { const currentTopic = this.topicModel; @@ -719,7 +723,10 @@ export default Controller.extend({ return; } - if (currentTopic.id !== composer.get("topic.id")) { + if ( + currentTopic.id !== composer.get("topic.id") && + (this.isStaffUser || !currentTopic.closed) + ) { const message = "

    " + I18n.t("composer.posting_not_on_topic") + "

    "; @@ -763,14 +770,15 @@ export default Controller.extend({ // TODO: This should not happen in model const imageSizes = {}; - $("#reply-control .d-editor-preview img").each((i, e) => { - const $img = $(e); - const src = $img.prop("src"); + document + .querySelectorAll("#reply-control .d-editor-preview img") + .forEach((e) => { + const src = e.src; - if (src && src.length) { - imageSizes[src] = { width: $img.width(), height: $img.height() }; - } - }); + if (src && src.length) { + imageSizes[src] = { width: e.naturalWidth, height: e.naturalHeight }; + } + }); const promise = composer .save({ imageSizes, editReason: this.editReason }) @@ -868,15 +876,22 @@ export default Controller.extend({ }, /** - Open the composer view + Open the composer view - @method open - @param {Object} opts Options for creating a post - @param {String} opts.action The action we're performing: edit, reply or createTopic - @param {Post} [opts.post] The post we're replying to - @param {Topic} [opts.topic] The topic we're replying to - @param {String} [opts.quote] If we're opening a reply from a quote, the quote we're making - **/ + @method open + @param {Object} opts Options for creating a post + @param {String} opts.action The action we're performing: edit, reply, createTopic, createSharedDraft, privateMessage + @param {String} opts.draftKey + @param {Post} [opts.post] The post we're replying to + @param {Topic} [opts.topic] The topic we're replying to + @param {String} [opts.quote] If we're opening a reply from a quote, the quote we're making + @param {Boolean} [opts.ignoreIfChanged] + @param {Boolean} [opts.disableScopedCategory] + @param {Number} [opts.categoryId] Sets `scopedCategoryId` and `categoryId` on the Composer model + @param {Number} [opts.prioritizedCategoryId] + @param {String} [opts.draftSequence] + @param {Boolean} [opts.skipDraftCheck] + **/ open(opts) { opts = opts || {}; @@ -898,6 +913,7 @@ export default Controller.extend({ showEditReason: false, editReason: null, scopedCategoryId: null, + prioritizedCategoryId: null, skipAutoSave: true, }); @@ -909,6 +925,16 @@ export default Controller.extend({ } } + if (opts.prioritizedCategoryId) { + const category = this.site.categories.findBy( + "id", + opts.prioritizedCategoryId + ); + if (category) { + this.set("prioritizedCategoryId", opts.prioritizedCategoryId); + } + } + // If we want a different draft than the current composer, close it and clear our model. if ( composerModel && @@ -1137,11 +1163,11 @@ export default Controller.extend({ let promise = new Promise((resolve, reject) => { if (this.get("model.hasMetaData") || this.get("model.replyDirty")) { - const controller = showModal("discard-draft", { + const modal = showModal("discard-draft", { model: this.model, modalClass: "discard-draft-modal", }); - controller.setProperties({ + modal.setProperties({ onDestroyDraft: () => { this.destroyDraft() .then(() => { diff --git a/app/assets/javascripts/discourse/app/controllers/create-account.js b/app/assets/javascripts/discourse/app/controllers/create-account.js index 6dc0fa372e..57b41f4449 100644 --- a/app/assets/javascripts/discourse/app/controllers/create-account.js +++ b/app/assets/javascripts/discourse/app/controllers/create-account.js @@ -268,7 +268,7 @@ export default Controller.extend( (isEmpty(this.accountUsername) || this.get("authOptions.email")) ) { // If email is valid and username has not been entered yet, - // or email and username were filled automatically by 3rd parth auth, + // or email and username were filled automatically by 3rd party auth, // then look for a registered username that matches the email. discourseDebounce(this, this.fetchExistingUsername, 500); } diff --git a/app/assets/javascripts/discourse/app/controllers/create-invite.js b/app/assets/javascripts/discourse/app/controllers/create-invite.js index 3237366b04..d24e452568 100644 --- a/app/assets/javascripts/discourse/app/controllers/create-invite.js +++ b/app/assets/javascripts/discourse/app/controllers/create-invite.js @@ -1,11 +1,11 @@ import Controller from "@ember/controller"; import { action } from "@ember/object"; -import { equal } from "@ember/object/computed"; +import { empty, notEmpty } from "@ember/object/computed"; import discourseComputed from "discourse-common/utils/decorators"; import { extractError } from "discourse/lib/ajax-error"; +import { getNativeContact } from "discourse/lib/pwa-utils"; import { bufferedProperty } from "discourse/mixins/buffered-content"; import ModalFunctionality from "discourse/mixins/modal-functionality"; -import { getNativeContact } from "discourse/lib/pwa-utils"; import Group from "discourse/models/group"; import Invite from "discourse/models/invite"; import I18n from "I18n"; @@ -24,7 +24,8 @@ export default Controller.extend( limitToEmail: false, autogenerated: false, - type: "link", + isLink: empty("buffered.email"), + isEmail: notEmpty("buffered.email"), onShow() { Group.findAll().then((groups) => { @@ -52,10 +53,7 @@ export default Controller.extend( }, setInvite(invite) { - this.setProperties({ - invite, - type: invite.email ? "email" : "link", - }); + this.set("invite", invite); }, setAutogenerated(value) { @@ -70,7 +68,7 @@ export default Controller.extend( const data = { ...this.buffered.buffer }; if (data.groupIds !== undefined) { - data.group_ids = data.groupIds; + data.group_ids = data.groupIds.length > 0 ? data.groupIds : ""; delete data.groupIds; } @@ -80,13 +78,12 @@ export default Controller.extend( delete data.topicTitle; } - if (this.type === "link") { - if (this.buffered.get("email")) { - data.email = ""; - data.custom_message = ""; + if (this.isLink) { + if (this.invite.email) { + data.email = data.custom_message = ""; } - } else if (this.type === "email") { - if (this.buffered.get("max_redemptions_allowed") > 1) { + } else if (this.isEmail) { + if (this.invite.max_redemptions_allowed > 1) { data.max_redemptions_allowed = 1; } @@ -106,7 +103,7 @@ export default Controller.extend( this.rollbackBuffer(); this.setAutogenerated(opts.autogenerated); if (!this.autogenerated) { - if (this.type === "email" && opts.sendEmail) { + if (this.isEmail && opts.sendEmail) { this.send("closeModal"); } else { this.appEvents.trigger("modal-body:flash", { @@ -126,9 +123,6 @@ export default Controller.extend( ); }, - isLink: equal("type", "link"), - isEmail: equal("type", "email"), - @discourseComputed( "currentUser.staff", "siteSettings.invite_link_max_redemptions_limit", @@ -156,46 +150,16 @@ export default Controller.extend( return staff || groups.any((g) => g.owner); }, - @discourseComputed("type", "buffered.email") - disabled(type, email) { - if (type === "email") { - return !email; - } - - return false; - }, - - @discourseComputed("buffered.hasBufferedChanges", "invite.email", "type") - changed(hasBufferedChanges, inviteEmail, type) { - return hasBufferedChanges || (inviteEmail ? "email" : "link") !== type; - }, - - @discourseComputed("currentUser.staff", "type") - hasAdvanced(staff, type) { - return staff || type === "email"; + @discourseComputed("currentUser.staff", "isEmail", "canInviteToGroup") + hasAdvanced(staff, isEmail, canInviteToGroup) { + return staff || isEmail || canInviteToGroup; }, @action copied() { - if (this.type === "email" && !this.buffered.get("email")) { - return this.appEvents.trigger("modal-body:flash", { - text: I18n.t("user.invited.invite.blank_email"), - messageClass: "error", - }); - } - this.save({ sendEmail: false, copy: true }); }, - @action - toggleLimitToEmail() { - const limitToEmail = !this.limitToEmail; - this.setProperties({ - limitToEmail, - type: limitToEmail ? "email" : "link", - }); - }, - @action saveInvite(sendEmail) { this.appEvents.trigger("modal-body:clearFlash"); diff --git a/app/assets/javascripts/discourse/app/controllers/delete-topic-confirm.js b/app/assets/javascripts/discourse/app/controllers/delete-topic-confirm.js index fb8de28430..f310161285 100644 --- a/app/assets/javascripts/discourse/app/controllers/delete-topic-confirm.js +++ b/app/assets/javascripts/discourse/app/controllers/delete-topic-confirm.js @@ -1,4 +1,4 @@ -import Controller, { inject } from "@ember/controller"; +import Controller, { inject as controller } from "@ember/controller"; import I18n from "I18n"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import { action } from "@ember/object"; @@ -7,7 +7,7 @@ import discourseComputed from "discourse-common/utils/decorators"; // Modal that displays confirmation text when user deletes a topic // The modal will display only if the topic exceeds a certain amount of views export default Controller.extend(ModalFunctionality, { - topicController: inject("topic"), + topicController: controller("topic"), deletingTopic: false, @discourseComputed("deletingTopic") diff --git a/app/assets/javascripts/discourse/app/controllers/discovery-sortable.js b/app/assets/javascripts/discourse/app/controllers/discovery-sortable.js index 87e44d6c65..8d02ca3c3b 100644 --- a/app/assets/javascripts/discourse/app/controllers/discovery-sortable.js +++ b/app/assets/javascripts/discourse/app/controllers/discovery-sortable.js @@ -1,4 +1,4 @@ -import Controller, { inject } from "@ember/controller"; +import Controller, { inject as controller } from "@ember/controller"; // Just add query params here to have them automatically passed to topic list filters. export const queryParams = { @@ -17,7 +17,7 @@ export const queryParams = { // Basic controller options const controllerOpts = { - discoveryTopics: inject("discovery/topics"), + discoveryTopics: controller("discovery/topics"), queryParams: Object.keys(queryParams), }; @@ -27,22 +27,21 @@ controllerOpts.queryParams.forEach((p) => { }); export function changeSort(sortBy) { - let { controller } = this; let model = this.controllerFor("discovery.topics").model; - if (sortBy === controller.order) { - controller.toggleProperty("ascending"); - model.updateSortParams(sortBy, controller.ascending); + + if (sortBy === this.controller.order) { + this.controller.toggleProperty("ascending"); + model.updateSortParams(sortBy, this.controller.ascending); } else { - controller.setProperties({ order: sortBy, ascending: false }); + this.controller.setProperties({ order: sortBy, ascending: false }); model.updateSortParams(sortBy, false); } } export function resetParams(skipParams = []) { - let { controller } = this; controllerOpts.queryParams.forEach((p) => { if (!skipParams.includes(p)) { - controller.set(p, queryParams[p].default); + this.controller.set(p, queryParams[p].default); } }); } diff --git a/app/assets/javascripts/discourse/app/controllers/discovery/topics.js b/app/assets/javascripts/discourse/app/controllers/discovery/topics.js index 6e33eb20fc..99afc1deda 100644 --- a/app/assets/javascripts/discourse/app/controllers/discovery/topics.js +++ b/app/assets/javascripts/discourse/app/controllers/discovery/topics.js @@ -18,7 +18,6 @@ import discourseComputed from "discourse-common/utils/decorators"; import { endWith } from "discourse/lib/computed"; import { routeAction } from "discourse/helpers/route-action"; import { inject as service } from "@ember/service"; -import showModal from "discourse/lib/show-modal"; import { userPath } from "discourse/lib/url"; const controllerOpts = { @@ -39,6 +38,18 @@ const controllerOpts = { order: readOnly("model.params.order"), ascending: readOnly("model.params.ascending"), + selected: null, + + @discourseComputed("model.filter", "model.topics.length") + showDismissRead(filter, topicsLength) { + return this._isFilterPage(filter, "unread") && topicsLength > 0; + }, + + @discourseComputed("model.filter", "model.topics.length") + showResetNew(filter, topicsLength) { + return this._isFilterPage(filter, "new") && topicsLength > 0; + }, + actions: { changeSort() { deprecated( @@ -98,17 +109,20 @@ const controllerOpts = { (this.router.currentRoute.queryParams["f"] || this.router.currentRoute.queryParams["filter"]) === "tracked"; - Topic.resetNew(this.category, !this.noSubcategories, tracked).then(() => + let topicIds = this.selected + ? this.selected.map((topic) => topic.id) + : null; + + Topic.resetNew(this.category, !this.noSubcategories, { + tracked, + topicIds, + }).then(() => this.send( "refresh", tracked ? { skipResettingParams: ["filter", "f"] } : {} ) ); }, - - dismissReadPosts() { - showModal("dismiss-read", { title: "topics.bulk.dismiss_read" }); - }, }, afterRefresh(filter, list, listModel = list) { @@ -122,32 +136,6 @@ const controllerOpts = { this.send("loadingComplete"); }, - isFilterPage: function (filter, filterType) { - if (!filter) { - return false; - } - return filter.match(new RegExp(filterType + "$", "gi")) ? true : false; - }, - - @discourseComputed("model.filter", "model.topics.length") - showDismissRead(filter, topicsLength) { - return this.isFilterPage(filter, "unread") && topicsLength > 0; - }, - - @discourseComputed("model.filter", "model.topics.length") - showResetNew(filter, topicsLength) { - return this.isFilterPage(filter, "new") && topicsLength > 0; - }, - - @discourseComputed("model.filter", "model.topics.length") - showDismissAtTop(filter, topicsLength) { - return ( - (this.isFilterPage(filter, "new") || - this.isFilterPage(filter, "unread")) && - topicsLength >= 15 - ); - }, - hasTopics: gt("model.topics.length", 0), allLoaded: empty("model.more_topics_url"), latest: endWith("model.filter", "latest"), diff --git a/app/assets/javascripts/discourse/app/controllers/edit-slow-mode.js b/app/assets/javascripts/discourse/app/controllers/edit-slow-mode.js index 66a0a0a038..254f65a1a7 100644 --- a/app/assets/javascripts/discourse/app/controllers/edit-slow-mode.js +++ b/app/assets/javascripts/discourse/app/controllers/edit-slow-mode.js @@ -14,7 +14,6 @@ export default Controller.extend(ModalFunctionality, { minutes: null, seconds: null, saveDisabled: false, - enabledUntil: null, showCustomSelect: equal("selectedSlowMode", "custom"), durationIsSet: or("hours", "minutes", "seconds"), @@ -87,11 +86,27 @@ export default Controller.extend(ModalFunctionality, { } }, - @discourseComputed("saveDisabled", "durationIsSet", "enabledUntil") + @discourseComputed( + "saveDisabled", + "durationIsSet", + "model.slow_mode_enabled_until" + ) submitDisabled(saveDisabled, durationIsSet, enabledUntil) { return saveDisabled || !durationIsSet || !enabledUntil; }, + @discourseComputed("model.slow_mode_seconds") + slowModeEnabled(slowModeSeconds) { + return slowModeSeconds && slowModeSeconds !== 0; + }, + + @discourseComputed("slowModeEnabled") + saveButtonLabel(slowModeEnabled) { + return slowModeEnabled + ? "topic.slow_mode_update.update" + : "topic.slow_mode_update.enable"; + }, + _setFromSeconds(seconds) { this.setProperties(fromSeconds(seconds)); }, @@ -121,7 +136,11 @@ export default Controller.extend(ModalFunctionality, { this._parseValue(this.seconds) ); - Topic.setSlowMode(this.model.id, seconds, this.enabledUntil) + Topic.setSlowMode( + this.model.id, + seconds, + this.model.slow_mode_enabled_until + ) .catch(popupAjaxError) .then(() => { this.set("model.slow_mode_seconds", seconds); diff --git a/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js b/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js new file mode 100644 index 0000000000..a7fd826af8 --- /dev/null +++ b/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js @@ -0,0 +1,101 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import { ajax } from "discourse/lib/ajax"; +import EmberObject, { action } from "@ember/object"; +import { extractError } from "discourse/lib/ajax-error"; +import { reload } from "discourse/helpers/page-reloader"; + +const UP = "up"; +const DOWN = "down"; + +export default Controller.extend(ModalFunctionality, { + loading: true, + columns: null, + labelKey: null, + + onShow() { + ajax("directory-columns.json") + .then((response) => { + this.setProperties({ + loading: false, + columns: response.directory_columns + .sort((a, b) => (a.position > b.position ? 1 : -1)) + .map((c) => EmberObject.create(c)), + }); + }) + .catch(extractError); + }, + + @action + save() { + this.set("loading", true); + const data = { + directory_columns: this.columns.map((c) => + c.getProperties("id", "enabled", "position") + ), + }; + + ajax("directory-columns.json", { type: "PUT", data }) + .then(() => { + reload(); + }) + .catch((e) => { + this.set("loading", false); + this.flash(extractError(e), "error"); + }); + }, + + @action + resetToDefault() { + let resetColumns = this.columns; + resetColumns + .sort((a, b) => + (a.automatic_position || a.user_field.position + 1000) > + (b.automatic_position || b.user_field.position + 1000) + ? 1 + : -1 + ) + .forEach((column, index) => { + column.setProperties({ + position: column.automatic_position || index + 1, + enabled: column.automatic, + }); + }); + this.set("columns", resetColumns); + this.notifyPropertyChange("columns"); + }, + + @action + moveUp(column) { + this._moveColumn(UP, column); + }, + + @action + moveDown(column) { + this._moveColumn(DOWN, column); + }, + + _moveColumn(direction, column) { + if ( + (direction === UP && column.position === 1) || + (direction === DOWN && column.position === this.columns.length) + ) { + return; + } + + const positionOnClick = column.position; + const newPosition = + direction === UP ? positionOnClick - 1 : positionOnClick + 1; + + const previousColumn = this.columns.find((c) => c.position === newPosition); + + column.set("position", newPosition); + previousColumn.set("position", positionOnClick); + + this.set( + "columns", + this.columns.sort((a, b) => (a.position > b.position ? 1 : -1)) + ); + this.notifyPropertyChange("columns"); + }, +}); diff --git a/app/assets/javascripts/discourse/app/controllers/feature-topic.js b/app/assets/javascripts/discourse/app/controllers/feature-topic.js index f723df6f3b..f32d496c8b 100644 --- a/app/assets/javascripts/discourse/app/controllers/feature-topic.js +++ b/app/assets/javascripts/discourse/app/controllers/feature-topic.js @@ -1,4 +1,4 @@ -import Controller, { inject } from "@ember/controller"; +import Controller, { inject as controller } from "@ember/controller"; import EmberObject from "@ember/object"; import I18n from "I18n"; import ModalFunctionality from "discourse/mixins/modal-functionality"; @@ -8,7 +8,7 @@ import { categoryLinkHTML } from "discourse/helpers/category-link"; import discourseComputed from "discourse-common/utils/decorators"; export default Controller.extend(ModalFunctionality, { - topicController: inject("topic"), + topicController: controller("topic"), loading: true, pinnedInCategoryCount: 0, diff --git a/app/assets/javascripts/discourse/app/controllers/grant-badge.js b/app/assets/javascripts/discourse/app/controllers/grant-badge.js index a0f060aef5..a35ef5b1d5 100644 --- a/app/assets/javascripts/discourse/app/controllers/grant-badge.js +++ b/app/assets/javascripts/discourse/app/controllers/grant-badge.js @@ -1,4 +1,4 @@ -import Controller, { inject } from "@ember/controller"; +import Controller, { inject as controller } from "@ember/controller"; import Badge from "discourse/models/badge"; import GrantBadgeController from "discourse/mixins/grant-badge-controller"; import I18n from "I18n"; @@ -9,7 +9,7 @@ import discourseComputed from "discourse-common/utils/decorators"; import { extractError } from "discourse/lib/ajax-error"; export default Controller.extend(ModalFunctionality, GrantBadgeController, { - topicController: inject("topic"), + topicController: controller("topic"), loading: true, saving: false, selectedBadgeId: null, diff --git a/app/assets/javascripts/discourse/app/controllers/insert-hyperlink.js b/app/assets/javascripts/discourse/app/controllers/insert-hyperlink.js index 42c3deeb32..07c13c87b3 100644 --- a/app/assets/javascripts/discourse/app/controllers/insert-hyperlink.js +++ b/app/assets/javascripts/discourse/app/controllers/insert-hyperlink.js @@ -22,7 +22,6 @@ export default Controller.extend(ModalFunctionality, { schedule("afterRender", () => { const element = document.querySelector(".insert-link"); - element.addEventListener("keydown", this.keyDown); element @@ -57,6 +56,8 @@ export default Controller.extend(ModalFunctionality, { this.set("searchResults", []); event.preventDefault(); event.stopPropagation(); + } else { + this.send("closeModal"); } break; } diff --git a/app/assets/javascripts/discourse/app/controllers/invites-show.js b/app/assets/javascripts/discourse/app/controllers/invites-show.js index 3c3658b06f..49f9f7998b 100644 --- a/app/assets/javascripts/discourse/app/controllers/invites-show.js +++ b/app/assets/javascripts/discourse/app/controllers/invites-show.js @@ -29,6 +29,7 @@ export default Controller.extend( invitedBy: readOnly("model.invited_by"), email: alias("model.email"), hiddenEmail: alias("model.hidden_email"), + emailVerifiedByLink: alias("model.email_verified_by_link"), accountUsername: alias("model.username"), passwordRequired: notEmpty("accountPassword"), successMessage: null, @@ -127,14 +128,16 @@ export default Controller.extend( "rejectedEmails.[]", "authOptions.email", "authOptions.email_valid", - "hiddenEmail" + "hiddenEmail", + "emailVerifiedByLink" ) emailValidation( email, rejectedEmails, externalAuthEmail, externalAuthEmailValid, - hiddenEmail + hiddenEmail, + emailVerifiedByLink ) { if (hiddenEmail) { return EmberObject.create({ @@ -157,12 +160,12 @@ export default Controller.extend( }); } - if (externalAuthEmail) { + if (externalAuthEmail && externalAuthEmailValid) { const provider = this.createAccount.authProviderDisplayName( this.get("authOptions.auth_provider") ); - if (externalAuthEmail === email && externalAuthEmailValid) { + if (externalAuthEmail === email) { return EmberObject.create({ ok: true, reason: I18n.t("user.email.authenticated", { @@ -179,6 +182,13 @@ export default Controller.extend( } } + if (emailVerifiedByLink) { + return EmberObject.create({ + ok: true, + reason: I18n.t("user.email.authenticated_by_invite"), + }); + } + if (emailValid(email)) { return EmberObject.create({ ok: true, diff --git a/app/assets/javascripts/discourse/app/controllers/move-to-topic.js b/app/assets/javascripts/discourse/app/controllers/move-to-topic.js index ad4df9e79e..d5e8709541 100644 --- a/app/assets/javascripts/discourse/app/controllers/move-to-topic.js +++ b/app/assets/javascripts/discourse/app/controllers/move-to-topic.js @@ -1,4 +1,4 @@ -import Controller, { inject } from "@ember/controller"; +import Controller, { inject as controller } from "@ember/controller"; import { alias, equal } from "@ember/object/computed"; import { mergeTopic, movePosts } from "discourse/models/topic"; import DiscourseURL from "discourse/lib/url"; @@ -41,7 +41,7 @@ export default Controller.extend(ModalFunctionality, { ]; }, - topicController: inject("topic"), + topicController: controller("topic"), selectedPostsCount: alias("topicController.selectedPostsCount"), selectedAllPosts: alias("topicController.selectedAllPosts"), selectedPosts: alias("topicController.selectedPosts"), diff --git a/app/assets/javascripts/discourse/app/controllers/navigation/categories.js b/app/assets/javascripts/discourse/app/controllers/navigation/categories.js index a36e1d837c..61a1678df3 100644 --- a/app/assets/javascripts/discourse/app/controllers/navigation/categories.js +++ b/app/assets/javascripts/discourse/app/controllers/navigation/categories.js @@ -1,6 +1,6 @@ import NavigationDefaultController from "discourse/controllers/navigation/default"; -import { inject } from "@ember/controller"; +import { inject as controller } from "@ember/controller"; export default NavigationDefaultController.extend({ - discoveryCategories: inject("discovery/categories"), + discoveryCategories: controller("discovery/categories"), }); diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/interface.js b/app/assets/javascripts/discourse/app/controllers/preferences/interface.js index 6e45ac746f..73bb3f5022 100644 --- a/app/assets/javascripts/discourse/app/controllers/preferences/interface.js +++ b/app/assets/javascripts/discourse/app/controllers/preferences/interface.js @@ -1,4 +1,4 @@ -import Controller, { inject } from "@ember/controller"; +import Controller, { inject as controller } from "@ember/controller"; import { iOSWithVisualViewport, isiPad, @@ -34,7 +34,7 @@ export default Controller.extend({ currentThemeId: -1, previewingColorScheme: false, selectedDarkColorSchemeId: null, - preferencesController: inject("preferences"), + preferencesController: controller("preferences"), makeColorSchemeDefault: true, init() { diff --git a/app/assets/javascripts/discourse/app/controllers/reorder-categories.js b/app/assets/javascripts/discourse/app/controllers/reorder-categories.js index 333108eefd..47da67b284 100644 --- a/app/assets/javascripts/discourse/app/controllers/reorder-categories.js +++ b/app/assets/javascripts/discourse/app/controllers/reorder-categories.js @@ -1,8 +1,6 @@ import discourseComputed, { on } from "discourse-common/utils/decorators"; -import BufferedMixin from "ember-buffered-proxy/mixin"; import BufferedProxy from "ember-buffered-proxy/proxy"; import Controller from "@ember/controller"; -import EmberObjectProxy from "@ember/object/proxy"; import Evented from "@ember/object/evented"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import { ajax } from "discourse/lib/ajax"; @@ -17,8 +15,7 @@ export default Controller.extend(ModalFunctionality, Evented, { @discourseComputed("site.categories.[]") categoriesBuffered(categories) { - const bufProxy = EmberObjectProxy.extend(BufferedMixin || BufferedProxy); - return (categories || []).map((c) => bufProxy.create({ content: c })); + return (categories || []).map((c) => BufferedProxy.create({ content: c })); }, categoriesOrdered: sort("categoriesBuffered", "categoriesSorting"), diff --git a/app/assets/javascripts/discourse/app/controllers/review-index.js b/app/assets/javascripts/discourse/app/controllers/review-index.js index 2c70242dcd..4c19441594 100644 --- a/app/assets/javascripts/discourse/app/controllers/review-index.js +++ b/app/assets/javascripts/discourse/app/controllers/review-index.js @@ -2,6 +2,7 @@ import Controller from "@ember/controller"; import I18n from "I18n"; import discourseComputed from "discourse-common/utils/decorators"; import { isPresent } from "@ember/utils"; +import { next } from "@ember/runloop"; export default Controller.extend({ queryParams: [ @@ -93,6 +94,10 @@ export default Controller.extend({ this.setProperties(range); }, + refreshModel() { + next(() => this.send("refreshRoute")); + }, + actions: { remove(ids) { if (!ids) { @@ -104,7 +109,7 @@ export default Controller.extend({ }); if (newList.length === 0) { - this.send("refreshRoute"); + this.refreshModel(); } else { this.set("reviewables", newList); } @@ -112,7 +117,7 @@ export default Controller.extend({ resetTopic() { this.set("topic_id", null); - this.send("refreshRoute"); + this.refreshModel(); }, refresh() { @@ -165,7 +170,7 @@ export default Controller.extend({ additional_filters: JSON.stringify(this.additionalFilters), }); - this.send("refreshRoute"); + this.refreshModel(); }, loadMore() { diff --git a/app/assets/javascripts/discourse/app/controllers/second-factor-add-security-key.js b/app/assets/javascripts/discourse/app/controllers/second-factor-add-security-key.js index d6b48db673..48839d0bbb 100644 --- a/app/assets/javascripts/discourse/app/controllers/second-factor-add-security-key.js +++ b/app/assets/javascripts/discourse/app/controllers/second-factor-add-security-key.js @@ -87,7 +87,7 @@ export default Controller.extend(ModalFunctionality, { attestation: "none", authenticatorSelection: { // see https://chromium.googlesource.com/chromium/src/+/master/content/browser/webauth/uv_preferred.md for why - // default value of preferred is not necesarrily what we want, it limits webauthn to only devices that support + // default value of preferred is not necessarily what we want, it limits webauthn to only devices that support // user verification, which usually requires entering a PIN userVerification: "discouraged", }, diff --git a/app/assets/javascripts/discourse/app/controllers/tag-groups-new.js b/app/assets/javascripts/discourse/app/controllers/tag-groups-new.js index e0adbb21fd..5487e1c94b 100644 --- a/app/assets/javascripts/discourse/app/controllers/tag-groups-new.js +++ b/app/assets/javascripts/discourse/app/controllers/tag-groups-new.js @@ -8,7 +8,7 @@ export default Controller.extend({ const tagGroups = this.tagGroups.model; tagGroups.pushObject(this.model); - this.transitionToRoute("tagGroups.edit", this.model); + this.transitionToRoute("tagGroups.index"); }, }, }); diff --git a/app/assets/javascripts/discourse/app/controllers/tag-show.js b/app/assets/javascripts/discourse/app/controllers/tag-show.js index 0bf4cec6a9..76b7d070cb 100644 --- a/app/assets/javascripts/discourse/app/controllers/tag-show.js +++ b/app/assets/javascripts/discourse/app/controllers/tag-show.js @@ -8,7 +8,6 @@ import Topic from "discourse/models/topic"; import { alias } from "@ember/object/computed"; import bootbox from "bootbox"; import { queryParams } from "discourse/controllers/discovery-sortable"; -import showModal from "discourse/lib/show-modal"; export default Controller.extend(BulkTopicSelection, FilterModeMixin, { application: controller(), @@ -93,48 +92,31 @@ export default Controller.extend(BulkTopicSelection, FilterModeMixin, { } }, - isFilterPage: function (filter, filterType) { - if (!filter) { - return false; - } - return filter.match(new RegExp(filterType + "$", "gi")) ? true : false; - }, - @discourseComputed("list.filter", "list.topics.length") showDismissRead(filter, topicsLength) { - return this.isFilterPage(filter, "unread") && topicsLength > 0; + return this._isFilterPage(filter, "unread") && topicsLength > 0; }, @discourseComputed("list.filter", "list.topics.length") showResetNew(filter, topicsLength) { - return this.isFilterPage(filter, "new") && topicsLength > 0; - }, - - @discourseComputed("list.filter", "list.topics.length") - showDismissAtTop(filter, topicsLength) { - return ( - (this.isFilterPage(filter, "new") || - this.isFilterPage(filter, "unread")) && - topicsLength >= 15 - ); + return this._isFilterPage(filter, "new") && topicsLength > 0; }, actions: { - dismissReadPosts() { - showModal("dismiss-read", { title: "topics.bulk.dismiss_read" }); - }, - resetNew() { const tracked = (this.router.currentRoute.queryParams["f"] || this.router.currentRoute.queryParams["filter"]) === "tracked"; - Topic.resetNew( - this.category, - !this.noSubcategories, + let topicIds = this.selected + ? this.selected.map((topic) => topic.id) + : null; + + Topic.resetNew(this.category, !this.noSubcategories, { tracked, - this.tag - ).then(() => + tag: this.tag, + topicIds, + }).then(() => this.send( "refresh", tracked ? { skipResettingParams: ["filter", "f"] } : {} diff --git a/app/assets/javascripts/discourse/app/controllers/topic.js b/app/assets/javascripts/discourse/app/controllers/topic.js index fcadfa4fef..c3e4060c9b 100644 --- a/app/assets/javascripts/discourse/app/controllers/topic.js +++ b/app/assets/javascripts/discourse/app/controllers/topic.js @@ -1036,7 +1036,8 @@ export default Controller.extend(bufferedProperty("model"), { options = { action: Composer.CREATE_TOPIC, draftKey: post.topic.draft_key, - categoryId: this.get("model.category.id"), + topicCategoryId: this.get("model.category.id"), + prioritizedCategoryId: this.get("model.category.id"), }; } @@ -1606,7 +1607,7 @@ export default Controller.extend(bufferedProperty("model"), { } // scroll to bottom is very specific to new posts from discobot - // hence the -2 check (dicobot id). We can shift all this code + // hence the -2 check (discobot id). We can shift all this code // to discobot plugin longer term if ( topic.get("isPrivateMessage") && @@ -1639,7 +1640,7 @@ export default Controller.extend(bufferedProperty("model"), { function () { const $post = $(`.topic-post article#post_${postNumber}`); - if ($post.length === 0 || isElementInViewport($post)) { + if ($post.length === 0 || isElementInViewport($post[0])) { return; } diff --git a/app/assets/javascripts/discourse/app/controllers/user-activity-bookmarks.js b/app/assets/javascripts/discourse/app/controllers/user-activity-bookmarks.js index a561911938..6733610ca1 100644 --- a/app/assets/javascripts/discourse/app/controllers/user-activity-bookmarks.js +++ b/app/assets/javascripts/discourse/app/controllers/user-activity-bookmarks.js @@ -1,4 +1,4 @@ -import Controller, { inject } from "@ember/controller"; +import Controller, { inject as controller } from "@ember/controller"; import Bookmark from "discourse/models/bookmark"; import I18n from "I18n"; import { Promise } from "rsvp"; @@ -6,8 +6,8 @@ import EmberObject, { action } from "@ember/object"; import discourseComputed from "discourse-common/utils/decorators"; export default Controller.extend({ - application: inject(), - user: inject(), + application: controller(), + user: controller(), content: null, loading: false, diff --git a/app/assets/javascripts/discourse/app/controllers/user-badges.js b/app/assets/javascripts/discourse/app/controllers/user-badges.js index a4d55048f9..8634e19e4d 100644 --- a/app/assets/javascripts/discourse/app/controllers/user-badges.js +++ b/app/assets/javascripts/discourse/app/controllers/user-badges.js @@ -1,14 +1,27 @@ import Controller, { inject as controller } from "@ember/controller"; -import { alias, sort } from "@ember/object/computed"; +import { action, computed } from "@ember/object"; +import { alias, filterBy, sort } from "@ember/object/computed"; export default Controller.extend({ user: controller(), username: alias("user.model.username_lower"), sortedBadges: sort("model", "badgeSortOrder"), + favoriteBadges: filterBy("model", "is_favorite", true), + canFavoriteMoreBadges: computed( + "favoriteBadges.length", + "model.meta.max_favorites", + function () { + return this.favoriteBadges.length < this.model.meta.max_favorites; + } + ), init() { this._super(...arguments); - this.badgeSortOrder = ["badge.badge_type.sort_order:desc", "badge.name"]; }, + + @action + favorite(badge) { + return badge.favorite(); + }, }); diff --git a/app/assets/javascripts/discourse/app/controllers/user-summary.js b/app/assets/javascripts/discourse/app/controllers/user-summary.js index fbfe0985df..1f950989f4 100644 --- a/app/assets/javascripts/discourse/app/controllers/user-summary.js +++ b/app/assets/javascripts/discourse/app/controllers/user-summary.js @@ -1,4 +1,4 @@ -import Controller, { inject } from "@ember/controller"; +import Controller, { inject as controller } from "@ember/controller"; import { alias } from "@ember/object/computed"; import discourseComputed from "discourse-common/utils/decorators"; import { durationTiny } from "discourse/lib/formatter"; @@ -7,7 +7,7 @@ import { durationTiny } from "discourse/lib/formatter"; const MAX_BADGES = 6; export default Controller.extend({ - userController: inject("user"), + userController: controller("user"), user: alias("userController.model"), @discourseComputed("model.badges.length") diff --git a/app/assets/javascripts/discourse/app/controllers/user.js b/app/assets/javascripts/discourse/app/controllers/user.js index 5bb235bac7..34d032e4a6 100644 --- a/app/assets/javascripts/discourse/app/controllers/user.js +++ b/app/assets/javascripts/discourse/app/controllers/user.js @@ -1,4 +1,4 @@ -import Controller, { inject } from "@ember/controller"; +import Controller, { inject as controller } from "@ember/controller"; import EmberObject, { computed, set } from "@ember/object"; import { and, equal, gt, not, or } from "@ember/object/computed"; import CanCheckEmails from "discourse/mixins/can-check-emails"; @@ -15,7 +15,7 @@ import { inject as service } from "@ember/service"; export default Controller.extend(CanCheckEmails, { router: service(), - userNotifications: inject("user-notifications"), + userNotifications: controller("user-notifications"), adminTools: optionalService(), @discourseComputed("model.username") diff --git a/app/assets/javascripts/discourse/app/controllers/users.js b/app/assets/javascripts/discourse/app/controllers/users.js index fe78c4caa2..98f48728bc 100644 --- a/app/assets/javascripts/discourse/app/controllers/users.js +++ b/app/assets/javascripts/discourse/app/controllers/users.js @@ -1,6 +1,7 @@ import Controller, { inject as controller } from "@ember/controller"; import { action } from "@ember/object"; import discourseDebounce from "discourse-common/lib/debounce"; +import showModal from "discourse/lib/show-modal"; import { equal } from "@ember/object/computed"; import { longDate } from "discourse/lib/formatter"; import { observes } from "discourse-common/utils/decorators"; @@ -9,13 +10,14 @@ export default Controller.extend({ application: controller(), queryParams: ["period", "order", "asc", "name", "group", "exclude_usernames"], period: "weekly", - order: "likes_received", + order: "", asc: null, name: "", group: null, nameInput: null, exclude_usernames: null, isLoading: false, + columns: null, showTimeRead: equal("period", "all"), @@ -23,9 +25,15 @@ export default Controller.extend({ this.set("isLoading", true); this.set("nameInput", params.name); + this.set("order", params.order); + + const custom_field_columns = this.columns.filter((c) => !c.automatic); + const user_field_ids = custom_field_columns + .map((c) => c.user_field_id) + .join("|"); this.store - .find("directoryItem", params) + .find("directoryItem", Object.assign(params, { user_field_ids })) .then((model) => { const lastUpdatedAt = model.get("resultSetMeta.last_updated_at"); this.setProperties({ @@ -39,6 +47,11 @@ export default Controller.extend({ }); }, + @action + showEditColumnsModal() { + showModal("edit-user-directory-columns"); + }, + @action onFilterChanged(filter) { discourseDebounce(this, this._setName, filter, 500); diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-label.js b/app/assets/javascripts/discourse/app/helpers/directory-item-label.js new file mode 100644 index 0000000000..56723ee716 --- /dev/null +++ b/app/assets/javascripts/discourse/app/helpers/directory-item-label.js @@ -0,0 +1,10 @@ +import { htmlSafe } from "@ember/template"; +import { registerUnbound } from "discourse-common/lib/helpers"; +import I18n from "I18n"; + +export default registerUnbound("mobile-directory-item-label", function (args) { + // Args should include key/values { item, column } + + const count = args.item.get(args.column.name); + return htmlSafe(I18n.t(`directory.${args.column.name}`, { count })); +}); diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js b/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js new file mode 100644 index 0000000000..aeab4bcbe1 --- /dev/null +++ b/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js @@ -0,0 +1,16 @@ +import { htmlSafe } from "@ember/template"; +import { registerUnbound } from "discourse-common/lib/helpers"; + +export default registerUnbound( + "directory-item-user-field-value", + function (args) { + // Args should include key/values { item, column } + + const value = + args.item.user && args.item.user.user_fields + ? args.item.user.user_fields[args.column.user_field_id] + : null; + const content = value || "-"; + return htmlSafe(`${content}`); + } +); diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-value.js b/app/assets/javascripts/discourse/app/helpers/directory-item-value.js new file mode 100644 index 0000000000..a3c6e3d6d3 --- /dev/null +++ b/app/assets/javascripts/discourse/app/helpers/directory-item-value.js @@ -0,0 +1,11 @@ +import { htmlSafe } from "@ember/template"; +import { registerUnbound } from "discourse-common/lib/helpers"; +import { number } from "discourse/lib/formatter"; + +export default registerUnbound("directory-item-value", function (args) { + // Args should include key/values { item, column } + + return htmlSafe( + `${number(args.item.get(args.column.name))}` + ); +}); diff --git a/app/assets/javascripts/discourse/app/helpers/directory-table-header-title.js b/app/assets/javascripts/discourse/app/helpers/directory-table-header-title.js new file mode 100644 index 0000000000..632e311463 --- /dev/null +++ b/app/assets/javascripts/discourse/app/helpers/directory-table-header-title.js @@ -0,0 +1,19 @@ +import { registerUnbound } from "discourse-common/lib/helpers"; +import I18n from "I18n"; +import { iconHTML } from "discourse-common/lib/icon-library"; +import { htmlSafe } from "@ember/template"; + +export default registerUnbound("directory-table-header-title", function (args) { + // Args should include key/values { field, labelKey, icon, translated } + + let html = ""; + if (args.icon) { + html += iconHTML(args.icon); + } + let labelKey = args.labelKey || `directory.${args.field}`; + + html += args.translated + ? args.field + : I18n.t(labelKey + "_long", { defaultValue: I18n.t(labelKey) }); + return htmlSafe(html); +}); diff --git a/app/assets/javascripts/discourse/app/helpers/user-avatar.js b/app/assets/javascripts/discourse/app/helpers/user-avatar.js index 20af1a2c02..e0893b1089 100644 --- a/app/assets/javascripts/discourse/app/helpers/user-avatar.js +++ b/app/assets/javascripts/discourse/app/helpers/user-avatar.js @@ -59,7 +59,7 @@ function renderAvatar(user, options) { const description = get(user, "description"); // if a description has been provided if (description && description.length > 0) { - // preprend the username before the description + // prepend the username before the description title = I18n.t("user.avatar.name_and_description", { name: displayName, description, diff --git a/app/assets/javascripts/discourse/app/index.html b/app/assets/javascripts/discourse/app/index.html index 848e92b3d1..1fedae93ae 100644 --- a/app/assets/javascripts/discourse/app/index.html +++ b/app/assets/javascripts/discourse/app/index.html @@ -25,9 +25,6 @@
    -
    -
    - diff --git a/app/assets/javascripts/discourse/app/initializers/badging.js b/app/assets/javascripts/discourse/app/initializers/badging.js index f5a73b6436..73c90b0aa1 100644 --- a/app/assets/javascripts/discourse/app/initializers/badging.js +++ b/app/assets/javascripts/discourse/app/initializers/badging.js @@ -1,4 +1,4 @@ -// Updates the PWA badging if avaliable +// Updates the PWA badging if available export default { name: "badging", after: "message-bus", diff --git a/app/assets/javascripts/discourse/app/initializers/message-bus.js b/app/assets/javascripts/discourse/app/initializers/message-bus.js index 13ccf4601b..2e2df03710 100644 --- a/app/assets/javascripts/discourse/app/initializers/message-bus.js +++ b/app/assets/javascripts/discourse/app/initializers/message-bus.js @@ -2,7 +2,7 @@ import { isProduction, isTesting } from "discourse-common/config/environment"; // Initialize the message bus to receive messages. import getURL from "discourse-common/lib/get-url"; import { handleLogoff } from "discourse/lib/ajax"; -import userPresent from "discourse/lib/user-presence"; +import userPresent, { onPresenceChange } from "discourse/lib/user-presence"; const LONG_POLL_AFTER_UNSEEN_TIME = 1200000; // 20 minutes const CONNECTIVITY_ERROR_CLASS = "message-bus-offline"; @@ -51,6 +51,18 @@ export default { // we do not want to start anything till document is complete messageBus.stop(); + // This will notify MessageBus to force a long poll after user becomes + // present + // When 20 minutes pass we stop long polling due to "shouldLongPollCallback". + onPresenceChange({ + unseenTime: LONG_POLL_AFTER_UNSEEN_TIME, + callback: () => { + if (messageBus.onVisibilityChange) { + messageBus.onVisibilityChange(); + } + }, + }); + if (siteSettings.login_required && !user) { // Endpoint is not available in this case, so don't try return; diff --git a/app/assets/javascripts/discourse/app/initializers/post-decorations.js b/app/assets/javascripts/discourse/app/initializers/post-decorations.js index 4d3465833c..b425a81a2e 100644 --- a/app/assets/javascripts/discourse/app/initializers/post-decorations.js +++ b/app/assets/javascripts/discourse/app/initializers/post-decorations.js @@ -88,6 +88,7 @@ export default { const oneboxTypes = { amazon: "discourse-amazon", + githubactions: "fab-github", githubblob: "fab-github", githubcommit: "fab-github", githubpullrequest: "fab-github", diff --git a/app/assets/javascripts/discourse/app/initializers/subscribe-user-notifications.js b/app/assets/javascripts/discourse/app/initializers/subscribe-user-notifications.js index 2e93d643ea..52895fd0ad 100644 --- a/app/assets/javascripts/discourse/app/initializers/subscribe-user-notifications.js +++ b/app/assets/javascripts/discourse/app/initializers/subscribe-user-notifications.js @@ -73,17 +73,19 @@ export default { ); if (staleIndex === -1) { - // this gets a bit tricky, unread pms are bumped to front + // high priority and unread notifications are first let insertPosition = 0; - if (lastNotification.notification_type !== 6) { - insertPosition = oldNotifications.findIndex( - (n) => n.notification_type !== 6 || n.read + + if (!lastNotification.high_priority || lastNotification.read) { + const nextPosition = oldNotifications.findIndex( + (n) => !n.high_priority || n.read ); - insertPosition = - insertPosition === -1 - ? oldNotifications.length - 1 - : insertPosition; + + if (nextPosition !== -1) { + insertPosition = nextPosition; + } } + oldNotifications.insertAt( insertPosition, EmberObject.create(lastNotification) diff --git a/app/assets/javascripts/discourse/app/initializers/url-redirects.js b/app/assets/javascripts/discourse/app/initializers/url-redirects.js index 78ec8c0487..8496b1f55a 100644 --- a/app/assets/javascripts/discourse/app/initializers/url-redirects.js +++ b/app/assets/javascripts/discourse/app/initializers/url-redirects.js @@ -9,8 +9,9 @@ export default { const currentUser = container.lookup("current-user:main"); if (currentUser) { const username = currentUser.get("username"); + const escapedUsername = username.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); DiscourseURL.rewrite( - new RegExp(`^/u/${username}/?$`, "i"), + new RegExp(`^/u/${escapedUsername}/?$`, "i"), `/u/${username}/activity` ); } diff --git a/app/assets/javascripts/discourse/app/lib/ajax.js b/app/assets/javascripts/discourse/app/lib/ajax.js index 36330cabf3..48b690c845 100644 --- a/app/assets/javascripts/discourse/app/lib/ajax.js +++ b/app/assets/javascripts/discourse/app/lib/ajax.js @@ -70,6 +70,12 @@ export function ajax() { args = arguments[1]; } + let ignoreUnsent = true; + if (args.ignoreUnsent !== undefined) { + ignoreUnsent = args.ignoreUnsent; + delete args.ignoreUnsent; + } + function performAjax(resolve, reject) { args.headers = args.headers || {}; @@ -112,7 +118,7 @@ export function ajax() { args.error = (xhr, textStatus, errorThrown) => { // 0 represents the `UNSENT` state - if (xhr.readyState === 0) { + if (ignoreUnsent && xhr.readyState === 0) { // Make sure we log pretender errors in test mode if (textStatus === "error" && isTesting()) { throw errorThrown; @@ -128,7 +134,7 @@ export function ajax() { Session.current().set("csrfToken", null); } - // If it's a parsererror, don't reject + // If it's a parser error, don't reject if (xhr.status === 200) { return args.success(xhr); } @@ -162,10 +168,6 @@ export function ajax() { args.headers["Discourse-Script"] = true; } - if (args.type === "GET" && args.cache !== true) { - args.cache = true; // Disable JQuery cache busting param, which was created to deal with IE8 - } - ajaxObj = $.ajax(getURL(url), args); } diff --git a/app/assets/javascripts/discourse/app/lib/autocomplete.js b/app/assets/javascripts/discourse/app/lib/autocomplete.js index d95cc46e52..99b77405a9 100644 --- a/app/assets/javascripts/discourse/app/lib/autocomplete.js +++ b/app/assets/javascripts/discourse/app/lib/autocomplete.js @@ -99,7 +99,7 @@ export default function (options) { let div = null; let prevTerm = null; - // By default, when the autcomplete popup is rendered it has the + // By default, when the autocomplete popup is rendered it has the // first suggestion 'selected', and pressing enter key inserts // the first suggestion into the input box. // If you want to stop that behavior, i.e. have the popup renders diff --git a/app/assets/javascripts/discourse/app/lib/category-tag-search.js b/app/assets/javascripts/discourse/app/lib/category-tag-search.js index ef84ac811f..eba355ad61 100644 --- a/app/assets/javascripts/discourse/app/lib/category-tag-search.js +++ b/app/assets/javascripts/discourse/app/lib/category-tag-search.js @@ -33,7 +33,6 @@ function searchTags(term, categories, limit) { function () { oldSearch = $.ajax(getURL("/tags/filter/search"), { type: "GET", - cache: true, data: { limit: limit, q }, }); diff --git a/app/assets/javascripts/discourse/app/lib/email-provider-default-settings.js b/app/assets/javascripts/discourse/app/lib/email-provider-default-settings.js new file mode 100644 index 0000000000..fa2939f6ac --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/email-provider-default-settings.js @@ -0,0 +1,22 @@ +const GMAIL = { + imap: { + imap_server: "imap.gmail.com", + imap_port: "993", + imap_ssl: true, + }, + smtp: { + smtp_server: "smtp.gmail.com", + smtp_port: "587", + smtp_ssl: true, + }, +}; + +export default function emailProviderDefaultSettings(provider, protocol) { + provider = provider.toLowerCase(); + protocol = protocol.toLowerCase(); + + switch (provider) { + case "gmail": + return GMAIL[protocol]; + } +} diff --git a/app/assets/javascripts/discourse/app/lib/eyeline.js b/app/assets/javascripts/discourse/app/lib/eyeline.js index f7f318865d..f2ca4a8c37 100644 --- a/app/assets/javascripts/discourse/app/lib/eyeline.js +++ b/app/assets/javascripts/discourse/app/lib/eyeline.js @@ -67,7 +67,7 @@ Eyeline.prototype.update = function () { } // It's seen if... - // ...the element is vertically within the top and botom + // ...the element is vertically within the top and bottom if (elemTop <= docViewBottom && elemTop >= docViewTop) { markSeen = true; } diff --git a/app/assets/javascripts/discourse/app/lib/hash.js b/app/assets/javascripts/discourse/app/lib/hash.js index 71396274b9..a4fd849a88 100644 --- a/app/assets/javascripts/discourse/app/lib/hash.js +++ b/app/assets/javascripts/discourse/app/lib/hash.js @@ -1,6 +1,6 @@ /*eslint no-bitwise:0 */ -// Note: before changing this be aware the same algo is used server side for avatars. +// Note: before changing this be aware the same algorithm is used server side for avatars. export function hashString(str) { let hash = 0; for (let i = 0; i < str.length; i++) { diff --git a/app/assets/javascripts/discourse/app/lib/is-element-in-viewport.js b/app/assets/javascripts/discourse/app/lib/is-element-in-viewport.js index 5bac8fafc8..21d725fd32 100644 --- a/app/assets/javascripts/discourse/app/lib/is-element-in-viewport.js +++ b/app/assets/javascripts/discourse/app/lib/is-element-in-viewport.js @@ -1,6 +1,6 @@ export default function (element) { - if (element instanceof jQuery) { - element = element[0]; + if (!element) { + return; } const $window = $(window), diff --git a/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js b/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js index dbe5dbb685..3ad6a9cc24 100644 --- a/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js +++ b/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js @@ -88,7 +88,7 @@ const DEFAULT_BINDINGS = { "x r": { click: "#dismiss-new,#dismiss-new-top,#dismiss-posts,#dismiss-posts-top", }, // dismiss new/posts - "x t": { click: "#dismiss-topics,#dismiss-topics-top" }, // dismiss topics + "x t": { click: "#dismiss-topics-bottom,#dismiss-topics-top" }, // dismiss topics }; const animationDuration = 100; diff --git a/app/assets/javascripts/discourse/app/lib/load-script.js b/app/assets/javascripts/discourse/app/lib/load-script.js index 9eebebff36..7966e41d47 100644 --- a/app/assets/javascripts/discourse/app/lib/load-script.js +++ b/app/assets/javascripts/discourse/app/lib/load-script.js @@ -99,7 +99,6 @@ export default function loadScript(url, opts) { ajax({ url: fullUrl, dataType: "text", - cache: true, }).then(cb); } else { // Always load JavaScript with script tag to avoid Content Security Policy inline violations diff --git a/app/assets/javascripts/discourse/app/lib/page-tracker.js b/app/assets/javascripts/discourse/app/lib/page-tracker.js index be74113978..76a937f195 100644 --- a/app/assets/javascripts/discourse/app/lib/page-tracker.js +++ b/app/assets/javascripts/discourse/app/lib/page-tracker.js @@ -23,7 +23,7 @@ export function startPageTracking(router, appEvents, documentTitle) { return; } router.on("routeDidChange", (transition) => { - // we ocassionally prevent tracking of replaced pages when only query params changed + // we occasionally prevent tracking of replaced pages when only query params changed // eg: google analytics const replacedOnlyQueryParams = transition.urlMethod === "replace" && transition.queryParamsOnly; diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js index 21c7142637..5f8a2ff4c4 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.js +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js @@ -52,6 +52,7 @@ import { addPostSmallActionIcon } from "discourse/widgets/post-small-action"; import { addQuickAccessProfileItem } from "discourse/widgets/quick-access-profile"; import { addTagsHtmlCallback } from "discourse/lib/render-tags"; import { addToolbarCallback } from "discourse/components/d-editor"; +import { addTopicParticipantClassesCallback } from "discourse/widgets/topic-map"; import { addTopicTitleDecorator } from "discourse/components/topic-title"; import { addUserMenuGlyph } from "discourse/widgets/user-menu"; import { addUsernameSelectorDecorator } from "discourse/helpers/decorate-username-selector"; @@ -73,7 +74,7 @@ import { replaceTagRenderer } from "discourse/lib/render-tag"; import { setNewCategoryDefaultColors } from "discourse/routes/new-category"; // If you add any methods to the API ensure you bump up this number -const PLUGIN_API_VERSION = "0.11.3"; +const PLUGIN_API_VERSION = "0.11.4"; class PluginApi { constructor(version, container) { @@ -757,12 +758,24 @@ class PluginApi { * * Example: * - * addPostClassesCallback((atts) => {if (atts.post_number == 1) return ["first"];}) + * addPostClassesCallback((attrs) => {if (attrs.post_number == 1) return ["first"];}) **/ addPostClassesCallback(callback) { addPostClassesCallback(callback); } + /** + * Adds a callback to be called before rendering a topic participant that + * that returns custom classes to add to the participant element + * + * Example: + * + * addTopicParticipantClassesCallback((attrs) => {if (attrs.primary_group_name == "moderator") return ["important-participant"];}) + **/ + addTopicParticipantClassesCallback(callback) { + addTopicParticipantClassesCallback(callback); + } + /** * * Adds a callback to be executed on the "transformed" post that is passed to the post diff --git a/app/assets/javascripts/discourse/app/lib/plugin-connectors.js b/app/assets/javascripts/discourse/app/lib/plugin-connectors.js index 16b7fafdb3..71d922e367 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-connectors.js +++ b/app/assets/javascripts/discourse/app/lib/plugin-connectors.js @@ -1,4 +1,3 @@ -import Site from "discourse/models/site"; import { buildRawConnectorCache } from "discourse-common/lib/raw-templates"; import deprecated from "discourse-common/lib/deprecated"; @@ -26,17 +25,8 @@ const DefaultConnectorClass = { }; function findOutlets(collection, callback) { - const disabledPlugins = Site.currentProp("disabled_plugins") || []; - Object.keys(collection).forEach(function (res) { if (res.indexOf("/connectors/") !== -1) { - // Skip any disabled plugins - for (let i = 0; i < disabledPlugins.length; i++) { - if (res.indexOf("/" + disabledPlugins[i] + "/") !== -1) { - return; - } - } - const segments = res.split("/"); let outletName = segments[segments.length - 2]; const uniqueName = segments[segments.length - 1]; diff --git a/app/assets/javascripts/discourse/app/lib/register-topic-footer-button.js b/app/assets/javascripts/discourse/app/lib/register-topic-footer-button.js index cdafab891f..ec3c019172 100644 --- a/app/assets/javascripts/discourse/app/lib/register-topic-footer-button.js +++ b/app/assets/javascripts/discourse/app/lib/register-topic-footer-button.js @@ -33,7 +33,7 @@ export function registerTopicFooterButton(button) { ariaLabel: null, translatedAriaLabel: null, - // is this button disaplyed in the mobile dropdown or as an inline button ? + // is this button displayed in the mobile dropdown or as an inline button ? dropdown: false, // css class appended to the button diff --git a/app/assets/javascripts/discourse/app/lib/screen-track.js b/app/assets/javascripts/discourse/app/lib/screen-track.js index 0e17697f0e..3391c8ccfe 100644 --- a/app/assets/javascripts/discourse/app/lib/screen-track.js +++ b/app/assets/javascripts/discourse/app/lib/screen-track.js @@ -156,7 +156,6 @@ export default class { ajax("/topics/timings", { data, - cache: false, type: "POST", headers: { "X-SILENCE-LOGGER": "true", diff --git a/app/assets/javascripts/discourse/app/lib/show-modal.js b/app/assets/javascripts/discourse/app/lib/show-modal.js index f2602e0196..db3067fbcf 100644 --- a/app/assets/javascripts/discourse/app/lib/show-modal.js +++ b/app/assets/javascripts/discourse/app/lib/show-modal.js @@ -41,6 +41,8 @@ export default function (name, opts) { route.render(fullName, renderArgs); if (opts.title) { modalController.set("title", I18n.t(opts.title)); + } else if (opts.titleTranslated) { + modalController.set("title", opts.titleTranslated); } else { modalController.set("title", null); } diff --git a/app/assets/javascripts/discourse/app/lib/text.js b/app/assets/javascripts/discourse/app/lib/text.js index f12acbfab7..f83d506913 100644 --- a/app/assets/javascripts/discourse/app/lib/text.js +++ b/app/assets/javascripts/discourse/app/lib/text.js @@ -21,7 +21,8 @@ function getOpts(opts) { customEmojiTranslation: context.site.custom_emoji_translation, siteSettings: context.siteSettings, formatUsername, - watchedWordsReplacements: context.site.watched_words_replace, + watchedWordsReplace: context.site.watched_words_replace, + watchedWordsLink: context.site.watched_words_link, }, opts ); diff --git a/app/assets/javascripts/discourse/app/lib/transform-post.js b/app/assets/javascripts/discourse/app/lib/transform-post.js index 44dbe1d5cd..b2d5e01ce2 100644 --- a/app/assets/javascripts/discourse/app/lib/transform-post.js +++ b/app/assets/javascripts/discourse/app/lib/transform-post.js @@ -148,6 +148,7 @@ export default function transformPost( postAtts.actionCodeWho = post.action_code_who; postAtts.topicUrl = topic.get("url"); postAtts.isSaving = post.isSaving; + postAtts.staged = post.staged; if (post.notice) { postAtts.notice = post.notice; diff --git a/app/assets/javascripts/discourse/app/lib/url.js b/app/assets/javascripts/discourse/app/lib/url.js index ec875d4033..67732d5144 100644 --- a/app/assets/javascripts/discourse/app/lib/url.js +++ b/app/assets/javascripts/discourse/app/lib/url.js @@ -1,5 +1,6 @@ import getURL, { withoutPrefix } from "discourse-common/lib/get-url"; import { next, schedule } from "@ember/runloop"; +import Category from "discourse/models/category"; import EmberObject from "@ember/object"; import LockOn from "discourse/lib/lock-on"; import Session from "discourse/models/session"; @@ -33,7 +34,7 @@ const SERVER_SIDE_ONLY = [ /^\/styleguide/, ]; -// The amount of height (in pixles) that we factor in when jumpEnd is called so +// The amount of height (in pixels) that we factor in when jumpEnd is called so // that we show a little bit of the post text even on mobile devices instead of // scrolling to "suggested topics". const JUMP_END_BUFFER = 250; @@ -515,4 +516,13 @@ export function getCategoryAndTagUrl(category, subcategories, tag) { return getURL(url || "/"); } +export function getEditCategoryUrl(category, subcategories, tab) { + let url = `/c/${Category.slugFor(category)}/edit`; + + if (tab) { + url += `/${tab}`; + } + return getURL(url); +} + export default _urlInstance; diff --git a/app/assets/javascripts/discourse/app/lib/user-presence.js b/app/assets/javascripts/discourse/app/lib/user-presence.js index 2758338faa..081fff4553 100644 --- a/app/assets/javascripts/discourse/app/lib/user-presence.js +++ b/app/assets/javascripts/discourse/app/lib/user-presence.js @@ -25,8 +25,30 @@ export default function (maxUnseenTime) { } } +const callbacks = []; + +const MIN_DELTA = 60000; + export function seenUser() { + let lastSeenTime = seenUserTime; seenUserTime = Date.now(); + let delta = seenUserTime - lastSeenTime; + + if (lastSeenTime && delta > MIN_DELTA) { + callbacks.forEach((info) => { + if (delta > info.unseenTime) { + info.callback(); + } + }); + } +} + +// register a callback for cases where presence changed +export function onPresenceChange(maxUnseenTime, callback) { + if (maxUnseenTime < MIN_DELTA) { + throw "unseenTime is too short"; + } + callbacks.push({ unseenTime: maxUnseenTime, callback: callback }); } // We could piggieback on the Scroll mixin, but it is not applied diff --git a/app/assets/javascripts/discourse/app/lib/user-search.js b/app/assets/javascripts/discourse/app/lib/user-search.js index 980d92a417..0f14ca40b4 100644 --- a/app/assets/javascripts/discourse/app/lib/user-search.js +++ b/app/assets/javascripts/discourse/app/lib/user-search.js @@ -21,6 +21,7 @@ function performSearch( includeMessageableGroups, allowedUsers, groupMembersOf, + includeStagedUsers, resultsFn ) { let cached = cache[term]; @@ -49,6 +50,7 @@ function performSearch( include_messageable_groups: includeMessageableGroups, groups: groupMembersOf, topic_allowed_users: allowedUsers, + include_staged_users: includeStagedUsers, }, }); @@ -90,6 +92,7 @@ let debouncedSearch = function ( includeMessageableGroups, allowedUsers, groupMembersOf, + includeStagedUsers, resultsFn ) { discourseDebounce( @@ -103,6 +106,7 @@ let debouncedSearch = function ( includeMessageableGroups, allowedUsers, groupMembersOf, + includeStagedUsers, resultsFn, 300 ); @@ -157,7 +161,7 @@ function organizeResults(r, options) { return results; } -// all punctuations except for -, _ and . which are allowed in usernames +// all punctuation except for -, _ and . which are allowed in usernames // note: these are valid in names, but will end up tripping search anyway so just skip // this means searching for `sam saffron` is OK but if my name is `sam$ saffron` autocomplete // will not find me, which is a reasonable compromise @@ -189,7 +193,8 @@ export default function userSearch(options) { allowedUsers = options.allowedUsers, topicId = options.topicId, categoryId = options.categoryId, - groupMembersOf = options.groupMembersOf; + groupMembersOf = options.groupMembersOf, + includeStagedUsers = options.includeStagedUsers; if (oldSearch) { oldSearch.abort(); @@ -226,6 +231,7 @@ export default function userSearch(options) { includeMessageableGroups, allowedUsers, groupMembersOf, + includeStagedUsers, function (r) { cancel(clearPromise); resolve(organizeResults(r, options)); diff --git a/app/assets/javascripts/discourse/app/lib/utilities.js b/app/assets/javascripts/discourse/app/lib/utilities.js index 5cb6976d5d..d644ffc7d5 100644 --- a/app/assets/javascripts/discourse/app/lib/utilities.js +++ b/app/assets/javascripts/discourse/app/lib/utilities.js @@ -160,6 +160,7 @@ export function selectedText() { range.setEndBefore($postMenuArea); } + const $oneboxTest = $ancestor.closest("aside.onebox[data-onebox-src]"); const $codeBlockTest = $ancestor.parents("pre"); if ($codeBlockTest.length) { const $code = $(""); @@ -172,11 +173,21 @@ export function selectedText() { } else { $div.append($code); } + } else if ($oneboxTest.length) { + // This is a partial quote from a onebox. + // Treat it as though the entire onebox was quoted. + const oneboxUrl = $($oneboxTest).data("onebox-src"); + $div.append(oneboxUrl); } else { $div.append(range.cloneContents()); } } + $div.find("aside.onebox[data-onebox-src]").each(function () { + const oneboxUrl = $(this).data("onebox-src"); + $(this).replaceWith(oneboxUrl); + }); + return toMarkdown($div.html()); } @@ -452,9 +463,9 @@ const CODE_BLOCKS_REGEX = /^( |\t).*|`[^`]+`|^```[^]*?^```|\[code\][^]*?\[\/c // | | | | // | | | code blocks between [code] // | | | -// | | +--- code blocks between three backquote +// | | +--- code blocks between three backticks // | | -// | +----- inline code between backquotes +// | +----- inline code between backticks // | // +------- paragraphs starting with 4 spaces or tab diff --git a/app/assets/javascripts/discourse/app/lib/webauthn.js b/app/assets/javascripts/discourse/app/lib/webauthn.js index e33ffc2001..113ef3c06b 100644 --- a/app/assets/javascripts/discourse/app/lib/webauthn.js +++ b/app/assets/javascripts/discourse/app/lib/webauthn.js @@ -42,7 +42,7 @@ export function getWebauthnCredential( timeout: 60000, // see https://chromium.googlesource.com/chromium/src/+/master/content/browser/webauth/uv_preferred.md for why - // default value of preferred is not necesarrily what we want, it limits webauthn to only devices that support + // default value of preferred is not necessarily what we want, it limits webauthn to only devices that support // user verification, which usually requires entering a PIN userVerification: "discouraged", }, diff --git a/app/assets/javascripts/discourse/app/mixins/buffered-content.js b/app/assets/javascripts/discourse/app/mixins/buffered-content.js index 2b13f7d1d1..92b44f16fa 100644 --- a/app/assets/javascripts/discourse/app/mixins/buffered-content.js +++ b/app/assets/javascripts/discourse/app/mixins/buffered-content.js @@ -1,13 +1,11 @@ -import BufferedMixin from "ember-buffered-proxy/mixin"; import BufferedProxy from "ember-buffered-proxy/proxy"; -import EmberObjectProxy from "@ember/object/proxy"; import Mixin from "@ember/object/mixin"; import { computed } from "@ember/object"; export function bufferedProperty(property) { const mixin = { buffered: computed(property, function () { - return EmberObjectProxy.extend(BufferedMixin || BufferedProxy).create({ + return BufferedProxy.create({ content: this.get(property), }); }), diff --git a/app/assets/javascripts/discourse/app/mixins/bulk-topic-selection.js b/app/assets/javascripts/discourse/app/mixins/bulk-topic-selection.js index 7ba734c65d..edcae9b7f3 100644 --- a/app/assets/javascripts/discourse/app/mixins/bulk-topic-selection.js +++ b/app/assets/javascripts/discourse/app/mixins/bulk-topic-selection.js @@ -1,8 +1,8 @@ import Mixin from "@ember/object/mixin"; +import { or } from "@ember/object/computed"; +import { on } from "discourse-common/utils/decorators"; import { NotificationLevels } from "discourse/lib/notification-levels"; import Topic from "discourse/models/topic"; -import { alias } from "@ember/object/computed"; -import { on } from "discourse-common/utils/decorators"; import { inject as service } from "@ember/service"; export default Mixin.create({ @@ -12,13 +12,20 @@ export default Mixin.create({ autoAddTopicsToBulkSelect: false, selected: null, - canBulkSelect: alias("currentUser.staff"), + canBulkSelect: or("currentUser.staff", "showDismissRead", "showResetNew"), @on("init") resetSelected() { this.set("selected", []); }, + _isFilterPage(filter, filterType) { + if (!filter) { + return false; + } + return new RegExp(filterType + "$", "gi").test(filter); + }, + actions: { toggleBulkSelect() { this.toggleProperty("bulkSelectEnabled"); @@ -44,9 +51,7 @@ export default Mixin.create({ promise.then((result) => { if (result && result.topic_ids) { - const tracker = this.topicTrackingState; - result.topic_ids.forEach((t) => tracker.removeTopic(t)); - tracker.incrementMessageCount(); + this.topicTrackingState.removeTopics(result.topic_ids); } this.send("closeModal"); diff --git a/app/assets/javascripts/discourse/app/mixins/docking.js b/app/assets/javascripts/discourse/app/mixins/docking.js index 76ccd3a3f7..e8de346cbc 100644 --- a/app/assets/javascripts/discourse/app/mixins/docking.js +++ b/app/assets/javascripts/discourse/app/mixins/docking.js @@ -4,9 +4,9 @@ import { cancel, later } from "@ember/runloop"; const helper = { offset() { - const mainOffset = $("#main").offset(); - const offsetTop = mainOffset ? mainOffset.top : 0; - return (window.pageYOffset || $("html").scrollTop()) - offsetTop; + const main = document.querySelector("#main"); + const offsetTop = main ? main.offsetTop : 0; + return window.pageYOffset - offsetTop; }, }; @@ -32,8 +32,8 @@ export default Mixin.create({ didInsertElement() { this._super(...arguments); - $(window).bind("scroll.discourse-dock", this.queueDockCheck); - $(document).bind("touchmove.discourse-dock", this.queueDockCheck); + window.addEventListener("scroll", this.queueDockCheck); + document.addEventListener("touchmove", this.queueDockCheck); // dockCheck might happen too early on full page refresh this._initialTimer = later(this, this.safeDockCheck, 50); @@ -47,7 +47,7 @@ export default Mixin.create({ } cancel(this._initialTimer); - $(window).unbind("scroll.discourse-dock", this.queueDockCheck); - $(document).unbind("touchmove.discourse-dock", this.queueDockCheck); + window.removeEventListener("scroll", this.queueDockCheck); + document.removeEventListener("touchmove", this.queueDockCheck); }, }); diff --git a/app/assets/javascripts/discourse/app/mixins/open-composer.js b/app/assets/javascripts/discourse/app/mixins/open-composer.js index 8b21b283a1..250469854a 100644 --- a/app/assets/javascripts/discourse/app/mixins/open-composer.js +++ b/app/assets/javascripts/discourse/app/mixins/open-composer.js @@ -14,7 +14,8 @@ export default Mixin.create({ } this.controllerFor("composer").open({ - categoryId, + prioritizedCategoryId: categoryId, + topicCategoryId: categoryId, action: Composer.CREATE_TOPIC, draftKey: controller.get("model.draft_key") || Composer.NEW_TOPIC_KEY, draftSequence: controller.get("model.draft_sequence") || 0, diff --git a/app/assets/javascripts/discourse/app/mixins/pan-events.js b/app/assets/javascripts/discourse/app/mixins/pan-events.js index b1e1835d95..b40fd68eab 100644 --- a/app/assets/javascripts/discourse/app/mixins/pan-events.js +++ b/app/assets/javascripts/discourse/app/mixins/pan-events.js @@ -3,7 +3,6 @@ import Mixin from "@ember/object/mixin"; Pan events is a mixin that allows components to detect and respond to swipe gestures It fires callbacks for panStart, panEnd, panMove with the pan state, and the original event. **/ -export const SWIPE_VELOCITY = 40; export const SWIPE_DISTANCE_THRESHOLD = 50; export const SWIPE_VELOCITY_THRESHOLD = 0.12; export const MINIMUM_SWIPE_DISTANCE = 5; @@ -14,35 +13,38 @@ export default Mixin.create({ didInsertElement() { this._super(...arguments); - this.addTouchListeners($(this.element)); + this.addTouchListeners(this.element); }, willDestroyElement() { this._super(...arguments); - this.removeTouchListeners($(this.element)); + this.removeTouchListeners(this.element); }, - addTouchListeners($element) { + addTouchListeners(element) { if (this.site.mobileView) { - $element - .on("touchstart", (e) => e.touches && this._panStart(e.touches[0])) - .on("touchmove", (e) => { - const touchEvent = e.touches[0]; - touchEvent.type = "pointermove"; - this._panMove(touchEvent, e); - }) - .on("touchend", (e) => this._panMove({ type: "pointerup" }, e)) - .on("touchcancel", (e) => this._panMove({ type: "pointercancel" }, e)); + this.touchStart = (e) => e.touches && this._panStart(e.touches[0]); + this.touchMove = (e) => { + const touchEvent = e.touches[0]; + touchEvent.type = "pointermove"; + this._panMove(touchEvent, e); + }; + this.touchEnd = (e) => this._panMove({ type: "pointerup" }, e); + this.touchCancel = (e) => this._panMove({ type: "pointercancel" }, e); + + element.addEventListener("touchstart", this.touchStart); + element.addEventListener("touchmove", this.touchMove); + element.addEventListener("touchend", this.touchEnd); + element.addEventListener("touchcancel", this.touchCancel); } }, - removeTouchListeners($element) { + removeTouchListeners(element) { if (this.site.mobileView) { - $element - .off("touchstart") - .off("touchmove") - .off("touchend") - .off("touchcancel"); + element.removeEventListener("touchstart", this.touchStart); + element.removeEventListener("touchmove", this.touchMove); + element.removeEventListener("touchend", this.touchEnd); + element.removeEventListener("touchcancel", this.touchCancel); } }, diff --git a/app/assets/javascripts/discourse/app/models/bookmark.js b/app/assets/javascripts/discourse/app/models/bookmark.js index d295ebc74c..52b032e264 100644 --- a/app/assets/javascripts/discourse/app/models/bookmark.js +++ b/app/assets/javascripts/discourse/app/models/bookmark.js @@ -137,7 +137,7 @@ const Bookmark = RestModel.extend({ url += "?" + $.param(params); } - return ajax(url, { cache: "false" }); + return ajax(url); }, loadMore(additionalParams) { diff --git a/app/assets/javascripts/discourse/app/models/category.js b/app/assets/javascripts/discourse/app/models/category.js index 4de28e476d..af65076dba 100644 --- a/app/assets/javascripts/discourse/app/models/category.js +++ b/app/assets/javascripts/discourse/app/models/category.js @@ -426,7 +426,7 @@ Category.reopenClass({ findBySlugPathWithID(slugPathWithID) { let parts = slugPathWithID.split("/").filter(Boolean); - // slugs found by star/glob pathing in emeber do not automatically url decode - ensure that these are decoded + // slugs found by star/glob pathing in ember do not automatically url decode - ensure that these are decoded if (this.slugEncoded()) { parts = parts.map((urlPart) => decodeURI(urlPart)); } diff --git a/app/assets/javascripts/discourse/app/models/composer.js b/app/assets/javascripts/discourse/app/models/composer.js index b36abfb524..5b8f6c6815 100644 --- a/app/assets/javascripts/discourse/app/models/composer.js +++ b/app/assets/javascripts/discourse/app/models/composer.js @@ -1,6 +1,6 @@ import EmberObject, { set } from "@ember/object"; import { and, equal, not, or, reads } from "@ember/object/computed"; -import { cancel, later, next, throttle } from "@ember/runloop"; +import { next, throttle } from "@ember/runloop"; import discourseComputed, { observes, on, @@ -113,7 +113,6 @@ const Composer = RestModel.extend({ unlistTopic: false, noBump: false, draftSaving: false, - draftSaved: false, draftForceSave: false, archetypes: reads("site.archetypes"), @@ -693,15 +692,30 @@ const Composer = RestModel.extend({ } }, - /* - Open a composer + /** + Open a composer - opts: - action - The action we're performing: edit, reply or createTopic - post - The post we're replying to, if present - topic - The topic we're replying to, if present - quote - If we're opening a reply from a quote, the quote we're making - */ + @method open + @param {Object} opts + @param {String} opts.action The action we're performing: edit, reply, createTopic, createSharedDraft, privateMessage + @param {String} opts.draftKey + @param {String} opts.draftSequence + @param {Post} [opts.post] The post we're replying to, if present + @param {Topic} [opts.topic] The topic we're replying to, if present + @param {String} [opts.quote] If we're opening a reply from a quote, the quote we're making + @param {String} [opts.reply] + @param {String} [opts.recipients] + @param {Number} [opts.composerTime] + @param {Number} [opts.typingTime] + @param {Boolean} [opts.whisper] + @param {Boolean} [opts.noBump] + @param {String} [opts.archetypeId] One of `site.archetypes` e.g. `regular` or `private_message` + @param {Object} [opts.metaData] + @param {Number} [opts.categoryId] + @param {Number} [opts.postId] + @param {Number} [opts.destinationCategoryId] + @param {String} [opts.title] + **/ open(opts) { let promise = Promise.resolve(); @@ -946,23 +960,29 @@ const Composer = RestModel.extend({ this.set("composeState", SAVING); const rollback = throwAjaxError((error) => { - post.set("cooked", oldCooked); + post.setProperties({ cooked: oldCooked, staged: false }); + this.appEvents.trigger("post-stream:refresh", { id: post.id }); + this.set("composeState", OPEN); if (error.jqXHR && error.jqXHR.status === 409) { this.set("editConflict", true); } }); + post.setProperties({ cooked: props.cooked, staged: true }); + this.appEvents.trigger("post-stream:refresh", { id: post.id }); + return promise .then(() => { - // rest model only sets props after it is saved - post.set("cooked", props.cooked); return post.save(props).then((result) => { this.clearState(); return result; }); }) - .catch(rollback); + .catch(rollback) + .finally(() => { + post.set("staged", false); + }); }, serialize(serializer, dest) { @@ -1169,16 +1189,10 @@ const Composer = RestModel.extend({ } this.setProperties({ - draftSaved: false, draftSaving: true, draftConflictUser: null, }); - if (this._clearingStatus) { - cancel(this._clearingStatus); - this._clearingStatus = null; - } - let data = this.serialize(_draft_serializer); if (data.postId && !isEmpty(this.originalText)) { @@ -1203,7 +1217,7 @@ const Composer = RestModel.extend({ }); } else { this.setProperties({ - draftSaved: true, + draftStatus: null, draftConflictUser: null, draftForceSave: false, }); @@ -1255,23 +1269,6 @@ const Composer = RestModel.extend({ this.set("draftSaving", false); }); }, - - @observes("title", "reply") - dataChanged() { - const draftStatus = this.draftStatus; - - if (draftStatus && !this._clearingStatus) { - this._clearingStatus = later( - this, - () => { - this.setProperties({ draftStatus: null, draftConflictUser: null }); - this._clearingStatus = null; - this.setProperties({ draftSaving: false, draftSaved: false }); - }, - Ember.Test ? 0 : 1000 - ); - } - }, }); Composer.reopenClass({ diff --git a/app/assets/javascripts/discourse/app/models/draft.js b/app/assets/javascripts/discourse/app/models/draft.js index 5406c7f9b8..37dda0ba76 100644 --- a/app/assets/javascripts/discourse/app/models/draft.js +++ b/app/assets/javascripts/discourse/app/models/draft.js @@ -34,6 +34,7 @@ Draft.reopenClass({ owner: clientId, force_save: forceSave, }, + ignoreUnsent: false, }); }, }); diff --git a/app/assets/javascripts/discourse/app/models/group.js b/app/assets/javascripts/discourse/app/models/group.js index 429dcd0595..23a8e54f26 100644 --- a/app/assets/javascripts/discourse/app/models/group.js +++ b/app/assets/javascripts/discourse/app/models/group.js @@ -217,10 +217,12 @@ const Group = RestModel.extend({ smtp_server: this.smtp_server, smtp_port: this.smtp_port, smtp_ssl: this.smtp_ssl, + smtp_enabled: this.smtp_enabled, imap_server: this.imap_server, imap_port: this.imap_port, imap_ssl: this.imap_ssl, imap_mailbox_name: this.imap_mailbox_name, + imap_enabled: this.imap_enabled, email_username: this.email_username, email_password: this.email_password, flair_icon: null, diff --git a/app/assets/javascripts/discourse/app/models/post.js b/app/assets/javascripts/discourse/app/models/post.js index 77e1967f2e..5da002ab51 100644 --- a/app/assets/javascripts/discourse/app/models/post.js +++ b/app/assets/javascripts/discourse/app/models/post.js @@ -172,7 +172,6 @@ const Post = RestModel.extend({ return ajax(`/posts/${this.id}/recover`, { type: "PUT", - cache: false, }) .then((data) => { this.setProperties({ @@ -208,13 +207,9 @@ const Post = RestModel.extend({ } else { const key = this.post_number === 1 - ? "topic.deleted_by_author" - : "post.deleted_by_author"; - promise = cookAsync( - I18n.t(key, { - count: this.siteSettings.delete_removed_posts_after, - }) - ).then((cooked) => { + ? "topic.deleted_by_author_simple" + : "post.deleted_by_author_simple"; + promise = cookAsync(I18n.t(key)).then((cooked) => { this.setProperties({ cooked: cooked, can_delete: false, diff --git a/app/assets/javascripts/discourse/app/models/site.js b/app/assets/javascripts/discourse/app/models/site.js index fa59e1cc21..9fc38c92de 100644 --- a/app/assets/javascripts/discourse/app/models/site.js +++ b/app/assets/javascripts/discourse/app/models/site.js @@ -179,8 +179,10 @@ Site.reopenClass(Singleton, { } if (result.trust_levels) { - result.trustLevels = result.trust_levels.map((tl) => - TrustLevel.create(tl) + result.trustLevels = Object.entries(result.trust_levels).map( + ([key, id]) => { + return new TrustLevel(id, key); + } ); delete result.trust_levels; } diff --git a/app/assets/javascripts/discourse/app/models/topic-tracking-state.js b/app/assets/javascripts/discourse/app/models/topic-tracking-state.js index b00e8427ba..8b424ec007 100644 --- a/app/assets/javascripts/discourse/app/models/topic-tracking-state.js +++ b/app/assets/javascripts/discourse/app/models/topic-tracking-state.js @@ -1,11 +1,11 @@ import EmberObject, { get } from "@ember/object"; import discourseComputed, { on } from "discourse-common/utils/decorators"; import Category from "discourse/models/category"; +import { deepEqual, deepMerge } from "discourse-common/lib/object"; import DiscourseURL from "discourse/lib/url"; import { NotificationLevels } from "discourse/lib/notification-levels"; import PreloadStore from "discourse/lib/preload-store"; import User from "discourse/models/user"; -import { deepEqual } from "discourse-common/lib/object"; import { isEmpty } from "@ember/utils"; function isNew(topic) { @@ -13,6 +13,7 @@ function isNew(topic) { topic.last_read_post_number === null && ((topic.notification_level !== 0 && !topic.notification_level) || topic.notification_level >= NotificationLevels.TRACKING) && + topic.created_in_new_period && isUnseen(topic) ); } @@ -21,7 +22,8 @@ function isUnread(topic) { return ( topic.last_read_post_number !== null && topic.last_read_post_number < topic.highest_post_number && - topic.notification_level >= NotificationLevels.TRACKING + topic.notification_level >= NotificationLevels.TRACKING && + topic.unread_not_too_old ); } @@ -48,105 +50,47 @@ const TopicTrackingState = EmberObject.extend({ _setup() { this.unreadSequence = []; this.newSequence = []; - this.states = {}; + this.states = new Map(); + this.messageIncrementCallbacks = {}; + this.stateChangeCallbacks = {}; + this._trackedTopicLimit = 4000; }, + /** + * Subscribe to MessageBus channels which are used for publishing changes + * to the tracking state. Each message received will modify state for + * a particular topic. + * + * See app/models/topic_tracking_state.rb for the data payloads published + * to each of the channels. + * + * @method establishChannels + */ establishChannels() { - const tracker = this; - - const process = (data) => { - if (["muted", "unmuted"].includes(data.message_type)) { - tracker.trackMutedOrUnmutedTopic(data); - return; - } - - tracker.pruneOldMutedAndUnmutedTopics(); - - if (tracker.isMutedTopic(data.topic_id)) { - return; - } - - if ( - this.siteSettings.mute_all_categories_by_default && - !tracker.isUnmutedTopic(data.topic_id) - ) { - return; - } - - if (data.message_type === "delete") { - tracker.removeTopic(data.topic_id); - tracker.incrementMessageCount(); - } - - if (["new_topic", "latest"].includes(data.message_type)) { - const muted_category_ids = User.currentProp("muted_category_ids"); - if ( - muted_category_ids && - muted_category_ids.includes(data.payload.category_id) - ) { - return; - } - } - - if (["new_topic", "latest"].includes(data.message_type)) { - const mutedTagIds = User.currentProp("muted_tag_ids"); - if ( - hasMutedTags( - data.payload.topic_tag_ids, - mutedTagIds, - this.siteSettings - ) - ) { - return; - } - } - - if (data.message_type === "latest") { - tracker.notify(data); - } - - if (data.message_type === "dismiss_new") { - tracker.dismissNewTopic(data); - } - - if (["new_topic", "unread", "read"].includes(data.message_type)) { - tracker.notify(data); - const old = tracker.states["t" + data.topic_id]; - if (!deepEqual(old, data.payload)) { - tracker.states["t" + data.topic_id] = data.payload; - tracker.notifyPropertyChange("states"); - tracker.incrementMessageCount(); - } - } - }; - - this.messageBus.subscribe("/new", process); - this.messageBus.subscribe("/latest", process); + this.messageBus.subscribe("/new", this._processChannelPayload.bind(this)); + this.messageBus.subscribe( + "/latest", + this._processChannelPayload.bind(this) + ); if (this.currentUser) { this.messageBus.subscribe( "/unread/" + this.currentUser.get("id"), - process + this._processChannelPayload.bind(this) ); } this.messageBus.subscribe("/delete", (msg) => { - const old = tracker.states["t" + msg.topic_id]; - if (old) { - old.deleted = true; - } - tracker.incrementMessageCount(); + this.modifyStateProp(msg, "deleted", true); + this.incrementMessageCount(); }); this.messageBus.subscribe("/recover", (msg) => { - const old = tracker.states["t" + msg.topic_id]; - if (old) { - delete old.deleted; - } - tracker.incrementMessageCount(); + this.modifyStateProp(msg, "deleted", false); + this.incrementMessageCount(); }); this.messageBus.subscribe("/destroy", (msg) => { - tracker.incrementMessageCount(); + this.incrementMessageCount(); const currentRoute = DiscourseURL.router.currentRoute.parent; if ( currentRoute.name === "topic" && @@ -181,17 +125,6 @@ const TopicTrackingState = EmberObject.extend({ this.currentUser && this.currentUser.set(key, topics); }, - dismissNewTopic(data) { - data.payload.topic_ids.forEach((k) => { - const topic = this.states[`t${k}`]; - this.states[`t${k}`] = Object.assign({}, topic, { - is_seen: true, - }); - }); - this.notifyPropertyChange("states"); - this.incrementMessageCount(); - }, - pruneOldMutedAndUnmutedTopics() { const now = Date.now(); let mutedTopics = this.mutedTopics().filter( @@ -213,22 +146,50 @@ const TopicTrackingState = EmberObject.extend({ return !!this.unmutedTopics().findBy("topicId", topicId); }, + /** + * Updates the topic's last_read_post_number to the highestSeen post + * number, as long as the topic is being tracked. + * + * Calls onStateChange callbacks. + * + * @params {Number|String} topicId - The ID of the topic to set last_read_post_number for. + * @params {Number} highestSeen - The post number of the topic that should be + * used for last_read_post_number. + * @method updateSeen + */ updateSeen(topicId, highestSeen) { if (!topicId || !highestSeen) { return; } - const state = this.states["t" + topicId]; + const state = this.findState(topicId); + if (!state) { + return; + } + if ( - state && - (!state.last_read_post_number || - state.last_read_post_number < highestSeen) + !state.last_read_post_number || + state.last_read_post_number < highestSeen ) { - state.last_read_post_number = highestSeen; + this.modifyStateProp(topicId, "last_read_post_number", highestSeen); this.incrementMessageCount(); } }, - notify(data) { + /** + * Used to count incoming topics which will be displayed in a message + * at the top of the topic list, if hasIncoming is true (which is if + * incomingCount > 0). + * + * This will do nothing unless resetTracking or trackIncoming has been + * called; newIncoming will be null instead of an array. trackIncoming + * is called by various topic routes, as is resetTracking. + * + * @method notifyIncoming + * @param {Object} data - The data sent by TopicTrackingState to MessageBus + * which includes the message_type, payload of the topic, + * and the topic_id. + */ + notifyIncoming(data) { if (!this.newIncoming) { return; } @@ -240,6 +201,9 @@ const TopicTrackingState = EmberObject.extend({ const filterCategory = this.filterCategory; const categoryId = data.payload && data.payload.category_id; + // if we have a filter category currently and it is not the + // same as the topic category from the payload, then do nothing + // because it doesn't need to be counted as incoming if (filterCategory && filterCategory.get("id") !== categoryId) { const category = categoryId && Category.findById(categoryId); if ( @@ -250,46 +214,67 @@ const TopicTrackingState = EmberObject.extend({ } } + // always count a new_topic as incoming if ( ["all", "latest", "new"].includes(filter) && data.message_type === "new_topic" ) { - this.addIncoming(data.topic_id); + this._addIncoming(data.topic_id); } + // count an unread topic as incoming if (["all", "unread"].includes(filter) && data.message_type === "unread") { - const old = this.states["t" + data.topic_id]; + const old = this.findState(data); + + // the highest post number is equal to last read post number here + // because the state has already been modified based on the /unread + // messageBus message if (!old || old.highest_post_number === old.last_read_post_number) { - this.addIncoming(data.topic_id); + this._addIncoming(data.topic_id); } } + // always add incoming if looking at the latest list and a latest channel + // message comes through if (filter === "latest" && data.message_type === "latest") { - this.addIncoming(data.topic_id); + this._addIncoming(data.topic_id); } + // hasIncoming relies on this count this.set("incomingCount", this.newIncoming.length); }, - addIncoming(topicId) { - if (this.newIncoming.indexOf(topicId) === -1) { - this.newIncoming.push(topicId); - } - }, - + /** + * Resets the number of incoming topics to 0 and flushes the new topics + * from the array. Without calling this or trackIncoming the notifyIncoming + * method will do nothing. + * + * @method resetTracking + */ resetTracking() { this.newIncoming = []; this.set("incomingCount", 0); }, - // track how many new topics came for this filter + /** + * Track how many new topics came for the specified filter. + * + * Related/intertwined with notifyIncoming; the filter and filterCategory + * set here is used to determine whether or not to add incoming counts + * based on message types of incoming MessageBus messages (via establishChannels) + * + * @method trackIncoming + * @param {String} filter - Valid values are all, categories, and any topic list + * filters e.g. latest, unread, new. As well as this + * specific category and tag URLs like /tag/test/l/latest + * or c/cat/subcat/6/l/latest. + */ trackIncoming(filter) { this.newIncoming = []; - const split = filter.split("/"); + const split = filter.split("/"); if (split.length >= 4) { filter = split[split.length - 1]; - // c/cat/subcat/6/l/latest let category = Category.findSingleBySlug( split.splice(1, split.length - 4).join("/") ); @@ -302,145 +287,126 @@ const TopicTrackingState = EmberObject.extend({ this.set("incomingCount", 0); }, + /** + * Used to determine whether toshow the message at the top of the topic list + * e.g. "see 1 new or updated topic" + * + * @method incomingCount + */ @discourseComputed("incomingCount") hasIncoming(incomingCount) { return incomingCount && incomingCount > 0; }, - removeTopic(topic_id) { - delete this.states["t" + topic_id]; + /** + * Removes the topic ID provided from the tracker state. + * + * Calls onStateChange callbacks. + * + * @param {Number|String} topicId - The ID of the topic to remove from state. + * @method removeTopic + */ + removeTopic(topicId) { + this.states.delete(this._stateKey(topicId)); + this._afterStateChange(); }, - // If we have a cached topic list, we can update it from our tracking information. + /** + * Removes multiple topics from the state at once, and increments + * the message count. + * + * Calls onStateChange callbacks. + * + * @param {Array} topicIds - The IDs of the topic to removes from state. + * @method removeTopics + */ + removeTopics(topicIds) { + topicIds.forEach((topicId) => this.removeTopic(topicId)); + this.incrementMessageCount(); + this._afterStateChange(); + }, + + /** + * If we have a cached topic list, we can update it from our tracking information + * if the last_read_post_number or is_seen property does not match what the + * cached topic has. + * + * @method updateTopics + * @param {Array} topics - An array of Topic models. + */ updateTopics(topics) { if (isEmpty(topics)) { return; } - const states = this.states; - topics.forEach((t) => { - const state = states["t" + t.get("id")]; + topics.forEach((topic) => { + const state = this.findState(topic.get("id")); - if (state) { - const lastRead = t.get("last_read_post_number"); - const isSeen = t.get("is_seen"); - if ( - lastRead !== state.last_read_post_number || - isSeen !== state.is_seen - ) { - const postsCount = t.get("posts_count"); - let newPosts = postsCount - state.highest_post_number, - unread = postsCount - state.last_read_post_number; + if (!state) { + return; + } - if (newPosts < 0) { - newPosts = 0; - } - if (!state.last_read_post_number) { - unread = 0; - } - if (unread < 0) { - unread = 0; - } + const lastRead = topic.get("last_read_post_number"); + const isSeen = topic.get("is_seen"); - t.setProperties({ - highest_post_number: state.highest_post_number, - last_read_post_number: state.last_read_post_number, - new_posts: newPosts, - unread: unread, - is_seen: state.is_seen, - unseen: !state.last_read_post_number && isUnseen(state), - }); + if ( + lastRead !== state.last_read_post_number || + isSeen !== state.is_seen + ) { + const postsCount = topic.get("posts_count"); + let newPosts = postsCount - state.highest_post_number, + unread = postsCount - state.last_read_post_number; + + if (newPosts < 0) { + newPosts = 0; } + if (!state.last_read_post_number) { + unread = 0; + } + if (unread < 0) { + unread = 0; + } + + topic.setProperties({ + highest_post_number: state.highest_post_number, + last_read_post_number: state.last_read_post_number, + new_posts: newPosts, + unread: unread, + is_seen: state.is_seen, + unseen: !state.last_read_post_number && isUnseen(state), + }); } }); }, + /** + * Uses the provided topic list to apply changes to the in-memory topic + * tracking state, remove state as required, and also compensate for missing + * in-memory state. + * + * Any state changes will make a callback to all state change callbacks defined + * via onStateChange and all message increment callbacks defined via onMessageIncrement + * + * @method sync + * @param {TopicList} list + * @param {String} filter - The filter used for the list e.g. new/unread + * @param {Object} queryParams - The query parameters for the list e.g. page + */ sync(list, filter, queryParams) { - const tracker = this, - states = tracker.states; - if (!list || !list.topics) { return; } - // compensate for delayed "new" topics - // client side we know they are not new, server side we think they are - for (let i = list.topics.length - 1; i >= 0; i--) { - const state = states["t" + list.topics[i].id]; - if (state && state.last_read_post_number > 0) { - if (filter === "new") { - list.topics.splice(i, 1); - } else { - list.topics[i].set("unseen", false); - list.topics[i].set("dont_sync", true); - } - } - } + // make sure any server-side state matches reality in the client side + this._fixDelayedServerState(list, filter); - list.topics.forEach(function (topic) { - const row = tracker.states["t" + topic.id] || {}; - row.topic_id = topic.id; - row.notification_level = topic.notification_level; + // make sure all the state is up to date with what is accurate + // from the server + list.topics.forEach(this._syncStateFromListTopic.bind(this)); - if (topic.unseen) { - row.last_read_post_number = null; - } else if (topic.unread || topic.new_posts) { - row.last_read_post_number = - topic.highest_post_number - - ((topic.unread || 0) + (topic.new_posts || 0)); - } else { - if (!topic.dont_sync) { - delete tracker.states["t" + topic.id]; - } - return; - } - - row.highest_post_number = topic.highest_post_number; - if (topic.category) { - row.category_id = topic.category.id; - } - - if (topic.tags) { - row.tags = topic.tags; - } - - tracker.states["t" + topic.id] = row; - }); - - // Correct missing states, safeguard in case message bus is corrupt - let shouldCompensate = - (filter === "new" || filter === "unread") && !list.more_topics_url; - - if (shouldCompensate && queryParams) { - Object.keys(queryParams).forEach((k) => { - if (k !== "ascending" && k !== "order") { - shouldCompensate = false; - } - }); - } - - if (shouldCompensate) { - const ids = {}; - list.topics.forEach((r) => (ids["t" + r.id] = true)); - - Object.keys(tracker.states).forEach((k) => { - // we are good if we are on the list - if (ids[k]) { - return; - } - - const v = tracker.states[k]; - - if (filter === "unread" && isUnread(v)) { - // pretend read - v.last_read_post_number = v.highest_post_number; - } - - if (filter === "new" && isNew(v)) { - // pretend not new - v.last_read_post_number = 1; - } - }); + // correct missing states, safeguard in case message bus is corrupt + if (this._shouldCompensateState(list, filter, queryParams)) { + this._correctMissingState(list, filter); } this.incrementMessageCount(); @@ -448,6 +414,27 @@ const TopicTrackingState = EmberObject.extend({ incrementMessageCount() { this.incrementProperty("messageCount"); + Object.values(this.messageIncrementCallbacks).forEach((cb) => cb()); + }, + + _generateCallbackId() { + return Math.random().toString(12).substr(2, 9); + }, + + onMessageIncrement(cb) { + let callbackId = this._generateCallbackId(); + this.messageIncrementCallbacks[callbackId] = cb; + return callbackId; + }, + + onStateChange(cb) { + let callbackId = this._generateCallbackId(); + this.stateChangeCallbacks[callbackId] = cb; + return callbackId; + }, + + offStateChange(callbackId) { + delete this.stateChangeCallbacks[callbackId]; }, getSubCategoryIds(categoryId) { @@ -471,11 +458,11 @@ const TopicTrackingState = EmberObject.extend({ : this.getSubCategoryIds(categoryId); const mutedCategoryIds = this.currentUser && this.currentUser.muted_category_ids; - let filter = type === "new" ? isNew : isUnread; + let filterFn = type === "new" ? isNew : isUnread; - return Object.values(this.states).filter( + return Array.from(this.states.values()).filter( (topic) => - filter(topic) && + filterFn(topic) && topic.archetype !== "private_message" && !topic.deleted && (!categoryId || subcategoryIds.has(topic.category_id)) && @@ -499,46 +486,78 @@ const TopicTrackingState = EmberObject.extend({ ); }, - forEachTracked(fn) { - Object.values(this.states).forEach((topic) => { - if (topic.archetype !== "private_message" && !topic.deleted) { - let newTopic = isNew(topic); - let unreadTopic = isUnread(topic); - if (newTopic || unreadTopic) { - fn(topic, newTopic, unreadTopic); - } - } + /** + * Calls the provided callback for each of the currenty tracked topics + * we have in state. + * + * @method forEachTracked + * @param {Function} fn - The callback function to call with the topic, + * newTopic which is a boolean result of isNew, + * and unreadTopic which is a boolean result of + * isUnread. + */ + forEachTracked(fn, opts = {}) { + this._trackedTopics(opts).forEach((trackedTopic) => { + fn(trackedTopic.topic, trackedTopic.newTopic, trackedTopic.unreadTopic); }); }, - countTags(tags) { + /** + * Using the array of tags provided, tallys up all topics via forEachTracked + * that we are tracking, separated into new/unread/total. + * + * Total is only counted if opts.includeTotal is specified. + * + * Output (from input ["pending", "bug"]): + * + * { + * pending: { unreadCount: 6, newCount: 1, totalCount: 10 }, + * bug: { unreadCount: 0, newCount: 4, totalCount: 20 } + * } + * + * @method countTags + * @param opts - Valid options: + * * includeTotal - When true, a totalCount is incremented for + * all topics matching a tag. + */ + countTags(tags, opts = {}) { let counts = {}; tags.forEach((tag) => { counts[tag] = { unreadCount: 0, newCount: 0 }; - }); - - this.forEachTracked((topic, newTopic, unreadTopic) => { - if (topic.tags) { - tags.forEach((tag) => { - if (topic.tags.indexOf(tag) > -1) { - if (unreadTopic) { - counts[tag].unreadCount++; - } - if (newTopic) { - counts[tag].newCount++; - } - } - }); + if (opts.includeTotal) { + counts[tag].totalCount = 0; } }); + this.forEachTracked( + (topic, newTopic, unreadTopic) => { + if (topic.tags && topic.tags.length > 0) { + tags.forEach((tag) => { + if (topic.tags.indexOf(tag) > -1) { + if (unreadTopic) { + counts[tag].unreadCount++; + } + if (newTopic) { + counts[tag].newCount++; + } + + if (opts.includeTotal) { + counts[tag].totalCount++; + } + } + }); + } + }, + { includeAll: opts.includeTotal } + ); + return counts; }, countCategory(category_id, tagId) { let sum = 0; - Object.values(this.states).forEach((topic) => { + for (let topic of this.states.values()) { if ( topic.category_id === category_id && !topic.deleted && @@ -550,7 +569,7 @@ const TopicTrackingState = EmberObject.extend({ ? 1 : 0; } - }); + } return sum; }, @@ -577,21 +596,272 @@ const TopicTrackingState = EmberObject.extend({ }, loadStates(data) { - const states = this.states; + (data || []).forEach((topic) => { + this.modifyState(topic, topic); + }); + }, - // I am taking some shortcuts here to avoid 500 gets for a large list - if (data) { - data.forEach((topic) => { - states["t" + topic.topic_id] = topic; + modifyState(topic, data) { + this.states.set(this._stateKey(topic), data); + this._afterStateChange(); + }, + + modifyStateProp(topic, prop, data) { + const state = this.findState(topic); + if (state) { + state[prop] = data; + this._afterStateChange(); + } + }, + + findState(topicOrId) { + return this.states.get(this._stateKey(topicOrId)); + }, + + /* + * private + */ + + // fix delayed "new" topics by removing the now seen + // topic from the list (for the "new" list) or setting the topic + // to "seen" for other lists. + // + // client side we know they are not new, server side we think they are. + // this can happen if the list is cached or the update to the state + // for a particular seen topic has not yet reached the server. + _fixDelayedServerState(list, filter) { + for (let index = list.topics.length - 1; index >= 0; index--) { + const state = this.findState(list.topics[index].id); + if (state && state.last_read_post_number > 0) { + if (filter === "new") { + list.topics.splice(index, 1); + } else { + list.topics[index].set("unseen", false); + list.topics[index].set("prevent_sync", true); + } + } + } + }, + + // this updates the topic in the state to match the + // topic from the list (e.g. updates category, highest read post + // number, tags etc.) + _syncStateFromListTopic(topic) { + const state = this.findState(topic.id) || {}; + + // make a new copy so we aren't modifying the state object directly while + // we make changes + const newState = { ...state }; + + newState.topic_id = topic.id; + newState.notification_level = topic.notification_level; + + // see ListableTopicSerializer for unread/unseen/new_posts and other + // topic property logic + if (topic.unseen) { + newState.last_read_post_number = null; + } else if (topic.unread || topic.new_posts) { + newState.last_read_post_number = + topic.highest_post_number - + ((topic.unread || 0) + (topic.new_posts || 0)); + } else { + // remove the topic if it is no longer unread/new (it has been seen) + // and if there are too many topics in memory + if (!topic.prevent_sync && this._maxStateSizeReached()) { + this.removeTopic(topic.id); + } + return; + } + + newState.highest_post_number = topic.highest_post_number; + if (topic.category) { + newState.category_id = topic.category.id; + } + + if (topic.tags) { + newState.tags = topic.tags; + } + + this.modifyState(topic.id, newState); + }, + + // this stops sync of tracking state when list is filtered, in the past this + // would cause the tracking state to become inconsistent. + _shouldCompensateState(list, filter, queryParams) { + let shouldCompensate = + (filter === "new" || filter === "unread") && !list.more_topics_url; + + if (shouldCompensate && queryParams) { + Object.keys(queryParams).forEach((k) => { + if (k !== "ascending" && k !== "order") { + shouldCompensate = false; + } }); } + + return shouldCompensate; + }, + + // any state that is not in the provided list must be updated + // based on the filter selected so we do not have any incorrect + // state in the list + _correctMissingState(list, filter) { + const ids = {}; + list.topics.forEach((topic) => (ids[this._stateKey(topic.id)] = true)); + + for (let topicKey of this.states.keys()) { + // if the topic is already in the list then there is + // no compensation needed; we already have latest state + // from the backend + if (ids[topicKey]) { + return; + } + + const newState = { ...this.findState(topicKey) }; + if (filter === "unread" && isUnread(newState)) { + // pretend read. if unread, the highest_post_number will be greater + // than the last_read_post_number + newState.last_read_post_number = newState.highest_post_number; + } + + if (filter === "new" && isNew(newState)) { + // pretend not new. if the topic is new, then last_read_post_number + // will be null. + newState.last_read_post_number = 1; + } + + this.modifyState(topicKey, newState); + } + }, + + // processes the data sent via messageBus, called by establishChannels + _processChannelPayload(data) { + if (["muted", "unmuted"].includes(data.message_type)) { + this.trackMutedOrUnmutedTopic(data); + return; + } + + this.pruneOldMutedAndUnmutedTopics(); + + if (this.isMutedTopic(data.topic_id)) { + return; + } + + if ( + this.siteSettings.mute_all_categories_by_default && + !this.isUnmutedTopic(data.topic_id) + ) { + return; + } + + if (["new_topic", "latest"].includes(data.message_type)) { + const muted_category_ids = User.currentProp("muted_category_ids"); + if ( + muted_category_ids && + muted_category_ids.includes(data.payload.category_id) + ) { + return; + } + } + + if (["new_topic", "latest"].includes(data.message_type)) { + const mutedTagIds = User.currentProp("muted_tag_ids"); + if ( + hasMutedTags(data.payload.topic_tag_ids, mutedTagIds, this.siteSettings) + ) { + return; + } + } + + const old = this.findState(data); + + if (data.message_type === "latest") { + this.notifyIncoming(data); + + if ((old && old.tags) !== data.payload.tags) { + this.modifyStateProp(data, "tags", data.payload.tags); + this.incrementMessageCount(); + } + } + + if (data.message_type === "dismiss_new") { + this._dismissNewTopics(data.payload.topic_ids); + } + + if (["new_topic", "unread", "read"].includes(data.message_type)) { + this.notifyIncoming(data); + if (!deepEqual(old, data.payload)) { + if (data.message_type === "read") { + let mergeData = {}; + + // we have to do this because the "read" event does not + // include tags; we don't want them to be overridden + if (old) { + mergeData = { + tags: old.tags, + topic_tag_ids: old.topic_tag_ids, + }; + } + + this.modifyState(data, deepMerge(data.payload, mergeData)); + } else { + this.modifyState(data, data.payload); + } + this.incrementMessageCount(); + } + } + }, + + _dismissNewTopics(topicIds) { + topicIds.forEach((topicId) => { + this.modifyStateProp(topicId, "is_seen", true); + }); + this.incrementMessageCount(); + }, + + _addIncoming(topicId) { + if (this.newIncoming.indexOf(topicId) === -1) { + this.newIncoming.push(topicId); + } + }, + + _trackedTopics(opts = {}) { + return Array.from(this.states.values()) + .map((topic) => { + if (topic.archetype !== "private_message" && !topic.deleted) { + let newTopic = isNew(topic); + let unreadTopic = isUnread(topic); + if (newTopic || unreadTopic || opts.includeAll) { + return { topic, newTopic, unreadTopic }; + } + } + }) + .compact(); + }, + + _stateKey(topicOrId) { + if (typeof topicOrId === "number") { + return `t${topicOrId}`; + } else if (typeof topicOrId === "string" && topicOrId.indexOf("t") > -1) { + return topicOrId; + } else { + return `t${topicOrId.topic_id}`; + } + }, + + _afterStateChange() { + this.notifyPropertyChange("states"); + Object.values(this.stateChangeCallbacks).forEach((cb) => cb()); + }, + + _maxStateSizeReached() { + return this.states.size >= this._trackedTopicLimit; }, }); export function startTracking(tracking) { const data = PreloadStore.get("topicTrackingStates"); tracking.loadStates(data); - tracking.initialStatesLength = data && data.length; tracking.establishChannels(); PreloadStore.remove("topicTrackingStates"); } diff --git a/app/assets/javascripts/discourse/app/models/topic.js b/app/assets/javascripts/discourse/app/models/topic.js index 37be80796c..deef4b89f4 100644 --- a/app/assets/javascripts/discourse/app/models/topic.js +++ b/app/assets/javascripts/discourse/app/models/topic.js @@ -20,7 +20,7 @@ import getURL from "discourse-common/lib/get-url"; import { longDate } from "discourse/lib/formatter"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { resolveShareUrl } from "discourse/helpers/share-url"; -import { userPath } from "discourse/lib/url"; +import DiscourseURL, { userPath } from "discourse/lib/url"; export function loadTopicView(topic, args) { const data = deepMerge({}, args); @@ -172,12 +172,7 @@ const Topic = RestModel.extend({ @discourseComputed("related_messages") relatedMessages(relatedMessages) { if (relatedMessages) { - const store = this.store; - - return this.set( - "related_messages", - relatedMessages.map((st) => store.createRecord("topic", st)) - ); + return relatedMessages.map((st) => this.store.createRecord("topic", st)); } }, @@ -429,6 +424,9 @@ const Topic = RestModel.extend({ "details.can_delete": false, "details.can_recover": true, }); + if (!deleted_by.staff) { + DiscourseURL.redirectTo("/"); + } }) .catch(popupAjaxError); }, @@ -756,7 +754,14 @@ Topic.reopenClass({ }); }, - resetNew(category, include_subcategories, tracked = false, tag = false) { + resetNew(category, include_subcategories, opts = {}) { + let { tracked, tag, topicIds } = { + tracked: false, + tag: null, + topicIds: null, + ...opts, + }; + const data = { tracked }; if (category) { data.category_id = category.id; @@ -765,6 +770,9 @@ Topic.reopenClass({ if (tag) { data.tag_id = tag.id; } + if (topicIds) { + data.topic_ids = topicIds; + } return ajax("/topics/reset-new", { type: "PUT", data }); }, diff --git a/app/assets/javascripts/discourse/app/models/trust-level.js b/app/assets/javascripts/discourse/app/models/trust-level.js index b793195506..2ee5136fda 100644 --- a/app/assets/javascripts/discourse/app/models/trust-level.js +++ b/app/assets/javascripts/discourse/app/models/trust-level.js @@ -1,6 +1,22 @@ -import RestModel from "discourse/models/rest"; -import { fmt } from "discourse/lib/computed"; +import { computed } from "@ember/object"; +import I18n from "I18n"; -export default RestModel.extend({ - detailedName: fmt("id", "name", "%@ - %@"), -}); +export default class TrustLevel { + constructor(id, key) { + this.id = id; + this._key = key; + } + + @computed + get name() { + return I18n.t(`trust_levels.names.${this._key}`); + } + + @computed + get detailedName() { + return I18n.t("trust_levels.detailed_name", { + level: this.id, + name: this.name, + }); + } +} diff --git a/app/assets/javascripts/discourse/app/models/user-badge.js b/app/assets/javascripts/discourse/app/models/user-badge.js index 0aaff3f561..a4c090354f 100644 --- a/app/assets/javascripts/discourse/app/models/user-badge.js +++ b/app/assets/javascripts/discourse/app/models/user-badge.js @@ -4,8 +4,11 @@ import { Promise } from "rsvp"; import Topic from "discourse/models/topic"; import User from "discourse/models/user"; import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; import discourseComputed from "discourse-common/utils/decorators"; +const DEFAULT_USER_BADGES_META = { max_favorites: 2 }; + const UserBadge = EmberObject.extend({ @discourseComputed postUrl: function () { @@ -19,6 +22,15 @@ const UserBadge = EmberObject.extend({ type: "DELETE", }); }, + + favorite() { + return ajax(`/user_badges/${this.id}/toggle_favorite`, { type: "PUT" }) + .then((json) => { + this.set("is_favorite", json.user_badge.is_favorite); + return this; + }) + .catch(popupAjaxError); + }, }); UserBadge.reopenClass({ @@ -86,6 +98,7 @@ UserBadge.reopenClass({ userBadges.grant_count = json.user_badge_info.grant_count; userBadges.username = json.user_badge_info.username; } + userBadges.meta = json.meta || DEFAULT_USER_BADGES_META; return userBadges; } }, diff --git a/app/assets/javascripts/discourse/app/models/user-drafts-stream.js b/app/assets/javascripts/discourse/app/models/user-drafts-stream.js index 90864e13ff..e703e5acb8 100644 --- a/app/assets/javascripts/discourse/app/models/user-drafts-stream.js +++ b/app/assets/javascripts/discourse/app/models/user-drafts-stream.js @@ -66,7 +66,7 @@ export default RestModel.extend({ this.set("loading", true); - return ajax(findUrl, { cache: "false" }) + return ajax(findUrl) .then((result) => { if (result && result.no_results_help) { this.set("noContentHelp", result.no_results_help); diff --git a/app/assets/javascripts/discourse/app/models/user-posts-stream.js b/app/assets/javascripts/discourse/app/models/user-posts-stream.js index b152630bc1..53f48c9f24 100644 --- a/app/assets/javascripts/discourse/app/models/user-posts-stream.js +++ b/app/assets/javascripts/discourse/app/models/user-posts-stream.js @@ -50,7 +50,7 @@ export default EmberObject.extend({ this.set("loading", true); - return ajax(this.url, { cache: false }) + return ajax(this.url) .then((result) => { if (result) { const posts = result.map((post) => UserAction.create(post)); diff --git a/app/assets/javascripts/discourse/app/models/user-stream.js b/app/assets/javascripts/discourse/app/models/user-stream.js index b66c95d516..21b6b53d2c 100644 --- a/app/assets/javascripts/discourse/app/models/user-stream.js +++ b/app/assets/javascripts/discourse/app/models/user-stream.js @@ -100,7 +100,7 @@ export default RestModel.extend({ } this.set("loading", true); - return ajax(findUrl, { cache: "false" }) + return ajax(findUrl) .then((result) => { if (result && result.no_results_help) { this.set("noContentHelp", result.no_results_help); diff --git a/app/assets/javascripts/discourse/app/models/user.js b/app/assets/javascripts/discourse/app/models/user.js index 6d09211cd1..4d8a9cfe08 100644 --- a/app/assets/javascripts/discourse/app/models/user.js +++ b/app/assets/javascripts/discourse/app/models/user.js @@ -229,7 +229,7 @@ const User = RestModel.extend({ const allowedUsers = details && details.get("allowed_users"); const groups = details && details.get("allowed_groups"); - // directly targetted so go to inbox + // directly targeted so go to inbox if (!groups || (allowedUsers && allowedUsers.findBy("id", userId))) { return userPath(`${username}/messages`); } else { @@ -524,27 +524,23 @@ const User = RestModel.extend({ loadUserAction(id) { const stream = this.stream; - return ajax(`/user_actions/${id}.json`, { cache: "false" }).then( - (result) => { - if (result && result.user_action) { - const ua = result.user_action; + return ajax(`/user_actions/${id}.json`).then((result) => { + if (result && result.user_action) { + const ua = result.user_action; - if ( - (this.get("stream.filter") || ua.action_type) !== ua.action_type - ) { - return; - } - if (!this.get("stream.filter") && !this.inAllStream(ua)) { - return; - } - - ua.title = emojiUnescape(escapeExpression(ua.title)); - const action = UserAction.collapseStream([UserAction.create(ua)]); - stream.set("itemsLoaded", stream.get("itemsLoaded") + 1); - stream.get("content").insertAt(0, action[0]); + if ((this.get("stream.filter") || ua.action_type) !== ua.action_type) { + return; } + if (!this.get("stream.filter") && !this.inAllStream(ua)) { + return; + } + + ua.title = emojiUnescape(escapeExpression(ua.title)); + const action = UserAction.collapseStream([UserAction.create(ua)]); + stream.set("itemsLoaded", stream.get("itemsLoaded") + 1); + stream.get("content").insertAt(0, action[0]); } - ); + }); }, inAllStream(ua) { diff --git a/app/assets/javascripts/discourse/app/pre-initializers/theme-errors-handler.js b/app/assets/javascripts/discourse/app/pre-initializers/theme-errors-handler.js index 05a9a0e153..dd08a637ce 100644 --- a/app/assets/javascripts/discourse/app/pre-initializers/theme-errors-handler.js +++ b/app/assets/javascripts/discourse/app/pre-initializers/theme-errors-handler.js @@ -29,7 +29,6 @@ function reportToLogster(name, error) { Ember.$.ajax(getURL("/logs/report_js_error"), { data, type: "POST", - cache: false, }); } diff --git a/app/assets/javascripts/discourse/app/routes/edit-category.js b/app/assets/javascripts/discourse/app/routes/edit-category.js index f9ebcf1255..a3c43dbff5 100644 --- a/app/assets/javascripts/discourse/app/routes/edit-category.js +++ b/app/assets/javascripts/discourse/app/routes/edit-category.js @@ -11,6 +11,13 @@ export default DiscourseRoute.extend({ ); }, + afterModel(model) { + if (!model.can_edit) { + this.replaceWith("/404"); + return; + } + }, + titleToken() { return I18n.t("category.edit_dialog_title", { categoryName: this.currentModel.name, diff --git a/app/assets/javascripts/discourse/app/routes/group-manage-email.js b/app/assets/javascripts/discourse/app/routes/group-manage-email.js index 1ba2f5d5b5..5f8a26e43b 100644 --- a/app/assets/javascripts/discourse/app/routes/group-manage-email.js +++ b/app/assets/javascripts/discourse/app/routes/group-manage-email.js @@ -5,7 +5,8 @@ export default DiscourseRoute.extend({ showFooter: true, beforeModel() { - if (!this.siteSettings.enable_imap && !this.siteSettings.enable_smtp) { + // cannot configure IMAP without SMTP being enabled + if (!this.siteSettings.enable_smtp) { return this.transitionTo("group.manage.profile"); } }, diff --git a/app/assets/javascripts/discourse/app/routes/groups-index.js b/app/assets/javascripts/discourse/app/routes/groups-index.js index 41684664ea..a054f348c7 100644 --- a/app/assets/javascripts/discourse/app/routes/groups-index.js +++ b/app/assets/javascripts/discourse/app/routes/groups-index.js @@ -1,24 +1,24 @@ import DiscourseRoute from "discourse/routes/discourse"; import I18n from "I18n"; -export default DiscourseRoute.extend({ +export default class GroupsIndexRoute extends DiscourseRoute { titleToken() { return I18n.t("groups.index.title"); - }, + } - queryParams: { + queryParams = { order: { refreshModel: true, replace: true }, asc: { refreshModel: true, replace: true }, filter: { refreshModel: true }, type: { refreshModel: true, replace: true }, username: { refreshModel: true }, - }, + }; model(params) { return params; - }, + } setupController(controller, params) { controller.loadGroups(params); - }, -}); + } +} diff --git a/app/assets/javascripts/discourse/app/routes/review-index.js b/app/assets/javascripts/discourse/app/routes/review-index.js index b4d15e68e1..187c91c953 100644 --- a/app/assets/javascripts/discourse/app/routes/review-index.js +++ b/app/assets/javascripts/discourse/app/routes/review-index.js @@ -55,6 +55,18 @@ export default DiscourseRoute.extend({ }); } }); + + this.messageBus.subscribe("/reviewable_counts", (data) => { + if (data.updates) { + this.controller.reviewables.forEach((reviewable) => { + const updates = data.updates[reviewable.id]; + if (updates) { + reviewable.setProperties(updates); + reviewable.set("stale", true); + } + }); + } + }); }, deactivate() { diff --git a/app/assets/javascripts/discourse/app/routes/users.js b/app/assets/javascripts/discourse/app/routes/users.js index c055d4e2cc..8c059adab8 100644 --- a/app/assets/javascripts/discourse/app/routes/users.js +++ b/app/assets/javascripts/discourse/app/routes/users.js @@ -1,5 +1,6 @@ import DiscourseRoute from "discourse/routes/discourse"; import I18n from "I18n"; +import PreloadStore from "discourse/lib/preload-store"; export default DiscourseRoute.extend({ queryParams: { @@ -36,11 +37,14 @@ export default DiscourseRoute.extend({ }, model(params) { - return params; + const columns = PreloadStore.get("directoryColumns"); + params.order = params.order || columns[0].name; + return { params, columns }; }, - setupController(controller, params) { - controller.loadUsers(params); + setupController(controller, model) { + controller.set("columns", model.columns); + controller.loadUsers(model.params); }, actions: { diff --git a/app/assets/javascripts/discourse/app/templates/application.hbs b/app/assets/javascripts/discourse/app/templates/application.hbs index 8b3ecd788e..9296b8b8b9 100644 --- a/app/assets/javascripts/discourse/app/templates/application.hbs +++ b/app/assets/javascripts/discourse/app/templates/application.hbs @@ -8,7 +8,7 @@ toggleMobileView=(route-action "toggleMobileView") toggleAnonymous=(route-action "toggleAnonymous") logout=(route-action "logout")}} - {{software-update-prompt id="software-update-prompt"}} + {{software-update-prompt}} {{plugin-outlet name="below-site-header" tagName="" args=(hash currentPath=router._router.currentPath)}} diff --git a/app/assets/javascripts/discourse/app/templates/components/badge-card.hbs b/app/assets/javascripts/discourse/app/templates/components/badge-card.hbs index 415e4738c0..fc05530843 100644 --- a/app/assets/javascripts/discourse/app/templates/components/badge-card.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/badge-card.hbs @@ -4,7 +4,26 @@ {{#if badge.has_badge}} {{d-icon "check"}} {{/if}} -
    + +{{#if canFavorite}} + {{#if isFavorite}} + {{d-button + icon="star" + class="favorite-btn" + action=onFavoriteClick + }} + {{else}} + {{d-button + icon="far-star" + class="favorite-btn" + action=onFavoriteClick + title=(if canFavoriteMoreBadges "badges.favorite_max_not_reached" "badges.favorite_max_reached") + disabled=(not canFavoriteMoreBadges) + }} + {{/if}} +{{/if}} + +
    diff --git a/app/assets/javascripts/discourse/app/templates/components/bread-crumbs.hbs b/app/assets/javascripts/discourse/app/templates/components/bread-crumbs.hbs index 354e4de4ec..df1de24a36 100644 --- a/app/assets/javascripts/discourse/app/templates/components/bread-crumbs.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/bread-crumbs.hbs @@ -4,6 +4,8 @@ category=breadcrumb.category categories=breadcrumb.options tagId=tag.id + editingCategory=editingCategory + editingCategoryTab=editingCategoryTab options=(hash parentCategory=breadcrumb.parentCategory subCategory=breadcrumb.isSubcategory @@ -14,7 +16,7 @@ {{/if}} {{/each}} -{{#if siteSettings.tagging_enabled}} +{{#if showTagsSection}} {{#if additionalTags}} {{tags-intersection-chooser currentCategory=category diff --git a/app/assets/javascripts/discourse/app/templates/components/bulk-select-button.hbs b/app/assets/javascripts/discourse/app/templates/components/bulk-select-button.hbs index bdc803ae47..77306f93e1 100644 --- a/app/assets/javascripts/discourse/app/templates/components/bulk-select-button.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/bulk-select-button.hbs @@ -1,5 +1,7 @@ -{{#if selected}} -
    - {{d-button class="btn-default bulk-select-btn" action=(action "showBulkActions") icon="wrench"}} -
    +{{#if canDoBulkActions}} + {{#if selected}} +
    + {{d-button class="btn-default bulk-select-btn" action=(action "showBulkActions") icon="wrench"}} +
    + {{/if}} {{/if}} diff --git a/app/assets/javascripts/discourse/app/templates/components/choose-topic.hbs b/app/assets/javascripts/discourse/app/templates/components/choose-topic.hbs index bae33d6b87..7d384bccb2 100644 --- a/app/assets/javascripts/discourse/app/templates/components/choose-topic.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/choose-topic.hbs @@ -1,4 +1,6 @@ - + {{text-field value=topicTitle placeholderKey="choose_topic.title.placeholder" id="choose-topic-title"}} diff --git a/app/assets/javascripts/discourse/app/templates/components/d-modal.hbs b/app/assets/javascripts/discourse/app/templates/components/d-modal.hbs index 6b57bd1c21..7067f487ef 100644 --- a/app/assets/javascripts/discourse/app/templates/components/d-modal.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/d-modal.hbs @@ -3,7 +3,7 @@ +
    +
    +
    + +
    +

    +Description +

    +
    +

    +You should learn a programming language every year, as recommended by The Pragmatic Programmer. But if one per year is good, how about Seven Languages in Seven Weeks? In this book you'll get a hands-o ... +

    +
    +
    +
    + +
    +

    +Description +

    +
    +

    Product Description

    +
    +

    You should learn a programming language every year, as recommended by The Pragmatic Programmer. But if one per year is good, how about Seven Languages in Seven Weeks? In this book you'll get a hands-on tour of Clojure, Haskell, Io, Prolog, Scala, Erlang, and Ruby. Whether or not your favorite language is on that list, you'll broaden your perspective of programming by examining these languages side-by-side. You'll learn something new from each, and best of all, you'll learn how to learn a language quickly.

    Ruby, Io, Prolog, Scala, Erlang, Clojure, Haskell. With Seven Languages in Seven Weeks, by Bruce A. Tate, you'll go beyond the syntax-and beyond the 20-minute tutorial you'll find someplace online. This book has an audacious goal: to present a meaningful exploration of seven languages within a single book. Rather than serve as a complete reference or installation guide, Seven Languages hits what's essential and unique about each language. Moreover, this approach will help teach you how to grok new languages.

    For each language, you'll solve a nontrivial problem, using techniques that show off the language's most important features. As the book proceeds, you'll discover the strengths and weaknesses of the languages, while dissecting the process of learning languages quickly--for example, finding the typing and programming models, decision structures, and how you interact with them.

    Among this group of seven, you'll explore the most critical programming models of our time. Learn the dynamic typing that makes Ruby, Python, and Perl so flexible and compelling. Understand the underlying prototype system that's at the heart of JavaScript. See how pattern matching in Prolog shaped the development of Scala and Erlang. Discover how pure functional programming in Haskell is different from the Lisp family of languages, including Clojure.

    Explore the concurrency techniques that are quickly becoming the backbone of a new generation of Internet applications. Find out how to use Erlang's let-it-crash philosophy for building fault-tolerant systems. Understand the actor model that drives concurrency design in Io and Scala. Learn how Clojure uses versioning to solve some of the most difficult concurrency problems.

    It's all here, all in one place. Use the concepts from one language to find creative solutions in another-or discover a language that may become one of your favorites.

    +
    +

    Review

    +
    +

    ""I have been programming for 25 years in a variety of hardware and software languages. After reading Seven Languages in Seven Weeks, I am starting to understand how to evaluate languages for their objective strengths and weaknesses. More importantly, I feel as if I could pick one of them to actually get some work done.""--Chris Kappler, Senior scientist Raytheon, BBN Technologies

    ""I spent most of my time as a computer sciences student saying I didn't want to be a software developer and then became one anyway. Seven Languages in Seven Weeks expanded my way of thinking about problems and reminded me what I love about programming.""--Travis Kaspar, Software engineer, Northrop Grumman

    ""Do you want seven kick starts into learning your "language of the year"? Do you want your thinking challenged about programming in general? Look no further than this book. I personally was taken back in time to my undergraduate computer science days, coasting through my programming languages survey course. The difference is that Bruce won't let you coast through this course! This isn't a leisurely read--you'll have to work this book. I believe you'll find it both mindblowing and intensely practical at the same time.""--Matt Stine Group leader, Research Application Development, St. Jude Children's Research Hospital

    +
    +

    About the Author

    +
    +

    Bruce Tate runs RapidRed, an Austin, TX-based practice that consults on lightweight development in Ruby. Previously he worked at IBM in roles ranging from a database systems programmer to Java consultant. He left IBM to work for several startups in roles ranging from Client Solutions Director to CTO. He speaks internationally and is the author of more than ten books, including From Java to Ruby, Deploying Rails Applications, the best-selling Bitter series, Beyond Java, and the Jolt-winning Better, Faster, Lighter Java.

    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +

    +Features & details +

    +
    + +
    +
      +
    • + +Publication date: + +November 10, 2010 +
    • +
    • + +Publisher: + +Pragmatic Bookshelf +
    • +
    • + +Language: + +English +
    • +
    +
    +
    +
    +
    + +
    +

    +About this item +

    +
    +

    +Product Details +

    + +
    +
      +
    • + +Publication date: + +November 10, 2010 +
    • +
    • + +Publisher: + +Pragmatic Bookshelf +
    • +
    • + +Language: + +English +
    • +
    • + +ASIN: + +B00AYQNR46 +
    • +
    • + +Amazon.com Sales Rank: + +375493 +
    • +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + + +

    Customer Reviews

    58 customer reviews
    4.2 out of 5 stars4.2 out of 5 stars
    +
    +
    +
    +
    + +Rated by customers interested in + +
    + +
    +
    + + +
    + +
    +
    + +

    Top reviews

    +
    See all 58 reviews
    Write a review
    +
    +
    +
    +
    +
    +
    +
    +
    + + + +
    + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    \ No newline at end of file diff --git a/spec/fixtures/onebox/amazon-error.response b/spec/fixtures/onebox/amazon-error.response new file mode 100644 index 0000000000..78060d6c3f --- /dev/null +++ b/spec/fixtures/onebox/amazon-error.response @@ -0,0 +1,38 @@ + + + + + + + + + + +
    +
    + + Sorry! We couldn't find that page. Try searching or go to Amazon's home page. + +
    + + Dogs of Amazon + + +
    + + diff --git a/spec/fixtures/onebox/amazon-og.response b/spec/fixtures/onebox/amazon-og.response new file mode 100644 index 0000000000..0b16d9c6a1 --- /dev/null +++ b/spec/fixtures/onebox/amazon-og.response @@ -0,0 +1,3675 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Amazon.com: Christine: Rebecca Hall, Michael C. Hall, Antonio Campos, Craig Shilowich: Amazon Digital Services LLC + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Amazon Vehicles + + + + + +Beauty + + + + + +Best Books of the Month + + + + + +STEM + + + + + +nav_sap_plcc_ascpsc + + + + + + + + + + + +Electronics Dads and Grads Gift Guide + + + + + +Starting at $39.99 + + + + + +Wickedly Prime + + + + + +Handmade Wedding Shop + + + + + +Home Gift Guide +Father's Day Gifts +Home Gift Guide + + + + + +Shop Popular Services + + + + + +ALongStrangeTrip +ALongStrangeTrip +ALongStrangeTrip + + + + + + Introducing Echo Show + + + + + +All-New Fire 7, starting at $49.99 + + + + + +Kindle Oasis + + + + + +AutoRip in CDs & Vinyl + + + + + +Shop Now + + + + + +toystl17_gno + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    + + + + + + + + + + +

    + Christine + 2017 + +

    + + + + + + + + + + + + + + + R + CC + + +
    + + + + + + +
    + + + + + + + + + + + + + + + +
    + +
    + + + + + + + + + 3.5 out of 5 stars + + + + (84) + + + + + + + + + + + + + + + + + + + + + IMDb + 7/10 + +
    +
    + + + + + + +
    +
    +
    + +
    + +
    + + + +
    +
    +
    + + +
    +
    + + + + + + +
    + + +

    When renting, you have 30 days to start watching this video, and 48 hours to finish once started.

    + + +
    + + + +
    Rent Movie HD $4.99
    +
    +
    + + + + +
    + + + + +
    + + + +
    Buy Movie HD $12.99
    +
    +
    + + + + +
    + +
    + + + + + + + + + + + + +
    + +
    + +
    +

    + Rent +

    + + +

    When renting, you have 30 days to start watching this video, and 48 hours to finish once started.

    + + +
    + + + +
    Rent Movie HD $4.99
    +
    +
    + + + + +
    + + + + +
    + + + +
    Rent Movie SD $3.99
    +
    +
    + + + + +
    + +
    + + +
    +

    + Buy +

    + + + + +
    + + + +
    Buy Movie HD $12.99
    +
    +
    + + + + +
    + + + + +
    + + + +
    Buy Movie SD $9.99
    +
    +
    + + + + +
    + +
    + + + + + + + + + +
    + +
    + + + + + More Purchase Options + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + +
    + +
    +
    + + + + + + + + + + + + + + + +
    + +
    + +
    +

    CHRISTINE is the story of an aspiring newswoman caught in the midst of a personal and professional life crisis. Between unrequited love, frustration at work, a tumultuous home, and self-doubt; she begins to spiral down a dark path.

    +
    +
    +
    Starring:
    +
    Rebecca Hall, Michael C. Hall
    +
    Runtime:
    +
    1 hour, 59 minutes
    +
    +

    Available to watch on supported devices.

    +
    + + +
    + + + +
    + + + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    + + + +
    +
    + By placing your order or playing a video, you agree to our Terms of Use. Sold by Amazon Digital Services LLC. Additional taxes may apply. +
    +
    + + + + + +
    +
    +
    + +
    + + + + +
    + +
    +
    +
    + +
    + + + + + + + + + +
    +
    + +

    + Product details +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Genres + + Drama + +
    + Director + + Antonio Campos + +
    + Starring + + Rebecca Hall, Michael C. Hall + +
    + Studio + + The Orchard + +
    + MPAA rating + + R (Restricted) + +
    + Captions and subtitles + + English + + + + Details + + + + +
    + Purchase rights + + Stream instantly + + + + Details + + + + +
    + Format + + Amazon Video (streaming online video) + +
    + +
    +
    + + + + + + + + + + + + + + + + + +
    +
    + +

    + Other formats +

    + + +
    +
    + + + + + + + + + + + +
    + +
    +
    +
    +
    + + +

    Customer Reviews

    Top Customer Reviews

    on March 2, 2017
    Format: Amazon Video|Verified Purchase
    33 comments| + 13 people found this helpful. + + Was this review helpful to you?YesNoReport abuse
    on January 26, 2017
    Format: Amazon Video|Verified Purchase
    0Comment| + 13 people found this helpful. + + Was this review helpful to you?YesNoReport abuse
    on May 12, 2017
    Format: Amazon Video|Verified Purchase
    0Comment| + One person found this helpful. + + Was this review helpful to you?YesNoReport abuse
    on January 26, 2017
    Format: Amazon Video|Verified Purchase
    0Comment| + 2 people found this helpful. + + Was this review helpful to you?YesNoReport abuse
    on February 26, 2017
    Format: Amazon Video|Verified Purchase
    0Comment| + 3 people found this helpful. + + Was this review helpful to you?YesNoReport abuse
    on March 6, 2017
    Format: Amazon Video|Verified Purchase
    0Comment| + 2 people found this helpful. + + Was this review helpful to you?YesNoReport abuse
    on February 25, 2017
    Format: Amazon Video|Verified Purchase
    0Comment| + 2 people found this helpful. + + Was this review helpful to you?YesNoReport abuse
    on February 23, 2017
    Format: Amazon Video|Verified Purchase
    0Comment| + One person found this helpful. + + Was this review helpful to you?YesNoReport abuse

    Most Recent Customer Reviews

    +
    +
    + +
    +
    +
    + + + + + + + + + + + +
    + +
    + + + + + + + + + + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + diff --git a/spec/fixtures/onebox/amazon.response b/spec/fixtures/onebox/amazon.response new file mode 100644 index 0000000000..42d25aece7 --- /dev/null +++ b/spec/fixtures/onebox/amazon.response @@ -0,0 +1,3773 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages (Pragmatic Programmers): Bruce Tate: 8601234653110: Amazon.com: Books + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +

    Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages (Pragmatic Programmers) + +

    +
    +
    +
    +
    +
    +1st Edition +
    +
    +
    +ISBN-13: + 978-1934356593, +ISBN-10: + 193435659X +
    +
    + +
    + +
    +
    +
    +
    + + + +
    +
    +
    +
    + +
      +
    • +
    • +
    +
    +
    + +
    + + +
    Double-tap to zoom
    + +
    +
    +
    + +
    +
    + + + + +
    +
    +

    +Select Format +

    + +
    + +
    +
    +
    +
    + +$ + + +21 + + +11 + +
    +
    +
    + + + + + + +
    +
    + +Save $13.84 (40%) + +
    +
    +
    +
    +
    + + +
    +
    + + + + + + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +Save an extra $1.29 at checkout. +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +In Stock. + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + +This item ships to Germany. Want it Monday, Feb. 19? Order within 4 hrs 42 mins and choose AmazonGlobal Priority Shipping at checkout. + + +
    + + + +
    +
    +
    +
    +Ships from and sold by Amazon.com. +Gift-wrap available. +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +FREE Shipping on orders over $25 +
    +
    +
    + + +
    +
    +
    + +Sold by Mayon Collectibles and fulfilled by Amazon. + +
    +
    + +Access codes and supplements are not guaranteed with used items. + +
    +
    + +
    +
    + + + +Ship to: + + +Germany + + +
    +
    +
    + +To see addresses, please + +
    +
    + +
    +
    or
    +
    + +Use this location: + +
    +
    +
    +
    +
    + +
    +
    +
    + +Please enter a valid US zip code. + +
    + + +
    +
    or
    +
    + +
    +
    +
    +
    +
    + +
    + + +
    +
    +
    +
    + + + + + + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + + + +Ship to: + + +Germany + + +
    +
    + + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    + +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    + +
    + + +
    + + +
    +
    +
    +
    + +
    +
    +
    + +
    + + + + + + + + + + + +
    + +
    +
    +Share this product with friends +
    + + + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    +Frequently bought together +

    +
    +
    +
    Choose items to buy together.
    +
      +
    • Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages (Pragmatic Programmers)
    • +
    • +Seven More Languages in Seven Weeks: Languages That Are Shaping the Future +
    • +
    • +Seven Databases in Seven Weeks: A Guide to Modern Databases and the NoSQL Movement +
    • +
    +
    +
    + +
    + +
    +
    +

    Frequently bought together

    +
    +
    +
    +
    Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages (Pragmatic Programmers)
    +
    + +
    +
    +
    +$21.11 +
    Paperback
    +
    FREE Shipping on orders over $25. Details
    +
    In Stock.
    +
    Ships from and sold by Amazon.com.
    +
    +
    +
    +
    Seven More Languages in Seven Weeks: Languages That Are Shaping the Future
    +
    + +
    +
    +
    +$28.54 +
    Paperback
    +
    FREE Shipping. Details
    +
    Only 16 left in stock (more on the way).
    +
    Ships from and sold by Amazon.com.
    +
    +
    +
    +
    Seven Databases in Seven Weeks: A Guide to Modern Databases and the NoSQL Movement
    +
    + +
    +
    +
    +$26.28 +
    Paperback
    +
    FREE Shipping. Details
    +
    Only 12 left in stock (more on the way).
    +
    Ships from and sold by Amazon.com.
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +

    About this item +

    +
    +
    +
    +

    From the manufacturer

    +
    + + + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +Seven Languages in Seven Weeks + +Seven More Languages in Seven Weeks + +Seven Databases in Seven Weeks + +Seven Web Frameworks in Seven Weeks + +Seven Concurrency Models in Seven Weeks + +Seven Mobile Apps in Seven Weeks +
    +Seven Languages in Seven Weeks + +Seven More Languages in Seven Weeks + +Seven Databases in Seven Weeks + +Seven Web Frameworks in Seven Weeks + +Seven Concurrency Models in Seven Weeks + +Seven Mobile Apps in Seven Weeks +
    + +Subtitle + + + +A Pragmatic Guide to Learning Programming Languages + + + +Languages That Are Shaping the Future + + + +A Guide to Modern Databases and the NoSQL Movement + + + +Adventures in Better Web Apps + + + +When Threads Unravel + + + +Native Apps, Multiple Platforms + +
    + +Content Coverage + + + +Clojure, Haskell, Io, Prolog, Scala, Erlang, and Ruby + + + +Lua, Factor, Elixir, Elm, Julia, MiniKanren, and Idris + + + +Redis, Neo4J, CouchDB, MongoDB, HBase, Riak and Postgres + + + +Sinatra, CanJS, AngularJS, Ring, Webmachine, Yesod, and Immutant + + + +Threads & locks, functional programming, separating identity & state, actors, sequential processes, data parallelism, and the lambda architecture + + + +iOS, Android, Windows, RubyMotion, React Native, and Xamarin + +
    + +Pages + + + +328 pages + + + +320 pages + + + +354 pages + + + +304 pages + + + +300 pages + + + +360 pages + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +

    +Description +

    +
    +

    +You should learn a programming language every year, as recommended by The Pragmatic Programmer. But if one per year is good, how about Seven Languages in Seven Weeks? In this book you'll get a hands-o ... +

    +
    +
    +
    + +
    +

    +Description +

    +
    +

    Product description

    +
    +

    +

    You should learn a programming language every year, as recommended by The Pragmatic Programmer. But if one per year is good, how about Seven Languages in Seven Weeks? In this book you'll get a hands-on tour of Clojure, Haskell, Io, Prolog, Scala, Erlang, and Ruby. Whether or not your favorite language is on that list, you'll broaden your perspective of programming by examining these languages side-by-side. You'll learn something new from each, and best of all, you'll learn how to learn a language quickly.

    Ruby, Io, Prolog, Scala, Erlang, Clojure, Haskell. With Seven Languages in Seven Weeks, by Bruce A. Tate, you'll go beyond the syntax-and beyond the 20-minute tutorial you'll find someplace online. This book has an audacious goal: to present a meaningful exploration of seven languages within a single book. Rather than serve as a complete reference or installation guide, Seven Languages hits what's essential and unique about each language. Moreover, this approach will help teach you how to grok new languages.

    For each language, you'll solve a nontrivial problem, using techniques that show off the language's most important features. As the book proceeds, you'll discover the strengths and weaknesses of the languages, while dissecting the process of learning languages quickly--for example, finding the typing and programming models, decision structures, and how you interact with them.

    Among this group of seven, you'll explore the most critical programming models of our time. Learn the dynamic typing that makes Ruby, Python, and Perl so flexible and compelling. Understand the underlying prototype system that's at the heart of JavaScript. See how pattern matching in Prolog shaped the development of Scala and Erlang. Discover how pure functional programming in Haskell is different from the Lisp family of languages, including Clojure.

    Explore the concurrency techniques that are quickly becoming the backbone of a new generation of Internet applications. Find out how to use Erlang's let-it-crash philosophy for building fault-tolerant systems. Understand the actor model that drives concurrency design in Io and Scala. Learn how Clojure uses versioning to solve some of the most difficult concurrency problems.

    It's all here, all in one place. Use the concepts from one language to find creative solutions in another-or discover a language that may become one of your favorites.

    +

    +
    +

    Review

    +
    +

    +

    ""I have been programming for 25 years in a variety of hardware and software languages. After reading Seven Languages in Seven Weeks, I am starting to understand how to evaluate languages for their objective strengths and weaknesses. More importantly, I feel as if I could pick one of them to actually get some work done.""--Chris Kappler, Senior scientist Raytheon, BBN Technologies

    +

    ""I spent most of my time as a computer sciences student saying I didn't want to be a software developer and then became one anyway. Seven Languages in Seven Weeks expanded my way of thinking about problems and reminded me what I love about programming.""--Travis Kaspar, Software engineer, Northrop Grumman

    +

    ""Do you want seven kick starts into learning your "language of the year"? Do you want your thinking challenged about programming in general? Look no further than this book. I personally was taken back in time to my undergraduate computer science days, coasting through my programming languages survey course. The difference is that Bruce won't let you coast through this course! This isn't a leisurely read--you'll have to work this book. I believe you'll find it both mindblowing and intensely practical at the same time.""--Matt Stine Group leader, Research Application Development, St. Jude Children's Research Hospital

    +

    +
    +

    About the Author

    +
    +

    +

    +

    Bruce Tate runs RapidRed, an Austin, TX-based practice that consults on lightweight development in Ruby. Previously he worked at IBM in roles ranging from a database systems programmer to Java consultant. He left IBM to work for several startups in roles ranging from Client Solutions Director to CTO. He speaks internationally and is the author of more than ten books, including From Java to Ruby, Deploying Rails Applications, the best-selling Bitter series, Beyond Java, and the Jolt-winning Better, Faster, Lighter Java.

    +
    +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +

    +Features & details +

    +
    +

    +Product information + + +

    +
    +

    + + + + + + + + + + + + + + +
    +
    +
    +
    + +
    +

    +About this item +

    +
    +

    +Product information + + +

    +
    +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + +Pricing: + + +Savings are based on the strikethrough price. This is either the previous Amazon price or the + +List Price. + + +
    +
    +
    +
    +
    +
    +
    +
    + + +

    Customer Reviews

    58 customer reviews
    4.2 out of 5 stars4.2 out of 5 stars
    +
    +
    +
    +
    + +Rated by customers interested in + +
    + +
    +
    + + +
    + +
    +
    + +

    Top reviews

    +
    See all 58 reviews
    Write a review
    +
    +
    +
    +
    +
    +
    + +
    + +
    + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + +
    \ No newline at end of file diff --git a/spec/fixtures/onebox/cloudapp-gif.response b/spec/fixtures/onebox/cloudapp-gif.response new file mode 100644 index 0000000000..a24ad59906 --- /dev/null +++ b/spec/fixtures/onebox/cloudapp-gif.response @@ -0,0 +1,66 @@ + + + + + giphy.gif + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + diff --git a/spec/fixtures/onebox/cloudapp-jpg.response b/spec/fixtures/onebox/cloudapp-jpg.response new file mode 100644 index 0000000000..3718a31abb --- /dev/null +++ b/spec/fixtures/onebox/cloudapp-jpg.response @@ -0,0 +1,66 @@ + + + + + Image 2016-11-27 at 10.47.21 PM.jpg + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + diff --git a/spec/fixtures/onebox/cloudapp-mp4.response b/spec/fixtures/onebox/cloudapp-mp4.response new file mode 100644 index 0000000000..2a4d889691 --- /dev/null +++ b/spec/fixtures/onebox/cloudapp-mp4.response @@ -0,0 +1,65 @@ + + + + + click-link.mp4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + diff --git a/spec/fixtures/onebox/cloudapp-others.response b/spec/fixtures/onebox/cloudapp-others.response new file mode 100644 index 0000000000..f9f3914547 --- /dev/null +++ b/spec/fixtures/onebox/cloudapp-others.response @@ -0,0 +1,66 @@ + + + + + + sample.pdf + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + diff --git a/spec/fixtures/onebox/cnn.response b/spec/fixtures/onebox/cnn.response new file mode 100644 index 0000000000..3c0e90f64f --- /dev/null +++ b/spec/fixtures/onebox/cnn.response @@ -0,0 +1,4 @@ +People are fostering and adopting pets during the pandemic + + + diff --git a/spec/fixtures/onebox/dailymail.response b/spec/fixtures/onebox/dailymail.response new file mode 100644 index 0000000000..e5aa0c8f3e --- /dev/null +++ b/spec/fixtures/onebox/dailymail.response @@ -0,0 +1,5914 @@ + + + + + Brutality or justice? The truth behind the tarred and feathered drug dealer | Mail Online + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + +
    + + + + + + +
    +
     
    + + + + + + + +
    +
    +
    + +
    + +
    +
    +
    +
    +
      + + +
    • + +
    • +
    • + + + +
    • +
    • + +
    • +
    +
    + +
    +
    + + + + +
    +
    +
    +
    + +

    Brutality or justice? The truth behind the tarred and feathered drug dealer

    By ANDREW MALONE

    Last updated at 00:47 01 September 2007


    It was the most chilling image of the week ... a drug-dealer tarred and feathered in a medieval act of retribution. Sheer savagery? Or the desperate response of a community that decided to fight back?

    The bar fell silent. Some drinkers put down their pints and walked out. Others suddenly became engrossed in newspapers, turning their stools around so that their faces couldn't be seen by anyone approaching through the only door.

    "We don't know anything. Nothing at all," said one man in his 40s, looking up sharply from behind the pages of the Belfast Telegraph. "It's not a good idea to ask too many questions around here, ye know what I'm saying, my friend?"

    Strangers are not welcome in the Taughmonagh Social Club, a working man's bar less than 100 yards from where a mess of tar and feathers still litter the pavement, following one of the most shocking acts of violence and public humiliation since the Troubles ended.

    Scroll down for more ...

    + tarred feathered +

    +

    But an older man sitting alone had been studying me. He got up from his seat and called two of the other silent drinkers into a far corner of the bar. They stood in a huddle; nobody could hear what was being said. I waited.

    After a short discussion between the three, I was ordered into an unlit backroom of the club, which is just two miles from the centre of Belfast now booming with upmarket restaurants, five-star hotels and non-stop construction as a result of the peace dividend after decades of civil war.

    In the private room, under black and white photographs of famous moments in the history of Glasgow Rangers Football Club - which until recently was a Protestant-only team - the truth about the chilling events of last week was revealed for the first time.

    The older man - measured, polite - had decided it was time the world was told why a man should be abducted, tied to a lamppost and have boiling tar poured over his head and body before being 'decorated' with feathers.

    This show of "community justice" may have happened in Northern Ireland, but the professed reasons behind it may strike a chord with millions of law-abiding people in communities across the UK - where the police and courts are each day failing countless victims of violent crime.

    Indeed, the man in the unlit backroom, who is happy to be called "William", but refuses to give his real name, insists this is simply the story of ordinary people driven to take the law into their own hands.

    And whether you agree, or regard his words as a shameless attempt to defend the indefensible, his account gives a brutal insight into the grim reality of life in the harsher parts of "peaceful" Ulster.

    "This man had been warned," he says. "This man was known to have been dealing drugs in our community. If you have kids rolling through the doors with their eyes all over their heads, you know that something is not right.

    "It doesn't take Sherlock Holmes to work it out. Selling drugs to children is not on. The community wants drug dealers off the street, but they have no confidence in the police. If police catch these people dealing, they don't do anything.

    "He was making money out of this. He was starting people off with drugs. What follows is that you have people breaking into houses, stealing cars, that sort of thing - just to pay for their drug habit.

    "Then they start mugging people - old ladies and such like. The community goes to the dogs. We can't be standing for that. It's just not on."

    And so it was that local man Jock Nelson was subjected to this most brutal form of public punishment.

    Nelson had been living in the area for years. Indeed, until recently he could be found propping up the bar alongside "William" and his staunch Loyalist friends in the club.

    But he had problems. He had recently lost his job as a doorman at Lavery's, a popular bar and nightclub near the centre of Belfast. Locals say he was sacked because of using and selling drugs - a charge denied by Lavery's.

    Nelson also had marital problems with Julie, his wife and mother of their four children. He had moved out of the family home in the area, although his parents and sister had remained in Taughmonagh. But Nelson started coming back to the streets around the social club, dealing drugs, locals say, to teenage children.

    He was repeatedly warned to keep away - but chose to ignore those words of wisdom. It was to prove a grave mistake.

    Scroll down for more ...

    + +

    +

    + +

    +

    Late last Saturday night, word swept the social club that Nelson was back - dealing drugs to children in a nearby park. Over drinks, a plan was hatched to put him out of business for good.

    William says: "We are not stupid - we know that kids will smoke a bit of weed here and there. We don't want them to, but they do and it's probably not going to kill them in the long run. But this man [he refuses to use Nelson's name throughout the conversation] was selling hard drugs. We'd had enough."

    The following night, Nelson was again spotted in the area. Children questioned by their parents had admitted he had been selling them drugs - not just "weed", but also crack cocaine and heroin.

    Men with "woolly faces" - the local codeword for balaclavas - gathered nearby. After living through decades of violence between Catholic and Loyalist paramilitaries, the men of Taughmonagh questioned Nelson the only way they knew how: with extreme prejudice.

    After savagely beating him and searching his pockets, William says they found five or six bags of crack cocaine. They dragged Nelson through the streets as women and children looked on.

    The guilty man did not take his punishment well. Screaming for mercy, he was tied to a lamppost outside the local shops, opposite the park where he had been selling the drugs.

    As locals watched in silence, another man in a balaclava appeared from near the social club. He was carrying a bucket of boiling tar and pillows. Nelson's shirt was pulled down over his shoulders, to ensure the tar burned his flesh.

    Realising what was about to happen, Nelson "lost control of his bowels", through sheer terror, according to William. "But please don't write that. People might feel sorry for him."

    The tar was poured over the offending drug dealer. Then the pillows were torn up and the feathers tipped over him - a punishment designed to ensure that he carried the "mark of justice" by the mob around with him for days.

    A piece of cardboard with the words: "I'm a drug dealing scumbag" was strung round his neck. Then photographs were taken to serve as a warning to others that drug dealing will not be tolerated in Taughmonagh.

    The pictures were sent to local newspapers - and subsequently beamed around the world. Belfast's politicians were horrified, saying it was a "barbaric act" that had "no place in a civilised society". Police appealed for witnesses; by last night, none had come forward.

    Nothing, surely, can excuse such horrific savagery on our streets - and such casual contempt for the basic principles of justice. Yet, many people in areas across Britain will recognise the sense of impotence felt by the people of Taughmonagh, a rugged, working-class estate with the Union Jack hanging from virtually every house. There is a real sense of community in the area.

    "Everybody here has grown up together," says Moira, a married woman with two children who works in a shop nearby. "We know everyone - the mums, the kids, the aunties, the dads. Here, we know everybody else's business. We look after each other."

    The tarring and feathering certainly seems to have had the effect the community wanted: Jock Nelson fled the city soon after the attack.

    "He's gone away to Scotland," Jean Nelson, the man's mother, said. "He's not here. He just wants to get away from everything for a while."

    Asked what she felt about her son's involvement in drugs, she was furious. "That's slander. How dare you say that. Who told you where I live? Who told you my name? How would you like it if this was happening to you? We still have to live here. Get away! Just get away!"

    A close friend of Jock Nelson's said he had gone away for a "few days" with his estranged wife and children until things calmed down. "There is no chance of him talking about this. It's too dangerous."

    In many respects, Nelson was lucky: drug dealing and other anti-social behaviour often proves deadly in Belfast. Fedup with the lack of police action against criminals operating in their locality, there is a long tradition of summary justice being meted out on the streets.

    First practised on informers and enemies from rival paramilitary groups, the technique of "knee-capping" - where the victim is shot in both legs, permanently disabling them - became synonymous with daily life during the Troubles.

    But after the 1994 ceasefire between the warring Protestant and Catholic factions, many of the weapons were either decommissioned or hidden, forcing the paramilitaries to come up with fresh methods, or resurrect old ones, to deal with local troublemakers.

    Once used against Catholic women caught having relationships with British soldiers during the Troubles, the first recorded incident of tarring and feathering came in 1191, when Richard I of England ordered soldiers to punish thieves in the Holy Land during the Crusades.

    In America, this technique was used in the 18th century, when the criminal was covered in tar and feathers before being paraded through the town on the back of a horse-drawn cart. According to records from the time, the "aim was to hurt and humiliate a person enough to leave town and cause no more mischief".

    The punishment rarely causes serious injury although it does cause minor burns. Tar boils at 60C rather than 100c for water and the tar has frequently cooled by the time it is poured over the victim. Jock Nelson was not taken to hospital after last week's incident.

    While pictures of last week's tarring and feathering made international headlines, there is a relentless unreported wave of violence by vigilantes against known criminals in both north and south of the border each month.

    With police either lacking the evidence to act, or too scared to enter streets which for decades were no-go areas, car thieves, paedophiles and drug dealers are regularly dealt with by the "men with woolly faces".

    In one case, James O'Donoghue, a convicted rapist, was attacked by four men in balaclavas and stabbed repeatedly before being locked in the back of a van with four vicious pitbull terriers.

    "I was kept there for an hour with those dogs," he said. "All I did was kept swinging and kicking, trying to defend myself. The men then dumped me at the side of the road. I got 216 stitches and went into cardiac arrest in hospital."

    The son of Jonny "Mad Dog" Adair, the psychotic former leader of the Ulster Freedom Fighters' notorious "C" Company, was shot in both legs in 2002 after being named as a drug dealer, leaving him maimed for life.

    Few in Belfast had any sympathy last week for criminals beaten or covered in tar and feathers as punishment. "They should just get shot," said Kevin Nolan, an office worker. "I'm not being nasty but they have made their choice and know the consequences."

    Yet some experts suspect that these acts are not simply designed to prevent crime spiralling out of control. For decades, Loyalist gangs have had links to the criminal underworld, prompting speculation that they are simply trying to "take out" rivals in Northern Ireland's lucrative drugs trade.

    Back at the Taughmonagh Social Club, William dismisses this notion. "This was nothing to do with paramilitaries," he says. "This was to do with a law-abiding community deciding to take action against a man who has been poisoning our children with drugs.

    "This is a strong community. There's very little housebreaking or any other crime here. If we see anyone's children behaving badly, we let their parents know and they deal with it. That's how it works - we're all in this together."

    Yet, on the streets of Belfast, which is successfully rebranding itself as a tourist destination, with guided tours up the Republican Falls and Loyalist Shankill Roads, there was little doubt about what would happen if anybody started dealing drugs on the streets.

    A group of three drug users I met had no fear of the police, saying that they might know they were using drugs, but they would never get enough evidence because the drugs would just be thrown away before they were arrested.

    "But there's no danger of us dealing drugs on the streets," said one man in his 20s, as a group of tourists walked past the Europa Hotel in the centre, marvelling at the fact it has been blown up more than any other hotel in the world.

    Slurping furtively from a can of lager - drinking on the street is banned in many areas - the young man added: "The paramilitaries would come after us. Some people say it's because they want to deal all the drugs.

    "I don't think it's because of that. I think it's just because they like violence - and I mean really like it. We wouldn't stand a chance if we sold drugs. We'd be dead within a week."

    Certainly, the streets of Belfast are remarkably safe places to walk, with little petty crime, drug-dealing or gangs of drunken youths roaming the streets.

    As a result of this curious by-product of the Northern Ireland peace process, it is no longer the law-abiding majority who are scared to go out after dark. These days, it seems, Belfast's criminals are the ones who live in mortal fear of being caught doing anything wrong.

    The question is, at what price? Vigilante justice betrays all the values that were supposedly being defended in the long fight against terrorism. We tolerate it at our peril.

    + + + + + +
    +
    + + + +
    +
    + +
    + + + + + + + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + + + + +

    No comments have so far been submitted. Why not be the first to send us your thoughts, + or debate this issue live on our message boards. +

    + +
    + + + + + + + +

    We are no longer accepting comments on this article.

    + + + +
    + + Who is this week's top commenter? + Find out now +
    + + +
    +
    + + +
    + + + +
    + +
    +
    + +
    +
    +
    + Bing +
    + + + + + + + + +
    + +
    + + + + +
    +
    + +
    + +
    +
    + +
    +
    + + + +
    +
    + +   +   +

    DON'T MISS

    + + +
    + +
    + + + + + + + +
    +
    + +
    +
    + +
    +
    + +   +   +

    MORE DON'T MISS

    + + +
    + +
    + + +
    +
    + +
    + +
    +
    + +
    + + + + + +
    +
    + +
    +
    + +
    + + + + + +
    + +
    +
    + + MailOnline iPad app + +
    +
    + +
    + + + + +
    + +
    + + + + +
    + +
    + +
    + +
    + +
    +
    +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    + + + + + + + + + + + + + + + + +
    +
    +
    +
    +

    Next story

    +

    + + 'America is coming to help': Obama to bomb Iraq to save thousands of non-Muslims trapped on mountains and forced to choose between starving to death and slaughter by ISIS fanatics + ISIS take hundreds of Yazidi women hostage in bid to call Obama's bluff as America begins bombing Iraq after Islamist fanatics reach the gates of former Kurdish safe haven where thousands have fled + +

    +
    + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + +
    +
    + + +
     
    + + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/discourse_topic.response b/spec/fixtures/onebox/discourse_topic.response new file mode 100644 index 0000000000..00a0e24fe9 --- /dev/null +++ b/spec/fixtures/onebox/discourse_topic.response @@ -0,0 +1,373 @@ + + + + + + Congratulations, most stars in 2013 GitHub Octoverse! - praise - Discourse Meta + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/discourse_topic_reply.response b/spec/fixtures/onebox/discourse_topic_reply.response new file mode 100644 index 0000000000..d204320e71 --- /dev/null +++ b/spec/fixtures/onebox/discourse_topic_reply.response @@ -0,0 +1,369 @@ + + + + + + Congratulations, most stars in 2013 GitHub Octoverse! - praise - Discourse Meta + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/etsy.response b/spec/fixtures/onebox/etsy.response new file mode 100644 index 0000000000..663dd727a9 --- /dev/null +++ b/spec/fixtures/onebox/etsy.response @@ -0,0 +1,5389 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Personalized Word Pillow Case Letter Symbol Text Cushion | Etsy + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + +
    + +
    + + +
    + + + + +
    + +
    + + + + + + + + + + + + + +
    + + +
    +
    +
    + + +
    +
    +
    +
    + + + + + + + + + + + +
    + + + +
    + + + + + + + +
    + + + + + + + +
    +
    + + +

    Personalized Word Pillow Case | Letter, Symbol, Text Cushion Cover | 18x18 inch / 45x45 cm Decorative Pillow | Modern Home Decor

    +
    + + + + + + + +
    +
    +
    +
    + + +
    +

    + Personalized Word Pillow Case | Letter, Symbol, Text Cushion Cover | 18x18 inch / 45x45 cm Decorative Pillow | Modern Home Decor +

    +
    + + + + + +
    + +

    + + US$ 36.97+ + + + US$ 41.08+ + +

    +

    + You save US$ 4.11 (10%) +

    + +
    + Local taxes included (where applicable) +
    +
    + + +
    +
    + +
    + +
    +
    Please select an option
    +
    + +
    + +
    +
    Please select a colour
    +
    + +

    + Last step: Enter the Fabric you want from options of the chart. Thank you!
    - - -
    DerniÚre étape: Entrez le Tissu désiré parmi les choix de la charte. Merci! +

    + +
    + 256 +
    +
    This item requires personalisation
    +
    You’ve reached the limit! Use 256 characters or less.
    +
    + +
    + +
    +
    Please select a quantity
    +
    +
    + + + +
    + + +
    +
    + + + + + + + + + +
    +
    +
    + + + +
    +
    +
    +
    + +
    +
    + Don't miss out. There's only 4 available and 3 other people have this in their basket right now. + +
    +
    +
    +
    + + + + +
    + +
    +
    +

    Item details

    + + +
    + +

    Handmade

    +
    + +
    +
    +
    Materials
    + +

    + Natural fiber fabrics, Vinyl, Threads, YKK invisible zipper +

    + +
    +
    +
    Dimensions
    + +

    Length: 18 Inches; Width: 18 Inches

    +
    +
    +
    + +
    +
    + +
    +
    + + - 15% off when you buy 2 items / 20% off when you buy 3 items -

    Allow your personality to shine through your decor; this contemporary and modern accent will help you do just that. Personalize this 18" x 18" (45 x 45 cm) pillow cover with your favorite letter, symbol or word in the fabric and vinyl color of your choice, have fun!

    >>> Here is how to create your own pillow cover (It's easy!): You just have to select the appliqué plus the primary color (vinyl for the appliqué) and write the fabric you want in the personalization box before adding the item to your cart.
    â–Č*This item is made to order just for you in 2 to 4 business days!*

    SPECIFICATIONS:
    â–Č Designed and handmade by us!
    â–Č 18" x 18" (45 x 45 cm) pillow cover {for same size pillow form or one size up}.
    â–Č 100% cotton, 50% cotton / 50% linen or 55% ramie / 45% cotton canvases {Pre-shrunk fabrics: the cover will keep its original shape after the first wash (No shrinkage!)}.
    â–Č Durable vinyl leather like sewn all around.
    â–Č Invisible zipper at the bottom {for an easy removal and clean finish}.
    â–Č Serged interior seams {makes it resistant to wash}.
    â–Č Pillow form is not included.

    CARE:
    Wash upside down and closed in cold water at gentle cycle or by hand with a gentle detergent. Do not use bleach. Dry flat or hang to dry. Iron *upside down* at medium high (cotton) temperature with medium steam.

    â–Č 12" x 18" (30 x 45 cm) lumbar size: http://www.etsy.com/listing/69325334
    â–Č 16" x 16" (40 x 40 cm) size: http://www.etsy.com/listing/103762611
    â–Č 20" x 20" (50 x 50 cm) size: http://www.etsy.com/listing/99807109

    Contact us for any questions ;-) Thanks for visiting!

    More from us, here on Etsy (!):
    Digital art prints: http://www.etsy.com/shop/RocailArt
    Vintage finds: http://www.etsy.com/shop/rocailoldandloved

    All Designs & images © 2010-2020 ROCAIL / ROCAIL Studio. All rights reserved. +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Delivery & returns

    + +
    +
    + + +
    + +
    + Ready to dispatch in 1–3 business days +
    +
    + +
    + From Canada +
    +
    +
    + +
    +
    +
    + + + +
    +
    +
    +
    + Sorry, this item doesn’t deliver to India. Contact the shop to find out about available delivery options. +
    + +
    +
    + + +
    +
    +
    +
    + +
    No returns or exchanges
    +
    + But please contact me if you have any problems with your order. +
    +
    +
    + +
    + +
    + +
    +
    +
    +

    + Meet RocailStudio +

    + +
    +
    + Melanie and Valerie +
    +
    +

    Melanie and Valerie

    +

    + Montreal, Canada +

    +
    +
    + +
    This seller usually responds within 24 hours.
    +
    + +
    + + +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +

    Reviews

    + + + + + 5 out of 5 stars + + + + + + + (1,238) + + + +
    +
    +
    + + + + + + + + + + + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    + + + +
    + +
    + + +
    +
    Montreal, Canada
    +
    6,634 Sales
    +
    On Etsy since 2010
    +
    +
    +
    + + +
    + +
    +
    + +
    + +
    + + + +
    +
    +
    + + + +
    + +
    + +
    +
    + + +
    +
    + +
    +
    + +
    +
    + + + + +
    + +
    + + + +
    +
    + +
    +
    + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/etsy_mobile.response b/spec/fixtures/onebox/etsy_mobile.response new file mode 100644 index 0000000000..0b7953dae9 --- /dev/null +++ b/spec/fixtures/onebox/etsy_mobile.response @@ -0,0 +1,5389 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Personalized Word Pillow Case Letter Symbol Text Cushion | Etsy + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + +
    + +
    + + +
    + + + + +
    + +
    + + + + + + + + + + + + + +
    + + +
    +
    +
    + + +
    +
    +
    +
    + + + + + + + + + + + +
    + + + +
    + + + + + + + +
    + + + + + + + +
    +
    + + +

    Personalized Word Pillow Case | Letter, Symbol, Text Cushion Cover | 18x18 inch / 45x45 cm Decorative Pillow | Modern Home Decor

    +
    + + + + + + + +
    +
    +
    +
    + + +
    +

    + Personalized Word Pillow Case | Letter, Symbol, Text Cushion Cover | 18x18 inch / 45x45 cm Decorative Pillow | Modern Home Decor +

    +
    + + + + + +
    + +

    + + US$ 36.97+ + + + US$ 41.08+ + +

    +

    + You save US$ 4.11 (10%) +

    + +
    + Local taxes included (where applicable) +
    +
    + + +
    +
    + +
    + +
    +
    Please select an option
    +
    + +
    + +
    +
    Please select a colour
    +
    + +

    + Last step: Enter the Fabric you want from options of the chart. Thank you!
    - - -
    DerniÚre étape: Entrez le Tissu désiré parmi les choix de la charte. Merci! +

    + +
    + 256 +
    +
    This item requires personalisation
    +
    You’ve reached the limit! Use 256 characters or less.
    +
    + +
    + +
    +
    Please select a quantity
    +
    +
    + + + +
    + + +
    +
    + + + + + + + + + +
    +
    +
    + + + +
    +
    +
    +
    + +
    +
    + Don't miss out. There's only 4 available and 3 other people have this in their basket right now. + +
    +
    +
    +
    + + + + +
    + +
    +
    +

    Item details

    + + +
    + +

    Handmade

    +
    + +
    +
    +
    Materials
    + +

    + Natural fiber fabrics, Vinyl, Threads, YKK invisible zipper +

    + +
    +
    +
    Dimensions
    + +

    Length: 18 Inches; Width: 18 Inches

    +
    +
    +
    + +
    +
    + +
    +
    + + - 15% off when you buy 2 items / 20% off when you buy 3 items -

    Allow your personality to shine through your decor; this contemporary and modern accent will help you do just that. Personalize this 18" x 18" (45 x 45 cm) pillow cover with your favorite letter, symbol or word in the fabric and vinyl color of your choice, have fun!

    >>> Here is how to create your own pillow cover (It's easy!): You just have to select the appliqué plus the primary color (vinyl for the appliqué) and write the fabric you want in the personalization box before adding the item to your cart.
    â–Č*This item is made to order just for you in 2 to 4 business days!*

    SPECIFICATIONS:
    â–Č Designed and handmade by us!
    â–Č 18" x 18" (45 x 45 cm) pillow cover {for same size pillow form or one size up}.
    â–Č 100% cotton, 50% cotton / 50% linen or 55% ramie / 45% cotton canvases {Pre-shrunk fabrics: the cover will keep its original shape after the first wash (No shrinkage!)}.
    â–Č Durable vinyl leather like sewn all around.
    â–Č Invisible zipper at the bottom {for an easy removal and clean finish}.
    â–Č Serged interior seams {makes it resistant to wash}.
    â–Č Pillow form is not included.

    CARE:
    Wash upside down and closed in cold water at gentle cycle or by hand with a gentle detergent. Do not use bleach. Dry flat or hang to dry. Iron *upside down* at medium high (cotton) temperature with medium steam.

    â–Č 12" x 18" (30 x 45 cm) lumbar size: http://www.etsy.com/listing/69325334
    â–Č 16" x 16" (40 x 40 cm) size: http://www.etsy.com/listing/103762611
    â–Č 20" x 20" (50 x 50 cm) size: http://www.etsy.com/listing/99807109

    Contact us for any questions ;-) Thanks for visiting!

    More from us, here on Etsy (!):
    Digital art prints: http://www.etsy.com/shop/RocailArt
    Vintage finds: http://www.etsy.com/shop/rocailoldandloved

    All Designs & images © 2010-2020 ROCAIL / ROCAIL Studio. All rights reserved. +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Delivery & returns

    + +
    +
    + + +
    + +
    + Ready to dispatch in 1–3 business days +
    +
    + +
    + From Canada +
    +
    +
    + +
    +
    +
    + + + +
    +
    +
    +
    + Sorry, this item doesn’t deliver to India. Contact the shop to find out about available delivery options. +
    + +
    +
    + + +
    +
    +
    +
    + +
    No returns or exchanges
    +
    + But please contact me if you have any problems with your order. +
    +
    +
    + +
    + +
    + +
    +
    +
    +

    + Meet RocailStudio +

    + +
    +
    + Melanie and Valerie +
    +
    +

    Melanie and Valerie

    +

    + Montreal, Canada +

    +
    +
    + +
    This seller usually responds within 24 hours.
    +
    + +
    + + +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +

    Reviews

    + + + + + 5 out of 5 stars + + + + + + + (1,238) + + + +
    +
    +
    + + + + + + + + + + + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    + + + +
    + +
    + + +
    +
    Montreal, Canada
    +
    6,634 Sales
    +
    On Etsy since 2010
    +
    +
    +
    + + +
    + +
    +
    + +
    + +
    + + + +
    +
    +
    + + + +
    + +
    + +
    +
    + + +
    +
    + +
    +
    + +
    +
    + + + + +
    + +
    + + + +
    +
    + +
    +
    + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/gfycat.response b/spec/fixtures/onebox/gfycat.response new file mode 100644 index 0000000000..6e34867afb --- /dev/null +++ b/spec/fixtures/onebox/gfycat.response @@ -0,0 +1,40 @@ + +Goal 11: Kerbal GIF by Gif Your Game (@gifyourgame) | Find, Make & Share Gfycat GIFs
    diff --git a/spec/fixtures/onebox/giphy.response b/spec/fixtures/onebox/giphy.response new file mode 100644 index 0000000000..f21f475a77 --- /dev/null +++ b/spec/fixtures/onebox/giphy.response @@ -0,0 +1,335 @@ + + + + + + + + + + Happy So Excited GIF - Find & Share on GIPHY + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    + +
    + + + + +
    + + + + + + + + +
    + +
    +
    + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/githubactions_actions_run.response b/spec/fixtures/onebox/githubactions_actions_run.response new file mode 100644 index 0000000000..56a881f13e --- /dev/null +++ b/spec/fixtures/onebox/githubactions_actions_run.response @@ -0,0 +1,176 @@ +{ + "id": 873214216, + "name": "Linting", + "node_id": "WFR_lAHOADEiqs4Ac4CqzjQMMQg", + "head_branch": "simplify-deleted-post-copy", + "head_sha": "929e4ee5e93e53f45c7be78fcf81b734120af9c0", + "run_number": 2687, + "event": "pull_request", + "status": "completed", + "conclusion": "success", + "workflow_id": 5947272, + "check_suite_id": 2820222471, + "check_suite_node_id": "MDEwOkNoZWNrU3VpdGUyODIwMjIyNDcx", + "url": "https://api.github.com/repos/discourse/discourse/actions/runs/873214216", + "html_url": "https://github.com/discourse/discourse/actions/runs/873214216", + "pull_requests": [ + + ], + "created_at": "2021-05-25T01:10:28Z", + "updated_at": "2021-05-25T01:15:56Z", + "jobs_url": "https://api.github.com/repos/discourse/discourse/actions/runs/873214216/jobs", + "logs_url": "https://api.github.com/repos/discourse/discourse/actions/runs/873214216/logs", + "check_suite_url": "https://api.github.com/repos/discourse/discourse/check-suites/2820222471", + "artifacts_url": "https://api.github.com/repos/discourse/discourse/actions/runs/873214216/artifacts", + "cancel_url": "https://api.github.com/repos/discourse/discourse/actions/runs/873214216/cancel", + "rerun_url": "https://api.github.com/repos/discourse/discourse/actions/runs/873214216/rerun", + "workflow_url": "https://api.github.com/repos/discourse/discourse/actions/workflows/5947272", + "head_commit": { + "id": "929e4ee5e93e53f45c7be78fcf81b734120af9c0", + "tree_id": "3d8f1301a94fd5bad68684e2fe1212ad890dcd79", + "message": "Remove deleted_by_author key\n\nThis has to be done to avoid errors because the old translation\nhad a one and many key, whereas the new one has no count.", + "timestamp": "2021-05-25T01:09:40Z", + "author": { + "name": "Martin Brennan", + "email": "martin@discourse.org" + }, + "committer": { + "name": "Martin Brennan", + "email": "martin@discourse.org" + } + }, + "repository": { + "id": 7569578, + "node_id": "MDEwOlJlcG9zaXRvcnk3NTY5NTc4", + "name": "discourse", + "full_name": "discourse/discourse", + "private": false, + "owner": { + "login": "discourse", + "id": 3220138, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjMyMjAxMzg=", + "avatar_url": "https://avatars.githubusercontent.com/u/3220138?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/discourse", + "html_url": "https://github.com/discourse", + "followers_url": "https://api.github.com/users/discourse/followers", + "following_url": "https://api.github.com/users/discourse/following{/other_user}", + "gists_url": "https://api.github.com/users/discourse/gists{/gist_id}", + "starred_url": "https://api.github.com/users/discourse/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/discourse/subscriptions", + "organizations_url": "https://api.github.com/users/discourse/orgs", + "repos_url": "https://api.github.com/users/discourse/repos", + "events_url": "https://api.github.com/users/discourse/events{/privacy}", + "received_events_url": "https://api.github.com/users/discourse/received_events", + "type": "Organization", + "site_admin": false + }, + "html_url": "https://github.com/discourse/discourse", + "description": "A platform for community discussion. Free, open, simple.", + "fork": false, + "url": "https://api.github.com/repos/discourse/discourse", + "forks_url": "https://api.github.com/repos/discourse/discourse/forks", + "keys_url": "https://api.github.com/repos/discourse/discourse/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/discourse/discourse/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/discourse/discourse/teams", + "hooks_url": "https://api.github.com/repos/discourse/discourse/hooks", + "issue_events_url": "https://api.github.com/repos/discourse/discourse/issues/events{/number}", + "events_url": "https://api.github.com/repos/discourse/discourse/events", + "assignees_url": "https://api.github.com/repos/discourse/discourse/assignees{/user}", + "branches_url": "https://api.github.com/repos/discourse/discourse/branches{/branch}", + "tags_url": "https://api.github.com/repos/discourse/discourse/tags", + "blobs_url": "https://api.github.com/repos/discourse/discourse/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/discourse/discourse/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/discourse/discourse/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/discourse/discourse/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/discourse/discourse/statuses/{sha}", + "languages_url": "https://api.github.com/repos/discourse/discourse/languages", + "stargazers_url": "https://api.github.com/repos/discourse/discourse/stargazers", + "contributors_url": "https://api.github.com/repos/discourse/discourse/contributors", + "subscribers_url": "https://api.github.com/repos/discourse/discourse/subscribers", + "subscription_url": "https://api.github.com/repos/discourse/discourse/subscription", + "commits_url": "https://api.github.com/repos/discourse/discourse/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/discourse/discourse/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/discourse/discourse/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/discourse/discourse/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/discourse/discourse/contents/{+path}", + "compare_url": "https://api.github.com/repos/discourse/discourse/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/discourse/discourse/merges", + "archive_url": "https://api.github.com/repos/discourse/discourse/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/discourse/discourse/downloads", + "issues_url": "https://api.github.com/repos/discourse/discourse/issues{/number}", + "pulls_url": "https://api.github.com/repos/discourse/discourse/pulls{/number}", + "milestones_url": "https://api.github.com/repos/discourse/discourse/milestones{/number}", + "notifications_url": "https://api.github.com/repos/discourse/discourse/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/discourse/discourse/labels{/name}", + "releases_url": "https://api.github.com/repos/discourse/discourse/releases{/id}", + "deployments_url": "https://api.github.com/repos/discourse/discourse/deployments" + }, + "head_repository": { + "id": 7569578, + "node_id": "MDEwOlJlcG9zaXRvcnk3NTY5NTc4", + "name": "discourse", + "full_name": "discourse/discourse", + "private": false, + "owner": { + "login": "discourse", + "id": 3220138, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjMyMjAxMzg=", + "avatar_url": "https://avatars.githubusercontent.com/u/3220138?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/discourse", + "html_url": "https://github.com/discourse", + "followers_url": "https://api.github.com/users/discourse/followers", + "following_url": "https://api.github.com/users/discourse/following{/other_user}", + "gists_url": "https://api.github.com/users/discourse/gists{/gist_id}", + "starred_url": "https://api.github.com/users/discourse/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/discourse/subscriptions", + "organizations_url": "https://api.github.com/users/discourse/orgs", + "repos_url": "https://api.github.com/users/discourse/repos", + "events_url": "https://api.github.com/users/discourse/events{/privacy}", + "received_events_url": "https://api.github.com/users/discourse/received_events", + "type": "Organization", + "site_admin": false + }, + "html_url": "https://github.com/discourse/discourse", + "description": "A platform for community discussion. Free, open, simple.", + "fork": false, + "url": "https://api.github.com/repos/discourse/discourse", + "forks_url": "https://api.github.com/repos/discourse/discourse/forks", + "keys_url": "https://api.github.com/repos/discourse/discourse/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/discourse/discourse/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/discourse/discourse/teams", + "hooks_url": "https://api.github.com/repos/discourse/discourse/hooks", + "issue_events_url": "https://api.github.com/repos/discourse/discourse/issues/events{/number}", + "events_url": "https://api.github.com/repos/discourse/discourse/events", + "assignees_url": "https://api.github.com/repos/discourse/discourse/assignees{/user}", + "branches_url": "https://api.github.com/repos/discourse/discourse/branches{/branch}", + "tags_url": "https://api.github.com/repos/discourse/discourse/tags", + "blobs_url": "https://api.github.com/repos/discourse/discourse/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/discourse/discourse/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/discourse/discourse/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/discourse/discourse/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/discourse/discourse/statuses/{sha}", + "languages_url": "https://api.github.com/repos/discourse/discourse/languages", + "stargazers_url": "https://api.github.com/repos/discourse/discourse/stargazers", + "contributors_url": "https://api.github.com/repos/discourse/discourse/contributors", + "subscribers_url": "https://api.github.com/repos/discourse/discourse/subscribers", + "subscription_url": "https://api.github.com/repos/discourse/discourse/subscription", + "commits_url": "https://api.github.com/repos/discourse/discourse/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/discourse/discourse/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/discourse/discourse/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/discourse/discourse/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/discourse/discourse/contents/{+path}", + "compare_url": "https://api.github.com/repos/discourse/discourse/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/discourse/discourse/merges", + "archive_url": "https://api.github.com/repos/discourse/discourse/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/discourse/discourse/downloads", + "issues_url": "https://api.github.com/repos/discourse/discourse/issues{/number}", + "pulls_url": "https://api.github.com/repos/discourse/discourse/pulls{/number}", + "milestones_url": "https://api.github.com/repos/discourse/discourse/milestones{/number}", + "notifications_url": "https://api.github.com/repos/discourse/discourse/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/discourse/discourse/labels{/name}", + "releases_url": "https://api.github.com/repos/discourse/discourse/releases{/id}", + "deployments_url": "https://api.github.com/repos/discourse/discourse/deployments" + } +} \ No newline at end of file diff --git a/spec/fixtures/onebox/githubactions_pr.response b/spec/fixtures/onebox/githubactions_pr.response new file mode 100644 index 0000000000..9992a476c4 --- /dev/null +++ b/spec/fixtures/onebox/githubactions_pr.response @@ -0,0 +1,369 @@ +{ + "url": "https://api.github.com/repos/discourse/discourse/pulls/13128", + "id": 651671568, + "node_id": "MDExOlB1bGxSZXF1ZXN0NjUxNjcxNTY4", + "html_url": "https://github.com/discourse/discourse/pull/13128", + "diff_url": "https://github.com/discourse/discourse/pull/13128.diff", + "patch_url": "https://github.com/discourse/discourse/pull/13128.patch", + "issue_url": "https://api.github.com/repos/discourse/discourse/issues/13128", + "number": 13128, + "state": "closed", + "locked": false, + "title": "simplify post and topic deletion language", + "user": { + "login": "coding-horror", + "id": 1522517, + "node_id": "MDQ6VXNlcjE1MjI1MTc=", + "avatar_url": "https://avatars.githubusercontent.com/u/1522517?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/coding-horror", + "html_url": "https://github.com/coding-horror", + "followers_url": "https://api.github.com/users/coding-horror/followers", + "following_url": "https://api.github.com/users/coding-horror/following{/other_user}", + "gists_url": "https://api.github.com/users/coding-horror/gists{/gist_id}", + "starred_url": "https://api.github.com/users/coding-horror/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/coding-horror/subscriptions", + "organizations_url": "https://api.github.com/users/coding-horror/orgs", + "repos_url": "https://api.github.com/users/coding-horror/repos", + "events_url": "https://api.github.com/users/coding-horror/events{/privacy}", + "received_events_url": "https://api.github.com/users/coding-horror/received_events", + "type": "User", + "site_admin": false + }, + "body": "based on feedback from Matt Haughey, we don't need to use so many words when describing a deleted topic or post.\r\n\r\n\r\n", + "created_at": "2021-05-24T22:16:01Z", + "updated_at": "2021-05-25T02:04:11Z", + "closed_at": "2021-05-25T02:04:10Z", + "merged_at": "2021-05-25T02:04:10Z", + "merge_commit_sha": "50926f614342336793f1b78672b387d545d48de3", + "assignee": null, + "assignees": [ + + ], + "requested_reviewers": [ + + ], + "requested_teams": [ + + ], + "labels": [ + + ], + "milestone": null, + "draft": false, + "commits_url": "https://api.github.com/repos/discourse/discourse/pulls/13128/commits", + "review_comments_url": "https://api.github.com/repos/discourse/discourse/pulls/13128/comments", + "review_comment_url": "https://api.github.com/repos/discourse/discourse/pulls/comments{/number}", + "comments_url": "https://api.github.com/repos/discourse/discourse/issues/13128/comments", + "statuses_url": "https://api.github.com/repos/discourse/discourse/statuses/929e4ee5e93e53f45c7be78fcf81b734120af9c0", + "head": { + "label": "discourse:simplify-deleted-post-copy", + "ref": "simplify-deleted-post-copy", + "sha": "929e4ee5e93e53f45c7be78fcf81b734120af9c0", + "user": { + "login": "discourse", + "id": 3220138, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjMyMjAxMzg=", + "avatar_url": "https://avatars.githubusercontent.com/u/3220138?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/discourse", + "html_url": "https://github.com/discourse", + "followers_url": "https://api.github.com/users/discourse/followers", + "following_url": "https://api.github.com/users/discourse/following{/other_user}", + "gists_url": "https://api.github.com/users/discourse/gists{/gist_id}", + "starred_url": "https://api.github.com/users/discourse/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/discourse/subscriptions", + "organizations_url": "https://api.github.com/users/discourse/orgs", + "repos_url": "https://api.github.com/users/discourse/repos", + "events_url": "https://api.github.com/users/discourse/events{/privacy}", + "received_events_url": "https://api.github.com/users/discourse/received_events", + "type": "Organization", + "site_admin": false + }, + "repo": { + "id": 7569578, + "node_id": "MDEwOlJlcG9zaXRvcnk3NTY5NTc4", + "name": "discourse", + "full_name": "discourse/discourse", + "private": false, + "owner": { + "login": "discourse", + "id": 3220138, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjMyMjAxMzg=", + "avatar_url": "https://avatars.githubusercontent.com/u/3220138?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/discourse", + "html_url": "https://github.com/discourse", + "followers_url": "https://api.github.com/users/discourse/followers", + "following_url": "https://api.github.com/users/discourse/following{/other_user}", + "gists_url": "https://api.github.com/users/discourse/gists{/gist_id}", + "starred_url": "https://api.github.com/users/discourse/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/discourse/subscriptions", + "organizations_url": "https://api.github.com/users/discourse/orgs", + "repos_url": "https://api.github.com/users/discourse/repos", + "events_url": "https://api.github.com/users/discourse/events{/privacy}", + "received_events_url": "https://api.github.com/users/discourse/received_events", + "type": "Organization", + "site_admin": false + }, + "html_url": "https://github.com/discourse/discourse", + "description": "A platform for community discussion. Free, open, simple.", + "fork": false, + "url": "https://api.github.com/repos/discourse/discourse", + "forks_url": "https://api.github.com/repos/discourse/discourse/forks", + "keys_url": "https://api.github.com/repos/discourse/discourse/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/discourse/discourse/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/discourse/discourse/teams", + "hooks_url": "https://api.github.com/repos/discourse/discourse/hooks", + "issue_events_url": "https://api.github.com/repos/discourse/discourse/issues/events{/number}", + "events_url": "https://api.github.com/repos/discourse/discourse/events", + "assignees_url": "https://api.github.com/repos/discourse/discourse/assignees{/user}", + "branches_url": "https://api.github.com/repos/discourse/discourse/branches{/branch}", + "tags_url": "https://api.github.com/repos/discourse/discourse/tags", + "blobs_url": "https://api.github.com/repos/discourse/discourse/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/discourse/discourse/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/discourse/discourse/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/discourse/discourse/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/discourse/discourse/statuses/{sha}", + "languages_url": "https://api.github.com/repos/discourse/discourse/languages", + "stargazers_url": "https://api.github.com/repos/discourse/discourse/stargazers", + "contributors_url": "https://api.github.com/repos/discourse/discourse/contributors", + "subscribers_url": "https://api.github.com/repos/discourse/discourse/subscribers", + "subscription_url": "https://api.github.com/repos/discourse/discourse/subscription", + "commits_url": "https://api.github.com/repos/discourse/discourse/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/discourse/discourse/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/discourse/discourse/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/discourse/discourse/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/discourse/discourse/contents/{+path}", + "compare_url": "https://api.github.com/repos/discourse/discourse/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/discourse/discourse/merges", + "archive_url": "https://api.github.com/repos/discourse/discourse/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/discourse/discourse/downloads", + "issues_url": "https://api.github.com/repos/discourse/discourse/issues{/number}", + "pulls_url": "https://api.github.com/repos/discourse/discourse/pulls{/number}", + "milestones_url": "https://api.github.com/repos/discourse/discourse/milestones{/number}", + "notifications_url": "https://api.github.com/repos/discourse/discourse/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/discourse/discourse/labels{/name}", + "releases_url": "https://api.github.com/repos/discourse/discourse/releases{/id}", + "deployments_url": "https://api.github.com/repos/discourse/discourse/deployments", + "created_at": "2013-01-12T00:25:55Z", + "updated_at": "2021-05-26T11:54:17Z", + "pushed_at": "2021-05-26T14:34:39Z", + "git_url": "git://github.com/discourse/discourse.git", + "ssh_url": "git@github.com:discourse/discourse.git", + "clone_url": "https://github.com/discourse/discourse.git", + "svn_url": "https://github.com/discourse/discourse", + "homepage": "https://www.discourse.org", + "size": 369664, + "stargazers_count": 33354, + "watchers_count": 33354, + "language": "Ruby", + "has_issues": false, + "has_projects": false, + "has_downloads": true, + "has_wiki": false, + "has_pages": false, + "forks_count": 7246, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 31, + "license": { + "key": "other", + "name": "Other", + "spdx_id": "NOASSERTION", + "url": null, + "node_id": "MDc6TGljZW5zZTA=" + }, + "forks": 7246, + "open_issues": 31, + "watchers": 33354, + "default_branch": "master" + } + }, + "base": { + "label": "discourse:master", + "ref": "master", + "sha": "b8a08d21e0e9c73f3fdd1ef99c6b8dfe00f5c4f7", + "user": { + "login": "discourse", + "id": 3220138, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjMyMjAxMzg=", + "avatar_url": "https://avatars.githubusercontent.com/u/3220138?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/discourse", + "html_url": "https://github.com/discourse", + "followers_url": "https://api.github.com/users/discourse/followers", + "following_url": "https://api.github.com/users/discourse/following{/other_user}", + "gists_url": "https://api.github.com/users/discourse/gists{/gist_id}", + "starred_url": "https://api.github.com/users/discourse/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/discourse/subscriptions", + "organizations_url": "https://api.github.com/users/discourse/orgs", + "repos_url": "https://api.github.com/users/discourse/repos", + "events_url": "https://api.github.com/users/discourse/events{/privacy}", + "received_events_url": "https://api.github.com/users/discourse/received_events", + "type": "Organization", + "site_admin": false + }, + "repo": { + "id": 7569578, + "node_id": "MDEwOlJlcG9zaXRvcnk3NTY5NTc4", + "name": "discourse", + "full_name": "discourse/discourse", + "private": false, + "owner": { + "login": "discourse", + "id": 3220138, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjMyMjAxMzg=", + "avatar_url": "https://avatars.githubusercontent.com/u/3220138?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/discourse", + "html_url": "https://github.com/discourse", + "followers_url": "https://api.github.com/users/discourse/followers", + "following_url": "https://api.github.com/users/discourse/following{/other_user}", + "gists_url": "https://api.github.com/users/discourse/gists{/gist_id}", + "starred_url": "https://api.github.com/users/discourse/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/discourse/subscriptions", + "organizations_url": "https://api.github.com/users/discourse/orgs", + "repos_url": "https://api.github.com/users/discourse/repos", + "events_url": "https://api.github.com/users/discourse/events{/privacy}", + "received_events_url": "https://api.github.com/users/discourse/received_events", + "type": "Organization", + "site_admin": false + }, + "html_url": "https://github.com/discourse/discourse", + "description": "A platform for community discussion. Free, open, simple.", + "fork": false, + "url": "https://api.github.com/repos/discourse/discourse", + "forks_url": "https://api.github.com/repos/discourse/discourse/forks", + "keys_url": "https://api.github.com/repos/discourse/discourse/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/discourse/discourse/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/discourse/discourse/teams", + "hooks_url": "https://api.github.com/repos/discourse/discourse/hooks", + "issue_events_url": "https://api.github.com/repos/discourse/discourse/issues/events{/number}", + "events_url": "https://api.github.com/repos/discourse/discourse/events", + "assignees_url": "https://api.github.com/repos/discourse/discourse/assignees{/user}", + "branches_url": "https://api.github.com/repos/discourse/discourse/branches{/branch}", + "tags_url": "https://api.github.com/repos/discourse/discourse/tags", + "blobs_url": "https://api.github.com/repos/discourse/discourse/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/discourse/discourse/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/discourse/discourse/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/discourse/discourse/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/discourse/discourse/statuses/{sha}", + "languages_url": "https://api.github.com/repos/discourse/discourse/languages", + "stargazers_url": "https://api.github.com/repos/discourse/discourse/stargazers", + "contributors_url": "https://api.github.com/repos/discourse/discourse/contributors", + "subscribers_url": "https://api.github.com/repos/discourse/discourse/subscribers", + "subscription_url": "https://api.github.com/repos/discourse/discourse/subscription", + "commits_url": "https://api.github.com/repos/discourse/discourse/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/discourse/discourse/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/discourse/discourse/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/discourse/discourse/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/discourse/discourse/contents/{+path}", + "compare_url": "https://api.github.com/repos/discourse/discourse/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/discourse/discourse/merges", + "archive_url": "https://api.github.com/repos/discourse/discourse/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/discourse/discourse/downloads", + "issues_url": "https://api.github.com/repos/discourse/discourse/issues{/number}", + "pulls_url": "https://api.github.com/repos/discourse/discourse/pulls{/number}", + "milestones_url": "https://api.github.com/repos/discourse/discourse/milestones{/number}", + "notifications_url": "https://api.github.com/repos/discourse/discourse/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/discourse/discourse/labels{/name}", + "releases_url": "https://api.github.com/repos/discourse/discourse/releases{/id}", + "deployments_url": "https://api.github.com/repos/discourse/discourse/deployments", + "created_at": "2013-01-12T00:25:55Z", + "updated_at": "2021-05-26T11:54:17Z", + "pushed_at": "2021-05-26T14:34:39Z", + "git_url": "git://github.com/discourse/discourse.git", + "ssh_url": "git@github.com:discourse/discourse.git", + "clone_url": "https://github.com/discourse/discourse.git", + "svn_url": "https://github.com/discourse/discourse", + "homepage": "https://www.discourse.org", + "size": 369664, + "stargazers_count": 33354, + "watchers_count": 33354, + "language": "Ruby", + "has_issues": false, + "has_projects": false, + "has_downloads": true, + "has_wiki": false, + "has_pages": false, + "forks_count": 7246, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 31, + "license": { + "key": "other", + "name": "Other", + "spdx_id": "NOASSERTION", + "url": null, + "node_id": "MDc6TGljZW5zZTA=" + }, + "forks": 7246, + "open_issues": 31, + "watchers": 33354, + "default_branch": "master" + } + }, + "_links": { + "self": { + "href": "https://api.github.com/repos/discourse/discourse/pulls/13128" + }, + "html": { + "href": "https://github.com/discourse/discourse/pull/13128" + }, + "issue": { + "href": "https://api.github.com/repos/discourse/discourse/issues/13128" + }, + "comments": { + "href": "https://api.github.com/repos/discourse/discourse/issues/13128/comments" + }, + "review_comments": { + "href": "https://api.github.com/repos/discourse/discourse/pulls/13128/comments" + }, + "review_comment": { + "href": "https://api.github.com/repos/discourse/discourse/pulls/comments{/number}" + }, + "commits": { + "href": "https://api.github.com/repos/discourse/discourse/pulls/13128/commits" + }, + "statuses": { + "href": "https://api.github.com/repos/discourse/discourse/statuses/929e4ee5e93e53f45c7be78fcf81b734120af9c0" + } + }, + "author_association": "MEMBER", + "auto_merge": null, + "active_lock_reason": null, + "merged": true, + "mergeable": null, + "rebaseable": null, + "mergeable_state": "unknown", + "merged_by": { + "login": "martin-brennan", + "id": 920448, + "node_id": "MDQ6VXNlcjkyMDQ0OA==", + "avatar_url": "https://avatars.githubusercontent.com/u/920448?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/martin-brennan", + "html_url": "https://github.com/martin-brennan", + "followers_url": "https://api.github.com/users/martin-brennan/followers", + "following_url": "https://api.github.com/users/martin-brennan/following{/other_user}", + "gists_url": "https://api.github.com/users/martin-brennan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/martin-brennan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/martin-brennan/subscriptions", + "organizations_url": "https://api.github.com/users/martin-brennan/orgs", + "repos_url": "https://api.github.com/users/martin-brennan/repos", + "events_url": "https://api.github.com/users/martin-brennan/events{/privacy}", + "received_events_url": "https://api.github.com/users/martin-brennan/received_events", + "type": "User", + "site_admin": false + }, + "comments": 0, + "review_comments": 0, + "maintainer_can_modify": false, + "commits": 3, + "additions": 10, + "deletions": 19, + "changed_files": 5 +} \ No newline at end of file diff --git a/spec/fixtures/onebox/githubactions_pr_run.response b/spec/fixtures/onebox/githubactions_pr_run.response new file mode 100644 index 0000000000..7cc64a5894 --- /dev/null +++ b/spec/fixtures/onebox/githubactions_pr_run.response @@ -0,0 +1,109 @@ +{ + "id": 2660861130, + "node_id": "MDg6Q2hlY2tSdW4yNjYwODYxMTMw", + "head_sha": "929e4ee5e93e53f45c7be78fcf81b734120af9c0", + "external_id": "ca395085-040a-526b-2ce8-bdc85f692774", + "url": "https://api.github.com/repos/discourse/discourse/check-runs/2660861130", + "html_url": "https://github.com/discourse/discourse/runs/2660861130", + "details_url": "https://github.com/discourse/discourse/runs/2660861130", + "status": "completed", + "conclusion": "success", + "started_at": "2021-05-25T01:10:38Z", + "completed_at": "2021-05-25T01:15:51Z", + "output": { + "title": null, + "summary": null, + "text": null, + "annotations_count": 0, + "annotations_url": "https://api.github.com/repos/discourse/discourse/check-runs/2660861130/annotations" + }, + "name": "run", + "check_suite": { + "id": 2820222471 + }, + "app": { + "id": 15368, + "slug": "github-actions", + "node_id": "MDM6QXBwMTUzNjg=", + "owner": { + "login": "github", + "id": 9919, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjk5MTk=", + "avatar_url": "https://avatars.githubusercontent.com/u/9919?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github", + "html_url": "https://github.com/github", + "followers_url": "https://api.github.com/users/github/followers", + "following_url": "https://api.github.com/users/github/following{/other_user}", + "gists_url": "https://api.github.com/users/github/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github/subscriptions", + "organizations_url": "https://api.github.com/users/github/orgs", + "repos_url": "https://api.github.com/users/github/repos", + "events_url": "https://api.github.com/users/github/events{/privacy}", + "received_events_url": "https://api.github.com/users/github/received_events", + "type": "Organization", + "site_admin": false + }, + "name": "GitHub Actions", + "description": "Automate your workflow from idea to production", + "external_url": "https://help.github.com/en/actions", + "html_url": "https://github.com/apps/github-actions", + "created_at": "2018-07-30T09:30:17Z", + "updated_at": "2019-12-10T19:04:12Z", + "permissions": { + "actions": "write", + "checks": "write", + "contents": "write", + "deployments": "write", + "discussions": "write", + "issues": "write", + "metadata": "read", + "organization_packages": "write", + "packages": "write", + "pages": "write", + "pull_requests": "write", + "repository_hooks": "write", + "repository_projects": "write", + "security_events": "write", + "statuses": "write", + "vulnerability_alerts": "read" + }, + "events": [ + "check_run", + "check_suite", + "create", + "delete", + "deployment", + "deployment_status", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issues", + "issue_comment", + "label", + "milestone", + "page_build", + "project", + "project_card", + "project_column", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "push", + "registry_package", + "release", + "repository", + "repository_dispatch", + "status", + "watch", + "workflow_dispatch", + "workflow_run" + ] + }, + "pull_requests": [ + + ] +} \ No newline at end of file diff --git a/spec/fixtures/onebox/githubblob.response b/spec/fixtures/onebox/githubblob.response new file mode 100644 index 0000000000..9cf89bef5a --- /dev/null +++ b/spec/fixtures/onebox/githubblob.response @@ -0,0 +1,46 @@ +require_dependency 'oneboxer/handlebars_onebox' + +module Oneboxer + class GithubBlobOnebox < HandlebarsOnebox + + matcher /^https?:\/\/(?:www\.)?github\.com\/[^\/]+\/[^\/]+\/blob\/.*/ + favicon 'github.png' + + def translate_url + m = @url.match(/github\.com\/(?[^\/]+)\/(?[^\/]+)\/blob\/(?[^\/]+)\/(?[^#]+)(#(L(?[^-]*)(-L(?.*))?))?/mi) + if m + @from = (m[:from] || -1).to_i + @to = (m[:to] || -1).to_i + @file = m[:file] + return "https://raw.github.com/#{m[:user]}/#{m[:repo]}/#{m[:sha1]}/#{m[:file]}" + end + nil + end + + def parse(data) + + if @from > 0 + if @to < 0 + @from = @from - 10 + @to = @from + 20 + end + if @to > @from + data = data.split("\n")[@from..@to].join("\n") + end + end + + extension = @file.split(".")[-1] + @lang = extension + + truncated = false + if data.length > SiteSetting.onebox_max_chars + data = data[0..SiteSetting.onebox_max_chars-1] + truncated = true + end + + {content: data, truncated: truncated} + end + + end +end + diff --git a/spec/fixtures/onebox/githubcommit.response b/spec/fixtures/onebox/githubcommit.response new file mode 100644 index 0000000000..46944600ab --- /dev/null +++ b/spec/fixtures/onebox/githubcommit.response @@ -0,0 +1,87 @@ +{ + "sha": "803d023e2307309f8b776ab3b8b7e38ba91c0919", + "commit": { + "author": { + "name": "Sam", + "email": "sam.saffron@gmail.com", + "date": "2013-08-02T02:03:53Z" + }, + "committer": { + "name": "Sam", + "email": "sam.saffron@gmail.com", + "date": "2013-08-02T02:16:44Z" + }, + "message": "Fixed GitHub auth, GitHub can provide us with a valid email - so automatically log in for those cases", + "tree": { + "sha": "8e0f3e17bb5ee3edc5701229dc1ad82dc5a41de6", + "url": "https://api.github.com/repos/discourse/discourse/git/trees/8e0f3e17bb5ee3edc5701229dc1ad82dc5a41de6" + }, + "url": "https://api.github.com/repos/discourse/discourse/git/commits/803d023e2307309f8b776ab3b8b7e38ba91c0919", + "comment_count": 0 + }, + "url": "https://api.github.com/repos/discourse/discourse/commits/803d023e2307309f8b776ab3b8b7e38ba91c0919", + "html_url": "https://github.com/discourse/discourse/commit/803d023e2307309f8b776ab3b8b7e38ba91c0919", + "comments_url": "https://api.github.com/repos/discourse/discourse/commits/803d023e2307309f8b776ab3b8b7e38ba91c0919/comments", + "author": { + "login": "SamSaffron", + "id": 5213, + "avatar_url": "https://2.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce?d=https%3A%2F%2Fidenticons.github.com%2F7d3010c11d08cf990b7614d2c2ca9098.png", + "gravatar_id": "3dcae8378d46c244172a115c28ca49ce", + "url": "https://api.github.com/users/SamSaffron", + "html_url": "https://github.com/SamSaffron", + "followers_url": "https://api.github.com/users/SamSaffron/followers", + "following_url": "https://api.github.com/users/SamSaffron/following{/other_user}", + "gists_url": "https://api.github.com/users/SamSaffron/gists{/gist_id}", + "starred_url": "https://api.github.com/users/SamSaffron/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/SamSaffron/subscriptions", + "organizations_url": "https://api.github.com/users/SamSaffron/orgs", + "repos_url": "https://api.github.com/users/SamSaffron/repos", + "events_url": "https://api.github.com/users/SamSaffron/events{/privacy}", + "received_events_url": "https://api.github.com/users/SamSaffron/received_events", + "type": "User" + }, + "committer": { + "login": "SamSaffron", + "id": 5213, + "avatar_url": "https://2.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce?d=https%3A%2F%2Fidenticons.github.com%2F7d3010c11d08cf990b7614d2c2ca9098.png", + "gravatar_id": "3dcae8378d46c244172a115c28ca49ce", + "url": "https://api.github.com/users/SamSaffron", + "html_url": "https://github.com/SamSaffron", + "followers_url": "https://api.github.com/users/SamSaffron/followers", + "following_url": "https://api.github.com/users/SamSaffron/following{/other_user}", + "gists_url": "https://api.github.com/users/SamSaffron/gists{/gist_id}", + "starred_url": "https://api.github.com/users/SamSaffron/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/SamSaffron/subscriptions", + "organizations_url": "https://api.github.com/users/SamSaffron/orgs", + "repos_url": "https://api.github.com/users/SamSaffron/repos", + "events_url": "https://api.github.com/users/SamSaffron/events{/privacy}", + "received_events_url": "https://api.github.com/users/SamSaffron/received_events", + "type": "User" + }, + "parents": [ + { + "sha": "cf333268d5b48946a659f173716aecc1096d7e66", + "url": "https://api.github.com/repos/discourse/discourse/commits/cf333268d5b48946a659f173716aecc1096d7e66", + "html_url": "https://github.com/discourse/discourse/commit/cf333268d5b48946a659f173716aecc1096d7e66" + } + ], + "stats": { + "total": 20, + "additions": 18, + "deletions": 2 + }, + "files": [ + { + "sha": "0edc93bbf3d28a5020ee8b2d44ed68d4e3706a1f", + "filename": "app/controllers/users/omniauth_callbacks_controller.rb", + "status": "modified", + "additions": 18, + "deletions": 2, + "changes": 20, + "blob_url": "https://github.com/discourse/discourse/blob/803d023e2307309f8b776ab3b8b7e38ba91c0919/app/controllers/users/omniauth_callbacks_controller.rb", + "raw_url": "https://github.com/discourse/discourse/raw/803d023e2307309f8b776ab3b8b7e38ba91c0919/app/controllers/users/omniauth_callbacks_controller.rb", + "contents_url": "https://api.github.com/repos/discourse/discourse/contents/app/controllers/users/omniauth_callbacks_controller.rb?ref=803d023e2307309f8b776ab3b8b7e38ba91c0919", + "patch": "@@ -210,6 +210,8 @@ def create_or_sign_on_user_using_openid(auth_token)\n \n if user_open_id.blank? && user = User.find_by_email(email)\n # we trust so do an email lookup\n+ # TODO some openid providers may not be trust worthy, allow for that\n+ # for now we are good (google, yahoo are trust worthy)\n user_open_id = UserOpenId.create(url: identity_url , user_id: user.id, email: email, active: true)\n end\n \n@@ -250,18 +252,32 @@ def create_or_sign_on_user_using_github(auth_token)\n \n data = auth_token[:info]\n screen_name = data[\"nickname\"]\n+ email = data[\"email\"]\n github_user_id = auth_token[\"uid\"]\n \n session[:authentication] = {\n github_user_id: github_user_id,\n- github_screen_name: screen_name\n+ github_screen_name: screen_name,\n+ email: email,\n+ email_valid: true\n }\n \n user_info = GithubUserInfo.where(github_user_id: github_user_id).first\n \n+ if !user_info && user = User.find_by_email(email)\n+ # we trust so do an email lookup\n+ user_info = GithubUserInfo.create(\n+ user_id: user.id,\n+ screen_name: screen_name,\n+ github_user_id: github_user_id\n+ )\n+ end\n+\n @data = {\n username: screen_name,\n- auth_provider: \"Github\"\n+ auth_provider: \"Github\",\n+ email: email,\n+ email_valid: true\n }\n \n process_user_info(user_info, screen_name)" + } + ] +} diff --git a/spec/fixtures/onebox/githubfolder-discourse-root.response b/spec/fixtures/onebox/githubfolder-discourse-root.response new file mode 100644 index 0000000000..23075c565b --- /dev/null +++ b/spec/fixtures/onebox/githubfolder-discourse-root.response @@ -0,0 +1,2358 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GitHub - discourse/discourse: A platform for community discussion. Free, open, simple. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Skip to content + + + + + + + + +
    + +
    + + + + + +
    + + + +
    + + + + + + + + + +
    +
    +
    + + + + + + + + + + + + + + + +
    + +
    + +
    +

    + + + / + + discourse + + +

    + + +
    + + + +
    +
    +

    + A platform for community discussion. Free, open, simple. +

    +
    + + + www.discourse.org + +
    + + +
    + + +
    +
    + + + +
    + + +
    +
    + + + + +
    + +
    + + + + + +
    + +
    + + + + + + +
    + +
    +
    + + + master + + + + +
    + + + +
    +
    +
    + +
    + + + + +
    + + + Go to file + + + + + + + +
    + + + Code + +
    + +
    +
    +
    + + + +
    +
    + + + + +
    +
    +

    Latest commit

    +
    + +
    +
     
    +
    +

    Git stats

    + +
    +
    +
    +

    Files

    + + + + + Permalink + +
    + + + Failed to load latest commit information. + +
    +
    +
    +
    Type
    +
    Name
    +
    Latest commit message
    +
    Commit time
    +
    + +
    +
    + + +
    + +
    + .github +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + app +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + bin +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + config +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + db +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + docs +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + images +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + lib +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + log +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + plugins +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + public +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + script +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + spec +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + test +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + vendor +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + .eslintrc +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + .rspec +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + Brewfile +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + Gemfile +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + README.md +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + Rakefile +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + adminjs +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + config.ru +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + d +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + jsapp +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + + + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    +
    + + +
    + +
    + yarn.lock +
    + +
    +
     
    +
    + +
    +
     
    +
    + +
    +
    + +
    + +
    + + +
    + +
    +
    +

    + README.md +

    +
    + + +
    +

    +

    Discourse is the 100% open source discussion platform built for the next decade of the Internet. Use it as a:

    +
      +
    • mailing list
    • +
    • discussion forum
    • +
    • long-form chat room
    • +
    +

    To learn more about the philosophy and goals of the project, visit discourse.org.

    +

    Screenshots

    +

    Boing Boing + + +

    +

    Mobile

    +

    Browse lots more notable Discourse instances.

    +

    Development

    +

    To get your environment setup, follow the community setup guide for your operating system.

    +
      +
    1. If you're on macOS, try the macOS development guide.
    2. +
    3. If you're on Ubuntu, try the Ubuntu development guide.
    4. +
    5. If you're on Windows, try the Windows 10 development guide.
    6. +
    +

    If you're familiar with how Rails works and are comfortable setting up your own environment, you can also try out the Discourse Advanced Developer Guide, which is aimed primarily at Ubuntu and macOS environments.

    +

    Before you get started, ensure you have the following minimum versions: Ruby 2.6+, PostgreSQL 10+, Redis 4.0+. If you're having trouble, please see our TROUBLESHOOTING GUIDE first!

    +

    Setting up Discourse

    +

    If you want to set up a Discourse forum for production use, see our Discourse Install Guide.

    +

    If you're looking for business class hosting, see discourse.org/buy.

    +

    Requirements

    +

    Discourse is built for the next 10 years of the Internet, so our requirements are high.

    +

    Discourse supports the latest, stable releases of all major browsers and platforms:

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    BrowsersTabletsPhones
    Apple SafariiPadOSiOS
    Google ChromeAndroidAndroid
    Microsoft Edge
    Mozilla Firefox
    +

    Built With

    +
      +
    • Ruby on Rails — Our back end API is a Rails app. It responds to requests RESTfully in JSON.
    • +
    • Ember.js — Our front end is an Ember.js app that communicates with the Rails API.
    • +
    • PostgreSQL — Our main data store is in Postgres.
    • +
    • Redis — We use Redis as a cache and for transient data.
    • +
    • BrowserStack — We use BrowserStack to test on real devices and browsers.
    • +
    +

    Plus lots of Ruby Gems, a complete list of which is at /master/Gemfile.

    +

    Contributing

    +

    Build Status

    +

    Discourse is 100% free and open source. We encourage and support an active, healthy community that +accepts contributions from the public – including you!

    +

    Before contributing to Discourse:

    +
      +
    1. Please read the complete mission statements on discourse.org. Yes we actually believe this stuff; you should too.
    2. +
    3. Read and sign the Electronic Discourse Forums Contribution License Agreement.
    4. +
    5. Dig into CONTRIBUTING.MD, which covers submitting bugs, requesting new features, preparing your code for a pull request, etc.
    6. +
    7. Always strive to collaborate with mutual respect.
    8. +
    9. Not sure what to work on? We've got some ideas.
    10. +
    +

    We look forward to seeing your pull requests!

    +

    Security

    +

    We take security very seriously at Discourse; all our code is 100% open source and peer reviewed. Please read our security guide for an overview of security measures in Discourse, or if you wish to report a security issue.

    +

    The Discourse Team

    +

    The original Discourse code contributors can be found in AUTHORS.MD. For a complete list of the many individuals that contributed to the design and implementation of Discourse, please refer to the official Discourse blog and GitHub's list of contributors.

    +

    Copyright / License

    +

    Copyright 2014 - 2020 Civilized Discourse Construction Kit, Inc.

    +

    Licensed under the GNU General Public License Version 2.0 (or later); +you may not use this work except in compliance with the License. +You may obtain a copy of the License in the LICENSE file, or at:

    +

    https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt

    +

    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.

    +

    Discourse logo and “Discourse Forum” ¼, Civilized Discourse Construction Kit, Inc.

    +

    Dedication

    +

    Discourse is built with love, Internet style.

    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +

    About

    + +

    + A platform for community discussion. Free, open, simple. +

    +
    + + + www.discourse.org + +
    + +

    Topics

    + + +

    Resources

    + + +

    License

    + + +
    +
    + +
    +
    +

    + + Contributors 794 +

    + + + +
      +
    • +
      +
    • +
    • +
      +
    • +
    • +
      +
    • +
    • +
      +
    • +
    • +
      +
    • +
    • +
      +
    • +
    • +
      +
    • +
    • +
      +
    • +
    • +
      +
    • +
    • +
      +
    • +
    • +
      +
    • +
    +
    + + +
    +
    + +
    + +
    + +
    +
    + +
    +
    + +
    + + + + + + +
    + + + You can’t perform that action at this time. +
    + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/githubfolder.response b/spec/fixtures/onebox/githubfolder.response new file mode 100644 index 0000000000..6e2ae42221 --- /dev/null +++ b/spec/fixtures/onebox/githubfolder.response @@ -0,0 +1,1549 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + discourse/spec/fixtures at master · discourse/discourse · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Skip to content + + + + + + + + +
    + +
    + + + + + +
    + + + +
    + + + + + + + + + +
    +
    +
    + + + + + + + + + + + + + + +
    + +
    + +
    +

    + + + / + + discourse + + +

    + + +
    + + + +
    + + +
    + + +
    +
    + + + +
    + +
    +
    + + + master + + + + +
    + + + +
    +
    +
    + +
    + + +
    +
    + discourse/spec/fixtures/ +
    +
    + + +
    + +
    discourse/spec/fixtures/
    + + + +
    +
    +

    Latest commit

    +
    + +
    + +
    +
    + + @jbrw +
    +
    + +
    + +
    + + +
    #11253)
    +
    +* FEATURE: display error if Oneboxing fails due to HTTP error
    +
    +- display warning if onebox URL is unresolvable
    +- display warning if attributes are missing
    +
    +* FEATURE: Use new Instagram oEmbed endpoint if access token is configured
    +
    +Instagram requires an Access Token to access their oEmbed endpoint. The requirements (from https://developers.facebook.com/docs/instagram/oembed/) are as follows:
    +
    +- a Facebook Developer account, which you can create at developers.facebook.com
    +- a registered Facebook app
    +- the oEmbed Product added to the app
    +- an Access Token
    +- The Facebook app must be in Live Mode
    +
    +The generated Access Token, once added to SiteSetting.facebook_app_access_token, will be passed to onebox. Onebox can then use this token to access the oEmbed endpoint to generate a onebox for Instagram.
    +
    +* DEV: update user agent string
    +
    +* DEV: don’t do HEAD requests against news.yahoo.com
    +
    +* DEV: Bump onebox version from 2.1.5 to 2.1.6
    +
    +* DEV: Avoid re-reading templates
    +
    +* DEV: Tweaks to onebox mustache templates
    +
    +* DEV: simplified error message for missing onebox data
    +
    +* Apply suggestions from code review
    +Co-authored-by: Gerhard Schlager <mail@gerhard-schlager.at>
    +
    + 331236d +
    +
    +
    +

    Git stats

    + +
    +
    +
    +

    Files

    + + + + Permalink + +
    + + + Failed to load latest commit information. + +
    +
    +
    +
    Type
    +
    Name
    +
    Latest commit message
    +
    Commit time
    +
    +
    + +
    +
    +
    + +
    +
    + + +
    + +
    + backups +
    + + + +
    + Aug 21, 2020 +
    + +
    +
    +
    + + +
    + +
    + csv +
    + + + +
    + Jul 29, 2020 +
    + +
    +
    +
    + + +
    + +
    + db +
    + + + +
    + Jun 16, 2020 +
    + +
    +
    +
    + + +
    + +
    + emails +
    + + + +
    + Jul 27, 2020 +
    + +
    +
    +
    + + +
    + +
    + encodings +
    + + + +
    + Aug 1, 2018 +
    + +
    +
    +
    + + +
    + +
    + feed +
    + + + +
    + Aug 1, 2018 +
    + +
    +
    +
    + + +
    + +
    + i18n +
    + + + +
    + Jun 5, 2019 +
    + +
    +
    +
    + + +
    + +
    + images +
    + + + +
    + Oct 26, 2020 +
    + +
    +
    +
    + + +
    + +
    + json +
    + + + +
    + Aug 24, 2020 +
    + +
    +
    +
    + + +
    + +
    + md +
    + + + +
    + Oct 8, 2019 +
    + +
    +
    +
    + + +
    + +
    + media +
    + + + +
    + Jun 17, 2020 +
    + +
    +
    +
    + + +
    + +
    + mmdb +
    + + + +
    + Oct 25, 2018 +
    + +
    +
    +
    + + +
    + +
    + multisite +
    + + + +
    + Aug 8, 2017 +
    + +
    +
    +
    + + +
    + +
    + onebox +
    + + + +
    + Nov 18, 2020 +
    + +
    +
    +
    + + +
    + +
    + pdf +
    + + + +
    + Jul 25, 2019 +
    + +
    +
    +
    + + +
    + +
    + plugins +
    + + + +
    + Nov 11, 2020 +
    + +
    +
    +
    + + +
    + +
    + scss +
    + + + +
    + Sep 21, 2018 +
    + +
    +
    +
    + + +
    + + + + + +
    + Aug 15, 2017 +
    + +
    +
    +
    + + +
    + + + + + +
    + Apr 15, 2020 +
    + +
    +
    +
    + + +
    + +
    + themes +
    + + + +
    + Oct 14, 2019 +
    + +
    +
    +
    + + +
    + +
    + woff2 +
    + + + +
    + May 10, 2017 +
    + +
    +
    +
    + + + + +
    + + + + + +
    +
    + +
    +
    + +
    + + + + + + +
    + + + You can’t perform that action at this time. +
    + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/githubgist.response b/spec/fixtures/onebox/githubgist.response new file mode 100644 index 0000000000..5975d95256 --- /dev/null +++ b/spec/fixtures/onebox/githubgist.response @@ -0,0 +1,310 @@ +{ + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b", + "forks_url": "https://api.github.com/gists/208fdd59fc4b4c39283b/forks", + "commits_url": "https://api.github.com/gists/208fdd59fc4b4c39283b/commits", + "id": "208fdd59fc4b4c39283b", + "git_pull_url": "https://gist.github.com/208fdd59fc4b4c39283b.git", + "git_push_url": "https://gist.github.com/208fdd59fc4b4c39283b.git", + "html_url": "https://gist.github.com/208fdd59fc4b4c39283b", + "files": { + "0.rb": { + "filename": "0.rb", + "type": "application/x-ruby", + "language": "Ruby", + "raw_url": "https://gist.githubusercontent.com/karreiro/208fdd59fc4b4c39283b/raw/42864e791652564ec50f773589df168998fbfdf7/0.rb", + "size": 384, + "truncated": false, + "content": "3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n" + }, + "1.js": { + "filename": "1.js", + "type": "application/javascript", + "language": "JavaScript", + "raw_url": "https://gist.githubusercontent.com/karreiro/208fdd59fc4b4c39283b/raw/767c3a1cec198cc2a9e6bf8a8043977cfcf3a469/1.js", + "size": 22, + "truncated": false, + "content": "console.log(\"Hey! ;)\")" + }, + "2.md": { + "filename": "2.md", + "type": "text/plain", + "language": "Markdown", + "raw_url": "https://gist.githubusercontent.com/karreiro/208fdd59fc4b4c39283b/raw/5da5715735f9d4d908003b4656426d53bfd69a96/2.md", + "size": 25, + "truncated": false, + "content": "#### Hey, this is a test!" + }, + "3.java": { + "filename": "3.java", + "type": "text/plain", + "language": "Java", + "raw_url": "https://gist.githubusercontent.com/karreiro/208fdd59fc4b4c39283b/raw/1a5f6d12fc557951f87b52f91c9cb8d6bdb2562d/3.java", + "size": 43, + "truncated": false, + "content": "System.out.println(\"Wow! This is a test!\");" + } + }, + "public": true, + "created_at": "2014-11-23T20:34:53Z", + "updated_at": "2014-11-26T01:06:05Z", + "description": "", + "comments": 0, + "user": null, + "comments_url": "https://api.github.com/gists/208fdd59fc4b4c39283b/comments", + "owner": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "forks": [ + + ], + "history": [ + { + "user": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "version": "e272e4f835e80f53fb61df2dca190fdc84b9077d", + "committed_at": "2014-11-26T01:06:05Z", + "change_status": { + "total": 4, + "additions": 2, + "deletions": 2 + }, + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b/e272e4f835e80f53fb61df2dca190fdc84b9077d" + }, + { + "user": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "version": "3e26db8aae98340fce9d0eed3e0105c78dc440e9", + "committed_at": "2014-11-26T01:05:48Z", + "change_status": { + "total": 48, + "additions": 24, + "deletions": 24 + }, + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b/3e26db8aae98340fce9d0eed3e0105c78dc440e9" + }, + { + "user": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "version": "4ad435c22f01aca33b6b9425505b257b8e79fe51", + "committed_at": "2014-11-26T01:05:16Z", + "change_status": { + "total": 22, + "additions": 21, + "deletions": 1 + }, + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b/4ad435c22f01aca33b6b9425505b257b8e79fe51" + }, + { + "user": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "version": "766d913a9bbe70181944a7a74818db5a1531d7e2", + "committed_at": "2014-11-26T00:56:12Z", + "change_status": { + "total": 2, + "additions": 2, + "deletions": 0 + }, + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b/766d913a9bbe70181944a7a74818db5a1531d7e2" + }, + { + "user": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "version": "0b66a247bcbcdaeaee33b43a3b8accd499f82c8d", + "committed_at": "2014-11-24T23:48:58Z", + "change_status": { + "total": 1, + "additions": 1, + "deletions": 0 + }, + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b/0b66a247bcbcdaeaee33b43a3b8accd499f82c8d" + }, + { + "user": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "version": "310b5888a5ee830a38972f0fbace28055ab05759", + "committed_at": "2014-11-24T23:47:40Z", + "change_status": { + "total": 99, + "additions": 0, + "deletions": 99 + }, + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b/310b5888a5ee830a38972f0fbace28055ab05759" + }, + { + "user": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "version": "dc19c6c9c36079f56363062eea81e448fe1f996e", + "committed_at": "2014-11-24T01:06:00Z", + "change_status": { + "total": 99, + "additions": 99, + "deletions": 0 + }, + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b/dc19c6c9c36079f56363062eea81e448fe1f996e" + }, + { + "user": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "version": "9ec949557a17391117a30aebcd907a14d61eae88", + "committed_at": "2014-11-23T20:34:53Z", + "change_status": { + "total": 1, + "additions": 1, + "deletions": 0 + }, + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b/9ec949557a17391117a30aebcd907a14d61eae88" + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/onebox/githubpullrequest.response b/spec/fixtures/onebox/githubpullrequest.response new file mode 100644 index 0000000000..f2c8c94a42 --- /dev/null +++ b/spec/fixtures/onebox/githubpullrequest.response @@ -0,0 +1,216 @@ +{ + "url": "https://api.github.com/repos/discourse/discourse/pulls/1253", + "id": 7186071, + "html_url": "https://github.com/discourse/discourse/pull/1253", + "diff_url": "https://github.com/discourse/discourse/pull/1253.diff", + "patch_url": "https://github.com/discourse/discourse/pull/1253.patch", + "issue_url": "https://github.com/discourse/discourse/pull/1253", + "number": 1253, + "state": "closed", + "title": "Add audio onebox", + "user": { + "login": "jamesaanderson", + "id": 2722987, + "avatar_url": "https://0.gravatar.com/avatar/b3e9977094ce189bbb493cf7f9adea21?d=https%3A%2F%2Fidenticons.github.com%2Fb4a68f5d10a482ee680e30f88540942a.png", + "gravatar_id": "b3e9977094ce189bbb493cf7f9adea21", + "url": "https://api.github.com/users/jamesaanderson", + "html_url": "https://github.com/jamesaanderson", + "followers_url": "https://api.github.com/users/jamesaanderson/followers", + "following_url": "https://api.github.com/users/jamesaanderson/following{/other_user}", + "gists_url": "https://api.github.com/users/jamesaanderson/gists{/gist_id}", + "starred_url": "https://api.github.com/users/jamesaanderson/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/jamesaanderson/subscriptions", + "organizations_url": "https://api.github.com/users/jamesaanderson/orgs", + "repos_url": "https://api.github.com/users/jamesaanderson/repos", + "events_url": "https://api.github.com/users/jamesaanderson/events{/privacy}", + "received_events_url": "https://api.github.com/users/jamesaanderson/received_events", + "type": "User" + }, + "body": "http://meta.discourse.org/t/audio-html5-tag/8168\n", + "created_at": "2013-07-26T02:05:53Z", + "updated_at": "2013-07-26T15:31:57Z", + "closed_at": "2013-07-26T15:30:57Z", + "merged_at": "2013-07-26T15:30:57Z", + "merge_commit_sha": null, + "assignee": null, + "milestone": null, + "commits_url": "https://github.com/discourse/discourse/pull/1253/commits", + "review_comments_url": "https://github.com/discourse/discourse/pull/1253/comments", + "review_comment_url": "/repos/discourse/discourse/pulls/comments/{number}", + "comments_url": "https://api.github.com/repos/discourse/discourse/issues/1253/comments", + "head": { + "label": "jamesaanderson:add-audio-onebox", + "ref": "add-audio-onebox", + "sha": "d7d3be1130c665cc7fab9f05dbf32335229137a6", + "user": { + "login": "jamesaanderson", + "id": 2722987, + "avatar_url": "https://0.gravatar.com/avatar/b3e9977094ce189bbb493cf7f9adea21?d=https%3A%2F%2Fidenticons.github.com%2Fb4a68f5d10a482ee680e30f88540942a.png", + "gravatar_id": "b3e9977094ce189bbb493cf7f9adea21", + "url": "https://api.github.com/users/jamesaanderson", + "html_url": "https://github.com/jamesaanderson", + "followers_url": "https://api.github.com/users/jamesaanderson/followers", + "following_url": "https://api.github.com/users/jamesaanderson/following{/other_user}", + "gists_url": "https://api.github.com/users/jamesaanderson/gists{/gist_id}", + "starred_url": "https://api.github.com/users/jamesaanderson/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/jamesaanderson/subscriptions", + "organizations_url": "https://api.github.com/users/jamesaanderson/orgs", + "repos_url": "https://api.github.com/users/jamesaanderson/repos", + "events_url": "https://api.github.com/users/jamesaanderson/events{/privacy}", + "received_events_url": "https://api.github.com/users/jamesaanderson/received_events", + "type": "User" + }, + "repo": null + }, + "base": { + "label": "discourse:master", + "ref": "master", + "sha": "cc79d22f82ede170dd86a05274eb3c2c5eb02912", + "user": { + "login": "discourse", + "id": 3220138, + "avatar_url": "https://0.gravatar.com/avatar/b30fff48d257cdd17c4437afac19fd30?d=https%3A%2F%2Fidenticons.github.com%2Fa42d8d01d12f7137e49e7c1ee1b2b3f0.png", + "gravatar_id": "b30fff48d257cdd17c4437afac19fd30", + "url": "https://api.github.com/users/discourse", + "html_url": "https://github.com/discourse", + "followers_url": "https://api.github.com/users/discourse/followers", + "following_url": "https://api.github.com/users/discourse/following{/other_user}", + "gists_url": "https://api.github.com/users/discourse/gists{/gist_id}", + "starred_url": "https://api.github.com/users/discourse/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/discourse/subscriptions", + "organizations_url": "https://api.github.com/users/discourse/orgs", + "repos_url": "https://api.github.com/users/discourse/repos", + "events_url": "https://api.github.com/users/discourse/events{/privacy}", + "received_events_url": "https://api.github.com/users/discourse/received_events", + "type": "Organization" + }, + "repo": { + "id": 7569578, + "name": "discourse", + "full_name": "discourse/discourse", + "owner": { + "login": "discourse", + "id": 3220138, + "avatar_url": "https://0.gravatar.com/avatar/b30fff48d257cdd17c4437afac19fd30?d=https%3A%2F%2Fidenticons.github.com%2Fa42d8d01d12f7137e49e7c1ee1b2b3f0.png", + "gravatar_id": "b30fff48d257cdd17c4437afac19fd30", + "url": "https://api.github.com/users/discourse", + "html_url": "https://github.com/discourse", + "followers_url": "https://api.github.com/users/discourse/followers", + "following_url": "https://api.github.com/users/discourse/following{/other_user}", + "gists_url": "https://api.github.com/users/discourse/gists{/gist_id}", + "starred_url": "https://api.github.com/users/discourse/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/discourse/subscriptions", + "organizations_url": "https://api.github.com/users/discourse/orgs", + "repos_url": "https://api.github.com/users/discourse/repos", + "events_url": "https://api.github.com/users/discourse/events{/privacy}", + "received_events_url": "https://api.github.com/users/discourse/received_events", + "type": "Organization" + }, + "private": false, + "html_url": "https://github.com/discourse/discourse", + "description": "A platform for community discussion. Free, open, simple.", + "fork": false, + "url": "https://api.github.com/repos/discourse/discourse", + "forks_url": "https://api.github.com/repos/discourse/discourse/forks", + "keys_url": "https://api.github.com/repos/discourse/discourse/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/discourse/discourse/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/discourse/discourse/teams", + "hooks_url": "https://api.github.com/repos/discourse/discourse/hooks", + "issue_events_url": "https://api.github.com/repos/discourse/discourse/issues/events{/number}", + "events_url": "https://api.github.com/repos/discourse/discourse/events", + "assignees_url": "https://api.github.com/repos/discourse/discourse/assignees{/user}", + "branches_url": "https://api.github.com/repos/discourse/discourse/branches{/branch}", + "tags_url": "https://api.github.com/repos/discourse/discourse/tags", + "blobs_url": "https://api.github.com/repos/discourse/discourse/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/discourse/discourse/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/discourse/discourse/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/discourse/discourse/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/discourse/discourse/statuses/{sha}", + "languages_url": "https://api.github.com/repos/discourse/discourse/languages", + "stargazers_url": "https://api.github.com/repos/discourse/discourse/stargazers", + "contributors_url": "https://api.github.com/repos/discourse/discourse/contributors", + "subscribers_url": "https://api.github.com/repos/discourse/discourse/subscribers", + "subscription_url": "https://api.github.com/repos/discourse/discourse/subscription", + "commits_url": "https://api.github.com/repos/discourse/discourse/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/discourse/discourse/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/discourse/discourse/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/discourse/discourse/issues/comments/{number}", + "contents_url": "https://api.github.com/repos/discourse/discourse/contents/{+path}", + "compare_url": "https://api.github.com/repos/discourse/discourse/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/discourse/discourse/merges", + "archive_url": "https://api.github.com/repos/discourse/discourse/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/discourse/discourse/downloads", + "issues_url": "https://api.github.com/repos/discourse/discourse/issues{/number}", + "pulls_url": "https://api.github.com/repos/discourse/discourse/pulls{/number}", + "milestones_url": "https://api.github.com/repos/discourse/discourse/milestones{/number}", + "notifications_url": "https://api.github.com/repos/discourse/discourse/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/discourse/discourse/labels{/name}", + "created_at": "2013-01-12T00:25:55Z", + "updated_at": "2013-09-28T16:44:54Z", + "pushed_at": "2013-09-27T19:08:59Z", + "git_url": "git://github.com/discourse/discourse.git", + "ssh_url": "git@github.com:discourse/discourse.git", + "clone_url": "https://github.com/discourse/discourse.git", + "svn_url": "https://github.com/discourse/discourse", + "homepage": "http://www.discourse.org", + "size": 48020, + "watchers_count": 7857, + "language": "JavaScript", + "has_issues": true, + "has_downloads": true, + "has_wiki": true, + "forks_count": 1876, + "mirror_url": null, + "open_issues_count": 38, + "forks": 1876, + "open_issues": 38, + "watchers": 7857, + "master_branch": "master", + "default_branch": "master" + } + }, + "_links": { + "self": { + "href": "https://api.github.com/repos/discourse/discourse/pulls/1253" + }, + "html": { + "href": "https://github.com/discourse/discourse/pull/1253" + }, + "issue": { + "href": "https://api.github.com/repos/discourse/discourse/issues/1253" + }, + "comments": { + "href": "https://api.github.com/repos/discourse/discourse/issues/1253/comments" + }, + "review_comments": { + "href": "https://api.github.com/repos/discourse/discourse/pulls/1253/comments" + } + }, + "merged": true, + "mergeable": null, + "mergeable_state": "unknown", + "merged_by": { + "login": "eviltrout", + "id": 17538, + "avatar_url": "https://0.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9?d=https%3A%2F%2Fidenticons.github.com%2Fba01baa4856d494a66a0d5eca39f5418.png", + "gravatar_id": "c6e17f2ae2a215e87ff9e878a4e63cd9", + "url": "https://api.github.com/users/eviltrout", + "html_url": "https://github.com/eviltrout", + "followers_url": "https://api.github.com/users/eviltrout/followers", + "following_url": "https://api.github.com/users/eviltrout/following{/other_user}", + "gists_url": "https://api.github.com/users/eviltrout/gists{/gist_id}", + "starred_url": "https://api.github.com/users/eviltrout/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/eviltrout/subscriptions", + "organizations_url": "https://api.github.com/users/eviltrout/orgs", + "repos_url": "https://api.github.com/users/eviltrout/repos", + "events_url": "https://api.github.com/users/eviltrout/events{/privacy}", + "received_events_url": "https://api.github.com/users/eviltrout/received_events", + "type": "User" + }, + "comments": 2, + "review_comments": 0, + "commits": 1, + "additions": 19, + "deletions": 1, + "changed_files": 4 +} diff --git a/spec/fixtures/onebox/gitlabblob.response b/spec/fixtures/onebox/gitlabblob.response new file mode 100644 index 0000000000..9249619b61 --- /dev/null +++ b/spec/fixtures/onebox/gitlabblob.response @@ -0,0 +1,21 @@ +require_relative '../mixins/git_blob_onebox' + +module Onebox + module Engine + class GitlabBlobOnebox + def self.git_regexp + /^https?:\/\/(www\.)?gitlab\.com.*\/blob\// + end + include Onebox::Mixins::GitBlobOnebox + def raw_regexp + /gitlab\.com\/(?[^\/]+)\/(?[^\/]+)\/blob\/(?[^\/]+)\/(?[^#]+)(#(L(?[^-]*)(-L(?.*))?))?/mi + end + def raw_template(m) + "https://gitlab.com/#{m[:user]}/#{m[:repo]}/raw/#{m[:sha1]}/#{m[:file]}" + end + def title + Sanitize.fragment(URI.unescape(link).sub(/^https?\:\/\/gitlab\.com\//, '')) + end + end + end +end diff --git a/spec/fixtures/onebox/googledocs.response b/spec/fixtures/onebox/googledocs.response new file mode 100644 index 0000000000..be56d2cf1c --- /dev/null +++ b/spec/fixtures/onebox/googledocs.response @@ -0,0 +1,182 @@ +Lorem Ipsum! - Dokumenty Google
    Lorem Ipsum
     Udostępnij
    UĆŒywana przez Ciebie wersja przeglądarki nie jest juĆŒ obsƂugiwana. Uaktualnij przeglądarkę do obsƂugiwanej wersji.Zamknij

    diff --git a/spec/fixtures/onebox/googledrive.response b/spec/fixtures/onebox/googledrive.response new file mode 100644 index 0000000000..caaff11360 --- /dev/null +++ b/spec/fixtures/onebox/googledrive.response @@ -0,0 +1,304 @@ +test.txt - Google Drive + +
    Google Account
    John Doe
    xyz@gmail.com
    Main menu
    diff --git a/spec/fixtures/onebox/googlephotos.response b/spec/fixtures/onebox/googlephotos.response new file mode 100644 index 0000000000..dd4898a521 --- /dev/null +++ b/spec/fixtures/onebox/googlephotos.response @@ -0,0 +1,800 @@ + +Mesmerizing Singapore - Google Photos

    Press question mark to see available shortcut keys

    Mesmerizing Singapore
    Sep 23–29
     · 
    Shared
    Arpit Jalan (Owner)
    Add photos
    Automatically add photos of people & pets
    Select photos
    Tip: Drag photos & videos anywhere to upload
    Google apps
    Main menu
    diff --git a/spec/fixtures/onebox/googleplayapp.response b/spec/fixtures/onebox/googleplayapp.response new file mode 100644 index 0000000000..8c21dbacc2 --- /dev/null +++ b/spec/fixtures/onebox/googleplayapp.response @@ -0,0 +1,2269 @@ +Hulu: Stream TV, Movies & more - Apps on Google Play

    Hulu: Stream TV, Movies & more

    Contains Ads

    Enjoy all your TV in one place with a new Hulu experience – more personalized and intuitive than ever before.

    The choice is yours - select a plan featuring Hulu’s entire streaming library or one that includes the entire library plus 50+ top Live and On Demand channels.

    Access Hulu’s huge streaming library featuring current and past seasons from many popular shows exclusively streaming on Hulu including Seinfeld, Fargo, South Park and Fear the Walking Dead; bold Hulu Originals you can’t stream anywhere else including The Handmaid’s Tale, Harlots, The Mindy Project, and Casual; along with current shows, hit movies, kid’s series and more from many top channels including FOX, NBC, Disney Channel, ABC, Cartoon Network, FX and A&E. Limited and commercial-free options are available for Hulu plans without Live TV.

    And now choose from an option to stream Hulu’s entire library, plus over 50 top Live and On Demand channels, including FOX, ABC, NBC, CBS, ESPN, FX, NBCSN, FS1, History Channel and TNT. Watch live sports from top pro and college leagues plus regional sports networks available in many areas. Plus, enjoy national news with local feeds available in select cities, popular kids shows and can’t-miss events.

    Features

    With any subscription, you’ll enjoy the following features that enhance how you watch TV:
    ‱ The more you watch, the better it gets. Enjoy a reimagined TV experience that adjusts to your tastes every time you use Hulu.
    ‱ Create up to 6 personalized profiles for the whole household. Enjoy your own collection of shows, movies, networks, and more.
    ‱ Track your favorites with My Stuff. Add shows, networks, and movies for quick access across your devices.
    ‱ Browse while you watch with Fliptray for recommendations of what to watch next.

    Hulu with Live TV (Beta) provides access to additional features including:
    ‱ Record Live TV with your Cloud DVR to watch your favorites anytime.
    ‱ Watch concurrent streams on multiple devices.
    ‱ Track and record games from your favorite teams with My Teams.

    Download the Hulu app now, and choose the Hulu with Live TV (Beta) plan which includes the entire Hulu streaming library plus over 50 Live and On Demand channels. Limited and No Commercials plans featuring Hulu’s streaming library without Live TV are also available – the choice is yours.

    If you’re new to Hulu, your base Hulu subscription fee will be $7.99/month for the Limited Commercials plan or $11.99/month for the No Commercials plan, or starting at $39.99/month for a Hulu with Live TV (Beta) plan as a recurring transaction starting the end of your free trial (unless you cancel during the free trial). Payment will automatically renew unless you cancel your account at least 24 hours before the end of the current subscription month. You can manage your subscription, cancel anytime, or turn off auto-renewal by accessing your Hulu account via Settings. Hulu is available to US customers only.

    Terms of Use: http://www.hulu.com/terms

    Privacy Policy: http://www.hulu.com/privacy

    This app features third party software, enabling third parties to calculate measurement statistics (e.g., Nielsen’s TV Ratings).

    We may work with mobile advertising companies to help deliver online and in-app advertisements tailored to your interests based on your activities on our website and apps and on other, unaffiliated website and apps. To learn more, visit www.aboutads.info. To opt-out of online interest-based advertising, visit www.aboutads.info/choices. To opt-out of cross-app advertising, download the App Choices app at www.aboutads.info/appchoices. Hulu is committed to complying with the DAA’s Self-Regulatory Principles for Online Behavioral Advertising and the DAA’s Application of Self-Regulatory Principles for the Mobile Environment.

    Hulu, LLC

    Web Site: https://www.hulu.com/

    Support: https://help.hulu.com/
    Read more
    4.0
    325,156 total
    5
    4
    3
    2
    1
    Loading...

    What's New

    Various performance improvements and fixes
    Read more

    Additional Information

    Updated
    October 1, 2018
    Size
    Varies with device
    Installs
    10,000,000+
    Current Version
    Varies with device
    Requires Android
    5.0 and up
    Content Rating
    Rated for 12+
    Parental Guidance Recommended
    Interactive Elements
    Shares Info
    Permissions
    Offered By
    Hulu
    ©2018 GoogleSite Terms of ServicePrivacyDevelopersArtistsAbout Google|Location: IndiaLanguage: EnglishAll prices include GST.
    By purchasing this item, you are transacting with Google Payments and agreeing to the Google Payments Terms of Service and Privacy Notice.
    diff --git a/spec/fixtures/onebox/image.response b/spec/fixtures/onebox/image.response new file mode 100644 index 0000000000..03fdde670c Binary files /dev/null and b/spec/fixtures/onebox/image.response differ diff --git a/spec/fixtures/onebox/imdb.response b/spec/fixtures/onebox/imdb.response new file mode 100644 index 0000000000..ac6bb7a302 --- /dev/null +++ b/spec/fixtures/onebox/imdb.response @@ -0,0 +1,2186 @@ +Rudy (1993) - IMDb
    + + + + +
    + + + +
    diff --git a/spec/fixtures/onebox/imgur.response b/spec/fixtures/onebox/imgur.response new file mode 100644 index 0000000000..58cc2ec9a9 --- /dev/null +++ b/spec/fixtures/onebox/imgur.response @@ -0,0 +1,836 @@ + + + + + + + + + + Did you <b>miss me</b>? - Album on Imgur + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    + + + + + + + + + + + New post + + + +
    + +
    +
    +
    +
    + + + + + + + + + + + +
    +
    + + + + +
    +
    + +
    + +
    +
    +
    + +

    Did you <b>miss me</b>?

    + +
    + +
    + + + + by + + + + 16h + + + + +
    +
    + +
    + + + + + + + + + + +
    + +
    + + +
    + +
    + + + +
    + + + + + + +
    + + + + + + +
    + +
    +
    + +
    + +
    + + +
    +
    + +
    + +
    + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    +
    +
    +
    TAKE ME UP
    +
    +
    +
    +
    +
    +
    + +
    + + +
    + + + +
    + +
    + +
    +
    +
    +
    +

    Embed Code

    + +
    + +
    +
    +
    +
    + +
    +
    +
    +

    Use old embed code

    +
    +
    +
    + Copy and paste the HTML below into your website: +
    + +
    +
    +
    +
    + +
    + +
    +
    + + +

    Preview

    +
    +
      +
    • + # +
    • +
    • + # +
    • +
    • + # +
    • +
    • + +
    • +
    +
    +

    Hide old embed code

    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/instagram.response b/spec/fixtures/onebox/instagram.response new file mode 100644 index 0000000000..f16dc1b5a7 --- /dev/null +++ b/spec/fixtures/onebox/instagram.response @@ -0,0 +1,12 @@ +{ + "version":"1.0", + "author_name":"natgeo", + "provider_name":"Instagram", + "provider_url":"https:\/\/www.instagram.com\/", + "type":"rich", + "width":658, + "html":"\u003Cblockquote class=\"instagram-media\" data-instgrm-captioned data-instgrm-permalink=\"https:\/\/www.instagram.com\/p\/CARbvuYDm3Q\/?utm_source=ig_embed&utm_campaign=loading\" data-instgrm-version=\"13\" style=\" background:#FFF; border:0; border-radius:3px; box-shadow:0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15); margin: 1px; max-width:658px; min-width:326px; padding:0; width:99.375\u0025; width:-webkit-calc(100\u0025 - 2px); width:calc(100\u0025 - 2px);\">\u003Cdiv style=\"padding:16px;\"> \u003Ca href=\"https:\/\/www.instagram.com\/p\/CARbvuYDm3Q\/?utm_source=ig_embed&utm_campaign=loading\" style=\" background:#FFFFFF; line-height:0; padding:0 0; text-align:center; text-decoration:none; width:100\u0025;\" target=\"_blank\"> \u003Cdiv style=\" display: flex; flex-direction: row; align-items: center;\"> \u003Cdiv style=\"background-color: #F4F4F4; border-radius: 50\u0025; flex-grow: 0; height: 40px; margin-right: 14px; width: 40px;\">\u003C\/div> \u003Cdiv style=\"display: flex; flex-direction: column; flex-grow: 1; justify-content: center;\"> \u003Cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; margin-bottom: 6px; width: 100px;\">\u003C\/div> \u003Cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; width: 60px;\">\u003C\/div>\u003C\/div>\u003C\/div>\u003Cdiv style=\"padding: 19\u0025 0;\">\u003C\/div> \u003Cdiv style=\"display:block; height:50px; margin:0 auto 12px; width:50px;\">\u003Csvg width=\"50px\" height=\"50px\" viewBox=\"0 0 60 60\" version=\"1.1\" xmlns=\"https:\/\/www.w3.org\/2000\/svg\" xmlns:xlink=\"https:\/\/www.w3.org\/1999\/xlink\">\u003Cg stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\">\u003Cg transform=\"translate(-511.000000, -20.000000)\" fill=\"#000000\">\u003Cg>\u003Cpath d=\"M556.869,30.41 C554.814,30.41 553.148,32.076 553.148,34.131 C553.148,36.186 554.814,37.852 556.869,37.852 C558.924,37.852 560.59,36.186 560.59,34.131 C560.59,32.076 558.924,30.41 556.869,30.41 M541,60.657 C535.114,60.657 530.342,55.887 530.342,50 C530.342,44.114 535.114,39.342 541,39.342 C546.887,39.342 551.658,44.114 551.658,50 C551.658,55.887 546.887,60.657 541,60.657 M541,33.886 C532.1,33.886 524.886,41.1 524.886,50 C524.886,58.899 532.1,66.113 541,66.113 C549.9,66.113 557.115,58.899 557.115,50 C557.115,41.1 549.9,33.886 541,33.886 M565.378,62.101 C565.244,65.022 564.756,66.606 564.346,67.663 C563.803,69.06 563.154,70.057 562.106,71.106 C561.058,72.155 560.06,72.803 558.662,73.347 C557.607,73.757 556.021,74.244 553.102,74.378 C549.944,74.521 548.997,74.552 541,74.552 C533.003,74.552 532.056,74.521 528.898,74.378 C525.979,74.244 524.393,73.757 523.338,73.347 C521.94,72.803 520.942,72.155 519.894,71.106 C518.846,70.057 518.197,69.06 517.654,67.663 C517.244,66.606 516.755,65.022 516.623,62.101 C516.479,58.943 516.448,57.996 516.448,50 C516.448,42.003 516.479,41.056 516.623,37.899 C516.755,34.978 517.244,33.391 517.654,32.338 C518.197,30.938 518.846,29.942 519.894,28.894 C520.942,27.846 521.94,27.196 523.338,26.654 C524.393,26.244 525.979,25.756 528.898,25.623 C532.057,25.479 533.004,25.448 541,25.448 C548.997,25.448 549.943,25.479 553.102,25.623 C556.021,25.756 557.607,26.244 558.662,26.654 C560.06,27.196 561.058,27.846 562.106,28.894 C563.154,29.942 563.803,30.938 564.346,32.338 C564.756,33.391 565.244,34.978 565.378,37.899 C565.522,41.056 565.552,42.003 565.552,50 C565.552,57.996 565.522,58.943 565.378,62.101 M570.82,37.631 C570.674,34.438 570.167,32.258 569.425,30.349 C568.659,28.377 567.633,26.702 565.965,25.035 C564.297,23.368 562.623,22.342 560.652,21.575 C558.743,20.834 556.562,20.326 553.369,20.18 C550.169,20.033 549.148,20 541,20 C532.853,20 531.831,20.033 528.631,20.18 C525.438,20.326 523.257,20.834 521.349,21.575 C519.376,22.342 517.703,23.368 516.035,25.035 C514.368,26.702 513.342,28.377 512.574,30.349 C511.834,32.258 511.326,34.438 511.181,37.631 C511.035,40.831 511,41.851 511,50 C511,58.147 511.035,59.17 511.181,62.369 C511.326,65.562 511.834,67.743 512.574,69.651 C513.342,71.625 514.368,73.296 516.035,74.965 C517.703,76.634 519.376,77.658 521.349,78.425 C523.257,79.167 525.438,79.673 528.631,79.82 C531.831,79.965 532.853,80.001 541,80.001 C549.148,80.001 550.169,79.965 553.369,79.82 C556.562,79.673 558.743,79.167 560.652,78.425 C562.623,77.658 564.297,76.634 565.965,74.965 C567.633,73.296 568.659,71.625 569.425,69.651 C570.167,67.743 570.674,65.562 570.82,62.369 C570.966,59.17 571,58.147 571,50 C571,41.851 570.966,40.831 570.82,37.631\">\u003C\/path>\u003C\/g>\u003C\/g>\u003C\/g>\u003C\/svg>\u003C\/div>\u003Cdiv style=\"padding-top: 8px;\"> \u003Cdiv style=\" color:#3897f0; font-family:Arial,sans-serif; font-size:14px; font-style:normal; font-weight:550; line-height:18px;\"> View this post on Instagram\u003C\/div>\u003C\/div>\u003Cdiv style=\"padding: 12.5\u0025 0;\">\u003C\/div> \u003Cdiv style=\"display: flex; flex-direction: row; margin-bottom: 14px; align-items: center;\">\u003Cdiv> \u003Cdiv style=\"background-color: #F4F4F4; border-radius: 50\u0025; height: 12.5px; width: 12.5px; transform: translateX(0px) translateY(7px);\">\u003C\/div> \u003Cdiv style=\"background-color: #F4F4F4; height: 12.5px; transform: rotate(-45deg) translateX(3px) translateY(1px); width: 12.5px; flex-grow: 0; margin-right: 14px; margin-left: 2px;\">\u003C\/div> \u003Cdiv style=\"background-color: #F4F4F4; border-radius: 50\u0025; height: 12.5px; width: 12.5px; transform: translateX(9px) translateY(-18px);\">\u003C\/div>\u003C\/div>\u003Cdiv style=\"margin-left: 8px;\"> \u003Cdiv style=\" background-color: #F4F4F4; border-radius: 50\u0025; flex-grow: 0; height: 20px; width: 20px;\">\u003C\/div> \u003Cdiv style=\" width: 0; height: 0; border-top: 2px solid transparent; border-left: 6px solid #f4f4f4; border-bottom: 2px solid transparent; transform: translateX(16px) translateY(-4px) rotate(30deg)\">\u003C\/div>\u003C\/div>\u003Cdiv style=\"margin-left: auto;\"> \u003Cdiv style=\" width: 0px; border-top: 8px solid #F4F4F4; border-right: 8px solid transparent; transform: translateY(16px);\">\u003C\/div> \u003Cdiv style=\" background-color: #F4F4F4; flex-grow: 0; height: 12px; width: 16px; transform: translateY(-4px);\">\u003C\/div> \u003Cdiv style=\" width: 0; height: 0; border-top: 8px solid #F4F4F4; border-left: 8px solid transparent; transform: translateY(-4px) translateX(8px);\">\u003C\/div>\u003C\/div>\u003C\/div> \u003Cdiv style=\"display: flex; flex-direction: column; flex-grow: 1; justify-content: center; margin-bottom: 24px;\"> \u003Cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; margin-bottom: 6px; width: 224px;\">\u003C\/div> \u003Cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; width: 144px;\">\u003C\/div>\u003C\/div>\u003C\/a>\u003Cp style=\" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; line-height:17px; margin-bottom:0; margin-top:8px; overflow:hidden; padding:8px 0 7px; text-align:center; text-overflow:ellipsis; white-space:nowrap;\">\u003Ca href=\"https:\/\/www.instagram.com\/p\/CARbvuYDm3Q\/?utm_source=ig_embed&utm_campaign=loading\" style=\" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; font-style:normal; font-weight:normal; line-height:17px; text-decoration:none;\" target=\"_blank\">A post shared by National Geographic (\u0040natgeo)\u003C\/a>\u003C\/p>\u003C\/div>\u003C\/blockquote>\n\u003Cscript async src=\"\/\/platform.instagram.com\/en_US\/embeds.js\">\u003C\/script>", + "thumbnail_url":"https:\/\/scontent.cdninstagram.com\/v\/t51.2885-15\/sh0.08\/e35\/s640x640\/97565241_163250548553285_9172168193050746487_n.jpg?_nc_ht=scontent.cdninstagram.com&_nc_cat=105&_nc_ohc=dnXCQ6urT_gAX9KlZ1l&_nc_tp=24&oh=b5fd90cdc61c5a8bba19b41e2f72040c&oe=5FDD8836", + "thumbnail_width":640, + "thumbnail_height":427 +} diff --git a/spec/fixtures/onebox/instagram_old_onebox.response b/spec/fixtures/onebox/instagram_old_onebox.response new file mode 100644 index 0000000000..b0c0a9a5c4 --- /dev/null +++ b/spec/fixtures/onebox/instagram_old_onebox.response @@ -0,0 +1,17 @@ + +{ +"version": "1.0", +"title": "Photo by Pete McBride @pedromcbride | For the first time in three decades, inhabitants of northern India are able to see the Himalaya\u2014thanks to reduced air pollution over the last few weeks. Considering that India experiences some of the worst pollution in the world, this is a literal breath of fresh air. When I was there, the air was so thick you could taste the smoke and fumes.\n\nThe coronavirus pandemic that has led to India's temporary reduction in pollutants has also put the country on the world's largest lockdown, and it's too soon to tell what impact that has had on curbing the disease\u2014as well as what the long-term effects will be on attitudes toward fresh air once the population returns to business as usual. For more on India and the environment, follow @pedromcbride. #india #himalaya #covid19 #pollution", +"author_name": "natgeo", +"author_url": "https://www.instagram.com/natgeo", +"author_id": 787132, "media_id": "2310750110684704208_787132", +"provider_name": "Instagram", +"provider_url": "https://www.instagram.com", +"type": "rich", +"width": 658, +"height": null, +"html": "\u003cblockquote class=\"instagram-media\" data-instgrm-captioned data-instgrm-permalink=\"https://www.instagram.com/p/CARbvuYDm3Q/?utm_source=ig_embed\u0026amp;utm_campaign=loading\" data-instgrm-version=\"13\" style=\" background:#FFF; border:0; border-radius:3px; box-shadow:0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15); margin: 1px; max-width:658px; min-width:326px; padding:0; width:99.375%; width:-webkit-calc(100% - 2px); width:calc(100% - 2px);\"\u003e\u003cdiv style=\"padding:16px;\"\u003e \u003ca href=\"https://www.instagram.com/p/CARbvuYDm3Q/?utm_source=ig_embed\u0026amp;utm_campaign=loading\" style=\" background:#FFFFFF; line-height:0; padding:0 0; text-align:center; text-decoration:none; width:100%;\" target=\"_blank\"\u003e \u003cdiv style=\" display: flex; flex-direction: row; align-items: center;\"\u003e \u003cdiv style=\"background-color: #F4F4F4; border-radius: 50%; flex-grow: 0; height: 40px; margin-right: 14px; width: 40px;\"\u003e\u003c/div\u003e \u003cdiv style=\"display: flex; flex-direction: column; flex-grow: 1; justify-content: center;\"\u003e \u003cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; margin-bottom: 6px; width: 100px;\"\u003e\u003c/div\u003e \u003cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; width: 60px;\"\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv style=\"padding: 19% 0;\"\u003e\u003c/div\u003e \u003cdiv style=\"display:block; height:50px; margin:0 auto 12px; width:50px;\"\u003e\u003csvg width=\"50px\" height=\"50px\" viewBox=\"0 0 60 60\" version=\"1.1\" xmlns=\"https://www.w3.org/2000/svg\" xmlns:xlink=\"https://www.w3.org/1999/xlink\"\u003e\u003cg stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\"\u003e\u003cg transform=\"translate(-511.000000, -20.000000)\" fill=\"#000000\"\u003e\u003cg\u003e\u003cpath d=\"M556.869,30.41 C554.814,30.41 553.148,32.076 553.148,34.131 C553.148,36.186 554.814,37.852 556.869,37.852 C558.924,37.852 560.59,36.186 560.59,34.131 C560.59,32.076 558.924,30.41 556.869,30.41 M541,60.657 C535.114,60.657 530.342,55.887 530.342,50 C530.342,44.114 535.114,39.342 541,39.342 C546.887,39.342 551.658,44.114 551.658,50 C551.658,55.887 546.887,60.657 541,60.657 M541,33.886 C532.1,33.886 524.886,41.1 524.886,50 C524.886,58.899 532.1,66.113 541,66.113 C549.9,66.113 557.115,58.899 557.115,50 C557.115,41.1 549.9,33.886 541,33.886 M565.378,62.101 C565.244,65.022 564.756,66.606 564.346,67.663 C563.803,69.06 563.154,70.057 562.106,71.106 C561.058,72.155 560.06,72.803 558.662,73.347 C557.607,73.757 556.021,74.244 553.102,74.378 C549.944,74.521 548.997,74.552 541,74.552 C533.003,74.552 532.056,74.521 528.898,74.378 C525.979,74.244 524.393,73.757 523.338,73.347 C521.94,72.803 520.942,72.155 519.894,71.106 C518.846,70.057 518.197,69.06 517.654,67.663 C517.244,66.606 516.755,65.022 516.623,62.101 C516.479,58.943 516.448,57.996 516.448,50 C516.448,42.003 516.479,41.056 516.623,37.899 C516.755,34.978 517.244,33.391 517.654,32.338 C518.197,30.938 518.846,29.942 519.894,28.894 C520.942,27.846 521.94,27.196 523.338,26.654 C524.393,26.244 525.979,25.756 528.898,25.623 C532.057,25.479 533.004,25.448 541,25.448 C548.997,25.448 549.943,25.479 553.102,25.623 C556.021,25.756 557.607,26.244 558.662,26.654 C560.06,27.196 561.058,27.846 562.106,28.894 C563.154,29.942 563.803,30.938 564.346,32.338 C564.756,33.391 565.244,34.978 565.378,37.899 C565.522,41.056 565.552,42.003 565.552,50 C565.552,57.996 565.522,58.943 565.378,62.101 M570.82,37.631 C570.674,34.438 570.167,32.258 569.425,30.349 C568.659,28.377 567.633,26.702 565.965,25.035 C564.297,23.368 562.623,22.342 560.652,21.575 C558.743,20.834 556.562,20.326 553.369,20.18 C550.169,20.033 549.148,20 541,20 C532.853,20 531.831,20.033 528.631,20.18 C525.438,20.326 523.257,20.834 521.349,21.575 C519.376,22.342 517.703,23.368 516.035,25.035 C514.368,26.702 513.342,28.377 512.574,30.349 C511.834,32.258 511.326,34.438 511.181,37.631 C511.035,40.831 511,41.851 511,50 C511,58.147 511.035,59.17 511.181,62.369 C511.326,65.562 511.834,67.743 512.574,69.651 C513.342,71.625 514.368,73.296 516.035,74.965 C517.703,76.634 519.376,77.658 521.349,78.425 C523.257,79.167 525.438,79.673 528.631,79.82 C531.831,79.965 532.853,80.001 541,80.001 C549.148,80.001 550.169,79.965 553.369,79.82 C556.562,79.673 558.743,79.167 560.652,78.425 C562.623,77.658 564.297,76.634 565.965,74.965 C567.633,73.296 568.659,71.625 569.425,69.651 C570.167,67.743 570.674,65.562 570.82,62.369 C570.966,59.17 571,58.147 571,50 C571,41.851 570.966,40.831 570.82,37.631\"\u003e\u003c/path\u003e\u003c/g\u003e\u003c/g\u003e\u003c/g\u003e\u003c/svg\u003e\u003c/div\u003e\u003cdiv style=\"padding-top: 8px;\"\u003e \u003cdiv style=\" color:#3897f0; font-family:Arial,sans-serif; font-size:14px; font-style:normal; font-weight:550; line-height:18px;\"\u003e View this post on Instagram\u003c/div\u003e\u003c/div\u003e\u003cdiv style=\"padding: 12.5% 0;\"\u003e\u003c/div\u003e \u003cdiv style=\"display: flex; flex-direction: row; margin-bottom: 14px; align-items: center;\"\u003e\u003cdiv\u003e \u003cdiv style=\"background-color: #F4F4F4; border-radius: 50%; height: 12.5px; width: 12.5px; transform: translateX(0px) translateY(7px);\"\u003e\u003c/div\u003e \u003cdiv style=\"background-color: #F4F4F4; height: 12.5px; transform: rotate(-45deg) translateX(3px) translateY(1px); width: 12.5px; flex-grow: 0; margin-right: 14px; margin-left: 2px;\"\u003e\u003c/div\u003e \u003cdiv style=\"background-color: #F4F4F4; border-radius: 50%; height: 12.5px; width: 12.5px; transform: translateX(9px) translateY(-18px);\"\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv style=\"margin-left: 8px;\"\u003e \u003cdiv style=\" background-color: #F4F4F4; border-radius: 50%; flex-grow: 0; height: 20px; width: 20px;\"\u003e\u003c/div\u003e \u003cdiv style=\" width: 0; height: 0; border-top: 2px solid transparent; border-left: 6px solid #f4f4f4; border-bottom: 2px solid transparent; transform: translateX(16px) translateY(-4px) rotate(30deg)\"\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv style=\"margin-left: auto;\"\u003e \u003cdiv style=\" width: 0px; border-top: 8px solid #F4F4F4; border-right: 8px solid transparent; transform: translateY(16px);\"\u003e\u003c/div\u003e \u003cdiv style=\" background-color: #F4F4F4; flex-grow: 0; height: 12px; width: 16px; transform: translateY(-4px);\"\u003e\u003c/div\u003e \u003cdiv style=\" width: 0; height: 0; border-top: 8px solid #F4F4F4; border-left: 8px solid transparent; transform: translateY(-4px) translateX(8px);\"\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e \u003cdiv style=\"display: flex; flex-direction: column; flex-grow: 1; justify-content: center; margin-bottom: 24px;\"\u003e \u003cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; margin-bottom: 6px; width: 224px;\"\u003e\u003c/div\u003e \u003cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; width: 144px;\"\u003e\u003c/div\u003e\u003c/div\u003e\u003c/a\u003e\u003cp style=\" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; line-height:17px; margin-bottom:0; margin-top:8px; overflow:hidden; padding:8px 0 7px; text-align:center; text-overflow:ellipsis; white-space:nowrap;\"\u003e\u003ca href=\"https://www.instagram.com/p/CARbvuYDm3Q/?utm_source=ig_embed\u0026amp;utm_campaign=loading\" style=\" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; font-style:normal; font-weight:normal; line-height:17px; text-decoration:none;\" target=\"_blank\"\u003eA post shared by National Geographic (@natgeo)\u003c/a\u003e\u003c/p\u003e\u003c/div\u003e\u003c/blockquote\u003e\n\u003cscript async src=\"//www.instagram.com/embed.js\"\u003e\u003c/script\u003e", +"thumbnail_url": "https://scontent-yyz1-1.cdninstagram.com/v/t51.2885-15/sh0.08/e35/s640x640/97565241_163250548553285_9172168193050746487_n.jpg?_nc_ht=scontent-yyz1-1.cdninstagram.com\u0026_nc_cat=105\u0026_nc_ohc=dnXCQ6urT_gAX99AO01\u0026_nc_tp=24\u0026oh=32b676a618164ab0248e2726767dae14\u0026oe=5FDD8836", +"thumbnail_width": 640, +"thumbnail_height": 427 +} diff --git a/spec/fixtures/onebox/kaltura.response b/spec/fixtures/onebox/kaltura.response new file mode 100644 index 0000000000..9195ad4cb2 --- /dev/null +++ b/spec/fixtures/onebox/kaltura.response @@ -0,0 +1,781 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Kaltura Overview - Kaltura Videos + + + + + + + + +
    +
    +
    +
    +
    +
    +
    + + + + + + +
    + + + + + +
    +
    + +
    + + + + +
    + + + + +
    + + + + +
    +
    +
    +
    +
    +
    +

    + Kaltura Overview

    +

    + + From Alon Finkelstein A year ago   + +

    +
    +
    + + + + + + likes + + + + + + + + views + + + + + + + comments + + + + + + + + + +
    +
    +
    +
    + + +
    +
    +
    + +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    + + +
    + +
    +
    + + + + + +
    +
    +
    + + + + +
    +
    + +
    +
    +
    + + + + + + +
    + + \ No newline at end of file diff --git a/spec/fixtures/onebox/meetup.response.html b/spec/fixtures/onebox/meetup.response.html new file mode 100644 index 0000000000..4e7099aed8 --- /dev/null +++ b/spec/fixtures/onebox/meetup.response.html @@ -0,0 +1,4419 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +February EmberTO Meet-up - +Toronto Ember.js Meetup (Toronto, ON) + + +| Meetup + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    addressalign-toparrow-leftarrow-rightbackbellblockcalendarcameraccwcheckchevron-downchevron-leftchevron-rightchevron-small-downchevron-small-leftchevron-small-rightchevron-small-upchevron-upcircle-with-checkcircle-with-crosscircle-with-pluscrossdots-three-verticaleditemptyheartexporteye-with-lineeyefacebookfolderfullheartglobegmailgooglegroupsimageimagesinstagramlinklocation-pinm-swarmSearchmailmessagesminusmoremuplabelShape 3 + Rectangle 1outlookpersonJoin Group on CardStartprice-ribbonImported LayersImported LayersImported Layersshieldstartickettrashtriangle-downtriangle-uptwitteruseryahoo
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + + + + +
    + + + + +
    + + +
    + + +
    + + + +
    +
    + + + + + + + + + + + + + +
    +
    +
    +
    + + + + + + + +
    +

    +February EmberTO Meet-up +

    +
    + + +
    + +
      +
    • + + + +Feb 5, 2015 · 6:30 PM + +
    • + +
    • +
      + + +

      This location is shown only to members

      + +
      +
    • + +
    + + +
    + +

    Hey Folks, 

    +

    We're trying new (read: bigger) venues on for size in 2015, starting with the wonderful new ExChange space in the BrightLane building for our February meet-up. 

    +

    This month we have our own Jorge Villalobos waxing on building faux-dynamic, SEO-friendly sites with Ember + Middleman, Precision Nutrition's Justin Giancola will be giving a lightning talk on using redis as a proxy when developing ember applications, and Taras Mankovski will be sharing some tips and tricks regarding Ember Table. 

    +

    Also, special thanks to Brightlane (an awesome new co-working facility) for donating their gorgeous new space to us for the evening as well! Check them out at http://brightlane.ca  

    +

    See you soon! And as another aside, sign up for the mailing list at http://torontoemberjs.com/to start getting the 411 of our meet-ups directly, including more in-depth insights into our speaker content and more info. on Toronto Ember happenings in 2015. 

    + +
    + +
    +
    +
    + + +
    +

    +Join or login to comment. +

    +
    + + + + + + + + + + + + + + + + +
      + + + + + + + + + + + + + + + + + + + + + + + + + +
    • Peter C.

      Sorry, can't make it to this meetup. I'll see you all at the next one!

      February 2, 2015

    • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    • Gianni C.

      hello i used ember once and i plan to do it again in the future

      8 · January 21, 2015

    • + + + + + + + + + + + + + + + + + + + +
    + + +
    +
    +
    + + + + + + +
    +
    + +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 55 went + + + + + + + + + + + + + + + + + + + + + +

    + + + + + + + + + + + + + + + + + + + + +
      + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Meghann O. + +
      + + + + + + + + + +
      Event Host
      + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Eric B. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Jesse B. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Aidan N. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Phil S. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Mike + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Andy T. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Tasveer S. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Misha P. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Emerson L. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Zeus G. + + +1 + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Mina S. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Kerry + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Andydrew + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Brian G. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + K M Rakibul I. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Carsten N. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Richard C. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Taras M. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Robin W. + +
      + + + + +
      + Co-Organizer +
      + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Natalie P. + + +1 + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Brennan M. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Justin G. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Tessa T. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Dan O. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Ian I. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Jorge V. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Mattia G. + +
      + + + + +
      + Organizer +
      + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Christophe­r M. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Joshua G. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Nate S. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Brenna O. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Gianni C. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Jaron A. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Joshua K. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + alen + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Adib S. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Irene + + +1 + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + + + + + + + + + + +
      +
      + +
      +
      + + + + +
      + Kenneth B. + +
      + + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + + + + + +
    • + + + + +
    • + + +
      +
      + +
      +
      + A former member + +
      +
      + + + + + + +
    • + + + + +
    • + + +
      +
      + +
      +
      + A former member + +
      +
      + + + + + + +
    • + + + + +
    • + + +
      +
      + +
      +
      + A former member + +
      +
      + + + + + + +
    • + + + + +
    • + + +
      +
      + +
      +
      + A former member + +
      +
      + + + + + + +
    • + + + + +
    • + + +
      +
      + +
      +
      + A former member + +
      +
      + + + + + + +
    • + + + + +
    • + + +
      +
      + +
      +
      + A former member + +
      +
      + + + + + + +
    • + + + + +
    • + + +
      +
      + +
      +
      + A former member + +
      +
      + + + + + + +
    • + + + + +
    • + + +
      +
      + +
      +
      + A former member + +
      +
      + + + + + + +
    • + + + + +
    • + + +
      +
      + +
      +
      + A former member + +
      +
      + + + + + + +
    • + + + + +
    • + + +
      +
      + +
      +
      + A former member + +
      +
      + + + + + + +
    • + + + + +
    • + + +
      +
      + +
      +
      + A former member + +
      + +2 + + + + guests + + +
      + +
      +
      + + + + + + +
    • + +
    + + + +
    + +
    + +
    +
    +
    + + + + + + + + + + + + + + +
    + + + + + + + +
    + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + +
    + + +

    + +Toronto, + +ON + + +

    +
    +Founded +Feb 12, 2014
    +
    + + +
    + +
    + + + +
    + +
    + + + + +

    + + + +Organizers: + + + +

    +
    +
    + + + +
    + + +Mattia Gheda, +Robin Ward and 2 more… + + +
    + + + + Contact + +
    +
    +
    +
    We're about:
    + +
    + + + + + + + + + + + + + + + +
    + + + + Open Source + · + + + + JavaScript + · + + + + Web Development + · + + + + JavaScript Libraries + · + + + + JavaScript Frameworks + · + + + + Front-end Development + · + + + + nodeJS + · + + + + Backbone.js + · + + + + Ember JS + · + + + + AngularJS + · + + + + JavaScript Applications + · + + + + Ember Data + + +
    + + +
    + +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + +

    People in this
    Meetup are also in:

    + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    + + + + + + + + + + + +
    + +

    Sign up

    + + + +

    Meetup members, Log in

    + +
    + +
    + +
    + + +
    + +
    +
    +or +
    +
    + +
    + + + + + + + + + + + +
    + +
    + +
    +
    + +
    +

    +By clicking "Sign up" or "Sign up using Facebook", you confirm that you accept our Terms of Service & Privacy Policy +

    +
    + + +
    + +
    +
    + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/meetup_oembed.response b/spec/fixtures/onebox/meetup_oembed.response new file mode 100644 index 0000000000..567890fea0 --- /dev/null +++ b/spec/fixtures/onebox/meetup_oembed.response @@ -0,0 +1 @@ +{"title":"February EmberTO Meet-up","height":397,"width":308,"html":" + + + + + + + + + + + + + + +
    + + + +
    + + + + + +
    + +
    +
    +
    +
    +
    +
    +

    ECMAScript 2015 : Deep Dive

    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +

    + David Leonard +

    +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +

    + What I do: +

    +
    +
    +
    +
    +

     

    +
      +
    • +

      + + Grad Student at CCNY + +

      +
    • +
    • +

      + + Game Developer + +

      +
    • +
    • +

      + + Yahoo! Developer Network + +

      +
    • +
    +
    +
    +
    +
    + +
    +
    +
    +

    Why ES6?

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    + Deep Dive +

    +
    +
    +
    +
    + +
    +
    +
    +
    +
      +
    • +

      Tooling 

      +
    • +
    • +

      Variables and Scoping

      +
    • +
    • +

      Strings

      +
    • +
    • +

      Destructuring

      +
    • +
    • +

      Parameter Handling

      +
    • +
    • +

      Arrow Functions

      +
    • +
    • +

      Classes

      +
    • +
    • +

      Modules

      +
    • +
    • +

      Generators

      +
    • +
    • +

      Promises

      +
    • +
    +
    +
    +
    +
    + +
    +
    +
    +

    Running ES6

    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +

    Variables and Scoping

    +
    +
    +

    + var vs. let / const +

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    var snack = 'Meow Mix';
    +
    +function getFood(food) {
    +    if (food) {
    +        var snack = 'Friskies';
    +        return snack;
    +    }
    +    return snack;
    +}
    +
    +getFood(false);
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    // undefined
    +
    +
    +
    +
    Credit: https://github.com/venegu
    +
    +
    +

    + var vs. let / const +

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    var snack = 'Meow Mix';
    +
    +function getFood(food) {
    +    var snack;
    +    
    +    if (food) {
    +        snack = 'Friskies';
    +        return snack;
    +    }
    +    return snack;
    +}
    +
    +getFood(false); 
    +
    +
    + + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    // undefined
    +
    +
    +

    + var vs. let / const +

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    let snack = 'Meow Mix';
    +
    +function getFood(food) {
    +
    +    if (food) {
    +        let snack = 'Friskies';
    +        return snack;
    +    }
    +    return snack;
    +}
    +
    +getFood(false); 
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    // A
    +
    +
    +
    +
    // B
    +
    +
    +
    +
    +
    // 'Meow Mix'
    +
    +
    +
    +
    Credit: https://github.com/venegu
    +
    +
    +

    + var vs. let / const +

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    let snack = 'Meow Mix';
    +
    +function getFood(food) {
    +
    +    if (food) {
    +        let snack = 'Friskies';
    +        return snack;
    +    }
    +    return snack;
    +}
    +
    +getFood(false); 
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    // A
    +
    +
    +
    +
    // B
    +
    +
    +
    +
    +
    // 'Meow Mix'
    +
    +
    +
    +
    Credit: https://github.com/venegu
    +
    +
    +

    IIFE   > Blocks

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    (function () {  
    +    var food = 'Meow Mix';
    +}());  
    +console.log(food);
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    // Reference Error
    +
    +
    +

    IIFE  > Blocks

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    {  
    +    let food = 'Meow Mix';
    +} 
    +console.log(food); 
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    // Reference Error
    +
    +
    +

    Scoping

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    +
    function Person(name) {
    +    this.name = name;
    +}
    +
    +Person.prototype.prefixName = function (arr) {
    +    return arr.map(function (character) {
    +        return this.name + character;
    +    });
    +};
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    // Cannot read property 'name' of undefined
    +
    +
    +
    +
    // A
    +
    +
    +
    +
    // B
    +
    +
    +
    + +
    +
    + + + + + + +
    +
    function Person(name) {
    +    this.name = name;
    +}
    +
    +Person.prototype.prefixName = function (arr) {
    +    var that = this;
    +    return arr.map(function (character) {
    +        return that.name + character;
    +    });
    +};
    +
    +
    +
    + +
    +
    +
    +
    +
    // Store this
    +
    +
    +
    +
    +

    Scoping

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    +
    function Person(name) {
    +    this.name = name;
    +}
    +
    +Person.prototype.prefixName = function (arr) {
    +    return arr.map(function (character) {
    +        return this.name + character;
    +    }, this);
    +}
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    Scoping

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    function Person(name) {
    +    this.name = name;
    +}
    +
    +Person.prototype.prefixName = function (arr) {
    +    return arr.map(function (character) {
    +        return this.name + character;
    +    }.bind(this));
    +}
    +
    +
    + +
    +
    +
    +
    +
    +

    Scoping

    +
    +
    +
    +

    Arrow Functions

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    + +
    +
    +
    +
    function Person(name) {
    +    this.name = name;
    +}
    +
    +Person.prototype.prefixName = function (arr) {
    +    return arr.map((character) => this.name + character );
    +}
    +
    +
    +
    +

    Arrow Functions

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    +
    const arr = [1, 2, 3, 4, 5];
    +const squares = arr.map(x => x * x);
    +
    +
    +
    const squares = arr.map(function (x) { return x * x });
    +
    +
    +
    + +
    +
    +
    +
    +
    // Function Expression
    +
    +
    +
    +
    // Terse 
    +
    +
    +

    Strings

    +
    +
    +
    +

    String.prototype.includes

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    var string = 'food';
    +var substring = 'foo';
    +console.log(string.indexOf(substring) > -1);
    +
    +
    + +
    +
    +
    const string = 'food';
    +const substring = 'foo';
    +console.log(string.includes(substring)); 
    +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    // true
    +
    +
    +
    // true
    +
    +
    +

    String.prototype.repeat

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    function repeat(string, count) {
    +    var strings = [];
    +    while(strings.length < count) {
    +        strings.push(string);
    +    }
    +    return strings.join('');
    +}
    +
    +
    +
    +
    +
    'meow'.repeat(3); 
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    // meowmeowmow
    +
    +
    +

    Template Literals: Escaping Characters

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    var text = "This string contains \"double quotes\" which are escaped."
    +
    +
    +
    +
    +
     
    + +
    let text = `This string contains "double quotes" which are escaped.`
    +
    + +

     

    +
    +
    +
    +
    + +
    +
    +
    +

    Template Literals: Interpolation

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    const name = 'Tiger';
    +const age = 13;
    +console.log(`My cat is named ${name} and is ${age} years old.`);
    +
    +
    +
    +
    var name = 'Tiger';
    +var age = 13;
    +console.log('My cat is named ' + name + ' and is ' + age + ' years old.');
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    Credit: https://github.com/venegu
    +
    +
    +

    Template Literals: Multi-line Strings

    +
    +
    +
    +
    + +
    +
    +
    +
    var text = (
    +  'cat\n' +
    +  'dog\n' +
    +  'nickelodeon'
    +)
    +
    +
    +
    var text = [
    +  'cat',
    +  'dog',
    +  'nickelodeon'
    +].join('\n')
    +
    +
    +
    var text = (
    +  `cat
    +  dog
    +  nickelodeon`
    +)
    +
    + + + +
    +
    + +
    +
    +
    +
    +
    +

    Template Literals: Expressions

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    let today = new Date()
    +let text = `The time and date is ${today.toLocaleString()}`
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +

    Template Literals: Multi-line Strings

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    +
    let book = {
    +  title: 'Harry Potter and The Sorcercers Stone',
    +  summary: 'Much magic. Such depth.',
    +  author: 'J.K. Rowling'
    +}
    +
    +let html = `<header>
    +  <h1>${book.title}</h1>
    +</header>
    +<section>
    +  <div>${book.summary}</div>
    +  <div>${book.author}</div>
    +</section>`
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +

    Destructuring

    +
    +
    +
    +

    Destructuring

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    +

    +
    var luke = { occupation: 'jedi', father: 'anakin' }
    +var {occupation, father} = luke;
    +console.log(occupation); // 'jedi'
    +console.log(father); // 'anakin'
    +
    +
    +
    var [a, b] = [10, 20]
    +console.log(a); // 10
    +
    + +
    console.log(b); // 20
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +

    Destructuring

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    +
    function getCoords () {
    +  return {
    +    x: 10,
    +    y: 22
    +  }
    +}
    +
    +var {x, y} = getCoords()
    +console.log(x); // 10
    +console.log(y); // 22
    +
    +
    +
    + +
    +
    +
    +
    +

    Modules

    +
    +
    +
    +

    Credit: https://www.flickr.com/photos/lucaohman/3473867313

    +
    +
    +

    Exporting in CommonJS

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    +
    module.exports = 1
    +module.exports = { foo: 'bar' }
    +module.exports = ['foo', 'bar']
    +module.exports = function bar () {}
    +
    +
    +
    export default 1
    +export default { foo: 'bar' }
    +export default ['foo', 'bar']
    +export default function bar () {}
    +
    +
    +
    + +
    +
    +
    +

    Named Exports

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    +
    module.exports.name = 'David';
    +module.exports.age = 25;
    +
    +
    +
    export var name = 'David';
    +export var age  = 25;​​
    +
    +
    +
    + +
    +
    +
    +

    Exporting in ES6

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    +
    // math/addition.js
    +function sumTwo(a, b) {
    +    return a + b;
    +}
    +
    +function sumThree(a, b) {
    +    return a + b + c;
    +}
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    export { sumTwo, sumThree };
    +
    +
    +

    Exporting in ES6

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    +
    export function sumTwo(a, b) {
    +    return a + b;
    +}
    +
    +export function sumThree(a, b) {
    +    return a + b + c;
    +}
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    Exporting default bindings

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    +
    function sumTwo(a, b) {
    +    return a + b;
    +}
    +
    +function sumThree(a, b) {
    +    return a + b + c;
    +}
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    var api = {
    +    sumTwo  : sumTwo,
    +    sumThree: sumThree
    +}
    +
    +
    +
    export default api
    +
    +
    +

    Importing Modules

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    +
    var _ = require('underscore');​
    +
    +
    +
    import _ from 'underscore';
    +
    +
    +
    import { sumTwo, sumThree } from 'math/addition'
    +
    +
    +
    import { 
    +  sumTwo as addTwoNumbers, 
    +  sumThree as sumThreeNumbers} from
    +} from 'math/addition'
    +
    +
    +
    import * as util from 'math/addition'
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +

    Parameters

    +
    +
    +
    +

    Default Parameters

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    +
    function addTwoNumbers(x, y) {
    +    x = x || 0;
    +    y = y || 0;
    +    return x + y;
    +}
    +
    +
    +
    function addTwoNumbers(x=0, y=0) {
    +    return x + y;
    +}
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    addTwoNumbers(2, 4); // 6
    +addTwoNumbers(2); // 2
    +addTwoNumbers(); // 0
    +
    +
    +

    Rest Parameters

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    +
    function logArguments() {
    +    for (var i=0; i < arguments.length; i++) {
    +        console.log(arguments[i]);
    +    }
    +}
    +
    +
    +
    function logArguments(...args) {
    +    for (let arg of args) {
    +        console.log(arg);
    +    }
    +}
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    Named Parameters

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    +
    function initializeCanvas(options) {
    +    var height = options.height || 600;
    +    var width  = options.width  || 400;
    +    var lineStroke = options.lineStroke || 'black';
    +}
    +
    +
    +
    function initializeCanvas(
    +    { height=600, width=400, lineStroke='black'}) {
    +        ...
    +    }
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    function initializeCanvas(
    +    { height=600, width=400, lineStroke='black'} = {}) {
    +        ...
    +    }
    +
    +
    +
    +

    Spread Operator

    +
    +
    +
    +
    + +
    +
    + + + + + + +
    +
    Math.max(...[-1, 100, 9001, -32]) // 9001
    +
    + +
    +
    var arr = [1, ...[2,3], 4];
    +console.log(arr); // [1, 2, 3, 4]
    +
    +
    +
    + +
    +
    +
    +
    +
    var arr1 = [0, 1, 2];
    +var arr2 = [3, 4, 5];
    +arr1.push(...arr2);
    +
    +
    +

    Classes

    +
    +
    +
    +

    Base Classes

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    function Person(name, age, gender) {
    +    this.name   = name;
    +    this.age    = age;
    +    this.gender = gender;
    +}
    +
    +Person.prototype.incrementAge = function () {
    +    return this.age += 1;
    +};
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    Extended Classes

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    function Personal(name, age, gender, occupation, hobby) {
    +    Person.call(this, name, age, gender);
    +    this.occupation = occupation;
    +    this.hobby = hobby;
    +}
    +
    +Personal.prototype = Object.create(Person.prototype);
    +Personal.prototype.constructor = Personal;
    +Personal.prototype.incrementAge = function () {
    +    return Person.prototype.incrementAge.call(this) += 1;
    +}
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Base Classes in ES6

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    class Person {
    +    constructor(name, age, gender) {
    +        this.name   = name;
    +        this.age    = age;
    +        this.gender = gender;
    +    }
    +    
    +    incrementAge() {
    +      this.age += 1;
    +    }
    +}
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    Extended Classes in ES6

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    class Personal extends Person {
    +    constructor(name, age, gender, occupation, hobby) {
    +      super(name, age, gender);
    +      this.occupation = occupation;
    +      this.hobby = hobby;
    +    }
    +    
    +    incrementAge() {
    +      super.incrementAge();
    +      this.age += 20;
    +      console.log(this.age);
    +    }
    +}
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    // Calls parent incrementAge()
    +
    +
    +

    Symbols

    +
    +
    +
    +

    Unique Property Keys

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    const key = Symbol();
    +const keyTwo = Symbol();
    +const object = {};
    +
    +
    +
    +
    + +
    +
    +
    +
    >> key === keyTwo 
    +>> false
    +
    +
    +
    object.key = 'Such magic.';
    +object.keyTwo = 'Much Uniqueness'
    +
    +
    +

    Symbols as Concepts

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    const anakin = 'jedi';
    +const yoda   = 'jedi master';
    +const luke   = 'jedi';
    +
    +
    +
    +
    + +
    +
    +
    +
    const anakin = Symbol();
    +const yoda   = Symbol();
    +const luke   = Symbol();
    +
    +
    +

    Maps

    +
    +
    +
    +

    (Hash) Maps in ES5

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    + +
    +
    +
    +
    var map = new Object();
    +map[key1] = 'value1';
    +map[key2] = 'value2';
    +
    +

    Seems functional, right...?

    +
    +
    +

    Get Own Properties

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    + +
    +
    +
    +
    function getOwnProperty(object, propertyKey) {
    +    return (object.hasOwnProperty(propertyKey) ? object[propertyKey]: undefined);
    +}
    +
    +
    +
    +

    We should be safe...right?

    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    + +
    +
    +
    +
    > getOwnProperty({ hasOwnProperty: 'Hah, overwritten'}, 'Pwned');
    +> TypeError: Propery 'hasOwnProperty' is not a function
    +
    +
    +
    +
    +
    +

    Credit: http://memesvault.com/nooo-meme-darth-vader/

    +
    +

    Second time is the charm.

    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    + +
    +
    +
    +
    function getOwnProperty(object, propertyKey) {
    +    return (Object.prototype.hasOwnProperty(object, propertyKey) ? object[propertyKey]: undefined);
    +}
    +
    +
    +
    +

    credit: http://deloiz.blogspot.com/2014/01/Pusheen.html

    +
    +
    +

    Maps in ES6

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    + +
    +
    +
    +
    let map = new Map();
    +> map.set('name', 'david');
    +> map.get('name'); // david
    +> map.has('name'); // true
    +
    +
    +
    +
    +
    +
    +
    +
    // key
    +
    +
    +
    +
    // value
    +
    +
    +
    +

    Keys can be more than strings!

    +
    +
    +

    Arbitrary values as keys

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    + +
    +
    +
    +
    let map = new Map([
    +    ['name', 'david'],
    +    [true, 'false'],
    +    [1, 'one'],
    +    [{}, 'object'],
    +    [function () {}, 'function']
    +]);
    +
    +
    +
    +
    +
    +
    +
    +
    +
    for (let key of map.keys()) {
    +    console.log(typeof key);
    +    // > string, boolean, number, object, function
    +};
    +
    +
    +

    .entries( )

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    + +
    +
    +
    +
    for (let entry of map.entries()) {
    +  console.log(entry[0], entry[1]);
    +}
    +
    +
    +
    for (let [key, value] of map.entries()) {
    +  console.log(key, value);
    +}
    +
    +
    +
    +
    +

    WeakMaps

    +
    +
    +
    +

    Classes 101

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    + +
    +
    +
    +
    class Person {
    +    constructor(age) {
    +        this.age = age;
    +    }
    +    
    +    incrementAge() {
    +      this.age += 1;
    +    }
    +}
    +
    +

    Private data?

    +
    +
    +

    Naming Conventions

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    + +
    +
    +
    +
    class Person {
    +    constructor(age) {
    +        this._age = age;
    +    }
    +    
    +    _incrementAge() {
    +      this._age += 1;
    +    }
    +}
    +
    +
    +
    +
    +

    WeakMaps to the rescue!

    +
    +
    +

    (Maybe they're not so weak)

    +
    +
    +

    WeakMaps for Privacy

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    + +
    +
    +
    +
    let _age = new WeakMap();
    +class Person { 
    +  constructor(age) {
    +    _age.set(this, age);
    +  }
    +
    +  incrementAge() {
    +    let age = _age.get(this);
    +      if(age > 90) {
    +        console.log('Midlife crisis');
    +      }
    +  }
    +}
    +
    +
    +
    > const person = new Person(90);
    +> person.incrementAge(); // 'Midlife crisis'
    +> Reflect.ownKeys(person); // []
    +
    + +

     

    +
    +
    +
    +
    +
    +
    +
    credit: http://wildermuth.com/images/pinky-promise_2.jpg
    +
    +
    +

    Promises

    +
    +
    +

    Callback Hell

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    func1(function (value1) {
    +  func2(value1, function(value2) {
    +    func3(value2, function(value3) {
    +      func4(value3, function(value4) {
    +        func5(value4, function(value5) {
    +          // Do something with value 5
    +        });
    +      });
    +    });
    +  });
    +});
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +

    D

    + +

    O

    + +

    O

    + +

    M

    +
    +
    +

    Promises

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    func1(value1)
    +  .then(func2(value1) { })
    +  .then(func3(value2) { })
    +  .then(func4(value3) { })
    +  .then(func5(value4) { 
    +    // Do something with value 5 
    +  });
    +
    +
    +
    + +
    +
    +
    +
    +

    Promises

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    +
    +
    + +
    +
    +
    +

    Promises

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    new Promise(resolve => resolve(data))
    +    .then(result => console.log(data));
    +
    +
    +
    +
    +new Promise((resolve, reject) => 
    +    reject(new Error('Failed to fufill Promise')))
    +    .catch(reason => console.log(reason));
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +

    Promises

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    + +
    +
    +
    +
    +

    Promises

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    var fetchJSON = function(url) {  
    +  return new Promise((resolve, reject) => {
    +    $.getJSON(url)
    +      .done((json) => resolve(json))
    +      .fail((xhr, status, err) => reject(status + err.message));
    +  });
    +}
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Parallelizing using Promises

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    var urls = [ 
    +  'http://www.api.com/items/1234',
    +  'http://www.api.com/items/4567'
    +];
    +
    +var urlPromises = urls.map(fetchJSON);
    +
    +Promise.all(urlPromises)  
    +  .then(function(results) {
    +     results.forEach(function(data) {
    +     });
    +  })
    +  .catch(function(err) {
    +    console.log("Failed: ", err);
    +  });
    +
    +
    +
    +
    +
    +
    +

    Generators

    +
    +
    +

    Syntax

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    function* sillyGenerator() {
    +    yield 1;
    +    yield 2;
    +    yield 3;
    +    yield 4;
    +}
    +
    +
    +
    var generator = sillyGenerator();
    +var value = generator.next();
    +> console.log(value); // { value: 1, done: false }
    +> console.log(value); // { value: 2, done: false }
    +> console.log(value); // { value: 3, done: false }
    +> console.log(value); // { value: 4, done: false }
    +
    +
    +
    +
    +
    +

    What about using return?

    +
    +
    +

    Return in a Generator

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    function* sillyGenerator() {
    +    yield 1;
    +    yield 2;
    +    yield 3;
    +    yield 4;
    +    return 5;
    +}
    +
    +for(let val of sillyGenerator()) {
    +    console.log(val); // 1, 2, 3, 4
    +} 
    +
    +
    +
    +
    +

    Real Generator Function

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    function* factorial(){
    +  let [current, total] = [0, 1];
    +
    +  while (true){
    +    yield total;
    +    current++;
    +    total = total * current;
    +  }
    +}
    +
    +for (let n of factorial()) {
    +  console.log(n); 
    +  if(n >= 100000) {
    +    break;
    +  }
    +}
    +
    +
    +
    +
    +
    +

    Writing Sync-Async 

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    function request(url) {
    +    getJSON(url, function(response) {
    +        generator.next(response);
    +    });
    +}
    +
    +function* getData() {
    +    var entry1 = yield request('http://some_api/item1');
    +    var data1  = JSON.parse(entry1);
    +    var entry2 = yield request('http://some_api/item2');
    +    var data2  = JSON.parse(entry2);
    +}
    +
    +
    +
    +
    +
    +

    Not without problems though...

    +
    +
    +

     

    + +
      +
    • +

      How do we handle errors?

      +
    • +
    • +

      getJSON not in control

      +
    • +
    • +

      Parallelize?

      +
    • +
    +
    +
    +
    +
    + +
    +
    +
    +

    Generators & Promises

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    function request(url) {
    +    return new Promise((resolve, reject) => {
    +        getJSON(url, resolve);
    +    });
    +}
    +
    +
    +
    +

    Generators & Promises

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    function iterateGenerator(gen) {
    +    var generator = gen();
    +    var ret;
    +    (function iterate(val) {
    +        ret = generator.next();
    +        if(!ret.done) {
    +            ret.value.then(iterate);
    +        } else {
    +            setTimeout(function() {
    +                iterate(ret.value);
    +            });
    +        }
    +    })(); 
    +}
    +
    +
    +
    +
    +
    +
    +

    Generators & Promises

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    iterateGenerator(function* getData() {
    +  var entry1 = yield request('http://some_api/item1');
    +  var data1  = JSON.parse(entry1);
    +  var entry2 = yield request('http://some_api/item2');
    +  var data2  = JSON.parse(entry2);
    +});
    +
    +

    Alternate Solution?

    +
    +
    +

    Beyond ES6

    +
    +
    +

    Async / Await (ES7)

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    var request = require('request');
    + 
    +function getJSON(url) {
    +
    +  request(url, function(error, response, body) {
    +    return body;
    +  });
    +}
    + 
    +function main() {
    +  var data = getJSON('http://some_api/item1');
    +  console.log(data); // Undefined
    +}
    + 
    +main();
    +
    +
    +
    +
    +
    +
    +
    +

    Async / Await (ES7)

    +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    +
    var request = require('request');
    + 
    +function getJSON(url) {
    +  return new Promise(function(resolve, reject) {
    +    request(url, function(error, response, body) {
    +      resolve(body);
    +    });
    +  });
    +}
    + 
    +async function main() {
    +  var data = await getJSON();
    +  console.log(data); // NOT undefined!
    +}
    + 
    +main();
    +console.log('The data is: ');
    +
    +
    +
    +
    +
    +
    +

    Thank you everyone!

    +
    + + + + +
    +
    +
    +
    +
    + +
    + +
    + +
    +
    +
    +

    ECMAScript 2015

    +

    By David Leonard

    +
    +
    + +
    +
    +
    +

    +

    +
    +
    + +
    + +
    +
    + + + + +
    + + + +
    + +
    +
    +

    ECMAScript 2015

    +

    An overview of ES6 features.

    +
      +
    • + + +
    • +
    • + + +
    • +
    • + + 3,451 +
    • +
    +
    + + + +
    +
    +
    + +
    +
    Loading comments...
    +
    + +
    +

    More from David Leonard

    + +
    + +
    + +
    + + +
    + + + + + + + + + + diff --git a/spec/fixtures/onebox/stackexchange-answer.response b/spec/fixtures/onebox/stackexchange-answer.response new file mode 100644 index 0000000000..b3426fd2de --- /dev/null +++ b/spec/fixtures/onebox/stackexchange-answer.response @@ -0,0 +1 @@ +{"items":[{"tags":["c","deobfuscation"],"owner":{"profile_image":"https://www.gravatar.com/avatar/4af3541c00d591e9a518b9c0b3b1190a?s=128&d=identicon&r=PG","display_name":"dasblinkenlight","link":"http://stackoverflow.com/users/335858/dasblinkenlight"},"last_activity_date":1461433376,"creation_date":1375356813,"answer_id":17992906,"link":"http://stackoverflow.com/questions/17992553/concept-behind-these-four-lines-of-tricky-c-code/17992906#17992906","title":"Concept behind these four lines of tricky C code"}],"has_more":false,"quota_max":300,"quota_remaining":291} \ No newline at end of file diff --git a/spec/fixtures/onebox/stackexchange-question.response b/spec/fixtures/onebox/stackexchange-question.response new file mode 100644 index 0000000000..52bcff8b24 --- /dev/null +++ b/spec/fixtures/onebox/stackexchange-question.response @@ -0,0 +1 @@ +{"items":[{"tags":["c","deobfuscation"],"owner":{"profile_image":"https://www.gravatar.com/avatar/a19d396231d67d604c92866b90fe723d?s=128&d=identicon&r=PG","display_name":"codeslayer1","link":"http://stackoverflow.com/users/2547190/codeslayer1"},"last_activity_date":1461433376,"creation_date":1375355768,"question_id":17992553,"link":"http://stackoverflow.com/questions/17992553/concept-behind-these-four-lines-of-tricky-c-code","title":"Concept behind these four lines of tricky C code"}],"has_more":false,"quota_max":300,"quota_remaining":292} \ No newline at end of file diff --git a/spec/fixtures/onebox/tenor.response b/spec/fixtures/onebox/tenor.response new file mode 100644 index 0000000000..66312c6cbe --- /dev/null +++ b/spec/fixtures/onebox/tenor.response @@ -0,0 +1,3 @@ + +Dance Happy GIF - Dance Happy Snoopy - Discover & Share GIFs

    Dance Happy GIF

    Dance Happy GIF - Dance Happy Snoopy GIFs
    CAPTION

    Share URL



    Embed

    Details

    File Size: 1667KB
    Duration: 1.800 sec
    Dimensions: 498x476
    Created: 10/6/2019, 7:27:50 PM
    \ No newline at end of file diff --git a/spec/fixtures/onebox/twitterstatus.response b/spec/fixtures/onebox/twitterstatus.response new file mode 100644 index 0000000000..8bb442d5d8 --- /dev/null +++ b/spec/fixtures/onebox/twitterstatus.response @@ -0,0 +1,2814 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Vyki Englert on Twitter: "I'm a sucker for pledges. @Peers Pledge #sharingeconomy http://t.co/T4Sc47KAzh" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + +
    +
    + +
      +
    • +

      Add a location to your Tweets

      +

      + When you tweet with a location, Twitter stores that location. + You can switch location on/off before each Tweet and always have the option to delete your location history. + Learn more +

      +
      + + +
      +
    • +
    +
    + +
    + +
    +
    + +
    +
    + + +
    +
    +
      +
      +
      + +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      + + + diff --git a/spec/fixtures/onebox/twitterstatus_quoted.response b/spec/fixtures/onebox/twitterstatus_quoted.response new file mode 100644 index 0000000000..e1c12efe70 --- /dev/null +++ b/spec/fixtures/onebox/twitterstatus_quoted.response @@ -0,0 +1,7199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Metallica en Twitter: "Thank you to everyone who came out for #MetInParis last night for helping us support @EMMAUSolidarite & @PompiersParis. #AWMH #MetalicaGivesBack
 https://t.co/00ZbffUluP" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Saltar al contenido + + + + + + + + + + + + + + + + + + + + + + +
      + + + + + + + + + + + + + + +
      +
      + +
        +
      • +

        Twittear con la ubicaciĂłn

        +

        + Puedes agregar la información de ubicación a tus Tweets, como tu ciudad o tu ubicación exacta, desde la web y a través de aplicaciones de terceros. Siempre tendrås la opción de eliminar el historial de ubicaciones de tus Tweets. + Mås información +

        +
        + + +
        +
      • +
      +
      + +
      + +
      +
      + +
      +
      + + +
      +
      +
        +
        +
        + +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/video.response b/spec/fixtures/onebox/video.response new file mode 100644 index 0000000000..abe11ec5c4 Binary files /dev/null and b/spec/fixtures/onebox/video.response differ diff --git a/spec/fixtures/onebox/wikimedia.response b/spec/fixtures/onebox/wikimedia.response new file mode 100644 index 0000000000..3481ce93a4 --- /dev/null +++ b/spec/fixtures/onebox/wikimedia.response @@ -0,0 +1 @@ +{"batchcomplete":"","query":{"normalized":[{"from":"File:Stones_members_montage2.jpg","to":"File:Stones members montage2.jpg"}],"pages":{"-1":{"ns":6,"title":"File:Stones members montage2.jpg","missing":"","known":"","imagerepository":"shared","imageinfo":[{"timestamp":"2010-12-07T23:13:30Z","user":"84user","thumburl":"https://upload.wikimedia.org/wikipedia/commons/thumb/a/af/Stones_members_montage2.jpg/500px-Stones_members_montage2.jpg","thumbwidth":500,"thumbheight":459,"url":"https://upload.wikimedia.org/wikipedia/commons/a/af/Stones_members_montage2.jpg","descriptionurl":"https://commons.wikimedia.org/wiki/File:Stones_members_montage2.jpg","descriptionshorturl":"https://commons.wikimedia.org/w/index.php?curid=12245228"}]}}}} \ No newline at end of file diff --git a/spec/fixtures/onebox/wikipedia.response b/spec/fixtures/onebox/wikipedia.response new file mode 100644 index 0000000000..3eb69cadd5 --- /dev/null +++ b/spec/fixtures/onebox/wikipedia.response @@ -0,0 +1,566 @@ + + + + +Billy Jack - Wikipedia, the free encyclopedia + + + + + + + + + + + + + + + + + + + + + + +
        +
        +
        + + +
        +

        Billy Jack

        +
        +
        From Wikipedia, the free encyclopedia
        +
        +
        + Jump to: navigation, search +
        +
        +
        This article is about the 1971 film. For the wrestler of a similar name, see Billy Jack Haynes.
        +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        Billy Jack
        Billy Jack poster.jpg
        +
        Theatrical release poster.
        +
        Directed byTom Laughlin
        +as T.C. Frank
        Produced byTom Laughlin
        +as Mary Rose Solti
        Written byTom Laughlin
        +(as Frank Christina)
        +Delores Taylor
        +(as Theresa Christina)
        StarringTom Laughlin
        +Delores Taylor
        Music byMundell Lowe, Dennis Lambert, Brian Potter
        CinematographyFred Koenekamp
        +John M. Stephens
        Editing byLarry Heath
        +Marion Rothman
        StudioNational Student Film Corporation
        Distributed byWarner Bros.
        Release datesMay 1, 1971
        Running time114 min.
        CountryUnited States
        LanguageEnglish
        Budget$800,000
        Box office$32,500,000[1]
        +

        Billy Jack is a 1971 action/drama independent film; the second of four films centering on a character of the same name which began with the movie The Born Losers (1967), played by Tom Laughlin, who directed and co-wrote the script. Filming began in Prescott, Arizona, in the fall of 1969, but the movie was not completed until 1971. American International Pictures pulled out, halting filming. 20th Century-Fox came forward and filming eventually resumed but when that studio refused to distribute the film, Warner Bros. stepped forward.

        +

        Still, the film lacked distribution, so Laughlin booked it in to theaters himself in 1971.[1] The film died at the box office in its initial run, but eventually took in more than $40 million in its 1973 re-release, with distribution supervised by Laughlin.

        +

        + +

        +

        Plot[edit]

        +

        Billy Jack is a "half-breed" American Navajo Indian[citation needed], a Green Beret Vietnam War veteran, and a hapkido master. The character made his début in The Born Losers (1967), a "biker film" about a motorcycle gang terrorizing a California town. Billy Jack rises to the occasion to defeat the gang when defending a college student with evidence against them for gang rape.

        +

        In the second film, Billy Jack, the hero defends the hippie-themed Freedom School and students from townspeople who do not understand or like the counterculture students. The school is organized by Jean Roberts (Delores Taylor).

        +

        In one scene, a group of Indian children from the school go to town for ice cream and are refused service and then abused and humiliated by Bernard Posner and his gang. This prompts a violent outburst by Billy. Later, Billy's girlfriend Jean is raped and an Indian student is murdered by Bernard (David Roya), the son of the county's corrupt political boss (Bert Freed). Billy confronts Bernard and sustains a gunshot wound before killing him with a hand strike to the throat, after Bernard was having sex with a 13-year-old girl. After a climactic shootout with the police, and pleading from Jean, Billy Jack surrenders to the authorities and is arrested. As he is driven away, a large crowd of supporters raise their fists as a show of defiance and support. The plot continues in the sequel, The Trial of Billy Jack.

        +

        Box-office and critical reception[edit]

        +

        The film was re-released in 1973 and earned an estimated $8,275,000 in North American rentals.[2]

        +

        Billy Jack holds a "Fresh" rating of 62% at Rotten Tomatoes.[3] As of February 2014 it has a score of 6.1 on IMDB.

        +

        In his Movie and Video Guide, film critic Leonard Maltin writes: "Seen today, its politics are highly questionable, and its 'message' of peace looks ridiculous, considering the amount of violence in the film."

        +

        Roger Ebert also saw the message of the film as self-contradictory, writing: "I'm also somewhat disturbed by the central theme of the movie. 'Billy Jack' seems to be saying the same thing as 'Born Losers,' that a gun is better than a constitution in the enforcement of justice."[4]

        +

        Delores Taylor received a Golden Globe nomination as Most Promising Newcoming Actress. Tom Laughlin won the grand prize for the film at the 1971 Taormina International Film Festival in Italy.

        +

        Soundtrack[edit]

        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        Billy Jack
        Soundtrack album by Mundell Lowe
        Released1972
        Recorded1971
        GenreFilm score
        LabelWarner Bros.
        +WS 1926
        ProducerMundell Lowe
        Mundell Lowe chronology
        + + + + + + +
        Satan in High Heels
        +(1961)
        Billy Jack
        +(1971)
        California Guitar
        +(1974)
        +
        +

        The film score was composed, arranged and conducted by Mundell Lowe and the soundtrack album was originally released on the Warner Bros. label.[5]

        +

        Reception[edit]

        +

        The Allmusic review states "a strange and striking combination of styles that somehow is effective... a listenable disc whose flaws only add to the warmth".[6] The film's theme song, "One Tin Soldier (The Legend of Billy Jack)" by the band Coven, became a Top 40 hit in 1971, and featured the chorus:

        +
        +

        Go ahead and hate your neighbor; go ahead and cheat a friend.
        +Do it in the name of heaven; you can justify it in the end.
        +There won't be any trumpets blowin' come the judgment day
        +On the bloody morning after, one tin soldier rides away

        +
        + + + + + + + + + + + + + + + +
        Professional ratings
        Review scores
        SourceRating
        Allmusic3.5/5 stars[6]
        +

        Track listing[edit]

        +

        All compositions by Mundell Lowe, except as indicated.

        +
          +
        1. "One Tin Soldier" (Dennis Lambert, Brian Potter) â€“ 3:18
        2. +
        3. "Hello Billy Jack" â€“ 0:45
        4. +
        5. "Old and the New" â€“ 1:00
        6. +
        7. "Johnnie" (Teresa Kelly) â€“ 2:35
        8. +
        9. "Look, Look to the Mountain" (Kelly) â€“ 1:40
        10. +
        11. "When Will Billy Love Me" (Lynn Baker) â€“ 3:24
        12. +
        13. "Freedom Over Me" (Gwen Smith) â€“ 0:35
        14. +
        15. "All Forked Tongue Talk Alike" â€“ 2:54
        16. +
        17. "Challenge" â€“ 2:20
        18. +
        19. "Rainbow Made of Children" (Baker) â€“ 3:50
        20. +
        21. "Most Beautiful Day" â€“ 0:30
        22. +
        23. "An Indian Dance" â€“ 1:15
        24. +
        25. "Ceremonial Dance" â€“ 1:59
        26. +
        27. "Flick of the Wrist" â€“ 2:15
        28. +
        29. "It's All She Left Me" â€“ 1:56
        30. +
        31. "You Shouldn't Do That" â€“ 3:21
        32. +
        33. "Ring Song" (Katy Moffatt) â€“ 4:25
        34. +
        35. "Thy Loving Hand" â€“ 1:35
        36. +
        37. "Say Goodbye 'Cause You're Leavin'" â€“ 2:36
        38. +
        39. "The Theme from Billy Jack" â€“ 2:21
        40. +
        41. "One Tin Soldier (End Title)" (Lambert, Potter) â€“ 1:06
        42. +
        +

        Personnel[edit]

        +
          +
        • Mundell Lowe: arranger, conductor
        • +
        • Coven featuring Jinx Dawson (tracks 1 & 21), Teresa Kelly (tracks 4 & 5), Lynn Baker (tracks 6 & 10), Gwen Smith (track 7), Katy Moffatt (track 17): vocals
        • +
        • Other unidentified musicians
        • +
        +

        Influence[edit]

        +

        Marketed as an action film, the story focuses on the plight of Native Americans during the civil rights movement. It attained a cult following among younger audiences due to its youth-oriented, anti-authority message and the then-novel martial arts fight scenes which predate the Bruce Lee/kung fu movie trend that followed.[7] The centerpiece of the film features Billy Jack, enraged over the mistreatment of his Indian friends, fighting racist thugs using hapkido techniques.

        +

        Billy Jack's wardrobe (black T-shirt, blue denim jacket, blue jeans, and a black hat with a beadwork band) would become nearly as iconic as the character.

        +

        The second major movie to make use of the word "fuck" (MASH being the first). A black student says the words "fucked up" during the scene where the Freedom school students are talking about the "Second Coming".

        +

        Billy Jack in popular culture[edit]

        +
          +
        • In 1975 (release date 12/30/1974), Firesign Theater, an American comedy group, made reference to Billy Jack on their album, "In The Next World, You're on Your Own," in the form of "Billy Jack Dog Food", and "I'm not Billy Jacking you," among other thematic references.
        • +
        • In 1975, musician Curtis Mayfield recorded and released a song titled, "Billy Jack" on his album There's No Place Like America Today.
        • +
        • In 1976 musician Paul Simon played "Billy Paul" (a parody of Billy Jack, unrelated[8] to musician Billy Paul) in a sketch on the second season of the NBC comedy show Saturday Night Live, after the film Billy Jack aired earlier that evening on NBC.
        • +
        • In 1982, a professional wrestler, Billy Jack Haynes, debuted as "Billy Jack" wearing a hat like Billy Jack. He changed his wrestling name from "Billy Jack" to "Billy Jack Haynes" after Tom Laughlin threatened to sue.
        • +
        • In the series Mystery Science Theater 3000, at least two episodes reference Billy Jack: on the episode Werewolf, after a fight breaks out between a racist dig supervisor and his Indian help, Tom Servo says, "This is where Billy Jack should come riding up."; on the episode Track of the Moon Beast, after the Native American professor finishes telling a story, Crow says, "Uh huh...do you know Billy Jack?"
        • +
        • In an episode of The Simpsons ("Bart of War"), Bart joins a Boy Scouts of America-like group called the "Pre-Teen Braves", and they engage in a rivalry with "the Cavalry Kids". A montage of the two groups fighting each other is set to Coven's version of One Tin Soldier.
        • +
        • The song "Kooler than Jesus" by My Life with the Thrill Kill Kult features samples from the film.
        • +
        • Billy Jack is referenced in an episode of Gilmore Girls ("Red Light on the Wedding Night") while Lorelai and Rory are watching the movie in their living room. At the line "Billy Jack, I'm gonna kill you if it's the last thing I do!", Lorelai responds, "Ugh, he so jinxed himself with that one." Rory replies, "Yeah, he should've said 'Billy Jack, I'm gonna kill you or buy myself a lovely chenille sweater.'"
        • +
        • Upon meeting serial killer Cary Stayner—then considered a possible material witness to a 1999 murder in Yosemite National Park—FBI Agent Jeff Rinek asked if Stayner had ever seen the movie Billy Jack, noting Stayner's resemblance to the film's hero. Initially, Stayner denied seeing the movie.[9] However, 90 minutes later, after building rapport during the drive to the FBI headquarters in Sacramento from the nudist resort where he was picked up, Stayner surprised Rinek by reciting several of Billy Jack's lines.[10]
        • +
        • In the motion picture Major Payne, Damon Wayans as the title character references the iconic fight scene quote "Now, what I'm goin' do is take this right foot and I'm 'a put it 'cross the left side your face."
        • +
        • In season three of the television series Sabrina The Teenage Witch, principal Mr. Kraft reveals that Billy Jack is his favorite film.
        • +
        • Billy Jack was referenced by Jim Carrey in Yes Man.
        • +
        • Metal band Goblin Cock have a song entitled "Ode to Billy Jack" on their 2009 album Come With Me if You Want to Live, which is a tribute to him.
        • +
        • In the movie Drillbit Taylor, actor Owen Wilson references Billy Jack by saying to a cast mate "I am gonna Billy Jack your ass."
        • +
        • In the episode of the animated show Pinky and the Brain, titled "Brainy Jack," Brain assumes the role of the titular Brainy Jack to trick a commune of hippies into helping him take over the world. Brain's wardrobe is a direct reference to Billy Jack, especially the hat with a beaded hat-band. Likewise, the song Pinky sings in the episode is a parody of "One Tin Soldier."
        • +
        • British electro band Relaxed Muscle (fronted by Jarvis Cocker, from Pulp) released a song called "Billy Jack" on their only album A Heavy Nite With... in 2003. It was released as a single with a music video that featured Cocker (as alter ego, Darren Spooner) in Western garb reminiscent of Billy Jack's trademark outfit.
        • +
        • In the Warehouse 13 Season 2 episode "13.1", the brain damaged Hugo Miller shouts "Billy Jack!" excitedly after Myka Bering kicks a gas station attendant who had pulled a gun.
        • +
        • In the book "The Berlin Blues", a play by Drew Hayden Taylor, the character named Trailer references Billy Jack when he says on page 92, "No Cirque du Billy Jack?" when the plan for Ojibway World which was supposed to be opening on the reserve falls through.
        • +
        • Bill Maher referenced Billy Jack in a July 2012 blog post about fundamentalist Mormons.
        • +
        +

        References[edit]

        +
        +
          +
        1. ^ a b Waxman, Sharon (June 20, 2005). "Billy Jack Is Ready to Fight the Good Fight Again". The New York Times. Retrieved 2011-01-02. 
        2. +
        3. ^ "Big Rental Films of 1973", Variety, 9 January 1974 p 19
        4. +
        5. ^ Billy Jack - Rotten Tomatoes
        6. +
        7. ^ Billy Jack - Roger Ebert
        8. +
        9. ^ Mundell Lowe discography accessed August 23, 2012
        10. +
        11. ^ a b Viglione, J. Allmusic Review accessed August 23, 2012
        12. +
        13. ^ Stewart, Jocelyn Y. (January 14, 2007). "Bong Soo Han, 73; grand master of hapkido won film fans for martial arts". Los Angeles Times. Retrieved 2010-11-25. 
        14. +
        15. ^ "The Story of Billy Paul". November 20, 1976. Retrieved 2012-07-15. 
        16. +
        17. ^ Finz, Stacy (December 15, 2002). "The Case of a Lifetime, Part One (2002, December 15)". SFGate.com. Retrieved 2008-12-10. 
        18. +
        19. ^ "The Case of a Lifetime, Part Two (2002, December 15)". SFGate.com. December 14, 2002. Retrieved 2008-12-10. 
        20. +
        +
        +

        External links[edit]

        + + + + + + + + + + + +
        +
        +
        +
        +
        +

        Navigation menu

        +
        + +
        + + +
        +
        + + + +
        +
        + +
        + + + + + + + diff --git a/spec/fixtures/onebox/wikipediaredirected.response b/spec/fixtures/onebox/wikipediaredirected.response new file mode 100644 index 0000000000..3c2f11c7c6 --- /dev/null +++ b/spec/fixtures/onebox/wikipediaredirected.response @@ -0,0 +1,899 @@ + + + +Ruby - Wikipedia, the free encyclopedia + + + + + + + + + + + + + + + + + + +
        +
        +
        + + +
        +

        Ruby

        +
        +
        From Wikipedia, the free encyclopedia
        +
        +
        + Jump to: navigation, search +
        +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        Ruby
        Ruby - Winza, Tanzania.jpg
        +
        Natural ruby crystals from Winza, Tanzania
        +
        General
        CategoryMineral variety
        Formula
        +(repeating unit)
        aluminium oxide with chromium, Al2O3:Cr
        Identification
        ColorRed, may be brownish, purplish, or pinkish
        Crystal habitVaries with locality. Terminated tabular hexagonal prisms.
        Crystal systemTrigonal (Hexagonal Scalenohedral), symbol (−3 2/m), space group R3c
        CleavageNo true cleavage
        FractureUneven or conchoidal
        Mohs scale hardness9.0
        LusterVitreous
        Streakwhite
        Diaphaneitytransparent
        Specific gravity4.0
        Refractive indexnω=1.768–1.772
        +nΔ=1.760–1.763
        Birefringence0.008
        PleochroismOrangey red, purplish red
        Ultraviolet fluorescencered under longwave
        Melting point2044 °C
        Solubilitynone
        Major varieties
        SapphireAny color except shades of red
        Corundumvarious colors
        EmeryGranular
        +

        A ruby is a pink to blood-red colored gemstone, a variety of the mineral corundum (aluminium oxide). The red color is caused mainly by the presence of the element chromium. Its name comes from ruber, Latin for red. Other varieties of gem-quality corundum are called sapphires. The ruby is considered one of the four precious stones, together with the sapphire, the emerald, and the diamond.[1]

        +

        Prices of rubies are primarily determined by color. The brightest and most valuable "red" called pigeon blood-red, commands a large premium over other rubies of similar quality. After color follows clarity: similar to diamonds, a clear stone will command a premium, but a ruby without any needle-like rutile inclusions may indicate that the stone has been treated. Cut and carat (weight) are also an important factor in determining the price.

        + +

        Physical properties[edit]

        +
        +
        +
        +
        +Crystal structure of ruby
        +
        +
        +

        Rubies have a hardness of 9.0 on the Mohs scale of mineral hardness. Among the natural gems only moissanite and diamond are harder, with diamond having a Mohs hardness of 10.0 and moissonite falling somewhere in between corundum (ruby) and diamond in hardness. Ruby is α-alumina (the most stable form of Al2O3) in which a small fraction of the aluminium3+ ions are replaced by chromium3+ ions. Each Cr3+ is surrounded octahedrally by six O2- ions. This crystallographic arrangement strongly affects each Cr3+, resulting in light absorption in the yellow-green region of the spectrum and thus in the red color of the gem. When yellow-green light is absorbed by Cr3+, it is re-emitted as red luminescence.[2] This red emission adds to the red color perceived by the subtraction of green and violet light from white light, and adds luster to the gem's appearance. When the optical arrangement is such that the emission is stimulated by 694-nanometer photons reflecting back and forth between two mirrors, the emission grows strongly in intensity. This effect was used by Theodore Maiman in 1960 to make the first successful laser, based on ruby.

        +

        All natural rubies have imperfections in them, including color impurities and inclusions of rutile needles known as "silk". Gemologists use these needle inclusions found in natural rubies to distinguish them from synthetics, simulants, or substitutes. Usually the rough stone is heated before cutting. Almost all rubies today are treated in some form, with heat treatment being the most common practice. However, rubies that are completely untreated but still of excellent quality command a large premium.

        +

        Some rubies show a three-point or six-point asterism or "star". These rubies are cut into cabochons to display the effect properly. Asterisms are best visible with a single-light source, and move across the stone as the light moves or the stone is rotated. Such effects occur when light is reflected off the "silk" (the structurally oriented rutile needle inclusions) in a certain way. This is one example where inclusions increase the value of a gemstone. Furthermore, rubies can show color changes—though this occurs very rarely—as well as chatoyancy or the "cat's eye" effect.

        +

        Color[edit]

        +

        Generally, gemstone-quality corundum in all shades of red, including pink, are called rubies.[3][4] However, in the United States, a minimum color saturation must be met to be called a ruby, otherwise the stone will be called a pink sapphire.[3] This distinction between rubies and pink sapphires is relatively new, having arisen sometime in the 20th century. If a distinction is made, the line separating a ruby from a pink sapphire is not clear and highly debated.[5] As a result of the difficulty and subjectiveness of such distinctions, trade organizations such as the International Colored Gemstone Association (ICA) have adopted the broader definition for ruby which encompasses its lighter shades, including pink.[6][7]

        +

        Natural occurrence[edit]

        +

        The Mogok Valley in Upper Myanmar (Burma) was for centuries the world's main source for rubies. That region has produced some of the finest rubies ever mined, but in recent years very few good rubies have been found there. The very best color in Myanmar rubies is sometimes described as "pigeon's blood." In central Myanmar, the area of Mong Hsu began producing rubies during the 1990s and rapidly became the world's main ruby mining area. The most recently found ruby deposit in Myanmar is in Namya (Namyazeik) located in the northern state of Kachin.

        +

        Rubies have historically been mined in Thailand, the Pailin and Samlout District of Cambodia, Burma, India, Afghanistan and in Pakistan. In Sri Lanka, lighter shades of rubies (often "pink sapphires") are more commonly found. After the Second World War ruby deposits were found in Tanzania, Madagascar, Vietnam, Nepal, Tajikistan, and Pakistan.

        +

        A few rubies have been found in the U.S. states of Montana, North Carolina, South Carolina and Wyoming. While searching for aluminous schists in Wyoming, geologist Dan Hausel noted an association of vermiculite with ruby and sapphire and located six previously undocumented deposits.[8]

        +

        More recently, large ruby deposits have been found under the receding ice shelf of Greenland.

        +

        Republic of Macedonia is the only country in mainland Europe to have naturally occurring rubies. They can mainly be found around the city of Prilep. Macedonian ruby has a unique raspberry color.

        +

        In 2002 rubies were found in the Waseges River area of Kenya. There are reports of a large deposit of rubies found in 2009 in Mozambique, in Nanhumbir in the Cabo Delgado district of Montepuez.[9]

        +

        Spinel, another red gemstone, is sometimes found along with rubies in the same gem gravel or marble. Red spinel may be mistaken for ruby by those lacking experience with gems. However, the finest red spinels can have a value approaching that of the average ruby.[10] The color of rubies varies from vermilion to red. The most desired color is "pigeon's blood", which is pure red with a hint of blue. If the color is too pink, the stone is a pink sapphire. The same is true if it is too violet – it is a violet sapphire. The best rubies and star rubies are bright red. Most rubies come from Burma and Thailand.

        +

        Factors affecting value[edit]

        +

        Diamonds are graded using criteria that have become known as the four Cs, namely color, cut, clarity and carat weight. Similarly natural rubies can be evaluated using the four Cs together with their size and geographic origin.

        +

        Color: In the evaluation of colored gemstones, color is the single most important factor. Color divides into three components; hue, saturation and tone. Hue refers to "color" as we normally use the term. Transparent gemstones occur in the following primary hues: red, orange, yellow, green, blue, violet. These are known as pure spectral hues.[11] In nature there are rarely pure hues so when speaking of the hue of a gemstone we speak of primary and secondary and sometimes tertiary hues. In ruby the primary hue must be red. All other hues of the gem species corundum are called sapphire. Ruby may exhibit a range of secondary hues. Orange, purple, violet and pink are possible.

        + +

        The finest ruby is best described as being a vivid medium-dark toned red. Secondary hues add an additional complication. Pink, orange, and purple are the normal secondary hues in ruby. Of the three, purple is preferred because, firstly, the purple reinforces the red making it appear richer.[11] Secondly, purple occupies a position on the color wheel halfway between red and blue. In Burma where the term pigeon blood originated, rubies are set in pure gold. Pure gold is itself a highly saturated yellow. Set a purplish-red ruby in yellow and the yellow neutralizes its complement blue leaving the stone appearing to be pure red in the setting.[citation needed]

        +

        Treatments and enhancements[edit]

        +

        Improving the quality of gemstones by treating them is common practice. Some treatments are used in almost all cases and are therefore considered acceptable. During the late 1990s, a large supply of low-cost materials caused a sudden surge in supply of heat-treated rubies, leading to a downward pressure on ruby prices.

        +

        Improvements used include color alteration, improving transparency by dissolving rutile inclusions, healing of fractures (cracks) or even completely filling them.

        +

        The most common treatment is the application of heat. Most, if not all, rubies at the lower end of the market are heat treated on the rough stones to improve color, remove purple tinge, blue patches and silk. These heat treatments typically occur around temperatures of 1800 °C (3300 °F).[12] Some rubies undergo a process of low tube heat, when the stone is heated over charcoal of a temperature of about 1300 °C (2400 °F) for 20 to 30 minutes. The silk is only partially broken as the color is improved.

        +

        Another treatment, which has become more frequent in recent years, is lead glass filling. Filling the fractures inside the ruby with lead glass (or a similar material) dramatically improves the transparency of the stone, making previously unsuitable rubies fit for applications in jewelry.[13] The process is done in four steps:

        +
          +
        1. The rough stones are pre-polished to eradicate all surface impurities that may affect the process
        2. +
        3. The rough is cleaned with hydrogen fluoride
        4. +
        5. The first heating process during which no fillers are added. The heating process eradicates impurities inside the fractures. Although this can be done at temperatures up to 1400 °C (2500 °F) it most likely occurs at a temperature of around 900 °C (1600 °F) since the rutile silk is still intact.
        6. +
        7. The second heating process in an electrical oven with different chemical additives. Different solutions and mixes have shown to be successful, however mostly lead-containing glass-powder is used at present. The ruby is dipped into oils, then covered with powder, embedded on a tile and placed in the oven where it is heated at around 900 °C (1600 °F) for one hour in an oxidizing atmosphere. The orange colored powder transforms upon heating into a transparent to yellow-colored paste, which fills all fractures. After cooling the color of the paste is fully transparent and dramatically improves the overall transparency of the ruby.[14]
        8. +
        +

        If a color needs to be added, the glass powder can be "enhanced" with copper or other metal oxides as well as elements such as sodium, calcium, potassium etc.

        +

        The second heating process can be repeated three to four times, even applying different mixtures.[15] When jewelry containing rubies is heated (for repairs) it should not be coated with boracic acid or any other substance, as this can etch the surface; it does not have to be "protected" like a diamond.

        +

        The treatment can easily be determined using a 10x loupe and determination focuses on finding bubbles either in the cavities or in the fractures that were filled with glass.[16]

        +

        Synthetic and imitation rubies[edit]

        +
        +
        +
        +
        +
        +
        +
        +
        Artificial ruby under a normal light (top) and under a green laser light (bottom). Red light is emitted
        +
        +
        +
        +

        In 1837 Gaudin made the first synthetic rubies by fusing potash alum at a high temperature with a little chromium as a pigment. In 1847 Ebelmen made white sapphire by fusing alumina in boric acid. In 1877 Frenic and Freil made crystal corundum from which small stones could be cut. Frimy and Auguste Verneuil manufactured artificial ruby by fusing BaF2 and Al2O3 with a little chromium at red heat. In 1903 Verneuil announced he could produce synthetic rubies on a commercial scale using this flame fusion process.[17] By 1910, Verneuil's laboratory had expanded into a 30 furnace production facility, with annual gemstone production having reached 1,000 kilograms (2,000 lb) in 1907.

        +

        Other processes in which synthetic rubies can be produced are through Czochralski's pulling process, flux process, and the hydrothermal process. Most synthetic rubies originate from flame fusion, due to the low costs involved. Synthetic rubies may have no imperfections visible to the naked eye but magnification may reveal curves, striae and gas bubbles. The fewer the number and the less obvious the imperfections, the more valuable the ruby is; unless there are no imperfections (i.e., a "perfect" ruby), in which case it will be suspected of being artificial. Dopants are added to some manufactured rubies so they can be identified as synthetic, but most need gemological testing to determine their origin.

        +

        Synthetic rubies have technological uses as well as gemological ones. Rods of synthetic ruby are used to make ruby lasers and masers. The first working laser was made by Theodore H. Maiman in 1960[18] at Hughes Research Laboratories in Malibu, California, beating several research teams including those of Charles H. Townes at Columbia University, Arthur Schawlow at Bell Labs,[19] and Gould at a company called TRG (Technical Research Group). Maiman used a solid-state light-pumped synthetic ruby to produce red laser light at a wavelength of 694 nanometers (nm). Ruby lasers are still in use. Rubies are also used in applications where high hardness is required such as at wear exposed locations in modern mechanical clockworks, or as scanning probe tips in a coordinate measuring machine.

        +

        Imitation rubies are also marketed. Red spinels, red garnets, and colored glass have been falsely claimed to be rubies. Imitations go back to Roman times and already in the 17th century techniques were developed to color foil red—by burning scarlet wool in the bottom part of the furnace—which was then placed under the imitation stone.[20] Trade terms such as balas ruby for red spinel and rubellite for red tourmaline can mislead unsuspecting buyers. Such terms are therefore discouraged from use by many gemological associations such as the Laboratory Manual Harmonisation Committee (LMHC).

        +

        Records and famous rubies[edit]

        + +
          +
        • The Smithsonian's National Museum of Natural History in Washington, D.C. has received one of the world's largest and finest ruby gemstones. The 23.1 carats (4.6 g) Burmese ruby, set in a platinum ring with diamonds, was donated by businessman and philanthropist Peter Buck in memory of his late wife Carmen LĂșcia. This gemstone displays a richly saturated red color combined with an exceptional transparency. The finely proportioned cut provides vivid red reflections. The stone was mined from the Mogok region of Burma (now Myanmar) in the 1930s.[21]
        • +
        • In 2007 the London jeweler Garrard & Co featured on their website a heart-shaped 40.63-carat ruby.[22]
        • +
        • On December 13/14, 2011 Elizabeth Taylor's complete jewellery collection was auctioned by Christie's. Several ruby-set pieces were included in the sale, notably a ring set with an 8.24 ct gem that broke the 'price-per-carat' record for rubies ($512,925 per carat, i.e. over $4.2 million in total),[23] and a necklace[24] that sold for over $3.7 million.
        • +
        • The Liberty Bell Ruby is the largest mined ruby in the world. It was stolen in a heist in 2011.[25]
        • +
        +

        Historical and cultural references[edit]

        +
          +
        • An early recorded transport and trading of rubies arises in the literature on the North Silk Road of China, wherein about 200 BC rubies were carried along this ancient trackway moving westward from China.[26]
        • +
        • Rubies have always been held in high esteem in Asian countries. They were used to ornament armor, scabbards, and harnesses of noblemen in India and China. Rubies were laid beneath the foundation of buildings to secure good fortune to the structure.[27]
        • +
        +

        See also[edit]

        + +

        References[edit]

        +
        +
          +
        1. ^ Precious Stones, Max Bauer, p. 2
        2. +
        3. ^ "Ruby: causes of color". Retrieved 15 may 2009. 
        4. +
        5. ^ a b Matlins, Antoinette Leonard (2010). Colored Gemstones. Gemstone Press. p. 203. ISBN 0-943763-72-X. 
        6. +
        7. ^ Reed, Peter (1991). Gemmology. Butterworth-Heinemann. p. 337. ISBN 0-7506-6449-5. 
        8. +
        9. ^ Wise, Richard G. "Gemstone Connoisseurship; The Finer Points, Part II". 
        10. +
        11. ^ Hughes, Richard W. "Walking the line in ruby & sapphire". ruby-sapphire.com. 
        12. +
        13. ^ Federman, David. "Pink Sapphire". Modern Jeweler. 
        14. +
        15. ^ Hausel, W. Dan (2009). Gems, Minerals and Rocks of Wyoming. Book Surge. p. 176. ISBN 1-4392-1856-0. 
        16. +
        17. ^ Mozambique: Police Seize Boat With 96 Illegal Immigrants. AllAfrica. 4 November 2010
        18. +
        19. ^ Wenk, Hans-Rudolf; Bulakh, A. G. (2004). Minerals: their constitution and origin. Cambridge, U.K.: Cambridge University Press. pp. 539–541. ISBN 0-521-52958-1. 
        20. +
        21. ^ a b Wise, Richard W. (2006). Secrets Of The Gem Trade, The Connoisseur's Guide To Precious Gemstones. Brunswick House Press. pp. 18–22. ISBN 0-9728223-8-0. 
        22. +
        23. ^ The Heat Treatment of Ruby and Sapphire. Bangkok, Thailand: Gemlab Inc. 1992. ISBN 0940965100. 
        24. +
        25. ^ Vincent Pardieu Lead Glass Filled/Repaired Rubies. Asian Institute of Gemological Sciences Gem Testing Laboratory. February 2005
        26. +
        27. ^ Richard W. Hughes (1997), Ruby & Sapphire, Boulder, CO, RWH Publishing, ISBN 978-0-9645097-6-4
        28. +
        29. ^ Milisenda, C C (2005). "Rubine mit bleihaltigen Glasern gefullt". Zeitschrift der Deutschen Gemmologischen Gesellschaft (in German) (Deutschen Gemmologischen Gesellschaft) 54 (1): 35–41. 
        30. +
        31. ^ "Lead Glass-Filled Rubies". GIA Global Dispatch (Gemological Institute of America). 2012. 
        32. +
        33. ^ "Bahadur: a Handbook of Precious Stones". 1943. Retrieved 2007-08-19. 
        34. +
        35. ^ Maiman, T.H. (1960). "Stimulated optical radiation in ruby". Nature 187 (4736): 493–494. Bibcode:1960Natur.187..493M. doi:10.1038/187493a0. 
        36. +
        37. ^ Hecht, Jeff (2005). Beam: The Race to Make the Laser. Oxford University Press. ISBN 0-19-514210-1. 
        38. +
        39. ^ "Thomas Nicols: A Lapidary or History of Gemstones". 1652. Retrieved 2007-08-19. 
        40. +
        41. ^ "The Carmen LĂșcia Ruby". Exhibitions. Retrieved 2008-02-28. 
        42. +
        43. ^ "Garrards – Treasures (large and important jewelry pieces)". Retrieved 2010-11-08. 
        44. +
        45. ^ The Legendary Jewels, Evening Sale & Jewelry (Sessions II and III) | Press Release | Christie's. Christies.com (2011-12-14). Retrieved on 2012-07-11.
        46. +
        47. ^ Elizabeth Taylor's ruby and diamond necklace. News.yahoo.com (2011-09-07). Retrieved on 2012-07-11.
        48. +
        49. ^ http://philadelphia.cbslocal.com/2012/01/09/irreplaceable-2-million-ruby-stolen-in-wilmington-jewelry-heist/
        50. +
        51. ^ C. Michael Hogan, Silk Road, North China, The Megalithic Portal. 19 November 2007
        52. +
        53. ^ Smith, Henry G. (1896). "Chapter 2, Sapphires, Rubies". Gems and Precious Stones. Charles Potter Government Printer, Australia. 
        54. +
        +
        +

        External links[edit]

        + + + + + + + +


        + + + + + + + + + + +
        +
        +
        +
        +
        +

        Navigation menu

        +
        + +
        + + +
        +
        + + + +
        +
        +
        + + + + + + +
        +
        + + + + + + + diff --git a/spec/fixtures/onebox/xkcd.response b/spec/fixtures/onebox/xkcd.response new file mode 100644 index 0000000000..00c7bd6df5 --- /dev/null +++ b/spec/fixtures/onebox/xkcd.response @@ -0,0 +1 @@ +{"month": "10", "num": 327, "link": "", "year": "2007", "news": "", "safe_title": "Exploits of a Mom", "transcript": "[[A woman is talking on the phone, holding a cup]]\nPhone: Hi, this is your son's school. We're having some computer trouble.\nMom: Oh dear\u00c3\u00a2\u00c2\u0080\u00c2\u0094did he break something?\nPhone: In a way\u00c3\u00a2\u00c2\u0080\u00c2\u0094\nPhone: Did you really name your son \"Robert'); DROP TABLE Students;--\" ?\nMom: Oh, yes. Little Bobby Tables, we call him.\nPhone: Well, we've lost this year's student records. I hope you're happy.\nMom: And I hope you've learned to sanitize your database inputs.\n{{title-text: Her daughter is named Help I'm trapped in a driver's license factory.}}", "alt": "Her daughter is named Help I'm trapped in a driver's license factory.", "img": "http:\/\/imgs.xkcd.com\/comics\/exploits_of_a_mom.png", "title": "Exploits of a Mom", "day": "10"} \ No newline at end of file diff --git a/spec/fixtures/onebox/youku-meta.response b/spec/fixtures/onebox/youku-meta.response new file mode 100644 index 0000000000..4948d1ab44 --- /dev/null +++ b/spec/fixtures/onebox/youku-meta.response @@ -0,0 +1 @@ +{"data":[{"ct":"f","cs":"2139","logo":"http:\/\/g2.ykimg.com\/1100641F46528C827D38F313B54FF057305EFD-1D91-46C5-821D-DC010415AC44","seed":6981,"tags":["\u674e\u5b97\u76db","\u5317\u4eac\u6f14\u5531\u4f1a","\u9ad8\u6e05"],"categories":"95","videoid":"159325444","vidEncoded":"XNjM3MzAxNzc2","username":"\u7f2a\u65af\u5de6\u53f3\u624b","userid":"330649584","title":"20131116\u674e\u5b97\u76db\u65e2\u7136\u9752\u6625\u7559\u4e0d\u4f4f\u5317\u4eac\u6f14\u5531\u4f1a_\u5c71\u4e18\u9ad8\u6e05","up":0,"down":0,"ts":"eCPhhDOr8KU0kcSeAbfYFkk","tsup":"eCPlljCr8KU0kcSeAqXcFkk","preview":{"thumbs":["05210001528C82AA6A074A508F0ED369"],"sectiontime":"6000","host":"http:\/\/g2.ykimg.com\/"},"key1":"b344b3f4","key2":"f502ea489c71b119","tt":"0","videoSource":"1","seconds":"426.46","streamfileids":{"flv":"38*15*38*38*38*40*38*40*38*38*53*40*30*57*7*0*2*43*53*0*25*57*43*15*48*53*2*63*63*38*38*43*38*40*25*57*32*49*60*32*32*2*58*60*30*48*58*32*60*63*63*0*58*60*38*32*15*63*40*58*63*57*43*2*40*57*","mp4":"38*15*38*38*38*30*38*40*38*38*53*40*30*57*7*25*48*43*53*0*25*57*43*15*48*53*2*63*63*38*38*43*38*40*25*57*32*49*60*32*32*2*58*60*30*48*58*32*60*63*63*0*58*60*38*32*15*63*40*58*63*57*43*2*40*57*","hd2":"38*15*38*38*38*43*38*15*38*38*53*40*30*57*30*40*7*49*53*0*25*57*43*15*48*53*2*63*63*38*38*43*38*40*25*57*32*49*60*32*32*2*58*60*30*48*58*32*60*63*63*0*58*60*38*32*15*63*40*58*63*57*43*2*40*57*"},"segs":{"flv":[{"no":"0","size":"6184613","seconds":205,"k":"37c1eef9d45943ef261e1651","k2":"143edcb51afd4d171"},{"no":"1","size":"9216438","seconds":222,"k":"220a2502b066ad2d24120d63","k2":"1139d718a87e8cc40"}],"mp4":[{"no":"0","size":"13698266","seconds":228,"k":"76da9c943b762c3724120d63","k2":"16661d1c25fdaee0c"},{"no":"1","size":"15953117","seconds":199,"k":"28efdc07f014982624120d63","k2":"13b9062ccfa57562d"}],"hd2":[{"no":"0","size":"27687337","seconds":205,"k":"29f6914d58f7f3a7282a1f3e","k2":"1bd4a6f687452e7fe"},{"no":"1","size":"16198622","seconds":119,"k":"524dd8fbfb7de533261e1651","k2":"163153a4deeaa0408"},{"no":"2","size":"21240220","seconds":102,"k":"e3cafd8cbe886d8e261e1651","k2":"1c4344748c6bdac6c"}]},"streamsizes":{"flv":"15401051","mp4":"29651383","hd2":"65126179"},"stream_ids":{"flv":"34575279","mp4":"34575265","hd2":"34575346"},"streamlogos":{"flv":1,"mp4":1,"hd2":1},"streamtypes":["flv","mp4","hd2"],"streamtypes_o":["hd2","flvhd","mp4"]}],"user":{"id":0},"controller":{"search_count":true,"mp4_restrict":1,"stream_mode":1,"video_capture":true,"hd3_enabled":false,"area_code":617,"dma_code":506,"continuous":0,"playmode":"normal","circle":false,"tsflag":false,"other_disable":false,"xplayer_disable":false,"app_disable":false,"share_disabled":false,"download_disabled":false,"pc_disabled":false,"pad_disabled":false,"mobile_disabled":false,"tv_disabled":false,"comment_disabled":false}} \ No newline at end of file diff --git a/spec/fixtures/onebox/youku.response b/spec/fixtures/onebox/youku.response new file mode 100644 index 0000000000..e669e3fd9c --- /dev/null +++ b/spec/fixtures/onebox/youku.response @@ -0,0 +1,1443 @@ + + + + +20131116æŽćź—ç››æ—ąç„¶é’æ˜„ç•™äžäœćŒ—äșŹæŒ”ć”±äŒš_ć±±äž˜é«˜æž…â€”ćœšçșżæ’­æ”Ÿâ€”äŒ˜é…·çœ‘ïŒŒè§†éą‘é«˜æž…ćœšçșżè§‚看 + + + + + + + + + + + + + + + + + + + + + + + +
        +
        +
        +
        + + + +
        +
        +
        + +
        +
        + +
        +
        + +
        + +
        +
        + +
        +
        + + + + +
        + +
        +
        +
        + +

        è§†éą‘: 20131116æŽćź—ç››æ—ąç„¶é’æ˜„ç•™äžäœćŒ—äșŹæŒ”ć”±äŒš_ć±±äž˜é«˜æž…

        +
        + +
        +
        + + + +
        +
        +
        +
        +
        +
        + +
        +
        +
        +
        + +
        +
        +
        +
        +
        + +
        + +
        +
        +
        + +
        +
        +
        +
        + +
        +
        +
        +
        +
        + 00:00/00:00 +
        +
        +
        æ ‡ć±
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        + +
        +
        +
        + +
        +
        +
        + +
        + +
        +
        + +
        + +
        +
        +
        +
        +
        +
        +
        +
        + Galaxy A ćčŽèœ»æ­Łć‘ćٰ +
        01:00
        +
        +
        + + +
        +
        +
        + 矀星挔绎äžșçˆ±çƒ­ç‰›ć„¶ +
        04:55
        +
        +
        + + +
        + +
        +
        + +
        + +
        + +
        +
        +
        + + +
        +
        +
        +
        + +
        +
        +
        +
        +
        + +
        +
        + + +
        +
        + + + + +
        + + + +
        + +
        + +
        +
        +
        +
          +
        • +
        • +
        • +
        • +
        + 戆äș«ç»™ć„œć‹ +
        +
        +
        +
        + + +
        + +
        + + + + + + +
        +
        + +
        +
        +
        +
        + +
        + +
        +
        èźą  é˜…
        +
        ◆◆
        +
        +
        +
        +
        æ—ąç„¶é’æ˜„ç•™äžäœïŒŒâ€œçˆ±çš„ä»Łä»·â€â€œć“­â€â€œéą†æ‚Ÿâ€ïŒ› +è¶Šèż‡â€œć±±äž˜â€â€œçŹ‘çșąć°˜â€ïŒŒâ€œćžŒæœ›â€â€œäœ è”°äœ çš„è·Żâ€ă€‚ + +æŽćź—ç››â€œæ—ąç„¶é’æ˜„ç•™äžäœâ€ 挗äșŹæŒ”ć”±äŒšïŒˆæ­Œć•ïŒ‰ +part1äșșç”Ÿçš„æąŠé†’æ—¶ćˆ† +1.ă€Šçˆ±æƒ…æœ‰ä»€äčˆé“ç†ă€‹ïŒˆćŒ è‰Ÿć˜‰ïŒ‰ +2.《濙侎ç›Čă€‹ïŒˆćŒ è‰Ÿć˜‰ïŒ‰ +3.ă€Šé˜żćź—çš„äž‰ä»¶äș‹ă€‹ïŒˆæŽćź—盛 +4.ă€Šç”Ÿć‘œäž­çš„çČŸç”ă€‹ïŒˆæŽćź—ç››ïŒ‰ +5....èŻŠæƒ…
        + +
        1ćčŽć‰ äžŠäŒ 
        + +
        +
        + + +
        + +
        +
        + +
        + +
        +
        + +
        +
        + + + + +
        + +
        0/300
        +
        +
        +
        + + +
        +
        +
        +
        +
        ć‘èĄšèŻ„èźș
        + +
        +
        + + + +
        +
        +
        +
        +
        +
        + + + +
        + + +
        +
        + + +
        +
        + +
        + +
        +
        + +
        +
        + + +
        +
        +
        +
        + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/spec/fixtures/onebox/youtube-channel.response b/spec/fixtures/onebox/youtube-channel.response new file mode 100644 index 0000000000..3db11c8b6e --- /dev/null +++ b/spec/fixtures/onebox/youtube-channel.response @@ -0,0 +1,5446 @@ + + + + + + + + + + + + + + Google Chrome + - YouTube + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        + + + + + + + + + + + + + + + + + + + + + + +
        + + + + + + + + + + +
        +
        +
        +
        + +
        + +
        +
        +
        +
        Upload
        +
        + + Google Chrome + +
        + +
        + +
        +
        +
        + +
        + + +
        + +
        + + +
        + +
        + + +
        + +
        + + + +
        + +
        + + +
        + +
        + + +
        + +
        + + +
        + +
        + + +
        + +
        + + +
        + +
        + + +
        + +
        + + +
        + +
        + + +
        +
        +
        + +
        +
        + +
        +
        + +
        + +
        + +
        +
        + +
        + +
        + +
        + + + + + +
        + +
        +
        +
        + + + + +
        +
        +
        +
        + + +
        +
        +
        +
        + + + + Google Chrome + + +
        + +
        +
        +
        + 846,509 + +
        + +
        +
        + +
        +
        +
        +

        + Subscription preferences + + +

        +
        +
        +
        +
        Loading...
        +
        + +
        +
        +
        +
        +

        + Loading icon + + +Loading... + +

        + +
        +
        +
        +
        + +
        +
        +
        +
        +
        +
        Working...
        +
        +
        + +
        +
        +
        +
        +
        + + +
        + +
        + +
        +

        + Google Chrome +

        +
        + +
        + +
        + + +
        + + + +
        + +
        +
        +
        +
        +
        + + + + + + + + + +
        +
        + +
        + + + + +
        + +
        +
        +
        + + + + + +
        +
        +
        +
        +
        + + +
        +
        + +
        +
        +
        +
        +
        Loading...
        +
        + +
        +
        +
        +
        + +
        +
        +
        +
        +
        +
        Working...
        +
        +
        + +
        +
        +
        +
        +
        + + +
        + to add this to Watch Later + +
        +
        +
        +

        +Add to +

        +
        +
        +
        +
        +
        + + + + + + + + diff --git a/spec/fixtures/onebox/youtube-embed.response b/spec/fixtures/onebox/youtube-embed.response new file mode 100644 index 0000000000..7395472b47 --- /dev/null +++ b/spec/fixtures/onebox/youtube-embed.response @@ -0,0 +1,7 @@ +YouTube
        \ No newline at end of file diff --git a/spec/fixtures/onebox/youtube-playlist.response b/spec/fixtures/onebox/youtube-playlist.response new file mode 100644 index 0000000000..1b67e5f95e --- /dev/null +++ b/spec/fixtures/onebox/youtube-playlist.response @@ -0,0 +1,221 @@ + + + + + + + + The web is what you make of it - YouTube + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/spec/fixtures/onebox/youtube.response b/spec/fixtures/onebox/youtube.response new file mode 100644 index 0000000000..4915ea2ca0 --- /dev/null +++ b/spec/fixtures/onebox/youtube.response @@ -0,0 +1,1625 @@ + + + + + + + + +96neko - orange - YouTube + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        +
        IN +
        Upload
        + +
        + + + + + + +
        + +
        + +
        + +
        +
        + +
        + +
        + +
        +

        + This video is unavailable. +

        +
        +
        +
        + + +
        + +
        + + + + + + + + + +
        + +
        + +
        +
        +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        +
        +
        +
        +
        +

        + + + + + + + + 96neko - orange + + +

        +
        + + +
        +
        +
        +

        Sign in to YouTube

        +
        + Sign in with your Google Account (YouTube, Google+, Gmail, Orkut, Picasa, or Chrome) to like korotto5810's video. + +
        +
          +
        • +
          +
        • +
        • +
          +
        • +
        • +
          +
        • +
        • +
          +
        • +
        • +
          +
        • +
        + +
        +
        +

        Sign in to YouTube

        +
        + Sign in with your Google Account (YouTube, Google+, Gmail, Orkut, Picasa, or Chrome) to dislike korotto5810's video. + +
        +
          +
        • +
          +
        • +
        • +
          +
        • +
        • +
          +
        • +
        • +
          +
        • +
        • +
          +
        • +
        + +
        +
        +
        +
        + + + + + + + + + + +
        +

        Sign in to YouTube

        +
        + Sign in with your Google Account (YouTube, Google+, Gmail, Orkut, Picasa, or Chrome) to add korotto5810's video to your playlist. + +
        +
          +
        • +
          +
        • +
        • +
          +
        • +
        • +
          +
        • +
        • +
          +
        • +
        • +
          +
        • +
        + +
        + +
        + + + + + + + + + + +
        +
        + +
        +
        +
        +
        +
        +

        + Uploaded on Apr 29, 2011 + +

        + +
        +

        From NicoNicoDouga
        http://nine.nicovideo.jp/watch/sm1423...

        MP3 download link
        http://nicosound.anyap.info/sound/sm1...
        「MP3 を 抜ć‡ș」←click

        eng & romaji
        http://www.animelyrics.com/doujin/voc...

        +
        +
        + +
          +
        • +

          +Category +

          +
          +

          Music

          + +
          +
        • + + +
        • +

          License

          +
          +

          +Standard YouTube License +

          + +
          +
        • +
        +
        + +
        +
          + +
        +
        + +
        +
        + +
        +
        + +
        +
        +
        +
        + +
        +
        +
        +

        + Loading icon + + +Loading... + +

        + +
        +
        +
        + +
        + +
        +
        +

        + Loading icon + + +Loading... + +

        + +
        +
        + +
        +
        +

        + Loading icon + + +Loading... + +

        + +
        +
        + +
        +
        +

        + Loading icon + + +Loading... + +

        + +
        +
        + +
        + +
        + +
        +
        + Ratings have been disabled for this video. +
        + +
        + +
        +
        + Rating is available when the video has been rented. +
        + +
        + +
        +
        + This feature is not available right now. Please try again later. +
        +
        + + +
        + +
        + + + +
        + +
        +
        +
        +

        + Loading icon + + +Loading... + +

        + +
        +
        + + +
        + + +
        +
        +
        + +
        + + + +
        + +
        + +
        +
        + +
        +
        +
        + +
        +
        + +
        + +
        + + +
        +
        + +
        +
        +
        +
        +
        Loading...
        +
        + +
        +
        +
        +
        + +
        +
        +
        +
        +
        +
        +
        Working...
        +
        +
        + +
        +
        +
        +
        +
        + + + +
        + to add this to Watch Later + +
        + + + + + + + +
        + + diff --git a/spec/fixtures/plugins/csp_extension/plugin.rb b/spec/fixtures/plugins/csp_extension/plugin.rb index cfab239dd3..71625d3b81 100644 --- a/spec/fixtures/plugins/csp_extension/plugin.rb +++ b/spec/fixtures/plugins/csp_extension/plugin.rb @@ -7,5 +7,7 @@ extend_content_security_policy( script_src: ['https://from-plugin.com'], - object_src: ['https://test-stripping.com'] + object_src: ['https://test-stripping.com'], + frame_ancestors: ['https://frame-ancestors-plugin.ext'], + manifest_src: ['https://manifest-src.com'] ) diff --git a/spec/integration/rate_limiting_spec.rb b/spec/integration/rate_limiting_spec.rb index 353569ef61..294bf62b59 100644 --- a/spec/integration/rate_limiting_spec.rb +++ b/spec/integration/rate_limiting_spec.rb @@ -10,10 +10,6 @@ describe 'rate limiter integration' do RateLimiter.clear_all! end - after do - RateLimiter.disable - end - it "will rate limit message bus requests once queueing" do freeze_time @@ -53,12 +49,13 @@ describe 'rate limiter integration' do it 'can cleanly limit requests and sets a Retry-After header' do freeze_time - #request.set_header("action_dispatch.show_exceptions", true) + + RateLimiter.clear_all! admin = Fabricate(:admin) api_key = Fabricate(:api_key, user: admin) - global_setting :max_admin_api_reqs_per_key_per_minute, 1 + global_setting :max_admin_api_reqs_per_minute, 1 get '/admin/api/keys.json', headers: { HTTP_API_KEY: api_key.key, diff --git a/spec/integrity/i18n_spec.rb b/spec/integrity/i18n_spec.rb index 2c7494274e..907524a39c 100644 --- a/spec/integrity/i18n_spec.rb +++ b/spec/integrity/i18n_spec.rb @@ -24,12 +24,6 @@ def is_yaml_compatible?(english, translated) end describe "i18n integrity checks" do - it 'has an i18n key for each Trust Levels' do - TrustLevel.all.each do |ts| - expect(ts.name).not_to match(/translation missing/) - end - end - it "has an i18n key for each Site Setting" do SiteSetting.all_settings.each do |s| next if s[:setting][/^test_/] diff --git a/spec/integrity/onceoff_integrity_spec.rb b/spec/integrity/onceoff_integrity_spec.rb index 7918995448..3fe53bb964 100644 --- a/spec/integrity/onceoff_integrity_spec.rb +++ b/spec/integrity/onceoff_integrity_spec.rb @@ -4,13 +4,13 @@ require "rails_helper" describe ::Jobs::Onceoff do it "can run all once off jobs without errors" do - # load all once offs - + # Load all once offs Dir[Rails.root + 'app/jobs/onceoff/*.rb'].each do |f| require_relative '../../app/jobs/onceoff/' + File.basename(f) end - ObjectSpace.each_object(Class).select { |klass| klass < ::Jobs::Onceoff }.each do |j| - j.new.execute_onceoff(nil) - end + + ObjectSpace.each_object(Class) + .select { |klass| klass.superclass == ::Jobs::Onceoff } + .each { |job| job.new.execute_onceoff(nil) } end end diff --git a/spec/jobs/bulk_invite_spec.rb b/spec/jobs/bulk_invite_spec.rb index 676d93c8fa..bfaa49c920 100644 --- a/spec/jobs/bulk_invite_spec.rb +++ b/spec/jobs/bulk_invite_spec.rb @@ -81,7 +81,7 @@ describe Jobs::BulkInvite do expect(existing_user.reload.groups).to eq([group1]) end - it 'can create staged users and prepulate user fields' do + it 'can create staged users and prepopulate user fields' do user_field = Fabricate(:user_field, name: "Location") user_field_color = Fabricate(:user_field, field_type: "dropdown", name: "Color") user_field_color.user_field_options.create!(value: "Red") diff --git a/spec/jobs/jobs_spec.rb b/spec/jobs/jobs_spec.rb index 5ee5ff09a7..113f8e5788 100644 --- a/spec/jobs/jobs_spec.rb +++ b/spec/jobs/jobs_spec.rb @@ -42,7 +42,7 @@ describe Jobs do end expect(jobs.length).to eq(2) - # Failed transation + # Failed transaction ActiveRecord::Base.transaction do Jobs.enqueue(:process_post, post_id: 1) raise ActiveRecord::Rollback diff --git a/spec/jobs/reviewable_priorities_spec.rb b/spec/jobs/reviewable_priorities_spec.rb index 65f232fee1..90dca2dc58 100644 --- a/spec/jobs/reviewable_priorities_spec.rb +++ b/spec/jobs/reviewable_priorities_spec.rb @@ -40,41 +40,47 @@ describe Jobs::ReviewablePriorities do expect(Reviewable.score_required_to_hide_post).to eq(8.33) end - it "will set priorities based on the maximum score" do - create_reviewables(Jobs::ReviewablePriorities.min_reviewables) + context 'when there are enough reviewables' do + let(:medium_threshold) { 8.0 } + let(:high_threshold) { 13.0 } + let(:score_to_hide_post) { 8.66 } - Jobs::ReviewablePriorities.new.execute({}) + it "will set priorities based on the maximum score" do + create_reviewables(Jobs::ReviewablePriorities.min_reviewables) - expect_min_score(:low, SiteSetting.reviewable_low_priority_threshold) - expect_min_score(:medium, 9.0) - expect_min_score(:high, 14.0) - expect(Reviewable.score_required_to_hide_post).to eq(9.33) - end + Jobs::ReviewablePriorities.new.execute({}) - it 'ignore negative scores when calculating priorities' do - create_reviewables(Jobs::ReviewablePriorities.min_reviewables) - negative_score = -9 - 10.times { create_with_score(negative_score) } + expect_min_score(:low, SiteSetting.reviewable_low_priority_threshold) + expect_min_score(:medium, medium_threshold) + expect_min_score(:high, high_threshold) + expect(Reviewable.score_required_to_hide_post).to eq(score_to_hide_post) + end - Jobs::ReviewablePriorities.new.execute({}) + it 'ignore negative scores when calculating priorities' do + create_reviewables(Jobs::ReviewablePriorities.min_reviewables) + negative_score = -9 + 10.times { create_with_score(negative_score) } - expect_min_score(:low, SiteSetting.reviewable_low_priority_threshold) - expect_min_score(:medium, 9.0) - expect_min_score(:high, 14.0) - expect(Reviewable.score_required_to_hide_post).to eq(9.33) - end + Jobs::ReviewablePriorities.new.execute({}) - it 'ignores non-approved reviewables' do - create_reviewables(Jobs::ReviewablePriorities.min_reviewables) - low_score = 2 - 10.times { create_with_score(low_score, status: :pending) } + expect_min_score(:low, SiteSetting.reviewable_low_priority_threshold) + expect_min_score(:medium, medium_threshold) + expect_min_score(:high, high_threshold) + expect(Reviewable.score_required_to_hide_post).to eq(score_to_hide_post) + end - Jobs::ReviewablePriorities.new.execute({}) + it 'ignores non-approved reviewables' do + create_reviewables(Jobs::ReviewablePriorities.min_reviewables) + low_score = 2 + 10.times { create_with_score(low_score, status: :pending) } - expect_min_score(:low, SiteSetting.reviewable_low_priority_threshold) - expect_min_score(:medium, 9.0) - expect_min_score(:high, 14.0) - expect(Reviewable.score_required_to_hide_post).to eq(9.33) + Jobs::ReviewablePriorities.new.execute({}) + + expect_min_score(:low, SiteSetting.reviewable_low_priority_threshold) + expect_min_score(:medium, medium_threshold) + expect_min_score(:high, high_threshold) + expect(Reviewable.score_required_to_hide_post).to eq(score_to_hide_post) + end end def expect_min_score(priority, score) diff --git a/spec/lib/backup_restore/meta_data_handler_spec.rb b/spec/lib/backup_restore/meta_data_handler_spec.rb index c9ba6cbaff..7d6ddc8ad4 100644 --- a/spec/lib/backup_restore/meta_data_handler_spec.rb +++ b/spec/lib/backup_restore/meta_data_handler_spec.rb @@ -34,9 +34,9 @@ describe BackupRestore::MetaDataHandler do end it "raises an exception when the metadata file contains invalid JSON" do - currupt_metadata = '{"version":20160329101122' + corrupt_metadata = '{"version":20160329101122' - with_metadata_file(currupt_metadata) do |dir| + with_metadata_file(corrupt_metadata) do |dir| expect { validate_metadata(backup_filename, dir) } .to raise_error(BackupRestore::MetaDataError) end diff --git a/spec/lib/backup_restore/shared_examples_for_backup_store.rb b/spec/lib/backup_restore/shared_examples_for_backup_store.rb index f6d3fbab3d..344fb17f2f 100644 --- a/spec/lib/backup_restore/shared_examples_for_backup_store.rb +++ b/spec/lib/backup_restore/shared_examples_for_backup_store.rb @@ -266,7 +266,7 @@ shared_examples "remote backup store" do expect(url).to match(upload_url_regex("default", filename, multisite: false)) end - it "raises an exeption when a file with same filename exists" do + it "raises an exception when a file with same filename exists" do expect { store.generate_upload_url(backup1.filename) } .to raise_exception(BackupRestore::BackupStore::BackupFileExists) end diff --git a/spec/lib/bookmark_manager_spec.rb b/spec/lib/bookmark_manager_spec.rb index bf9f1d2f54..11cbf642b1 100644 --- a/spec/lib/bookmark_manager_spec.rb +++ b/spec/lib/bookmark_manager_spec.rb @@ -109,7 +109,7 @@ RSpec.describe BookmarkManager do end end - context "when the post is inaccessable for the user" do + context "when the post is inaccessible for the user" do before do post.trash! end @@ -118,7 +118,7 @@ RSpec.describe BookmarkManager do end end - context "when the topic is inaccessable for the user" do + context "when the topic is inaccessible for the user" do before do post.topic.update(category: Fabricate(:private_category, group: Fabricate(:group))) end @@ -182,7 +182,7 @@ RSpec.describe BookmarkManager do ) end - it "saves the time and new reminder type and new name sucessfully" do + it "saves the time and new reminder type and new name successfully" do update_bookmark bookmark.reload expect(bookmark.name).to eq(new_name) diff --git a/spec/lib/bookmark_query_spec.rb b/spec/lib/bookmark_query_spec.rb index f75156ce6c..5511164d01 100644 --- a/spec/lib/bookmark_query_spec.rb +++ b/spec/lib/bookmark_query_spec.rb @@ -168,13 +168,13 @@ RSpec.describe BookmarkQuery do end describe "#list_all ordering" do - let!(:bookmark1) { Fabricate(:bookmark, user: user, updated_at: 1.day.ago) } - let!(:bookmark2) { Fabricate(:bookmark, user: user, updated_at: 2.days.ago) } - let!(:bookmark3) { Fabricate(:bookmark, user: user, updated_at: 6.days.ago) } - let!(:bookmark4) { Fabricate(:bookmark, user: user, updated_at: 4.days.ago) } - let!(:bookmark5) { Fabricate(:bookmark, user: user, updated_at: 3.days.ago) } + let!(:bookmark1) { Fabricate(:bookmark, user: user, updated_at: 1.day.ago, reminder_type: nil, reminder_at: nil) } + let!(:bookmark2) { Fabricate(:bookmark, user: user, updated_at: 2.days.ago, reminder_type: nil, reminder_at: nil) } + let!(:bookmark3) { Fabricate(:bookmark, user: user, updated_at: 6.days.ago, reminder_type: nil, reminder_at: nil) } + let!(:bookmark4) { Fabricate(:bookmark, user: user, updated_at: 4.days.ago, reminder_type: nil, reminder_at: nil) } + let!(:bookmark5) { Fabricate(:bookmark, user: user, updated_at: 3.days.ago, reminder_type: nil, reminder_at: nil) } - it "orders by updated_at" do + it "order defaults to updated_at DESC" do expect(bookmark_query.list_all.map(&:id)).to eq([ bookmark1.id, bookmark2.id, @@ -184,12 +184,40 @@ RSpec.describe BookmarkQuery do ]) end - it "puts pinned bookmarks first, in updated at order, then the rest in updated at order" do - bookmark3.update_column(:pinned, true) - bookmark4.update_column(:pinned, true) + it "orders by reminder_at, then updated_at" do + bookmark4.update_column(:reminder_type, Bookmark.reminder_types[:tomorrow]) + bookmark4.update_column(:reminder_at, 1.day.from_now) + bookmark5.update_column(:reminder_type, Bookmark.reminder_types[:tomorrow]) + bookmark5.update_column(:reminder_at, 26.hours.from_now) + expect(bookmark_query.list_all.map(&:id)).to eq([ bookmark4.id, + bookmark5.id, + bookmark1.id, + bookmark2.id, + bookmark3.id + ]) + + end + + it "shows pinned bookmarks first ordered by reminder_at ASC then updated_at DESC" do + bookmark3.update_column(:pinned, true) + bookmark3.update_column(:reminder_type, Bookmark.reminder_types[:tomorrow]) + bookmark3.update_column(:reminder_at, 1.day.from_now) + + bookmark4.update_column(:pinned, true) + bookmark4.update_column(:reminder_type, Bookmark.reminder_types[:tomorrow]) + bookmark4.update_column(:reminder_at, 28.hours.from_now) + + bookmark1.update_column(:pinned, true) + bookmark2.update_column(:pinned, true) + + bookmark5.update_column(:reminder_type, Bookmark.reminder_types[:tomorrow]) + bookmark5.update_column(:reminder_at, 1.day.from_now) + + expect(bookmark_query.list_all.map(&:id)).to eq([ bookmark3.id, + bookmark4.id, bookmark1.id, bookmark2.id, bookmark5.id diff --git a/spec/lib/content_security_policy_spec.rb b/spec/lib/content_security_policy_spec.rb index 5d4e0daafa..c203c850eb 100644 --- a/spec/lib/content_security_policy_spec.rb +++ b/spec/lib/content_security_policy_spec.rb @@ -155,6 +155,12 @@ describe ContentSecurityPolicy do end end + describe 'manifest-src' do + it 'is set to self' do + expect(parse(policy)['manifest-src']).to eq(["'self'"]) + end + end + describe 'frame-ancestors' do context 'with content_security_policy_frame_ancestors enabled' do before do @@ -188,26 +194,52 @@ describe ContentSecurityPolicy do end end - it 'can be extended by plugins' do - plugin = Class.new(Plugin::Instance) do - attr_accessor :enabled - def enabled? - @enabled + context 'with a plugin' do + let(:plugin_class) do + Class.new(Plugin::Instance) do + attr_accessor :enabled + def enabled? + @enabled + end end - end.new(nil, "#{Rails.root}/spec/fixtures/plugins/csp_extension/plugin.rb") + end - plugin.activate! - Discourse.plugins << plugin + it 'can extend script-src, object-src, manifest-src' do + plugin = plugin_class.new(nil, "#{Rails.root}/spec/fixtures/plugins/csp_extension/plugin.rb") - plugin.enabled = true - expect(parse(policy)['script-src']).to include('https://from-plugin.com') - expect(parse(policy)['object-src']).to include('https://test-stripping.com') - expect(parse(policy)['object-src']).to_not include("'none'") + plugin.activate! + Discourse.plugins << plugin - plugin.enabled = false - expect(parse(policy)['script-src']).to_not include('https://from-plugin.com') + plugin.enabled = true + expect(parse(policy)['script-src']).to include('https://from-plugin.com') + expect(parse(policy)['object-src']).to include('https://test-stripping.com') + expect(parse(policy)['object-src']).to_not include("'none'") + expect(parse(policy)['manifest-src']).to include("'self'") + expect(parse(policy)['manifest-src']).to include('https://manifest-src.com') - Discourse.plugins.pop + plugin.enabled = false + expect(parse(policy)['script-src']).to_not include('https://from-plugin.com') + expect(parse(policy)['manifest-src']).to_not include('https://manifest-src.com') + + Discourse.plugins.delete plugin + end + + it 'can extend frame_ancestors' do + SiteSetting.content_security_policy_frame_ancestors = true + plugin = plugin_class.new(nil, "#{Rails.root}/spec/fixtures/plugins/csp_extension/plugin.rb") + + plugin.activate! + Discourse.plugins << plugin + + plugin.enabled = true + expect(parse(policy)['frame-ancestors']).to include("'self'") + expect(parse(policy)['frame-ancestors']).to include('https://frame-ancestors-plugin.ext') + + plugin.enabled = false + expect(parse(policy)['frame-ancestors']).to_not include('https://frame-ancestors-plugin.ext') + + Discourse.plugins.delete plugin + end end it 'only includes unsafe-inline for qunit paths' do diff --git a/spec/lib/imap/providers/gmail_spec.rb b/spec/lib/imap/providers/gmail_spec.rb index dc6880e2a2..014cd1bc1e 100644 --- a/spec/lib/imap/providers/gmail_spec.rb +++ b/spec/lib/imap/providers/gmail_spec.rb @@ -70,4 +70,23 @@ RSpec.describe Imap::Providers::Gmail do provider.archive(main_uid) end end + + describe "#filter_mailboxes" do + it "filters down the gmail mailboxes to only show the relevant ones" do + mailboxes_with_attr = [ + Net::IMAP::MailboxList.new([:Hasnochildren], "/", "INBOX"), + Net::IMAP::MailboxList.new([:All, :Hasnochildren], "/", "[Gmail]/All Mail"), + Net::IMAP::MailboxList.new([:Drafts, :Hasnochildren], "/", "[Gmail]/Drafts"), + Net::IMAP::MailboxList.new([:Hasnochildren, :Important], "/", "[Gmail]/Important"), + Net::IMAP::MailboxList.new([:Hasnochildren, :Sent], "/", "[Gmail]/Sent Mail"), + Net::IMAP::MailboxList.new([:Hasnochildren, :Junk], "/", "[Gmail]/Spam"), + Net::IMAP::MailboxList.new([:Flagged, :Hasnochildren], "/", "[Gmail]/Starred"), + Net::IMAP::MailboxList.new([:Hasnochildren, :Trash], "/", "[Gmail]/Trash") + ] + + expect(provider.filter_mailboxes(mailboxes_with_attr)).to match_array([ + "INBOX", "[Gmail]/All Mail", "[Gmail]/Important" + ]) + end + end end diff --git a/spec/lib/onebox/engine/allowlisted_generic_onebox_spec.rb b/spec/lib/onebox/engine/allowlisted_generic_onebox_spec.rb new file mode 100644 index 0000000000..46991aa74a --- /dev/null +++ b/spec/lib/onebox/engine/allowlisted_generic_onebox_spec.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Onebox::Engine::AllowlistedGenericOnebox do + describe ".===" do + it "matches any domain" do + expect(described_class === URI('http://foo.bar/resource')).to be(true) + end + + it "doesn't match an IP address" do + expect(described_class === URI('http://1.2.3.4/resource')).to be(false) + expect(described_class === URI('http://1.2.3.4:1234/resource')).to be(false) + end + end + + describe 'html_providers' do + class HTMLOnebox < Onebox::Engine::AllowlistedGenericOnebox + def data + { + html: 'cool html', + height: 123, + provider_name: 'CoolSite', + } + end + end + + it "doesn't return the HTML when not in the `html_providers`" do + Onebox::Engine::AllowlistedGenericOnebox.html_providers = [] + expect(HTMLOnebox.new("http://coolsite.com").to_html).to be_nil + end + + it "returns the HMTL when in the `html_providers`" do + Onebox::Engine::AllowlistedGenericOnebox.html_providers = ['CoolSite'] + expect(HTMLOnebox.new("http://coolsite.com").to_html).to eq "cool html" + end + end + + describe 'rewrites' do + class DummyOnebox < Onebox::Engine::AllowlistedGenericOnebox + def generic_html + "" + end + end + + it "doesn't rewrite URLs that arent in the list" do + Onebox::Engine::AllowlistedGenericOnebox.rewrites = [] + expect(DummyOnebox.new("http://youtube.com").to_html).to eq "" + end + + it "rewrites URLs when allowlisted" do + Onebox::Engine::AllowlistedGenericOnebox.rewrites = %w(youtube.com) + expect(DummyOnebox.new("http://youtube.com").to_html).to eq "" + end + end + + describe 'oembed_providers' do + let(:url) { "http://www.meetup.com/Toronto-Ember-JS-Meetup/events/219939537" } + + before do + stub_request(:get, url).to_return(status: 200, body: onebox_response('meetup')) + stub_request(:get, "http://api.meetup.com/oembed?url=#{url}").to_return(status: 200, body: onebox_response('meetup_oembed')) + end + + it 'uses the endpoint for the url' do + onebox = described_class.new("http://www.meetup.com/Toronto-Ember-JS-Meetup/events/219939537") + expect(onebox.raw).not_to be_nil + expect(onebox.raw[:title]).to eq "February EmberTO Meet-up" + end + end + + describe "cookie support" do + let(:url) { "http://www.dailymail.co.uk/news/article-479146/Brutality-justice-The-truth-tarred-feathered-drug-dealer.html" } + + it "sends the cookie with the request" do + stub_request(:get, url) + .with(headers: { cookie: 'evil=trout' }) + .to_return(status: 200, body: onebox_response('dailymail')) + + onebox = described_class.new(url) + onebox.options = { cookie: "evil=trout" } + + expect(onebox.to_html).not_to be_empty + end + + it "fetches site_name and article_published_time tags" do + stub_request(:get, url).to_return(status: 200, body: onebox_response('dailymail')) + onebox = described_class.new(url) + + expect(onebox.to_html).to include("Mail Online – 8 Aug 14") + end + end + + describe 'canonical link' do + context 'uses canonical link if available' do + let(:mobile_url) { "https://m.etsy.com/in-en/listing/87673424/personalized-word-pillow-case-letter" } + let(:canonical_url) { "https://www.etsy.com/in-en/listing/87673424/personalized-word-pillow-case-letter" } + before do + stub_request(:get, mobile_url).to_return(status: 200, body: onebox_response('etsy_mobile')) + stub_request(:get, canonical_url).to_return(status: 200, body: onebox_response('etsy')) + end + + it 'fetches opengraph data and price from canonical link' do + onebox = described_class.new(mobile_url) + expect(onebox.to_html).not_to be_nil + expect(onebox.to_html).to include("images/favicon.ico") + expect(onebox.to_html).to include("Etsy") + expect(onebox.to_html).to include("Personalized Word Pillow Case") + expect(onebox.to_html).to include("Allow your personality to shine through your decor; this contemporary and modern accent will help you do just that.") + expect(onebox.to_html).to include("https://i.etsystatic.com/6088772/r/il/719b4b/1631899982/il_570xN.1631899982_2iay.jpg") + expect(onebox.to_html).to include("CAD 52.00") + end + end + + context 'does not use canonical link for Discourse topics' do + let(:discourse_topic_url) { "https://meta.discourse.org/t/congratulations-most-stars-in-2013-github-octoverse/12483" } + let(:discourse_topic_reply_url) { "https://meta.discourse.org/t/congratulations-most-stars-in-2013-github-octoverse/12483/2" } + before do + stub_request(:get, discourse_topic_url).to_return(status: 200, body: onebox_response('discourse_topic')) + stub_request(:get, discourse_topic_reply_url).to_return(status: 200, body: onebox_response('discourse_topic_reply')) + end + + it 'fetches opengraph data from original link' do + onebox = described_class.new(discourse_topic_reply_url) + expect(onebox.to_html).not_to be_nil + expect(onebox.to_html).to include("Congratulations, most stars in 2013 GitHub Octoverse!") + expect(onebox.to_html).to include("Thanks for that link and thank you – and everyone else who is contributing to the project!") + expect(onebox.to_html).to include("https://d11a6trkgmumsb.cloudfront.net/optimized/2X/d/d063b3b0807377d98695ee08042a9ba0a8c593bd_2_690x362.png") + end + end + end + + describe 'to_html' do + let(:original_link) { "http://www.dailymail.co.uk/pages/live/articles/news/news.html?in_article_id=479146&in_page_id=1770" } + let(:redirect_link) { 'http://www.dailymail.co.uk/news/article-479146/Brutality-justice-The-truth-tarred-feathered-drug-dealer.html' } + + before do + stub_request(:get, original_link).to_return( + status: 301, + headers: { + location: redirect_link, + } + ) + stub_request(:get, redirect_link).to_return(status: 200, body: onebox_response('dailymail')) + end + + around do |example| + previous_options = Onebox.options.to_h + example.run + Onebox.options = previous_options + end + + it "follows redirects and includes the summary" do + Onebox.options = { redirect_limit: 2 } + onebox = described_class.new(original_link) + expect(onebox.to_html).to include("It was the most chilling image of the week") + end + + it "recives an error with too many redirects" do + Onebox.options = { redirect_limit: 1 } + onebox = described_class.new(original_link) + expect(onebox.to_html).to be_nil + end + end + + describe 'missing description' do + context 'works without description if image is present' do + before do + stub_request(:get, "https://edition.cnn.com/2020/05/15/health/gallery/coronavirus-people-adopting-pets-photos/index.html") + .to_return(status: 200, body: onebox_response('cnn')) + + stub_request(:get, "https://www.cnn.com/2020/05/15/health/gallery/coronavirus-people-adopting-pets-photos/index.html") + .to_return(status: 200, body: onebox_response('cnn')) + end + + it 'shows basic onebox' do + onebox = described_class.new("https://edition.cnn.com/2020/05/15/health/gallery/coronavirus-people-adopting-pets-photos/index.html") + expect(onebox.to_html).not_to be_nil + expect(onebox.to_html).to include("https://edition.cnn.com/2020/05/15/health/gallery/coronavirus-people-adopting-pets-photos/index.html") + expect(onebox.to_html).to include("https://cdn.cnn.com/cnnnext/dam/assets/200427093451-10-coronavirus-people-adopting-pets-super-tease.jpg") + expect(onebox.to_html).to include("People are fostering and adopting pets during the pandemic") + end + end + end + + describe 'article html hosts' do + context 'returns article_html for hosts in article_html_hosts' do + before do + stub_request(:get, "https://www.imdb.com/title/tt0108002/") + .to_return(status: 200, body: onebox_response('imdb')) + end + + it 'shows article onebox' do + onebox = described_class.new("https://www.imdb.com/title/tt0108002/") + expect(onebox.to_html).to include("https://www.imdb.com/title/tt0108002") + expect(onebox.to_html).to include("https://m.media-amazon.com/images/M/MV5BZGUzMDU1YmQtMzBkOS00MTNmLTg5ZDQtZjY5Njk4Njk2MmRlXkEyXkFqcGdeQXVyNjc1NTYyMjg@._V1_FMjpg_UX1000_.jpg") + expect(onebox.to_html).to include("Rudy (1993) - IMDb") + expect(onebox.to_html).to include("Rudy: Directed by David Anspaugh. With Sean Astin, Jon Favreau, Ned Beatty, Greta Lind. Rudy has always been told that he was too small to play college football.") + end + end + end +end diff --git a/spec/lib/onebox/engine/amazon_onebox_spec.rb b/spec/lib/onebox/engine/amazon_onebox_spec.rb new file mode 100644 index 0000000000..9cf409bc05 --- /dev/null +++ b/spec/lib/onebox/engine/amazon_onebox_spec.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Onebox::Engine::AmazonOnebox do + context "regular amazon page" do + before do + @link = "https://www.amazon.com/Knit-Noro-Accessories-Colorful-Little/dp/193609620X" + @uri = "https://www.amazon.com/dp/193609620X" + + stub_request(:get, "https://www.amazon.com/Seven-Languages-Weeks-Programming-Programmers/dp/193435659X") + .to_return(status: 200, body: onebox_response("amazon")) + end + + include_context "engines" + it_behaves_like "an engine" + + describe "works with international domains" do + def check_link(tdl, link) + onebox_cls = Onebox::Matcher.new(link).oneboxed + expect(onebox_cls).to_not be(nil) + expect(onebox_cls.new(link).url).to include("https://www.amazon.#{tdl}") + end + + it "matches canadian domains" do + check_link("ca", "https://www.amazon.ca/Too-Much-Happiness-Alice-Munro-ebook/dp/B0031TZ98K/") + end + + it "matches german domains" do + check_link("de", "https://www.amazon.de/Buddenbrooks-Verfall-einer-Familie-Roman/dp/3596294312/") + end + + it "matches uk domains" do + check_link("co.uk", "https://www.amazon.co.uk/Pygmalion-George-Bernard-Shaw/dp/1420925237/") + end + + it "matches japanese domains" do + check_link("co.jp", "https://www.amazon.co.jp/%E9%9B%AA%E5%9B%BD-%E6%96%B0%E6%BD%AE%E6%96%87%E5%BA%AB-%E3%81%8B-1-1-%E5%B7%9D%E7%AB%AF-%E5%BA%B7%E6%88%90/dp/4101001014/") + end + + it "matches chinese domains" do + check_link("cn", "https://www.amazon.cn/%E5%AD%99%E5%AD%90%E5%85%B5%E6%B3%95-%E5%AD%99%E8%86%91%E5%85%B5%E6%B3%95-%E5%AD%99%E6%AD%A6/dp/B0011C40FC/") + end + + it "matches french domains" do + check_link("fr", "https://www.amazon.fr/Les-Mots-autres-%C3%A9crits-autobiographiques/dp/2070114147/") + end + + it "matches italian domains" do + check_link("it", "https://www.amazon.it/Tutte-poesie-Salvatore-Quasimodo/dp/8804520477/") + end + + it "matches spanish domains" do + check_link("es", "https://www.amazon.es/familia-Pascual-Duarte-Camilo-Jos%C3%A9-ebook/dp/B00EJRTKTW/") + end + + it "matches brazilian domains" do + check_link("com.br", "https://www.amazon.com.br/A-p%C3%A1tria-chuteiras-Nelson-Rodrigues-ebook/dp/B00J2B414Y/") + end + + it "matches indian domains" do + check_link("in", "https://www.amazon.in/Fireflies-Rabindranath-Tagore/dp/9381523169/") + end + + it "matches mexican domains" do + check_link("com.mx", "https://www.amazon.com.mx/Legend-Zelda-Links-Awakening-Nintendo/dp/B07SG15148/") + end + end + + describe "#url" do + let(:long_url) { "https://www.amazon.ca/gp/product/B087Z3N428?pf_rd_r=SXABADD0ZZ3NF9Q5F8TW&pf_rd_p=05378fd5-c43e-4948-99b1-a65b129fdd73&pd_rd_r=0237fb28-7f47-49f4-986a-be0c78e52863&pd_rd_w=FfIoI&pd_rd_wg=Hw4qq&ref_=pd_gw_unk" } + + it "maintains the same http/https scheme as the requested URL" do + expect(described_class.new("https://www.amazon.fr/gp/product/B01BYD0TZM").url) + .to eq("https://www.amazon.fr/dp/B01BYD0TZM") + + expect(described_class.new("http://www.amazon.fr/gp/product/B01BYD0TZM").url) + .to eq("https://www.amazon.fr/dp/B01BYD0TZM") + end + + it "removes parameters from the URL" do + expect(described_class.new(long_url).url) + .not_to include("?pf_rd_r") + end + end + + describe "#to_html" do + it "includes image" do + expect(html).to include("https://images-na.ssl-images-amazon.com/images/I/51opYcR6kVL._SY400_.jpg") + end + + it "includes description" do + expect(html).to include("You should learn a programming language every year, as recommended by The Pragmatic Programmer.") + end + + it "includes price" do + expect(html).to include("$21.11") + end + + it "includes title" do + expect(html).to include("Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages (Pragmatic Programmers)") + end + end + end + + context "amazon with opengraph" do + let(:link) { "https://www.amazon.com/dp/B01MFXN4Y2" } + let(:html) { described_class.new(link).to_html } + + before do + stub_request(:get, "https://www.amazon.com/dp/B01MFXN4Y2") + .to_return(status: 200, body: onebox_response("amazon-og")) + + stub_request(:get, "https://www.amazon.com/Christine-Rebecca-Hall/dp/B01MFXN4Y2") + .to_return(status: 200, body: onebox_response("amazon-og")) + end + + describe "#to_html" do + it "includes image" do + expect(html).to include("https://images-na.ssl-images-amazon.com/images/I/51nOF2iBa6L._SX940_.jpg") + end + + it "includes description" do + expect(html).to include("CHRISTINE is the story of an aspiring newswoman caught in the midst of a personal and professional life crisis. Between unrequited love, frustration at work, a tumultuous home, and self-doubt; she begins to spiral down a dark path.") + end + + it "includes title" do + expect(html).to include("Watch Christine online - Amazon Video") + end + end + end + + context "amazon book page" do + let(:link) { "https://www.amazon.com/dp/B00AYQNR46" } + let(:html) { described_class.new(link).to_html } + + before do + stub_request(:get, "https://www.amazon.com/dp/B00AYQNR46") + .to_return(status: 200, body: onebox_response("amazon")) + + stub_request(:get, "https://www.amazon.com/Seven-Languages-Weeks-Programming-Programmers/dp/193435659X") + .to_return(status: 200, body: onebox_response("amazon")) + end + + describe "#to_html" do + it "includes title and author" do + expect(html).to include("Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages (Pragmatic Programmers)") + expect(html).to include("Bruce Tate") + end + + it "includes ISBN" do + expect(html).to include("978-1934356593") + end + + it "includes publisher" do + expect(html).to include("Pragmatic Bookshelf") + end + end + end + + context "amazon ebook page" do + let(:link) { "https://www.amazon.com/dp/193435659X" } + let(:html) { described_class.new(link).to_html } + + before do + stub_request(:get, "https://www.amazon.com/dp/193435659X") + .to_return(status: 200, body: onebox_response("amazon-ebook")) + + stub_request(:get, "https://www.amazon.com/Seven-Languages-Weeks-Programming-Programmers-ebook/dp/B00AYQNR46") + .to_return(status: 200, body: onebox_response("amazon-ebook")) + end + + describe "#to_html" do + it "includes title and author" do + expect(html).to include("Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages (Pragmatic Programmers)") + expect(html).to include("Bruce Tate") + end + + it "includes image" do + expect(html).to include("https://images-na.ssl-images-amazon.com/images/I/51LZT%2BtSrTL._SX133_.jpg") + end + + it "includes ASIN" do + expect(html).to include("B00AYQNR46") + end + + it "includes rating" do + expect(html).to include("4.2 out of 5 stars") + end + + it "includes publisher" do + expect(html).to include("Pragmatic Bookshelf") + end + end + end + + context "non-standard response from Amazon" do + let(:link) { "https://www.amazon.com/dp/B0123ABCD3210" } + let(:onebox) { described_class.new(link) } + + before do + stub_request(:get, "https://www.amazon.com/dp/B0123ABCD3210") + .to_return(status: 200, body: onebox_response("amazon-error")) + end + + it "returns a blank result" do + expect(onebox.to_html).to eq("") + end + + it "produces a placeholder" do + expect(onebox.placeholder_html).to include('