diff --git a/.gitignore b/.gitignore index 25f45fa7f7..2b372eaa07 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ dump.rdb bin/* +data/ + .sass-cache/* public/csv/* public/plugins/* @@ -40,6 +42,7 @@ config/discourse.conf /tmp /logfile log/ +bootsnap-load-path-cache # Ignore plugins except for the bundled ones. /plugins/* @@ -48,6 +51,7 @@ log/ !/plugins/poll/ !/plugins/discourse-details/ !/plugins/discourse-nginx-performance-report +!/plugins/discourse-narrative-bot /plugins/*/auto_generated/ /spec/fixtures/plugins/my_plugin/auto_generated diff --git a/.image_optim.yml b/.image_optim.yml index 4a9ad0555c..746b85dc3b 100644 --- a/.image_optim.yml +++ b/.image_optim.yml @@ -2,10 +2,11 @@ skip_missing_workers: true allow_lossy: false # PNG advpng: false -optipng: +optipng: level: 2 pngcrush: false pngout: false pngquant: false # JPG jpegrecompress: false +timeout: 15 diff --git a/.mention-bot b/.mention-bot deleted file mode 100644 index 9893b57bb8..0000000000 --- a/.mention-bot +++ /dev/null @@ -1,8 +0,0 @@ -{ - "maxReviewers": 2, - "message": "Thanks @pullRequester for your pull request :+1:. By analyzing the blame information on this pull request, I identified @reviewers to be potential reviewers.", - "requiredOrgs": ["discourse"], - "skipCollaboratorPR": false, - "delayed": false, - "delayedUntil": "1d" -} diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000000..792d6e22e1 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,14 @@ +AllCops: + TargetRubyVersion: 2.3 + +Metrics/LineLength: + Max: 120 + +Metrics/MethodLength: + Enabled: false + +Style/Documentation: + Enabled: false + +Style/FrozenStringLiteralComment: + Enabled: False diff --git a/.ruby-version.sample b/.ruby-version.sample index 276cbf9e28..005119baaa 100644 --- a/.ruby-version.sample +++ b/.ruby-version.sample @@ -1 +1 @@ -2.3.0 +2.4.1 diff --git a/.travis.yml b/.travis.yml index ed1f7d20b1..1c681386e0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,8 +5,10 @@ env: - DISCOURSE_HOSTNAME=www.example.com - RUBY_GC_MALLOC_LIMIT=50000000 matrix: - - "RAILS_MASTER=0" - - "RAILS_MASTER=1" + - "RAILS_MASTER=0 QUNIT_RUN=0" + - "RAILS_MASTER=1 QUNIT_RUN=0" + - "RAILS_MASTER=0 QUNIT_RUN=1" + - "RAILS_MASTER=1 QUNIT_RUN=1" addons: postgresql: 9.5 @@ -19,12 +21,13 @@ addons: matrix: allow_failures: - - env: "RAILS_MASTER=1" - - rvm: rbx-2 + - env: "RAILS_MASTER=1 QUNIT_RUN=0" + - env: "RAILS_MASTER=1 QUNIT_RUN=1" fast_finish: true rvm: - - 2.3.1 + - 2.4.1 + - 2.3.3 services: - redis-server @@ -42,6 +45,7 @@ before_install: - git clone --depth=1 https://github.com/discourse/discourse-spoiler-alert.git plugins/discourse-spoiler-alert - git clone --depth=1 https://github.com/discourse/discourse-cakeday.git plugins/discourse-cakeday - git clone --depth=1 https://github.com/discourse/discourse-canned-replies.git plugins/discourse-canned-replies + - git clone --depth=1 https://github.com/discourse/discourse-slack-official.git plugins/discourse-slack-official - npm i -g eslint babel-eslint - eslint app/assets/javascripts - eslint --ext .es6 app/assets/javascripts @@ -56,4 +60,5 @@ install: - bash -c "if [ '$RAILS_MASTER' == '1' ]; then bundle update --retry=3 --jobs=3 arel rails seed-fu; fi" - bash -c "if [ '$RAILS_MASTER' == '0' ]; then bundle install --without development --deployment --retry=3 --jobs=3; fi" -script: "bundle exec rspec && bundle exec rake plugin:spec && bundle exec rake qunit:test['200000']" +script: + - bash -c "if [ '$QUNIT_RUN' == '0' ]; then bundle exec rspec && bundle exec rake plugin:spec; else bundle exec rake qunit:test['200000']; fi" diff --git a/.tx/config b/.tx/config index 718e97b54c..bd09128d4c 100644 --- a/.tx/config +++ b/.tx/config @@ -1,6 +1,6 @@ [main] host = https://www.transifex.com -lang_map = es_ES: es, fr_FR: fr, ko_KR: ko, pt_PT: pt, sk_SK: sk, vi_VN: vi +lang_map = el_GR: el, es_ES: es, fr_FR: fr, ko_KR: ko, pt_PT: pt, sk_SK: sk, vi_VN: vi [discourse-org.clientenyml] file_filter = config/locales/client..yml @@ -32,6 +32,18 @@ source_file = vendor/gems/discourse_imgur/lib/discourse_imgur/locale/server.en.y source_lang = en type = YML +[discourse-org.narrativeclientenyml] +file_filter = plugins/discourse-narrative-bot/config/locales/client..yml +source_file = plugins/discourse-narrative-bot/config/locales/client.en.yml +source_lang = en +type = YML + +[discourse-org.narrativeserverenyml] +file_filter = plugins/discourse-narrative-bot/config/locales/server..yml +source_file = plugins/discourse-narrative-bot/config/locales/server.en.yml +source_lang = en +type = YML + [discourse-org.403html] file_filter = public/403..html source_file = public/403.html diff --git a/Gemfile b/Gemfile index a0a8419621..a3222a8b26 100644 --- a/Gemfile +++ b/Gemfile @@ -2,6 +2,9 @@ source 'https://rubygems.org' # if there is a super emergency and rubygems is playing up, try #source 'http://production.cf.rubygems.org' +# does not install in linux ATM, so hack this for now +gem 'bootsnap', require: false + def rails_master? ENV["RAILS_MASTER"] == '1' end @@ -58,18 +61,16 @@ gem 'fast_xs' gem 'fast_xor' -# while we sort out https://github.com/sdsykes/fastimage/pull/46 -gem 'discourse_fastimage', '2.0.3', require: 'fastimage' +gem 'fastimage', '2.1.0' gem 'aws-sdk', require: false gem 'excon', require: false gem 'unf', require: false gem 'email_reply_trimmer', '0.1.6' -# note: for image_optim to correctly work you need to follow -# https://github.com/toy/image_optim -# pinned due to https://github.com/toy/image_optim/pull/75, docker image must be upgraded to upgrade -gem 'image_optim', '0.20.2' +# TODO Use official image_optim gem once https://github.com/toy/image_optim/pull/149 +# is merged. +gem 'discourse_image_optim', require: 'image_optim' gem 'multi_json' gem 'mustache' gem 'nokogiri' @@ -92,20 +93,16 @@ gem 'pry-rails', require: false gem 'r2', '~> 0.2.5', require: false gem 'rake' - +gem 'thor', require: false gem 'rest-client' gem 'rinku' gem 'sanitize' -gem 'sass' -gem 'sass-rails' gem 'sidekiq' -gem 'sidekiq-statistic' # for sidekiq web gem 'sinatra', require: false gem 'execjs', require: false gem 'mini_racer' -gem 'thin', require: false gem 'highline', require: false gem 'rack-protection' # security @@ -118,15 +115,18 @@ group :assets do end group :test do + gem 'webmock', require: false gem 'fakeweb', '~> 1.3.0', require: false gem 'minitest', require: false gem 'timecop' + # TODO: Remove once we upgrade to Rails 5. + gem 'test_after_commit' end group :test, :development do gem 'rspec' gem 'mock_redis' - gem 'listen', '0.7.3', require: false + gem 'listen', require: false gem 'certified', require: false # later appears to break Fabricate(:topic, category: category) gem 'fabrication', '2.9.8', require: false @@ -184,10 +184,11 @@ gem 'rmmseg-cpp', require: false gem 'logster' -# perftools only works on 1.9 atm -group :profile do - # travis refuses to install this, instead of fuffing, just avoid it for now - # - # if you need to profile, uncomment out this line - # gem 'rack-perftools_profiler', require: 'rack/perftools_profiler', platform: :mri_19 +gem 'sassc', require: false + + +if ENV["IMPORT"] == "1" + gem 'mysql2' + gem 'redcarpet' + gem 'sqlite3', '~> 1.3.13' end diff --git a/Gemfile.lock b/Gemfile.lock index 7ba72fcb34..d392a8cfd7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,47 +1,48 @@ GEM remote: https://rubygems.org/ specs: - actionmailer (4.2.7.1) - actionpack (= 4.2.7.1) - actionview (= 4.2.7.1) - activejob (= 4.2.7.1) + actionmailer (4.2.8) + actionpack (= 4.2.8) + actionview (= 4.2.8) + activejob (= 4.2.8) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 1.0, >= 1.0.5) - actionpack (4.2.7.1) - actionview (= 4.2.7.1) - activesupport (= 4.2.7.1) + actionpack (4.2.8) + actionview (= 4.2.8) + activesupport (= 4.2.8) rack (~> 1.6) rack-test (~> 0.6.2) rails-dom-testing (~> 1.0, >= 1.0.5) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (4.2.7.1) - activesupport (= 4.2.7.1) + actionview (4.2.8) + activesupport (= 4.2.8) builder (~> 3.1) erubis (~> 2.7.0) rails-dom-testing (~> 1.0, >= 1.0.5) - rails-html-sanitizer (~> 1.0, >= 1.0.2) + rails-html-sanitizer (~> 1.0, >= 1.0.3) active_model_serializers (0.8.3) activemodel (>= 3.0) - activejob (4.2.7.1) - activesupport (= 4.2.7.1) + activejob (4.2.8) + activesupport (= 4.2.8) globalid (>= 0.3.0) - activemodel (4.2.7.1) - activesupport (= 4.2.7.1) + activemodel (4.2.8) + activesupport (= 4.2.8) builder (~> 3.1) - activerecord (4.2.7.1) - activemodel (= 4.2.7.1) - activesupport (= 4.2.7.1) + activerecord (4.2.8) + activemodel (= 4.2.8) + activesupport (= 4.2.8) arel (~> 6.0) - activesupport (4.2.7.1) + activesupport (4.2.8) i18n (~> 0.7) - json (~> 1.7, >= 1.7.7) minitest (~> 5.1) thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) + addressable (2.5.1) + public_suffix (~> 2.0, >= 2.0.2) annotate (2.7.1) activerecord (>= 3.2, < 6.0) rake (>= 10.4, < 12.0) - arel (6.0.3) + arel (6.0.4) aws-sdk (2.5.3) aws-sdk-resources (= 2.5.3) aws-sdk-core (2.5.3) @@ -61,22 +62,30 @@ GEM rack (>= 0.9.0) binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) - builder (3.2.2) + bootsnap (0.3.0) + msgpack (~> 1.0) + builder (3.2.3) bullet (5.4.2) activesupport (>= 3.0.0) uniform_notifier (~> 1.10.0) byebug (9.0.6) certified (1.0.0) coderay (1.1.1) - concurrent-ruby (1.0.2) + concurrent-ruby (1.0.5) connection_pool (2.2.0) + crack (0.4.3) + safe_yaml (~> 1.0.0) crass (1.0.2) - daemons (1.2.4) debug_inspector (0.0.2) - diff-lcs (1.2.5) + diff-lcs (1.3) discourse-qunit-rails (0.0.9) railties - discourse_fastimage (2.0.3) + discourse_image_optim (0.24.5) + exifr (~> 1.2, >= 1.2.2) + fspath (~> 3.0) + image_size (~> 1.5) + in_threads (~> 1.3) + progress (~> 3.0, >= 3.0.1) domain_name (0.5.25) unf (>= 0.0.5, < 1.0.0) email_reply_trimmer (0.1.6) @@ -94,10 +103,9 @@ GEM railties (>= 3.1) ember-source (2.10.0) erubis (2.7.0) - eventmachine (1.2.0.1) - excon (0.53.0) + excon (0.55.0) execjs (2.7.0) - exifr (1.2.4) + exifr (1.2.5) fabrication (2.9.8) fakeweb (1.3.0) faraday (0.11.0) @@ -107,15 +115,17 @@ GEM rake rake-compiler fast_xs (0.8.0) - ffi (1.9.10) + fastimage (2.1.0) + ffi (1.9.18) flamegraph (0.9.5) foreman (0.82.0) thor (~> 0.19.1) - fspath (2.1.1) + fspath (3.1.0) gc_tracer (1.5.1) globalid (0.3.7) activesupport (>= 4.1.0) guess_html_encoding (0.0.11) + hashdiff (0.3.4) hashie (3.5.5) highline (1.7.8) hiredis (0.6.1) @@ -123,63 +133,59 @@ GEM http-cookie (1.0.2) domain_name (~> 0.5) http_accept_language (2.0.5) - i18n (0.7.0) - image_optim (0.20.2) - exifr (~> 1.1, >= 1.1.3) - fspath (~> 2.1) - image_size (~> 1.3) - in_threads (~> 1.3) - progress (~> 3.0, >= 3.0.1) - image_size (1.4.1) - in_threads (1.3.1) + i18n (0.8.1) + image_size (1.5.0) + in_threads (1.4.0) jmespath (1.3.1) jquery-rails (4.2.1) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (1.8.3) jwt (1.5.6) - kgio (2.10.0) - libv8 (5.3.332.38.3) - listen (0.7.3) - logster (1.2.5) + kgio (2.11.0) + libv8 (5.3.332.38.5) + listen (3.1.5) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + ruby_dep (~> 1.2) + logster (1.2.7) loofah (2.0.3) nokogiri (>= 1.5.9) lru_redux (1.1.0) - mail (2.6.4) + mail (2.6.5) mime-types (>= 1.16, < 4) memory_profiler (0.9.7) message_bus (2.0.2) rack (>= 1.1.3) metaclass (0.0.4) method_source (0.8.2) - mime-types (2.99.2) + mime-types (2.99.3) mini_portile2 (2.1.0) - mini_racer (0.1.7) + mini_racer (0.1.9) libv8 (~> 5.3) - minitest (5.9.1) + minitest (5.10.1) mocha (1.1.0) metaclass (~> 0.0.1) mock_redis (0.15.4) - moneta (0.8.1) - msgpack (0.7.6) + moneta (1.0.0) + msgpack (1.1.0) multi_json (1.12.1) multi_xml (0.6.0) multipart-post (2.0.0) - mustache (1.0.3) + mustache (1.0.5) netrc (0.11.0) - nokogiri (1.6.8.1) + nokogiri (1.7.2) mini_portile2 (~> 2.1.0) - nokogumbo (1.4.7) + nokogumbo (1.4.10) nokogiri - oauth (0.4.7) + oauth (0.5.1) oauth2 (1.3.1) faraday (>= 0.8, < 0.12) jwt (~> 1.0) multi_json (~> 1.3) multi_xml (~> 0.5) rack (>= 1.2, < 3) - oj (2.17.5) + oj (3.0.5) omniauth (1.6.1) hashie (>= 3.4.6, < 3.6.0) rack (>= 1.6.2, < 3) @@ -205,23 +211,22 @@ GEM omniauth-openid (1.0.1) omniauth (~> 1.0) rack-openid (~> 1.3.1) - omniauth-twitter (1.2.1) - json (~> 1.3) + omniauth-twitter (1.3.0) omniauth-oauth (~> 1.1) rack - onebox (1.7.7) + onebox (1.8.8) fast_blank (>= 1.0.0) - htmlentities (~> 4.3.4) - moneta (~> 0.8) + htmlentities (~> 4.3) + moneta (~> 1.0) multi_json (~> 1.11) mustache - nokogiri (~> 1.6.6) + nokogiri (~> 1.7) sanitize openid-redis-store (0.0.2) redis ruby-openid pg (0.19.0) - progress (3.1.1) + progress (3.3.1) pry (0.10.4) coderay (~> 1.1.0) method_source (~> 0.8.1) @@ -230,10 +235,11 @@ GEM pry (>= 0.9.10, < 0.11.0) pry-rails (0.3.4) pry (>= 0.9.10) + public_suffix (2.0.5) puma (3.6.0) r2 (0.2.6) - rack (1.6.5) - rack-mini-profiler (0.10.1) + rack (1.6.8) + rack-mini-profiler (0.10.4) rack (>= 1.2.0) rack-openid (1.3.1) rack (>= 1.1.0) @@ -242,34 +248,34 @@ GEM rack rack-test (0.6.3) rack (>= 1.0) - rails (4.2.7.1) - actionmailer (= 4.2.7.1) - actionpack (= 4.2.7.1) - actionview (= 4.2.7.1) - activejob (= 4.2.7.1) - activemodel (= 4.2.7.1) - activerecord (= 4.2.7.1) - activesupport (= 4.2.7.1) + rails (4.2.8) + actionmailer (= 4.2.8) + actionpack (= 4.2.8) + actionview (= 4.2.8) + activejob (= 4.2.8) + activemodel (= 4.2.8) + activerecord (= 4.2.8) + activesupport (= 4.2.8) bundler (>= 1.3.0, < 2.0) - railties (= 4.2.7.1) + railties (= 4.2.8) sprockets-rails rails-deprecated_sanitizer (1.0.3) activesupport (>= 4.2.0.alpha) - rails-dom-testing (1.0.7) + rails-dom-testing (1.0.8) activesupport (>= 4.2.0.beta, < 5.0) - nokogiri (~> 1.6.0) + nokogiri (~> 1.6) rails-deprecated_sanitizer (>= 1.0.1) rails-html-sanitizer (1.0.3) loofah (~> 2.0) rails_multisite (1.0.6) rails (> 4.2, < 5) - railties (4.2.7.1) - actionpack (= 4.2.7.1) - activesupport (= 4.2.7.1) + railties (4.2.8) + actionpack (= 4.2.8) + activesupport (= 4.2.8) rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) - raindrops (0.17.0) - rake (11.2.2) + raindrops (0.18.0) + rake (11.3.0) rake-compiler (0.9.9) rake rb-fsevent (0.9.7) @@ -279,7 +285,7 @@ GEM ffi (>= 1.0.6) msgpack (>= 0.4.3) trollop (>= 1.16.2) - redis (3.3.1) + redis (3.3.3) redis-namespace (1.5.2) redis (~> 3.0, >= 3.0.4) rest-client (1.8.0) @@ -317,17 +323,17 @@ GEM ruby-readability (0.7.0) guess_html_encoding (>= 0.0.4) nokogiri (>= 1.6.0) - sanitize (4.0.1) + ruby_dep (1.5.0) + safe_yaml (1.0.4) + sanitize (4.4.0) crass (~> 1.0.2) nokogiri (>= 1.4.4) nokogumbo (~> 1.4.1) - sass (3.2.19) - sass-rails (5.0.4) - railties (>= 4.0.0, < 5.0) - sass (~> 3.1) - sprockets (>= 2.8, < 4.0) - sprockets-rails (>= 2.0, < 4.0) - tilt (>= 1.1, < 3) + sass (3.4.23) + sassc (1.11.2) + bundler + ffi (~> 1.9.6) + sass (>= 3.3.0) seed-fu (2.3.5) activerecord (>= 3.1, < 4.3) activesupport (>= 3.1, < 4.3) @@ -342,8 +348,6 @@ GEM connection_pool (~> 2.2, >= 2.2.0) rack-protection (>= 1.5.0) redis (~> 3.2, >= 3.2.1) - sidekiq-statistic (1.2.0) - sidekiq (>= 3.3.4, < 5) simple-rss (1.3.1) sinatra (1.4.6) rack (~> 1.4) @@ -354,34 +358,36 @@ GEM spork-rails (4.0.0) rails (>= 3.0.0, < 5) spork (>= 1.0rc0) - sprockets (3.6.3) + sprockets (3.7.1) concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-rails (3.1.1) + sprockets-rails (3.2.0) actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) stackprof (0.2.10) - thin (1.7.0) - daemons (~> 1.0, >= 1.0.9) - eventmachine (~> 1.0, >= 1.0.4) - rack (>= 1, < 3) - thor (0.19.1) - thread_safe (0.3.5) + test_after_commit (1.1.0) + activerecord (>= 3.2) + thor (0.19.4) + thread_safe (0.3.6) tilt (2.0.5) timecop (0.8.1) trollop (2.1.2) - tzinfo (1.2.2) + tzinfo (1.2.3) thread_safe (~> 0.1) uglifier (3.0.2) execjs (>= 0.3.0, < 3) unf (0.1.4) unf_ext unf_ext (0.0.7.1) - unicorn (5.2.0) + unicorn (5.3.0) kgio (~> 2.6) raindrops (~> 0.7) uniform_notifier (1.10.0) + webmock (3.0.1) + addressable (>= 2.3.6) + crack (>= 0.3.2) + hashdiff PLATFORMS ruby @@ -394,11 +400,12 @@ DEPENDENCIES barber better_errors binding_of_caller + bootsnap bullet byebug certified discourse-qunit-rails - discourse_fastimage (= 2.0.3) + discourse_image_optim email_reply_trimmer (= 0.1.6) ember-handlebars-template (= 0.7.5) ember-rails (= 0.18.5) @@ -410,6 +417,7 @@ DEPENDENCIES fast_blank fast_xor fast_xs + fastimage (= 2.1.0) flamegraph foreman gc_tracer @@ -417,8 +425,7 @@ DEPENDENCIES hiredis htmlentities http_accept_language (~> 2.0.5) - image_optim (= 0.20.2) - listen (= 0.7.3) + listen logster lru_redux mail @@ -467,21 +474,21 @@ DEPENDENCIES rtlit ruby-readability sanitize - sass - sass-rails + sassc seed-fu (~> 2.3.5) shoulda sidekiq - sidekiq-statistic simple-rss sinatra spork-rails stackprof - thin + test_after_commit + thor timecop uglifier unf unicorn + webmock BUNDLED WITH - 1.13.7 + 1.14.6 diff --git a/README.md b/README.md index 359c8189f1..9a4c6f7595 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,24 @@ ![Logo](images/discourse.png) -Discourse is the 100% open source discussion platform built for the next decade of the Internet. It works as: +Discourse is the 100% open source discussion platform built for the next decade of the Internet. Use it as a: -- a mailing list -- a discussion forum -- a long-form chat room +- mailing list +- discussion forum +- long-form chat room To learn more about the philosophy and goals of the project, [visit **discourse.org**](http://www.discourse.org). ## Screenshots - - - - -Atom   -Soylent +Boing Boing + + + -Browse [lots more notable Discourse instances](http://www.discourse.org/faq/customers/). +Mobile + +Browse [lots more notable Discourse instances](https://www.discourse.org/customers). ## Development @@ -38,12 +38,12 @@ If you're looking for business class hosting, see [discourse.org/buy](https://ww Discourse is built for the *next* 10 years of the Internet, so our requirements are high: -| Browsers | Tablets | Smartphones | +| Browsers | Tablets | Phones | | -------- | ------- | ----------- | -| Safari 6.1+| iPad 2+ | iOS 7+ | -| Google Chrome 23+ | Android 4.3+ | Android 4.3+ | -| Internet Explorer 11+ | Windows 8 | Windows Phone 8 | -| Firefox 16+ | | +| Safari 6.1+ | iPad 3+ | iOS 8+ | +| Google Chrome 32+ | Android 4.3+ | Android 4.3+ | +| Internet Explorer 11+ | | | +| Firefox 27+ | | | ## Built With diff --git a/app/assets/fonts/FontAwesome.otf b/app/assets/fonts/FontAwesome.otf index 3ed7f8b48a..401ec0f36e 100644 Binary files a/app/assets/fonts/FontAwesome.otf and b/app/assets/fonts/FontAwesome.otf differ diff --git a/app/assets/fonts/fontawesome-webfont.eot b/app/assets/fonts/fontawesome-webfont.eot index 9b6afaedc0..e9f60ca953 100644 Binary files a/app/assets/fonts/fontawesome-webfont.eot and b/app/assets/fonts/fontawesome-webfont.eot differ diff --git a/app/assets/fonts/fontawesome-webfont.svg b/app/assets/fonts/fontawesome-webfont.svg index d05688e9e2..855c845e53 100644 --- a/app/assets/fonts/fontawesome-webfont.svg +++ b/app/assets/fonts/fontawesome-webfont.svg @@ -1,655 +1,2671 @@ - - + + +Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 + By ,,, +Copyright Dave Gandy 2016. All rights reserved. + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/fonts/fontawesome-webfont.ttf b/app/assets/fonts/fontawesome-webfont.ttf index 26dea7951a..35acda2fa1 100644 Binary files a/app/assets/fonts/fontawesome-webfont.ttf and b/app/assets/fonts/fontawesome-webfont.ttf differ diff --git a/app/assets/fonts/fontawesome-webfont.woff b/app/assets/fonts/fontawesome-webfont.woff index dc35ce3c2c..400014a4b0 100644 Binary files a/app/assets/fonts/fontawesome-webfont.woff and b/app/assets/fonts/fontawesome-webfont.woff differ diff --git a/app/assets/fonts/fontawesome-webfont.woff2 b/app/assets/fonts/fontawesome-webfont.woff2 index 500e517253..4d13fc6040 100644 Binary files a/app/assets/fonts/fontawesome-webfont.woff2 and b/app/assets/fonts/fontawesome-webfont.woff2 differ diff --git a/app/assets/images/favicons/pdf_48px.png b/app/assets/images/favicons/pdf_48px.png new file mode 100644 index 0000000000..ce99d6edc3 Binary files /dev/null and b/app/assets/images/favicons/pdf_48px.png differ diff --git a/app/assets/images/favicons/pdf_64px.png b/app/assets/images/favicons/pdf_64px.png new file mode 100644 index 0000000000..3c468bd584 Binary files /dev/null and b/app/assets/images/favicons/pdf_64px.png differ diff --git a/app/assets/javascripts/admin/adapters/theme.js.es6 b/app/assets/javascripts/admin/adapters/theme.js.es6 new file mode 100644 index 0000000000..df9c8830d1 --- /dev/null +++ b/app/assets/javascripts/admin/adapters/theme.js.es6 @@ -0,0 +1,20 @@ +import RestAdapter from 'discourse/adapters/rest'; + +export default RestAdapter.extend({ + basePath() { + return "/admin/"; + }, + + afterFindAll(results) { + let map = {}; + results.forEach(theme => {map[theme.id] = theme;}); + results.forEach(theme => { + let mapped = theme.get("child_themes") || []; + mapped = mapped.map(t => map[t.id]); + theme.set("childThemes", mapped); + }); + return results; + }, + + jsonMode: true +}); diff --git a/app/assets/javascripts/admin/components/ace-editor.js.es6 b/app/assets/javascripts/admin/components/ace-editor.js.es6 index a03865c40c..749ce2492d 100644 --- a/app/assets/javascripts/admin/components/ace-editor.js.es6 +++ b/app/assets/javascripts/admin/components/ace-editor.js.es6 @@ -1,12 +1,21 @@ import loadScript from 'discourse/lib/load-script'; import { observes } from 'ember-addons/ember-computed-decorators'; +const LOAD_ASYNC = !Ember.Test; + export default Ember.Component.extend({ mode: 'css', classNames: ['ace-wrapper'], _editor: null, _skipContentChangeEvent: null, + @observes('editorId') + editorIdChanged() { + if (this.get('autofocus')) { + this.send('focus'); + } + }, + @observes('content') contentChanged() { if (this._editor && !this._skipContentChangeEvent) { @@ -14,6 +23,13 @@ export default Ember.Component.extend({ } }, + @observes('mode') + modeChanged() { + if (LOAD_ASYNC && this._editor && !this._skipContentChangeEvent) { + this._editor.getSession().setMode("ace/mode/" + this.get('mode')); + } + }, + _destroyEditor: function() { if (this._editor) { this._editor.destroy(); @@ -23,6 +39,9 @@ export default Ember.Component.extend({ // xxx: don't run during qunit tests this.appEvents.off('ace:resize', this, this.resize); } + + $(window).off('ace:resize'); + }.on('willDestroyElement'), resize() { @@ -39,23 +58,47 @@ export default Ember.Component.extend({ if (!this.element || this.isDestroying || this.isDestroyed) { return; } const editor = loadedAce.edit(this.$('.ace')[0]); - editor.setTheme("ace/theme/chrome"); + if (LOAD_ASYNC) { + editor.setTheme("ace/theme/chrome"); + } editor.setShowPrintMargin(false); - editor.getSession().setMode("ace/mode/" + this.get('mode')); + editor.setOptions({fontSize: "14px"}); + if (LOAD_ASYNC) { + editor.getSession().setMode("ace/mode/" + this.get('mode')); + } editor.on('change', () => { this._skipContentChangeEvent = true; this.set('content', editor.getSession().getValue()); this._skipContentChangeEvent = false; }); editor.$blockScrolling = Infinity; + editor.renderer.setScrollMargin(10,10); this.$().data('editor', editor); this._editor = editor; + + $(window).off('ace:resize').on('ace:resize', ()=>{ + this.appEvents.trigger('ace:resize'); + }); + if (this.appEvents) { // xxx: don't run during qunit tests - this.appEvents.on('ace:resize', self, self.resize); + this.appEvents.on('ace:resize', ()=>this.resize()); + } + + if (this.get("autofocus")) { + this.send("focus"); } }); }); + }, + + actions: { + focus() { + if (this._editor) { + this._editor.focus(); + this._editor.navigateFileEnd(); + } + } } }); diff --git a/app/assets/javascripts/admin/components/admin-directory-toggle.js.es6 b/app/assets/javascripts/admin/components/admin-directory-toggle.js.es6 new file mode 100644 index 0000000000..e4d19613b5 --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-directory-toggle.js.es6 @@ -0,0 +1,33 @@ +import { iconHTML } from 'discourse-common/helpers/fa-icon'; +import { bufferedRender } from 'discourse-common/lib/buffered-render'; + +export default Ember.Component.extend(bufferedRender({ + tagName: 'th', + classNames: ['sortable'], + rerenderTriggers: ['order', 'ascending'], + + buildBuffer(buffer) { + const icon = this.get('icon'); + + if (icon) { + buffer.push(iconHTML(icon)); + } + + buffer.push(I18n.t(this.get('i18nKey'))); + + if (this.get('field') === this.get('order')) { + buffer.push(iconHTML(this.get('ascending') ? 'chevron-up' : 'chevron-down')); + } + }, + + click() { + const currentOrder = this.get('order'); + const field = this.get('field'); + + if (currentOrder === field) { + this.set('ascending', this.get('ascending') ? null : true); + } else { + this.setProperties({ order: field, ascending: null }); + } + } +})); diff --git a/app/assets/javascripts/admin/components/admin-wrapper.js.es6 b/app/assets/javascripts/admin/components/admin-wrapper.js.es6 new file mode 100644 index 0000000000..118728f66c --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-wrapper.js.es6 @@ -0,0 +1,11 @@ +export default Ember.Component.extend({ + didInsertElement() { + this._super(); + $('body').addClass('admin-interface'); + }, + + willDestroyElement() { + this._super(); + $('body').removeClass('admin-interface'); + } +}); diff --git a/app/assets/javascripts/admin/components/color-input.js.es6 b/app/assets/javascripts/admin/components/color-input.js.es6 index 98d5f6e6bb..005c4f5d4b 100644 --- a/app/assets/javascripts/admin/components/color-input.js.es6 +++ b/app/assets/javascripts/admin/components/color-input.js.es6 @@ -1,3 +1,5 @@ +import {default as loadScript, loadCSS } from 'discourse/lib/load-script'; + /** An input field for a color. @@ -6,19 +8,36 @@ @params valid is a boolean indicating if the input field is a valid color. **/ export default Ember.Component.extend({ + classNames: ['color-picker'], hexValueChanged: function() { var hex = this.get('hexValue'); + let $text = this.$('input.hex-input'); + if (this.get('valid')) { - this.$('input').attr('style', 'color: ' + (this.get('brightnessValue') > 125 ? 'black' : 'white') + '; background-color: #' + hex + ';'); + $text.attr('style', 'color: ' + (this.get('brightnessValue') > 125 ? 'black' : 'white') + '; background-color: #' + hex + ';'); + + if (this.get('pickerLoaded')) { + this.$('.picker').spectrum({color: "#" + this.get('hexValue')}); + } } else { - this.$('input').attr('style', ''); + $text.attr('style', ''); } }.observes('hexValue', 'brightnessValue', 'valid'), - _triggerHexChanged: function() { - var self = this; - Em.run.schedule('afterRender', function() { - self.hexValueChanged(); + didInsertElement() { + loadScript('/javascripts/spectrum.js').then(()=>{ + loadCSS('/javascripts/spectrum.css').then(()=>{ + Em.run.schedule('afterRender', ()=>{ + this.$('.picker').spectrum({color: "#" + this.get('hexValue')}) + .on("change.spectrum", (me, color)=>{ + this.set('hexValue', color.toHexString().replace("#","")); + }); + this.set('pickerLoaded', true); + }); + }); }); - }.on('didInsertElement') + Em.run.schedule('afterRender', ()=>{ + this.hexValueChanged(); + }); + } }); diff --git a/app/assets/javascripts/admin/components/customize-link.js.es6 b/app/assets/javascripts/admin/components/customize-link.js.es6 deleted file mode 100644 index 0600f6b5cd..0000000000 --- a/app/assets/javascripts/admin/components/customize-link.js.es6 +++ /dev/null @@ -1,12 +0,0 @@ -import { getOwner } from 'discourse-common/lib/get-owner'; - -export default Ember.Component.extend({ - router: function() { - return getOwner(this).lookup('router:main'); - }.property(), - - active: function() { - const id = this.get('customization.id'); - return this.get('router.url').indexOf(`/customize/css_html/${id}/css`) !== -1; - }.property('router.url', 'customization.id') -}); diff --git a/app/assets/javascripts/admin/components/disable-custom-stylesheets.js.es6 b/app/assets/javascripts/admin/components/disable-custom-stylesheets.js.es6 deleted file mode 100644 index f4d86899d0..0000000000 --- a/app/assets/javascripts/admin/components/disable-custom-stylesheets.js.es6 +++ /dev/null @@ -1,14 +0,0 @@ -export default Ember.Component.extend({ - willInsertElement() { - this._super(); - if (this.session.get("disableCustomCSS")) { - $("link.custom-css").attr("rel", ""); - this.session.set("disableCustomCSS", false); - } - }, - - willDestroyElement() { - this._super(); - $("link.custom-css").attr("rel", "stylesheet"); - } -}); diff --git a/app/assets/javascripts/admin/components/embeddable-host.js.es6 b/app/assets/javascripts/admin/components/embeddable-host.js.es6 index 2a5d7c030b..70c1ed272c 100644 --- a/app/assets/javascripts/admin/components/embeddable-host.js.es6 +++ b/app/assets/javascripts/admin/components/embeddable-host.js.es6 @@ -30,7 +30,7 @@ export default Ember.Component.extend(bufferedProperty('host'), { save() { if (this.get('cantSave')) { return; } - const props = this.get('buffered').getProperties('host', 'path_whitelist'); + const props = this.get('buffered').getProperties('host', 'path_whitelist', 'class_name'); props.category_id = this.get('categoryId'); const host = this.get('host'); diff --git a/app/assets/javascripts/admin/components/inline-edit-checkbox.js.es6 b/app/assets/javascripts/admin/components/inline-edit-checkbox.js.es6 new file mode 100644 index 0000000000..5c168760c7 --- /dev/null +++ b/app/assets/javascripts/admin/components/inline-edit-checkbox.js.es6 @@ -0,0 +1,36 @@ +import {default as computed, observes} from "ember-addons/ember-computed-decorators"; + +export default Ember.Component.extend({ + init(){ + this._super(); + this.set("checkedInternal", this.get("checked")); + }, + + classNames: ['inline-edit'], + + @observes("checked") + checkedChanged() { + this.set("checkedInternal", this.get("checked")); + }, + + @computed("labelKey") + label(key) { + return I18n.t(key); + }, + + @computed("checked", "checkedInternal") + changed(checked, checkedInternal) { + return (!!checked) !== (!!checkedInternal); + }, + + actions: { + cancelled(){ + this.set("checkedInternal", this.get("checked")); + }, + + finished(){ + this.set("checked", this.get("checkedInternal")); + this.sendAction(); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-backups-index.js.es6 b/app/assets/javascripts/admin/controllers/admin-backups-index.js.es6 index 558b9b4973..67b6c58d06 100644 --- a/app/assets/javascripts/admin/controllers/admin-backups-index.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-backups-index.js.es6 @@ -1,4 +1,3 @@ -import DiscourseURL from 'discourse/lib/url'; import { ajax } from 'discourse/lib/ajax'; export default Ember.Controller.extend({ @@ -39,7 +38,11 @@ export default Ember.Controller.extend({ }, download(backup) { - DiscourseURL.redirectTo(backup.get('link')); + let link = backup.get('filename'); + ajax("/admin/backups/" + link, { type: "PUT" }) + .then(() => { + bootbox.alert(I18n.t("admin.backups.operations.download.alert")); + }); } }, @@ -48,7 +51,7 @@ export default Ember.Controller.extend({ ajax("/admin/backups/readonly", { type: "PUT", data: { enable: enable } - }).then(function() { + }).then(() => { site.set("isReadOnly", enable); }); } diff --git a/app/assets/javascripts/admin/controllers/admin-customize-colors-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-colors-show.js.es6 new file mode 100644 index 0000000000..e333d2a58d --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-customize-colors-show.js.es6 @@ -0,0 +1,74 @@ +import computed from 'ember-addons/ember-computed-decorators'; + +export default Ember.Controller.extend({ + @computed("model.colors","onlyOverridden") + colors(allColors, onlyOverridden) { + if (onlyOverridden) { + return allColors.filter(color => color.get("overridden")); + } else { + return allColors; + } + }, + + actions: { + + revert: function(color) { + color.revert(); + }, + + undo: function(color) { + color.undo(); + }, + + copyToClipboard() { + $(".table.colors").hide(); + let area = $(""); + $(".table.colors").after(area); + area.text(this.get("model").schemeJson()); + let range = document.createRange(); + range.selectNode(area[0]); + window.getSelection().addRange(range); + let successful = document.execCommand('copy'); + if (successful) { + this.set("model.savingStatus", I18n.t("admin.customize.copied_to_clipboard")); + } else { + this.set("model.savingStatus", I18n.t("admin.customize.copy_to_clipboard_error")); + } + + setTimeout(()=>{ + this.set("model.savingStatus", null); + }, 2000); + + window.getSelection().removeAllRanges(); + + $(".table.colors").show(); + $(area).remove(); + }, + + copy() { + var newColorScheme = Em.copy(this.get('model'), true); + newColorScheme.set('name', I18n.t('admin.customize.colors.copy_name_prefix') + ' ' + this.get('model.name')); + newColorScheme.save().then(()=>{ + this.get('allColors').pushObject(newColorScheme); + this.replaceRoute('adminCustomize.colors.show', newColorScheme); + }); + }, + + save: function() { + this.get('model').save(); + }, + + destroy: function() { + + const model = this.get('model'); + return bootbox.confirm(I18n.t("admin.customize.colors.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), result => { + if (result) { + model.destroy().then(()=>{ + this.get('allColors').removeObject(model); + this.replaceRoute('adminCustomize.colors'); + }); + } + }); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-colors.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-colors.js.es6 index ae253aec84..87166e386f 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-colors.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-colors.js.es6 @@ -1,10 +1,14 @@ -export default Ember.Controller.extend({ - onlyOverridden: false, +import showModal from 'discourse/lib/show-modal'; +export default Ember.Controller.extend({ baseColorScheme: function() { return this.get('model').findBy('is_base', true); }.property('model.@each.id'), + baseColorSchemes: function() { + return this.get('model').filterBy('is_base', true); + }.property('model.@each.id'), + baseColors: function() { var baseColorsHash = Em.Object.create({}); _.each(this.get('baseColorScheme.colors'), function(color){ @@ -13,99 +17,25 @@ export default Ember.Controller.extend({ return baseColorsHash; }.property('baseColorScheme'), - removeSelected() { - this.get('model').removeObject(this.get('selectedItem')); - this.set('selectedItem', null); - }, - - filterContent: function() { - if (!this.get('selectedItem')) { return; } - - if (!this.get('onlyOverridden')) { - this.set('colors', this.get('selectedItem.colors')); - return; - } - - const matches = []; - _.each(this.get('selectedItem.colors'), function(color){ - if (color.get('overridden')) matches.pushObject(color); - }); - - this.set('colors', matches); - }.observes('onlyOverridden'), - - updateEnabled: function() { - var selectedItem = this.get('selectedItem'); - if (selectedItem.get('enabled')) { - this.get('model').forEach(function(c) { - if (c !== selectedItem) { - c.set('enabled', false); - c.startTrackingChanges(); - c.notifyPropertyChange('description'); - } - }); - } - }, - actions: { - selectColorScheme: function(colorScheme) { - if (this.get('selectedItem')) { this.get('selectedItem').set('selected', false); } - this.set('selectedItem', colorScheme); - this.set('colors', colorScheme.get('colors')); - colorScheme.set('savingStatus', null); - colorScheme.set('selected', true); - this.filterContent(); + + newColorSchemeWithBase(baseKey) { + const base = this.get('baseColorSchemes').findBy('base_scheme_id', baseKey); + const newColorScheme = Em.copy(base, true); + newColorScheme.set('name', I18n.t('admin.customize.colors.new_name')); + newColorScheme.set('base_scheme_id', base.get('base_scheme_id')); + newColorScheme.save().then(()=>{ + this.get('model').pushObject(newColorScheme); + newColorScheme.set('savingStatus', null); + this.replaceRoute('adminCustomize.colors.show', newColorScheme); + }); }, newColorScheme() { - const newColorScheme = Em.copy(this.get('baseColorScheme'), true); - newColorScheme.set('name', I18n.t('admin.customize.colors.new_name')); - this.get('model').pushObject(newColorScheme); - this.send('selectColorScheme', newColorScheme); - this.set('onlyOverridden', false); + showModal('admin-color-scheme-select-base', { model: this.get('baseColorSchemes'), admin: true}); }, - revert: function(color) { - color.revert(); - }, - undo: function(color) { - color.undo(); - }, - - toggleEnabled: function() { - var selectedItem = this.get('selectedItem'); - selectedItem.toggleProperty('enabled'); - selectedItem.save({enabledOnly: true}); - this.updateEnabled(); - }, - - save: function() { - this.get('selectedItem').save(); - this.updateEnabled(); - }, - - copy(colorScheme) { - var newColorScheme = Em.copy(colorScheme, true); - newColorScheme.set('name', I18n.t('admin.customize.colors.copy_name_prefix') + ' ' + colorScheme.get('name')); - this.get('model').pushObject(newColorScheme); - this.send('selectColorScheme', newColorScheme); - }, - - destroy: function() { - var self = this, - item = self.get('selectedItem'); - - return bootbox.confirm(I18n.t("admin.customize.colors.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), function(result) { - if (result) { - if (item.get('newRecord')) { - self.removeSelected(); - } else { - item.destroy().then(function(){ self.removeSelected(); }); - } - } - }); - } } }); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-css-html-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-css-html-show.js.es6 deleted file mode 100644 index 47cf280ae6..0000000000 --- a/app/assets/javascripts/admin/controllers/admin-customize-css-html-show.js.es6 +++ /dev/null @@ -1,78 +0,0 @@ -import { url } from 'discourse/lib/computed'; - -const sections = ['css', 'header', 'top', 'footer', 'head-tag', 'body-tag', - 'mobile-css', 'mobile-header', 'mobile-top', 'mobile-footer', - 'embedded-css']; - -const activeSections = {}; -sections.forEach(function(s) { - activeSections[Ember.String.camelize(s) + "Active"] = Ember.computed.equal('section', s); -}); - - -export default Ember.Controller.extend(activeSections, { - maximized: false, - section: null, - - previewUrl: url("model.key", "/?preview-style=%@"), - downloadUrl: url('model.id', '/admin/site_customizations/%@'), - - mobile: function() { - return this.get('section').indexOf('mobile-') === 0; - }.property('section'), - - maximizeIcon: function() { - return this.get('maximized') ? 'compress' : 'expand'; - }.property('maximized'), - - saveButtonText: function() { - return this.get('model.isSaving') ? I18n.t('saving') : I18n.t('admin.customize.save'); - }.property('model.isSaving'), - - saveDisabled: function() { - return !this.get('model.changed') || this.get('model.isSaving'); - }.property('model.changed', 'model.isSaving'), - - adminCustomizeCssHtml: Ember.inject.controller(), - - undoPreviewUrl: url('/?preview-style='), - defaultStyleUrl: url('/?preview-style=default'), - - actions: { - save() { - this.get('model').saveChanges(); - }, - - destroy() { - return bootbox.confirm(I18n.t("admin.customize.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), result => { - if (result) { - const model = this.get('model'); - model.destroyRecord().then(() => { - this.get('adminCustomizeCssHtml').get('model').removeObject(model); - this.transitionToRoute('adminCustomizeCssHtml'); - }); - } - }); - }, - - toggleMaximize: function() { - this.toggleProperty('maximized'); - }, - - toggleMobile: function() { - const section = this.get('section'); - - // Try to send to the same tab as before - let dest; - if (this.get('mobile')) { - dest = section.replace('mobile-', ''); - if (sections.indexOf(dest) === -1) { dest = 'css'; } - } else { - dest = 'mobile-' + section; - if (sections.indexOf(dest) === -1) { dest = 'mobile-css'; } - } - this.replaceRoute('adminCustomizeCssHtml.show', this.get('model.id'), dest); - } - } - -}); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 new file mode 100644 index 0000000000..fb91322edf --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 @@ -0,0 +1,159 @@ +import { url } from 'discourse/lib/computed'; +import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; + +export default Ember.Controller.extend({ + maximized: false, + section: null, + + targets: [ + {id: 0, name: I18n.t('admin.customize.theme.common')}, + {id: 1, name: I18n.t('admin.customize.theme.desktop')}, + {id: 2, name: I18n.t('admin.customize.theme.mobile')} + ], + + @computed('onlyOverridden') + showCommon() { + return this.shouldShow('common'); + }, + + @computed('onlyOverridden') + showDesktop() { + return this.shouldShow('desktop'); + }, + + @computed('onlyOverridden') + showMobile() { + return this.shouldShow('mobile'); + }, + + @observes('onlyOverridden') + onlyOverriddenChanged() { + if (this.get('onlyOverridden')) { + if (!this.get('model').hasEdited(this.get('currentTargetName'), this.get('fieldName'))) { + let target = (this.get('showCommon') && 'common') || + (this.get('showDesktop') && 'desktop') || + (this.get('showMobile') && 'mobile'); + + let fields = this.get('model.theme_fields'); + let field = fields && fields.find(f => (f.target === target)); + this.replaceRoute('adminCustomizeThemes.edit', this.get('model.id'), target, field && field.name); + } + } + }, + + shouldShow(target){ + if(!this.get("onlyOverridden")) { + return true; + } + return this.get("model").hasEdited(target); + }, + + currentTarget: 0, + + setTargetName: function(name) { + let target; + switch(name) { + case "common": target = 0; break; + case "desktop": target = 1; break; + case "mobile": target = 2; break; + } + + this.set("currentTarget", target); + }, + + @computed("currentTarget") + currentTargetName(target) { + switch(parseInt(target)) { + case 0: return "common"; + case 1: return "desktop"; + case 2: return "mobile"; + } + }, + + @computed("fieldName") + activeSectionMode(fieldName) { + return fieldName && fieldName.indexOf("scss") > -1 ? "scss" : "html"; + }, + + @computed("currentTargetName", "fieldName", "saving") + error(target, fieldName) { + return this.get('model').getError(target, fieldName); + }, + + @computed("fieldName", "currentTargetName") + editorId(fieldName, currentTarget) { + return fieldName + "|" + currentTarget; + }, + + @computed("fieldName", "currentTargetName", "model") + activeSection: { + get(fieldName, target, model) { + return model.getField(target, fieldName); + }, + set(value, fieldName, target, model) { + model.setField(target, fieldName, value); + return value; + } + }, + + @computed("currentTarget", "onlyOverridden") + fields(target, onlyOverridden) { + let fields = [ + "scss", "head_tag", "header", "after_header", "body_tag", "footer" + ]; + + if (parseInt(target) === 0) { + fields.push("embedded_scss"); + } + + if (onlyOverridden) { + const model = this.get("model"); + const targetName = this.get("currentTargetName"); + fields = fields.filter(name => model.hasEdited(targetName, name)); + } + + return fields.map(name=>{ + let hash = { + key: (`admin.customize.theme.${name}.text`), + name: name + }; + + if (name.indexOf("_tag") > 0) { + hash.icon = "file-text-o"; + } + + hash.title = I18n.t(`admin.customize.theme.${name}.title`); + + return hash; + }); + }, + + previewUrl: url('model.id', '/admin/themes/%@/preview'), + + maximizeIcon: function() { + return this.get('maximized') ? 'compress' : 'expand'; + }.property('maximized'), + + saveButtonText: function() { + return this.get('model.isSaving') ? I18n.t('saving') : I18n.t('admin.customize.save'); + }.property('model.isSaving'), + + saveDisabled: function() { + return !this.get('model.changed') || this.get('model.isSaving'); + }.property('model.changed', 'model.isSaving'), + + actions: { + save() { + this.set('saving', true); + this.get('model').saveChanges("theme_fields").finally(()=>{this.set('saving', false);}); + }, + + toggleMaximize: function() { + this.toggleProperty('maximized'); + Em.run.next(()=>{ + this.appEvents.trigger('ace:resize'); + }); + } + } + +}); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 new file mode 100644 index 0000000000..2873f62a9f --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 @@ -0,0 +1,199 @@ +import { default as computed } from 'ember-addons/ember-computed-decorators'; +import { url } from 'discourse/lib/computed'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import showModal from 'discourse/lib/show-modal'; + +const THEME_UPLOAD_VAR = 2; + +export default Ember.Controller.extend({ + + @computed("model", "allThemes") + parentThemes(model, allThemes) { + let parents = allThemes.filter(theme => + _.contains(theme.get("childThemes"), model)); + return parents.length === 0 ? null : parents; + }, + + @computed("model.theme_fields.@each") + hasEditedFields(fields) { + return fields.any(f=>!Em.isBlank(f.value)); + }, + + @computed('model.theme_fields.@each') + editedDescriptions(fields) { + let descriptions = []; + let description = target => { + let current = fields.filter(field => field.target === target && !Em.isBlank(field.value)); + if (current.length > 0) { + let text = I18n.t('admin.customize.theme.'+target); + let localized = current.map(f=>I18n.t('admin.customize.theme.'+f.name + '.text')); + return text + ": " + localized.join(" , "); + } + }; + ['common','desktop','mobile'].forEach(target=> { + descriptions.push(description(target)); + }); + return descriptions.reject(d=>Em.isBlank(d)); + }, + + previewUrl: url('model.id', '/admin/themes/%@/preview'), + + @computed("colorSchemeId", "model.color_scheme_id") + colorSchemeChanged(colorSchemeId, existingId) { + colorSchemeId = colorSchemeId === null ? null : parseInt(colorSchemeId); + return colorSchemeId !== existingId; + }, + + @computed("availableChildThemes", "model.childThemes.@each", "model", "allowChildThemes") + selectableChildThemes(available, childThemes, model, allowChildThemes) { + if (!allowChildThemes && (!childThemes || childThemes.length === 0)) { + return null; + } + + let themes = []; + available.forEach(t=> { + if (!childThemes || (childThemes.indexOf(t) === -1)) { + themes.push(t); + }; + }); + return themes.length === 0 ? null : themes; + }, + + @computed("allThemes", "allThemes.length", "model") + availableChildThemes(allThemes, count) { + if (count === 1) { + return null; + } + + let excludeIds = [this.get("model.id")]; + + let themes = []; + allThemes.forEach(theme => { + if (excludeIds.indexOf(theme.get("id")) === -1) { + themes.push(theme); + } + }); + + return themes; + }, + + downloadUrl: url('model.id', '/admin/themes/%@'), + + actions: { + + updateToLatest() { + this.set("updatingRemote", true); + this.get("model").updateToLatest() + .catch(popupAjaxError) + .finally(()=>{ + this.set("updatingRemote", false); + }); + }, + + checkForThemeUpdates() { + this.set("updatingRemote", true); + this.get("model").checkForUpdates() + .catch(popupAjaxError) + .finally(()=>{ + this.set("updatingRemote", false); + }); + }, + + addUploadModal() { + showModal('admin-add-upload', {admin: true, name: ''}); + }, + + addUpload(info) { + let model = this.get("model"); + model.setField('common', info.name, '', info.upload_id, THEME_UPLOAD_VAR); + model.saveChanges('theme_fields').catch(e => popupAjaxError(e)); + }, + + cancelChangeScheme() { + this.set("colorSchemeId", this.get("model.color_scheme_id")); + }, + changeScheme(){ + let schemeId = this.get("colorSchemeId"); + this.set("model.color_scheme_id", schemeId === null ? null : parseInt(schemeId)); + this.get("model").saveChanges("color_scheme_id"); + }, + startEditingName() { + this.set("oldName", this.get("model.name")); + this.set("editingName", true); + }, + cancelEditingName() { + this.set("model.name", this.get("oldName")); + this.set("editingName", false); + }, + finishedEditingName() { + this.get("model").saveChanges("name"); + this.set("editingName", false); + }, + + editTheme() { + let edit = ()=>this.transitionToRoute('adminCustomizeThemes.edit', this.get('model.id'), 'common', 'scss'); + + if (this.get("model.remote_theme")) { + bootbox.confirm(I18n.t("admin.customize.theme.edit_confirm"), result => { + if (result) { + edit(); + } + }); + } else { + edit(); + } + }, + + applyDefault() { + const model = this.get("model"); + model.saveChanges("default").then(()=>{ + if (model.get("default")) { + this.get("allThemes").forEach(theme=>{ + if (theme !== model && theme.get('default')) { + theme.set("default", false); + } + }); + } + }); + }, + + applyUserSelectable() { + this.get("model").saveChanges("user_selectable"); + }, + + addChildTheme() { + let themeId = parseInt(this.get("selectedChildThemeId")); + let theme = this.get("allThemes").findBy("id", themeId); + this.get("model").addChildTheme(theme); + }, + + removeUpload(upload) { + return bootbox.confirm( + I18n.t("admin.customize.theme.delete_upload_confirm"), + I18n.t("no_value"), + I18n.t("yes_value"), result => { + if (result) { + this.get("model").removeField(upload); + } + }); + }, + + removeChildTheme(theme) { + this.get("model").removeChildTheme(theme); + }, + + destroy() { + return bootbox.confirm(I18n.t("admin.customize.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), result => { + if (result) { + const model = this.get('model'); + model.destroyRecord().then(() => { + this.get('allThemes').removeObject(model); + this.transitionToRoute('adminCustomizeThemes'); + }); + } + }); + }, + + } + +}); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes.js.es6 new file mode 100644 index 0000000000..b9a897a26a --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-customize-themes.js.es6 @@ -0,0 +1,10 @@ +import { default as computed } from 'ember-addons/ember-computed-decorators'; + +export default Ember.Controller.extend({ + @computed('model', 'model.@each') + sortedThemes(themes) { + return _.sortBy(themes.content, t => { + return [!t.get("default"), !t.get("user_selectable"), t.get("name").toLowerCase()]; + }); + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-email-preview-digest.js.es6 b/app/assets/javascripts/admin/controllers/admin-email-preview-digest.js.es6 index 5da4b0afb5..8065d732fc 100644 --- a/app/assets/javascripts/admin/controllers/admin-email-preview-digest.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-email-preview-digest.js.es6 @@ -8,10 +8,6 @@ export default Ember.Controller.extend({ showSendEmailForm: Em.computed.notEmpty('model.html_content'), htmlEmpty: Em.computed.empty('model.html_content'), - iframeSrc: function() { - return ('data:text/html;charset=utf-8,' + encodeURI(this.get('model.html_content'))); - }.property('model.html_content'), - actions: { refresh() { const model = this.get('model'); diff --git a/app/assets/javascripts/admin/controllers/admin-group.js.es6 b/app/assets/javascripts/admin/controllers/admin-group.js.es6 index 6c3da11d7f..a0f4961b62 100644 --- a/app/assets/javascripts/admin/controllers/admin-group.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-group.js.es6 @@ -22,9 +22,9 @@ export default Ember.Controller.extend({ ]; }.property(), - @computed('model.visible', 'model.public', 'model.alias_level') + @computed('model.visible', 'model.public') disableMembershipRequestSetting(visible, publicGroup) { - return !visible || publicGroup || !this.get('model.canEveryoneMention'); + return !visible || publicGroup; }, @computed('model.visible', 'model.allow_membership_requests') diff --git a/app/assets/javascripts/admin/controllers/admin-groups-bulk-complete.js.es6 b/app/assets/javascripts/admin/controllers/admin-groups-bulk-complete.js.es6 new file mode 100644 index 0000000000..780543b724 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-groups-bulk-complete.js.es6 @@ -0,0 +1,4 @@ +export default Ember.Controller.extend({ + adminGroupsBulk: Ember.inject.controller(), + bulkAddResponse: Ember.computed.alias('adminGroupsBulk.bulkAddResponse') +}); diff --git a/app/assets/javascripts/admin/controllers/admin-groups-bulk.js.es6 b/app/assets/javascripts/admin/controllers/admin-groups-bulk.js.es6 index bf60519c5b..8f8b28f6b4 100644 --- a/app/assets/javascripts/admin/controllers/admin-groups-bulk.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-groups-bulk.js.es6 @@ -6,6 +6,7 @@ export default Ember.Controller.extend({ users: null, groupId: null, saving: false, + bulkAddResponse: null, @computed('saving', 'users', 'groupId') buttonDisabled(saving, users, groupId) { @@ -24,7 +25,8 @@ export default Ember.Controller.extend({ ajax('/admin/groups/bulk', { data: { users, group_id: this.get('groupId') }, method: 'PUT' - }).then(() => { + }).then(result => { + this.set('bulkAddResponse', result); this.transitionToRoute('adminGroups.bulkComplete'); }).catch(popupAjaxError).finally(() => { this.set('saving', false); diff --git a/app/assets/javascripts/admin/controllers/admin-groups-type-index.js.es6 b/app/assets/javascripts/admin/controllers/admin-groups-type-index.js.es6 new file mode 100644 index 0000000000..ca80f093d1 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-groups-type-index.js.es6 @@ -0,0 +1,11 @@ +import computed from 'ember-addons/ember-computed-decorators'; + +export default Ember.Controller.extend({ + adminGroupsType: Ember.inject.controller(), + sortedGroups: Ember.computed.alias("adminGroupsType.sortedGroups"), + + @computed("sortedGroups") + messageKey(sortedGroups) { + return `admin.groups.${sortedGroups.length > 0 ? 'none_selected' : 'no_custom_groups'}`; + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 b/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 index 98f135dc57..4899c4b59e 100644 --- a/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 @@ -5,9 +5,20 @@ import StaffActionLog from 'admin/models/staff-action-log'; export default Ember.Controller.extend({ loading: false, filters: null, + userHistoryActions: [], filtersExists: Ember.computed.gt('filterCount', 0), + filterActionIdChanged: function(){ + const filterActionId = this.get('filterActionId'); + if (filterActionId) { + this._changeFilters({ + action_name: this.get('userHistoryActions').findBy("id", parseInt(filterActionId,10)).name_raw, + action_id: filterActionId + }); + } + }.observes('filterActionId'), + actionFilter: function() { var name = this.get('filters.action_name'); if (name) { @@ -20,7 +31,6 @@ export default Ember.Controller.extend({ showInstructions: Ember.computed.gt('model.length', 0), refresh: function() { - var self = this; this.set('loading', true); var filters = this.get('filters'), @@ -37,10 +47,21 @@ export default Ember.Controller.extend({ }); this.set('filterCount', count); - StaffActionLog.findAll(params).then(function(result) { - self.set('model', result); - }).finally(function() { - self.set('loading', false); + StaffActionLog.findAll(params).then((result) => { + this.set('model', result.staff_action_logs); + if (this.get('userHistoryActions').length === 0) { + let actionTypes = result.user_history_actions.map(pair => { + return { + id: pair.id, + name: I18n.t("admin.logs.staff_actions.actions." + pair.name), + name_raw: pair.name + }; + }); + actionTypes = _.sortBy(actionTypes, row => row.name); + this.set('userHistoryActions', actionTypes); + } + }).finally(()=>{ + this.set('loading', false); }); }, @@ -63,6 +84,7 @@ export default Ember.Controller.extend({ changed.action_name = null; changed.action_id = null; changed.custom_type = null; + this.set("filterActionId", null); } else { changed[key] = null; } @@ -70,6 +92,7 @@ export default Ember.Controller.extend({ }, clearAllFilters: function() { + this.set("filterActionId", null); this.resetFilters(); }, diff --git a/app/assets/javascripts/admin/controllers/admin-plugins.js.es6 b/app/assets/javascripts/admin/controllers/admin-plugins.js.es6 index 8238b1d5af..5393a27c8f 100644 --- a/app/assets/javascripts/admin/controllers/admin-plugins.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-plugins.js.es6 @@ -1,16 +1,9 @@ -import computed from 'ember-addons/ember-computed-decorators'; - export default Ember.Controller.extend({ - @computed('model.@each.enabled_setting') - adminRoutes() { - let routes = []; - - this.get('model').forEach(p => { - if (this.siteSettings[p.get('enabled_setting')] && p.get('admin_route')) { - routes.push(p.get('admin_route')); + adminRoutes: function() { + return this.get('model').map(p => { + if (p.get('enabled')) { + return p.admin_route; } - }); - - return routes; - } + }).compact(); + }.property() }); diff --git a/app/assets/javascripts/admin/controllers/admin-reports.js.es6 b/app/assets/javascripts/admin/controllers/admin-reports.js.es6 index 1d4dac7e64..83fde41c7e 100644 --- a/app/assets/javascripts/admin/controllers/admin-reports.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-reports.js.es6 @@ -4,7 +4,7 @@ import Report from 'admin/models/report'; import computed from 'ember-addons/ember-computed-decorators'; export default Ember.Controller.extend({ - queryParams: ["mode", "start-date", "end-date", "category-id", "group-id"], + queryParams: ["mode", "start_date", "end_date", "category_id", "group_id"], viewMode: 'graph', viewingTable: Em.computed.equal('viewMode', 'table'), viewingGraph: Em.computed.equal('viewMode', 'graph'), @@ -28,7 +28,15 @@ export default Ember.Controller.extend({ @computed('model.type') showCategoryOptions(modelType) { - return !modelType.match(/_private_messages$/) && !modelType.match(/^page_view_/); + return [ + 'topics', + 'posts', + 'time_to_first_response_total', + 'topics_with_no_response', + 'flags', + 'likes', + 'bookmarks' + ].includes(modelType); }, @computed('model.type') @@ -42,13 +50,13 @@ export default Ember.Controller.extend({ this.set("refreshing", true); this.setProperties({ - 'start-date': this.get('startDate'), - 'end-date': this.get('endDate'), - 'category-id': this.get('categoryId'), + 'start_date': this.get('startDate'), + 'end_date': this.get('endDate'), + 'category_id': this.get('categoryId'), }); if (this.get('groupId')){ - this.set('group-id', this.get('groupId')); + this.set('group_id', this.get('groupId')); } q = Report.find(this.get("model.type"), this.get("startDate"), this.get("endDate"), this.get("categoryId"), this.get("groupId")); diff --git a/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 b/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 index 9cd65adf10..02a907d7af 100644 --- a/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 @@ -1,7 +1,6 @@ import debounce from 'discourse/lib/debounce'; export default Ember.Controller.extend({ - queryParams: ["filter"], filter: null, onlyOverridden: false, filtered: Ember.computed.notEmpty('filter'), diff --git a/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 b/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 index 14bc031c0a..834ed3912b 100644 --- a/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 @@ -1,8 +1,13 @@ import { ajax } from 'discourse/lib/ajax'; import CanCheckEmails from 'discourse/mixins/can-check-emails'; import { propertyNotEqual, setting } from 'discourse/lib/computed'; +import { userPath } from 'discourse/lib/url'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import computed from 'ember-addons/ember-computed-decorators'; export default Ember.Controller.extend(CanCheckEmails, { + editingUsername: false, + editingName: false, editingTitle: false, originalPrimaryGroupId: null, availableGroups: null, @@ -30,6 +35,11 @@ export default Ember.Controller.extend(CanCheckEmails, { return []; }.property('model.user_fields.[]'), + @computed('model.username_lower') + preferencesPath(username) { + return userPath(`${username}/preferences`); + }, + actions: { impersonate() { return this.get("model").impersonate(); }, @@ -54,23 +64,58 @@ export default Ember.Controller.extend(CanCheckEmails, { anonymize() { return this.get('model').anonymize(); }, destroy() { return this.get('model').destroy(); }, + toggleUsernameEdit() { + this.set('userUsernameValue', this.get('model.username')); + this.toggleProperty('editingUsername'); + }, + + saveUsername() { + const oldUsername = this.get('model.username'); + this.set('model.username', this.get('userUsernameValue')); + + return ajax(`/users/${oldUsername.toLowerCase()}/preferences/username`, { + data: { new_username: this.get('userUsernameValue') }, + type: 'PUT' + }).catch(e => { + this.set('model.username', oldUsername); + popupAjaxError(e); + }).finally(() => this.toggleProperty('editingUsername')); + }, + + toggleNameEdit() { + this.set('userNameValue', this.get('model.name')); + this.toggleProperty('editingName'); + }, + + saveName() { + const oldName = this.get('model.name'); + this.set('model.name', this.get('userNameValue')); + + return ajax(userPath(`${this.get('model.username').toLowerCase()}.json`), { + data: { name: this.get('userNameValue') }, + type: 'PUT' + }).catch(e => { + this.set('model.name', oldName); + popupAjaxError(e); + }).finally(() => this.toggleProperty('editingName')); + }, + toggleTitleEdit() { this.set('userTitleValue', this.get('model.title')); this.toggleProperty('editingTitle'); }, saveTitle() { - const self = this; + const prevTitle = this.get('userTitleValue'); - return ajax(`/users/${this.get('model.username').toLowerCase()}.json`, { + this.set('model.title', this.get('userTitleValue')); + return ajax(userPath(`${this.get('model.username').toLowerCase()}.json`), { data: {title: this.get('userTitleValue')}, type: 'PUT' - }).catch(function(e) { - bootbox.alert(I18n.t("generic_error_with_reason", {error: "http: " + e.status + " - " + e.body})); - }).finally(function() { - self.set('model.title', self.get('userTitleValue')); - self.toggleProperty('editingTitle'); - }); + }).catch(e => { + this.set('model.title', prevTitle); + popupAjaxError(e); + }).finally(() => this.toggleProperty('editingTitle')); }, generateApiKey() { diff --git a/app/assets/javascripts/admin/controllers/admin-users-list-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-users-list-show.js.es6 index 1d877dadaf..43af644266 100644 --- a/app/assets/javascripts/admin/controllers/admin-users-list-show.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-users-list-show.js.es6 @@ -1,9 +1,14 @@ import debounce from 'discourse/lib/debounce'; import { i18n } from 'discourse/lib/computed'; import AdminUser from 'admin/models/admin-user'; +import { observes } from 'ember-addons/ember-computed-decorators'; + export default Ember.Controller.extend({ query: null, + queryParams: ['order', 'ascending'], + order: null, + ascending: null, showEmails: false, refreshing: false, listFilter: null, @@ -39,14 +44,15 @@ export default Ember.Controller.extend({ this._refreshUsers(); }, 250).observes('listFilter'), + + @observes('order', 'ascending') _refreshUsers: function() { - var self = this; this.set('refreshing', true); - AdminUser.findAll(this.get('query'), { filter: this.get('listFilter'), show_emails: this.get('showEmails') }).then(function (result) { - self.set('model', result); - }).finally(function() { - self.set('refreshing', false); + AdminUser.findAll(this.get('query'), { filter: this.get('listFilter'), show_emails: this.get('showEmails'), order: this.get('order'), ascending: this.get('ascending') }).then( (result) => { + this.set('model', result); + }).finally( () => { + this.set('refreshing', false); }); }, diff --git a/app/assets/javascripts/admin/controllers/modals/admin-add-upload.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-add-upload.js.es6 new file mode 100644 index 0000000000..3d771a91e6 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/modals/admin-add-upload.js.es6 @@ -0,0 +1,63 @@ +import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import { ajax } from 'discourse/lib/ajax'; +import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; + +export default Ember.Controller.extend(ModalFunctionality, { + adminCustomizeThemesShow: Ember.inject.controller(), + + onShow() { + this.set('name', null); + this.set('fileSelected', false); + }, + + enabled: Em.computed.and('nameValid', 'fileSelected'), + disabled: Em.computed.not('enabled'), + + @computed('name') + nameValid(name) { + return name && name.match(/^[a-zA-Z0-9-_]+$/); + }, + + @observes('name') + uploadChanged(){ + let file = $('#file-input')[0]; + this.set('fileSelected', file && file.files[0]); + }, + + actions: { + updateName() { + let name = this.get('name'); + if (Em.isEmpty(name)) { + name = $('#file-input')[0].files[0].name; + this.set('name', name.split(".")[0]); + } + this.uploadChanged(); + }, + upload() { + + let options = { + type: 'POST' + }; + + options.processData = false; + options.contentType = false; + options.data = new FormData(); + let file = $('#file-input')[0].files[0]; + options.data.append('file', file); + + ajax('/admin/themes/upload_asset', options).then(result=>{ + let upload = { + upload_id: result.upload_id, + name: this.get('name'), + original_filename: file.name + }; + this.get('adminCustomizeThemesShow').send('addUpload', upload); + this.send('closeModal'); + }).catch(e => { + popupAjaxError(e); + }); + + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-color-scheme-select-base.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-color-scheme-select-base.js.es6 new file mode 100644 index 0000000000..94939fa09f --- /dev/null +++ b/app/assets/javascripts/admin/controllers/modals/admin-color-scheme-select-base.js.es6 @@ -0,0 +1,14 @@ +import ModalFunctionality from 'discourse/mixins/modal-functionality'; + +export default Ember.Controller.extend(ModalFunctionality, { + + adminCustomizeColors: Ember.inject.controller(), + + actions: { + selectBase() { + this.get('adminCustomizeColors') + .send('newColorSchemeWithBase', this.get('selectedBaseThemeId')); + this.send('closeModal'); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6 new file mode 100644 index 0000000000..d59d419ef5 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6 @@ -0,0 +1,35 @@ +import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import { ajax } from 'discourse/lib/ajax'; +// import computed from 'ember-addons/ember-computed-decorators'; + +export default Ember.Controller.extend(ModalFunctionality, { + local: Ember.computed.equal('selection', 'local'), + remote: Ember.computed.equal('selection', 'remote'), + selection: 'local', + adminCustomizeThemes: Ember.inject.controller(), + + actions: { + importTheme() { + + let options = { + type: 'POST' + }; + + if (this.get('local')) { + options.processData = false; + options.contentType = false; + options.data = new FormData(); + options.data.append('theme', $('#file-input')[0].files[0]); + } else { + options.data = {remote: this.get('uploadUrl')}; + } + + ajax('/admin/themes/import', options).then(result=>{ + const theme = this.store.createRecord('theme',result.theme); + this.get('adminCustomizeThemes').send('addTheme', theme); + this.send('closeModal'); + }); + + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-incoming-email.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-incoming-email.js.es6 index bc9cd2edd8..3d57d2a6c3 100644 --- a/app/assets/javascripts/admin/controllers/modals/admin-incoming-email.js.es6 +++ b/app/assets/javascripts/admin/controllers/modals/admin-incoming-email.js.es6 @@ -2,6 +2,7 @@ import ModalFunctionality from 'discourse/mixins/modal-functionality'; import IncomingEmail from 'admin/models/incoming-email'; import computed from 'ember-addons/ember-computed-decorators'; import { longDate } from 'discourse/lib/formatter'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; export default Ember.Controller.extend(ModalFunctionality, { @@ -12,6 +13,15 @@ export default Ember.Controller.extend(ModalFunctionality, { load(id) { return IncomingEmail.find(id).then(result => this.set("model", result)); + }, + + loadFromBounced(id) { + return IncomingEmail.findByBounced(id) + .then(result => this.set("model", result)) + .catch(error => { + this.send("closeModal"); + popupAjaxError(error); + }); } }); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-theme-change.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-theme-change.js.es6 new file mode 100644 index 0000000000..82aba506a2 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/modals/admin-theme-change.js.es6 @@ -0,0 +1,13 @@ +import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import { ajax } from 'discourse/lib/ajax'; + +export default Ember.Controller.extend(ModalFunctionality, { + loadDiff() { + this.set('loading', true); + ajax('/admin/logs/staff_action_logs/' + this.get('model.id') + '/diff') + .then(diff=>{ + this.set('loading', false); + this.set('diff', diff.side_by_side); + }); + } +}); diff --git a/app/assets/javascripts/admin/controllers/modals/change-site-customization-details.js.es6 b/app/assets/javascripts/admin/controllers/modals/change-site-customization-details.js.es6 deleted file mode 100644 index ca6ac31db1..0000000000 --- a/app/assets/javascripts/admin/controllers/modals/change-site-customization-details.js.es6 +++ /dev/null @@ -1,20 +0,0 @@ -import ModalFunctionality from 'discourse/mixins/modal-functionality'; - -export default Ember.Controller.extend(ModalFunctionality, { - previousSelected: Ember.computed.equal('selectedTab', 'previous'), - newSelected: Ember.computed.equal('selectedTab', 'new'), - - onShow: function() { - this.send("selectNew"); - }, - - actions: { - selectNew: function() { - this.set('selectedTab', 'new'); - }, - - selectPrevious: function() { - this.set('selectedTab', 'previous'); - } - } -}); diff --git a/app/assets/javascripts/admin/controllers/modals/delete-site-customization-details.js.es6 b/app/assets/javascripts/admin/controllers/modals/delete-site-customization-details.js.es6 deleted file mode 100644 index 95537e305a..0000000000 --- a/app/assets/javascripts/admin/controllers/modals/delete-site-customization-details.js.es6 +++ /dev/null @@ -1,7 +0,0 @@ -import ChangeSiteCustomizationDetailsController from "admin/controllers/modals/change-site-customization-details"; - -export default ChangeSiteCustomizationDetailsController.extend({ - onShow() { - this.send("selectPrevious"); - } -}); diff --git a/app/assets/javascripts/admin/models/admin-user.js.es6 b/app/assets/javascripts/admin/models/admin-user.js.es6 index 48764b671d..0720976e69 100644 --- a/app/assets/javascripts/admin/models/admin-user.js.es6 +++ b/app/assets/javascripts/admin/models/admin-user.js.es6 @@ -5,6 +5,7 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; import ApiKey from 'admin/models/api-key'; import Group from 'discourse/models/group'; import TL3Requirements from 'admin/models/tl3-requirements'; +import { userPath } from 'discourse/lib/url'; const AdminUser = Discourse.User.extend({ @@ -70,7 +71,12 @@ const AdminUser = Discourse.User.extend({ groupRemoved(groupId) { return ajax("/admin/users/" + this.get('id') + "/groups/" + groupId, { type: 'DELETE' - }).then(() => this.set('groups.[]', this.get('groups').rejectBy("id", groupId))); + }).then(() => { + this.set('groups.[]', this.get('groups').rejectBy("id", groupId)); + if (this.get('primary_group_id') === groupId) { + this.set('primary_group_id', null); + } + }); }, revokeApiKey() { @@ -114,11 +120,10 @@ const AdminUser = Discourse.User.extend({ }, revokeAdmin() { - const self = this; - return ajax("/admin/users/" + this.get('id') + "/revoke_admin", { + return ajax(`/admin/users/${this.get('id')}/revoke_admin`, { type: 'PUT' - }).then(function() { - self.setProperties({ + }).then(() => { + this.setProperties({ admin: false, can_grant_admin: true, can_revoke_admin: false @@ -127,15 +132,10 @@ const AdminUser = Discourse.User.extend({ }, grantAdmin() { - const self = this; - return ajax("/admin/users/" + this.get('id') + "/grant_admin", { + return ajax(`/admin/users/${this.get('id')}/grant_admin`, { type: 'PUT' - }).then(function() { - self.setProperties({ - admin: true, - can_grant_admin: false, - can_revoke_admin: true - }); + }).then(() => { + bootbox.alert(I18n.t("admin.user.grant_admin_confirm")); }).catch(popupAjaxError); }, @@ -346,7 +346,7 @@ const AdminUser = Discourse.User.extend({ }, sendActivationEmail() { - return ajax('/users/action/send_activation_email', { + return ajax(userPath('action/send_activation_email'), { type: 'POST', data: { username: this.get('username') } }).then(function() { diff --git a/app/assets/javascripts/admin/models/color-scheme.js.es6 b/app/assets/javascripts/admin/models/color-scheme.js.es6 index 743c779d6c..51e687f5c5 100644 --- a/app/assets/javascripts/admin/models/color-scheme.js.es6 +++ b/app/assets/javascripts/admin/models/color-scheme.js.es6 @@ -9,18 +9,26 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, { }, description: function() { - return "" + this.name + (this.enabled ? ' (*)' : ''); + return "" + this.name; }.property(), startTrackingChanges: function() { this.set('originals', { - name: this.get('name'), - enabled: this.get('enabled') + name: this.get('name') }); }, + schemeJson(){ + let buffer = []; + _.each(this.get('colors'), (c) => { + buffer.push(` "${c.get('name')}": "${c.get('hex')}"`); + }); + + return [`"${this.get("name")}": {`, buffer.join(",\n"), "}"].join("\n"); + }, + copy: function() { - var newScheme = ColorScheme.create({name: this.get('name'), enabled: false, can_edit: true, colors: Em.A()}); + var newScheme = ColorScheme.create({name: this.get('name'), can_edit: true, colors: Em.A()}); _.each(this.get('colors'), function(c){ newScheme.colors.pushObject(ColorSchemeColor.create({name: c.get('name'), hex: c.get('hex'), default_hex: c.get('default_hex')})); }); @@ -29,19 +37,16 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, { changed: function() { if (!this.originals) return false; - if (this.originals['name'] !== this.get('name') || this.originals['enabled'] !== this.get('enabled')) return true; + if (this.originals['name'] !== this.get('name')) return true; if (_.any(this.get('colors'), function(c){ return c.get('changed'); })) return true; return false; - }.property('name', 'enabled', 'colors.@each.changed', 'saving'), + }.property('name', 'colors.@each.changed', 'saving'), disableSave: function() { + if (this.get('theme_id')) { return false; } return !this.get('changed') || this.get('saving') || _.any(this.get('colors'), function(c) { return !c.get('valid'); }); }.property('changed'), - disableEnable: function() { - return !this.get('id') || this.get('saving'); - }.property('id', 'saving'), - newRecord: function() { return (!this.get('id')); }.property('id'), @@ -53,11 +58,11 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, { this.set('savingStatus', I18n.t('saving')); this.set('saving',true); - var data = { enabled: this.enabled }; + var data = {}; if (!opts || !opts.enabledOnly) { data.name = this.name; - + data.base_scheme_id = this.get('base_scheme_id'); data.colors = []; _.each(this.get('colors'), function(c) { if (!self.id || c.get('changed')) { @@ -78,8 +83,6 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, { _.each(self.get('colors'), function(c) { c.startTrackingChanges(); }); - } else { - self.set('originals.enabled', data.enabled); } self.set('savingStatus', I18n.t('saved')); self.set('saving', false); @@ -96,30 +99,25 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, { }); var ColorSchemes = Ember.ArrayProxy.extend({ - selectedItemChanged: function() { - var selected = this.get('selectedItem'); - _.each(this.get('content'),function(i) { - return i.set('selected', selected === i); - }); - }.observes('selectedItem') }); ColorScheme.reopenClass({ findAll: function() { var colorSchemes = ColorSchemes.create({ content: [], loading: true }); - ajax('/admin/color_schemes').then(function(all) { + return ajax('/admin/color_schemes').then(function(all) { _.each(all, function(colorScheme){ colorSchemes.pushObject(ColorScheme.create({ id: colorScheme.id, name: colorScheme.name, - enabled: colorScheme.enabled, is_base: colorScheme.is_base, + theme_id: colorScheme.theme_id, + theme_name: colorScheme.theme_name, + base_scheme_id: colorScheme.base_scheme_id, colors: colorScheme.colors.map(function(c) { return ColorSchemeColor.create({name: c.name, hex: c.hex, default_hex: c.default_hex}); }) })); }); - colorSchemes.set('loading', false); + return colorSchemes; }); - return colorSchemes; } }); diff --git a/app/assets/javascripts/admin/models/incoming-email.js.es6 b/app/assets/javascripts/admin/models/incoming-email.js.es6 index d0386b2bc4..470c3fa56f 100644 --- a/app/assets/javascripts/admin/models/incoming-email.js.es6 +++ b/app/assets/javascripts/admin/models/incoming-email.js.es6 @@ -19,6 +19,11 @@ IncomingEmail.reopenClass({ return ajax(`/admin/email/incoming/${id}.json`); }, + findByBounced(id) { + return ajax(`/admin/email/incoming_from_bounced/${id}.json`); + }, + + findAll(filter, offset) { filter = filter || {}; offset = offset || 0; diff --git a/app/assets/javascripts/admin/models/site-customization.js.es6 b/app/assets/javascripts/admin/models/site-customization.js.es6 deleted file mode 100644 index fe2176bf11..0000000000 --- a/app/assets/javascripts/admin/models/site-customization.js.es6 +++ /dev/null @@ -1,31 +0,0 @@ -import RestModel from 'discourse/models/rest'; - -const trackedProperties = [ - 'enabled', 'name', 'stylesheet', 'header', 'top', 'footer', 'mobile_stylesheet', - 'mobile_header', 'mobile_top', 'mobile_footer', 'head_tag', 'body_tag', 'embedded_css' -]; - -function changed() { - const originals = this.get('originals'); - if (!originals) { return false; } - return _.some(trackedProperties, (p) => originals[p] !== this.get(p)); -} - -const SiteCustomization = RestModel.extend({ - description: function() { - return "" + this.name + (this.enabled ? ' (*)' : ''); - }.property('selected', 'name', 'enabled'), - - changed: changed.property.apply(changed, trackedProperties.concat('originals')), - - startTrackingChanges: function() { - this.set('originals', this.getProperties(trackedProperties)); - }.on('init'), - - saveChanges() { - return this.save(this.getProperties(trackedProperties)).then(() => this.startTrackingChanges()); - }, - -}); - -export default SiteCustomization; diff --git a/app/assets/javascripts/admin/models/staff-action-log.js.es6 b/app/assets/javascripts/admin/models/staff-action-log.js.es6 index 24ec4fbcc0..e60d815f7c 100644 --- a/app/assets/javascripts/admin/models/staff-action-log.js.es6 +++ b/app/assets/javascripts/admin/models/staff-action-log.js.es6 @@ -39,7 +39,7 @@ const StaffActionLog = Discourse.Model.extend({ }.property('action_name'), useCustomModalForDetails: function() { - return _.contains(['change_site_customization', 'delete_site_customization'], this.get('action_name')); + return _.contains(['change_theme', 'delete_theme'], this.get('action_name')); }.property('action_name') }); @@ -57,10 +57,13 @@ StaffActionLog.reopenClass({ }, findAll: function(filters) { - return ajax("/admin/logs/staff_action_logs.json", { data: filters }).then(function(staff_actions) { - return staff_actions.map(function(s) { - return StaffActionLog.create(s); - }); + return ajax("/admin/logs/staff_action_logs.json", { data: filters }).then((data) => { + return { + staff_action_logs: data.staff_action_logs.map(function(s) { + return StaffActionLog.create(s); + }), + user_history_actions: data.user_history_actions + }; }); } }); diff --git a/app/assets/javascripts/admin/models/theme.js.es6 b/app/assets/javascripts/admin/models/theme.js.es6 new file mode 100644 index 0000000000..742d06317f --- /dev/null +++ b/app/assets/javascripts/admin/models/theme.js.es6 @@ -0,0 +1,156 @@ +import RestModel from 'discourse/models/rest'; +import { default as computed } from 'ember-addons/ember-computed-decorators'; + +const THEME_UPLOAD_VAR = 2; + +const Theme = RestModel.extend({ + + @computed('theme_fields') + themeFields(fields) { + + if (!fields) { + this.set('theme_fields', []); + return {}; + } + + let hash = {}; + if (fields) { + fields.forEach(field=>{ + if (!field.type_id || field.type_id < THEME_UPLOAD_VAR) { + hash[this.getKey(field)] = field; + } + }); + } + return hash; + }, + + @computed('theme_fields', 'theme_fields.@each') + uploads(fields) { + if (!fields) { + return []; + } + return fields.filter((f)=> f.target === 'common' && f.type_id === THEME_UPLOAD_VAR); + }, + + getKey(field){ + return field.target + " " + field.name; + }, + + hasEdited(target, name){ + if (name) { + return !Em.isEmpty(this.getField(target, name)); + } else { + let fields = this.get("theme_fields") || []; + return fields.any(field => (field.target === target && !Em.isEmpty(field.value))); + } + }, + + getError(target, name) { + let themeFields = this.get("themeFields"); + let key = this.getKey({target,name}); + let field = themeFields[key]; + return field ? field.error : ""; + }, + + getField(target, name) { + let themeFields = this.get("themeFields"); + let key = this.getKey({target, name}); + let field = themeFields[key]; + return field ? field.value : ""; + }, + + removeField(field) { + this.set("changed", true); + + field.upload_id = null; + field.value = null; + + return this.saveChanges("theme_fields"); + }, + + setField(target, name, value, upload_id, type_id) { + this.set("changed", true); + let themeFields = this.get("themeFields"); + let field = {name, target, value, upload_id, type_id}; + + // slow path for uploads and so on + if (type_id && type_id > 1) { + let fields = this.get("theme_fields"); + let existing = fields.find((f) => + f.target === target && + f.name === name && + f.type_id === type_id); + if (existing) { + existing.value = value; + existing.upload_id = upload_id; + } else { + fields.push(field); + } + return; + } + + // fast path + let key = this.getKey({target,name}); + let existingField = themeFields[key]; + if (!existingField) { + this.theme_fields.push(field); + themeFields[key] = field; + } else { + existingField.value = value; + } + }, + + @computed("childThemes.@each") + child_theme_ids(childThemes) { + if (childThemes) { + return childThemes.map(theme => Ember.get(theme, "id")); + } + }, + + removeChildTheme(theme) { + const childThemes = this.get("childThemes"); + childThemes.removeObject(theme); + return this.saveChanges("child_theme_ids"); + }, + + addChildTheme(theme){ + let childThemes = this.get("childThemes"); + if (!childThemes) { + childThemes = []; + this.set('childThemes', childThemes); + } + childThemes.removeObject(theme); + childThemes.pushObject(theme); + return this.saveChanges("child_theme_ids"); + }, + + @computed('name', 'default') + description: function(name, isDefault) { + if (isDefault) { + return I18n.t('admin.customize.theme.default_name', {name: name}); + } else { + return name; + } + }, + + checkForUpdates() { + return this.save({remote_check: true}) + .then(() => this.set("changed", false)); + }, + + updateToLatest() { + return this.save({remote_update: true}) + .then(() => this.set("changed", false)); + }, + + changed: false, + + saveChanges() { + const hash = this.getProperties.apply(this, arguments); + return this.save(hash) + .then(() => this.set("changed", false)); + }, + +}); + +export default Theme; diff --git a/app/assets/javascripts/admin/routes/admin-backups.js.es6 b/app/assets/javascripts/admin/routes/admin-backups.js.es6 index 95407e9ff3..6d1a425190 100644 --- a/app/assets/javascripts/admin/routes/admin-backups.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-backups.js.es6 @@ -85,7 +85,7 @@ export default Discourse.Route.extend({ if (confirmed) { Discourse.User.currentProp("hideReadOnlyAlert", true); backup.restore().then(function() { - self.controllerFor("adminBackupsLogs").clear(); + self.controllerFor("adminBackupsLogs").get("logs").clear(); self.controllerFor("adminBackups").set("model.isOperationRunning", true); self.transitionTo("admin.backups.logs"); }); diff --git a/app/assets/javascripts/admin/routes/admin-customize-colors-show.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-colors-show.js.es6 new file mode 100644 index 0000000000..3f8bdcddcd --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-customize-colors-show.js.es6 @@ -0,0 +1,18 @@ +export default Ember.Route.extend({ + + model(params) { + const all = this.modelFor('adminCustomize.colors'); + const model = all.findBy('id', parseInt(params.scheme_id)); + return model ? model : this.replaceWith('adminCustomize.colors.index'); + }, + + serialize(model) { + return {scheme_id: model.get('id')}; + }, + + setupController(controller, model) { + controller.set('model', model); + controller.set('allColors', this.modelFor('adminCustomize.colors')); + } +}); + diff --git a/app/assets/javascripts/admin/routes/admin-customize-colors.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-colors.js.es6 index 8a47f1ba21..043e571271 100644 --- a/app/assets/javascripts/admin/routes/admin-customize-colors.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-customize-colors.js.es6 @@ -6,9 +6,7 @@ export default Ember.Route.extend({ return ColorScheme.findAll(); }, - deactivate() { - this._super(); - this.controllerFor('adminCustomizeColors').set('selectedItem', null); - }, - + setupController(controller, model) { + controller.set("model", model); + } }); diff --git a/app/assets/javascripts/admin/routes/admin-customize-css-html-show.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-css-html-show.js.es6 deleted file mode 100644 index 7df829706f..0000000000 --- a/app/assets/javascripts/admin/routes/admin-customize-css-html-show.js.es6 +++ /dev/null @@ -1,11 +0,0 @@ -export default Ember.Route.extend({ - model(params) { - const all = this.modelFor('adminCustomizeCssHtml'); - const model = all.findBy('id', parseInt(params.site_customization_id)); - return model ? { model, section: params.section } : this.replaceWith('adminCustomizeCssHtml.index'); - }, - - setupController(controller, hash) { - controller.setProperties(hash); - } -}); diff --git a/app/assets/javascripts/admin/routes/admin-customize-css-html.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-css-html.js.es6 deleted file mode 100644 index 5bbb460959..0000000000 --- a/app/assets/javascripts/admin/routes/admin-customize-css-html.js.es6 +++ /dev/null @@ -1,26 +0,0 @@ -import showModal from 'discourse/lib/show-modal'; -import { popupAjaxError } from 'discourse/lib/ajax-error'; - -export default Ember.Route.extend({ - model() { - return this.store.findAll('site-customization'); - }, - - actions: { - importModal() { - showModal('upload-customization'); - }, - - newCustomization(obj) { - obj = obj || {name: I18n.t("admin.customize.new_style")}; - const item = this.store.createRecord('site-customization'); - - const all = this.modelFor('adminCustomizeCssHtml'); - const self = this; - item.save(obj).then(function() { - all.pushObject(item); - self.transitionTo('adminCustomizeCssHtml.show', item.get('id'), 'css'); - }).catch(popupAjaxError); - } - } -}); diff --git a/app/assets/javascripts/admin/routes/admin-customize-index.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-index.js.es6 index 45cb6e21fb..d8b1446dfb 100644 --- a/app/assets/javascripts/admin/routes/admin-customize-index.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-customize-index.js.es6 @@ -1,5 +1,5 @@ export default Ember.Route.extend({ beforeModel() { - this.transitionTo('adminCustomize.colors'); + this.transitionTo('adminCustomizeThemes'); } }); diff --git a/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6 new file mode 100644 index 0000000000..c1d3b225ff --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6 @@ -0,0 +1,27 @@ +export default Ember.Route.extend({ + model(params) { + const all = this.modelFor('adminCustomizeThemes'); + const model = all.findBy('id', parseInt(params.theme_id)); + return model ? { model, + target: params.target, + field_name: params.field_name + } : this.replaceWith('adminCustomizeThemes.index'); + }, + + serialize(wrapper) { + return { + model: wrapper.model, + target: wrapper.target || "common", + field_name: wrapper.field_name || "scss", + theme_id: wrapper.model.get("id") + }; + }, + + setupController(controller, wrapper) { + controller.set("model", wrapper.model); + controller.setTargetName(wrapper.target || "common"); + controller.set("fieldName", wrapper.field_name || "scss"); + this.controllerFor("adminCustomizeThemes").set("editingTheme", true); + }, + +}); diff --git a/app/assets/javascripts/admin/routes/admin-customize-themes-index.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-themes-index.js.es6 new file mode 100644 index 0000000000..b5fc281c1e --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-customize-themes-index.js.es6 @@ -0,0 +1,5 @@ +export default Ember.Route.extend({ + setupController() { + this.controllerFor("adminCustomizeThemes").set("editingTheme", false); + }, +}); diff --git a/app/assets/javascripts/admin/routes/admin-customize-themes-show.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-themes-show.js.es6 new file mode 100644 index 0000000000..8e925ba6dd --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-customize-themes-show.js.es6 @@ -0,0 +1,21 @@ +export default Ember.Route.extend({ + + serialize(model) { + return {theme_id: model.get('id')}; + }, + + model(params) { + const all = this.modelFor('adminCustomizeThemes'); + const model = all.findBy('id', parseInt(params.theme_id)); + return model ? model : this.replaceWith('adminCustomizeTheme.index'); + }, + + setupController(controller, model) { + controller.set("model", model); + const parentController = this.controllerFor("adminCustomizeThemes"); + parentController.set("editingTheme", false); + controller.set("allThemes", parentController.get("model")); + controller.set("colorSchemes", parentController.get("model.extras.color_schemes")); + controller.set("colorSchemeId", model.get("color_scheme_id")); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-customize-themes.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-themes.js.es6 new file mode 100644 index 0000000000..8291378b05 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-customize-themes.js.es6 @@ -0,0 +1,35 @@ +import showModal from 'discourse/lib/show-modal'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; + +export default Ember.Route.extend({ + model() { + return this.store.findAll('theme'); + }, + + setupController(controller, model) { + this._super(controller, model); + controller.set("editingTheme", false); + }, + + actions: { + importModal() { + showModal('admin-import-theme', {admin: true}); + }, + + addTheme(theme) { + const all = this.modelFor('adminCustomizeThemes'); + all.pushObject(theme); + this.transitionTo('adminCustomizeThemes.show', theme.get('id')); + }, + + + newTheme(obj) { + obj = obj || {name: I18n.t("admin.customize.new_style")}; + const item = this.store.createRecord('theme'); + + item.save(obj).then(() => { + this.send('addTheme', item); + }).catch(popupAjaxError); + } + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-email-bounced.js.es6 b/app/assets/javascripts/admin/routes/admin-email-bounced.js.es6 index 027a6c0f30..40dbc5a75f 100644 --- a/app/assets/javascripts/admin/routes/admin-email-bounced.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-email-bounced.js.es6 @@ -1,2 +1,14 @@ +import showModal from 'discourse/lib/show-modal'; import AdminEmailLogs from 'admin/routes/admin-email-logs'; -export default AdminEmailLogs.extend({ status: "bounced" }); + +export default AdminEmailLogs.extend({ + status: "bounced", + + actions: { + showIncomingEmail(id) { + showModal('admin-incoming-email', { admin: true }); + this.controllerFor("modals/admin-incoming-email").loadFromBounced(id); + } + } + +}); diff --git a/app/assets/javascripts/admin/routes/admin-flags-index.js.es6 b/app/assets/javascripts/admin/routes/admin-flags-index.js.es6 index d20793add2..c3f9a6d373 100644 --- a/app/assets/javascripts/admin/routes/admin-flags-index.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-flags-index.js.es6 @@ -1,5 +1,5 @@ export default Discourse.Route.extend({ - redirect: function() { + redirect() { this.replaceWith('adminFlags.list', 'active'); } }); diff --git a/app/assets/javascripts/admin/routes/admin-logs-staff-action-logs.js.es6 b/app/assets/javascripts/admin/routes/admin-logs-staff-action-logs.js.es6 index 48d04abc5f..698f90d77c 100644 --- a/app/assets/javascripts/admin/routes/admin-logs-staff-action-logs.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-logs-staff-action-logs.js.es6 @@ -6,11 +6,6 @@ export default Discourse.Route.extend({ this.render('admin/templates/logs/staff-action-logs', {into: 'adminLogs'}); }, - setupController: function(controller) { - controller.resetFilters(); - controller.refresh(); - }, - actions: { showDetailsModal(model) { showModal('admin-staff-action-log-details', { model, admin: true }); @@ -18,14 +13,9 @@ export default Discourse.Route.extend({ }, showCustomDetailsModal(model) { - const modalName = (model.action_name + '_details').replace(/\_/g, "-"); - - showModal(modalName, { - model, - admin: true, - templateName: 'site-customization-change' - }); - this.controllerFor('modal').set('modalClass', 'tabbed-modal log-details-modal'); + let modal = showModal('admin-theme-change', { model, admin: true}); + this.controllerFor('modal').set('modalClass', 'history-modal'); + modal.loadDiff(); } } }); diff --git a/app/assets/javascripts/admin/routes/admin-reports.js.es6 b/app/assets/javascripts/admin/routes/admin-reports.js.es6 index eb89a3306e..47ece70580 100644 --- a/app/assets/javascripts/admin/routes/admin-reports.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-reports.js.es6 @@ -7,11 +7,11 @@ @module Discourse **/ export default Discourse.Route.extend({ - queryParams: { mode: {}, "start-date": {}, "end-date": {}, "category-id": {}, "group-id": {}}, + queryParams: { mode: {}, "start_date": {}, "end_date": {}, "category_id": {}, "group_id": {} }, model: function(params) { const Report = require('admin/models/report').default; - return Report.find(params.type, params['start-date'], params['end-date'], params['category-id'], params['group-id']); + return Report.find(params.type, params['start_date'], params['end_date'], params['category_id'], params['group_id']); }, setupController: function(controller, model) { diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 index bd38784bb7..dd87207156 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -15,10 +15,14 @@ export default function() { }); this.route('adminCustomize', { path: '/customize', resetNamespace: true } ,function() { - this.route('colors'); - this.route('adminCustomizeCssHtml', { path: 'css_html', resetNamespace: true }, function() { - this.route('show', {path: '/:site_customization_id/:section'}); + this.route('colors', function() { + this.route('show', {path: '/:scheme_id'}); + }); + + this.route('adminCustomizeThemes', { path: 'themes', resetNamespace: true }, function() { + this.route('show', {path: '/:theme_id'}); + this.route('edit', {path: '/:theme_id/:target/:field_name/edit'}); }); this.route('adminSiteText', { path: '/site_texts', resetNamespace: true }, function() { diff --git a/app/assets/javascripts/admin/routes/admin-site-settings.js.es6 b/app/assets/javascripts/admin/routes/admin-site-settings.js.es6 index ba6e7d8761..f5f179712b 100644 --- a/app/assets/javascripts/admin/routes/admin-site-settings.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-site-settings.js.es6 @@ -1,6 +1,10 @@ import SiteSetting from 'admin/models/site-setting'; export default Discourse.Route.extend({ + queryParams: { + filter: { replace: true } + }, + model() { return SiteSetting.findAll(); }, diff --git a/app/assets/javascripts/admin/routes/admin-user-index.js.es6 b/app/assets/javascripts/admin/routes/admin-user-index.js.es6 index c10e5957d5..03053efeb9 100644 --- a/app/assets/javascripts/admin/routes/admin-user-index.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-user-index.js.es6 @@ -28,6 +28,14 @@ export default Discourse.Route.extend({ showSuspendModal(model) { showModal('admin-suspend-user', { model, admin: true }); this.controllerFor('modal').set('modalClass', 'suspend-user-modal'); + }, + + viewActionLogs(username) { + const controller = this.controllerFor('adminLogs.staffActionLogs'); + this.transitionTo('adminLogs.staffActionLogs').then(() => { + controller.set('filters', Ember.Object.create()); + controller._changeFilters({ target_user: username }); + }); } } }); diff --git a/app/assets/javascripts/admin/templates/admin.hbs b/app/assets/javascripts/admin/templates/admin.hbs index 7f34c00b77..780aad41fc 100644 --- a/app/assets/javascripts/admin/templates/admin.hbs +++ b/app/assets/javascripts/admin/templates/admin.hbs @@ -1,4 +1,4 @@ -{{#disable-custom-stylesheets class="container"}} +{{#admin-wrapper class="container"}}
@@ -34,4 +34,4 @@
-{{/disable-custom-stylesheets}} +{{/admin-wrapper}} diff --git a/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs b/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs index cc6310768a..304570c0a5 100644 --- a/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs +++ b/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs @@ -11,11 +11,11 @@ {{number report.yesterdayCount}} {{fa-icon "caret-up" class="up"}} {{fa-icon "caret-down" class="down"}} - + {{number report.lastSevenDaysCount}} {{fa-icon "caret-up" class="up"}} {{fa-icon "caret-down" class="down"}} - + {{number report.lastThirtyDaysCount}} {{fa-icon "caret-up" class="up"}} {{fa-icon "caret-down" class="down"}} diff --git a/app/assets/javascripts/admin/templates/components/customize-link.hbs b/app/assets/javascripts/admin/templates/components/customize-link.hbs deleted file mode 100644 index dd3c4104c7..0000000000 --- a/app/assets/javascripts/admin/templates/components/customize-link.hbs +++ /dev/null @@ -1,5 +0,0 @@ -
  • - - {{customization.description}} - -
  • diff --git a/app/assets/javascripts/admin/templates/components/embeddable-host.hbs b/app/assets/javascripts/admin/templates/components/embeddable-host.hbs index 5f2581138f..f4367abeff 100644 --- a/app/assets/javascripts/admin/templates/components/embeddable-host.hbs +++ b/app/assets/javascripts/admin/templates/components/embeddable-host.hbs @@ -2,6 +2,9 @@ {{input value=buffered.host placeholder="example.com" enter="save" class="host-name"}} + + {{input value=buffered.class_name placeholder="class" enter="save" class="class-name"}} + {{input value=buffered.path_whitelist placeholder="/blog/.*" enter="save" class="path-whitelist"}} @@ -14,6 +17,7 @@ {{else}} {{host.host}} + {{host.class_name}} {{host.path_whitelist}} {{category-badge host.category}} diff --git a/app/assets/javascripts/admin/templates/components/inline-edit-checkbox.hbs b/app/assets/javascripts/admin/templates/components/inline-edit-checkbox.hbs new file mode 100644 index 0000000000..3a651ad0df --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/inline-edit-checkbox.hbs @@ -0,0 +1,8 @@ + +{{#if changed}} + {{d-button action="finished" class="btn-primary btn-small submit-edit" icon="check"}} + {{d-button action="cancelled" class="btn-small cancel-edit" icon="times"}} +{{/if}} diff --git a/app/assets/javascripts/admin/templates/customize-colors-index.hbs b/app/assets/javascripts/admin/templates/customize-colors-index.hbs new file mode 100644 index 0000000000..62bbb7a8fc --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize-colors-index.hbs @@ -0,0 +1 @@ +

    {{i18n 'admin.customize.colors.about'}}

    diff --git a/app/assets/javascripts/admin/templates/customize-colors-show.hbs b/app/assets/javascripts/admin/templates/customize-colors-show.hbs new file mode 100644 index 0000000000..6b3c8a3e91 --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize-colors-show.hbs @@ -0,0 +1,62 @@ +
    +
    +

    {{#if model.theme_id}}{{model.name}}{{else}}{{text-field class="style-name" value=model.name}}{{/if}}

    +
    + {{#unless model.theme_id}} + + {{/unless}} + + + {{#if model.theme_id}} + {{i18n "admin.customize.theme_owner"}} + {{#link-to "adminCustomizeThemes.show" model.theme_id}}{{model.theme_name}}{{/link-to}} + {{else}} + + {{/if}} + {{model.savingStatus}} +
    + +
    + +
    + +
    + + {{#if colors.length}} + + + + + + + + + + {{#each colors as |c|}} + + + + + + {{/each}} + +
    {{i18n 'admin.customize.color'}}
    + {{c.translatedName}} +
    + {{c.description}} +
    {{color-input hexValue=c.hex brightnessValue=c.brightness valid=c.valid}} + {{#unless model.theme_id}} + + + {{/unless}} +
    + {{else}} +

    {{i18n 'search.no_results'}}

    + {{/if}} +
    +
    diff --git a/app/assets/javascripts/admin/templates/customize-colors.hbs b/app/assets/javascripts/admin/templates/customize-colors.hbs index 62b489075d..1ed39b8fcf 100644 --- a/app/assets/javascripts/admin/templates/customize-colors.hbs +++ b/app/assets/javascripts/admin/templates/customize-colors.hbs @@ -1,78 +1,17 @@ -
    +

    {{i18n 'admin.customize.colors.long_title'}}

      {{#each model as |scheme|}} {{#unless scheme.is_base}} -
    • {{scheme.description}}
    • +
    • + {{#link-to 'adminCustomize.colors.show' scheme replace=true}}{{fa-icon 'paint-brush'}}{{scheme.description}}{{/link-to}} +
    • {{/unless}} {{/each}}
    - +
    -{{#if selectedItem}} -
    -
    -

    {{text-field class="style-name" value=selectedItem.name}}

    - -
    - - - - - {{selectedItem.savingStatus}} -
    - -
    - -
    - -
    - - {{#if colors.length}} - - - - - - - - - - {{#each colors as |c|}} - - - - - - {{/each}} - -
    {{i18n 'admin.customize.color'}}
    - {{c.translatedName}} -
    - {{c.description}} -
    {{color-input hexValue=c.hex brightnessValue=c.brightness valid=c.valid}} - - -
    - {{else}} -

    {{i18n 'search.no_results'}}

    - {{/if}} -
    -
    -{{else}} -

    {{i18n 'admin.customize.colors.about'}}

    -{{/if}} +{{outlet}}
    diff --git a/app/assets/javascripts/admin/templates/customize-css-html-show.hbs b/app/assets/javascripts/admin/templates/customize-css-html-show.hbs deleted file mode 100644 index 024c670845..0000000000 --- a/app/assets/javascripts/admin/templates/customize-css-html-show.hbs +++ /dev/null @@ -1,75 +0,0 @@ -
    -
    - {{text-field class="style-name" value=model.name}} - {{fa-icon "download"}} {{i18n 'admin.export_json.button_text'}} - -
    - -
    - -
    - {{#if cssActive}}{{ace-editor content=model.stylesheet mode="scss"}}{{/if}} - {{#if headerActive}}{{ace-editor content=model.header mode="html"}}{{/if}} - {{#if topActive}}{{ace-editor content=model.top mode="html"}}{{/if}} - {{#if footerActive}}{{ace-editor content=model.footer mode="html"}}{{/if}} - {{#if headTagActive}}{{ace-editor content=model.head_tag mode="html"}}{{/if}} - {{#if bodyTagActive}}{{ace-editor content=model.body_tag mode="html"}}{{/if}} - {{#if embeddedCssActive}}{{ace-editor content=model.embedded_css mode="css"}}{{/if}} - {{#if mobileCssActive}}{{ace-editor content=model.mobile_stylesheet mode="scss"}}{{/if}} - {{#if mobileHeaderActive}}{{ace-editor content=model.mobile_header mode="html"}}{{/if}} - {{#if mobileTopActive}}{{ace-editor content=model.mobile_top mode="html"}}{{/if}} - {{#if mobileFooterActive}}{{ace-editor content=model.mobile_footer mode="html"}}{{/if}} -
    - - -
    -
    diff --git a/app/assets/javascripts/admin/templates/customize-css-html.hbs b/app/assets/javascripts/admin/templates/customize-css-html.hbs deleted file mode 100644 index 73b8e22c9f..0000000000 --- a/app/assets/javascripts/admin/templates/customize-css-html.hbs +++ /dev/null @@ -1,13 +0,0 @@ -
    -

    {{i18n 'admin.customize.css_html.long_title'}}

    -
      - {{#each model as |c|}} - {{customize-link customization=c}} - {{/each}} -
    - - {{d-button label="admin.customize.new" icon="plus" action="newCustomization" class="btn-primary"}} - {{d-button action="importModal" icon="upload" label="admin.customize.import"}} -
    - -{{outlet}} diff --git a/app/assets/javascripts/admin/templates/customize-themes-edit.hbs b/app/assets/javascripts/admin/templates/customize-themes-edit.hbs new file mode 100644 index 0000000000..fafce666a6 --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize-themes-edit.hbs @@ -0,0 +1,78 @@ +
    +
    +

    {{i18n 'admin.customize.theme.edit_css_html'}} {{#link-to 'adminCustomizeThemes.show' model.id replace=true}}{{model.name}}{{/link-to}}

    + + {{#if error}} +
    {{error}}
    + {{/if}} + +
    + +
    + +
    +
    +
    + +
    + +
    + + {{ace-editor content=activeSection editorId=editorId mode=activeSectionMode autofocus="true"}} + + +
    +
    diff --git a/app/assets/javascripts/admin/templates/customize-css-html-index.hbs b/app/assets/javascripts/admin/templates/customize-themes-index.hbs similarity index 100% rename from app/assets/javascripts/admin/templates/customize-css-html-index.hbs rename to app/assets/javascripts/admin/templates/customize-themes-index.hbs diff --git a/app/assets/javascripts/admin/templates/customize-themes-show.hbs b/app/assets/javascripts/admin/templates/customize-themes-show.hbs new file mode 100644 index 0000000000..6e89af4767 --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize-themes-show.hbs @@ -0,0 +1,140 @@ +
    +

    + {{#if editingName}} + {{text-field value=model.name autofocus="true"}} + {{d-button action="finishedEditingName" class="btn-primary btn-small submit-edit" icon="check"}} + {{d-button action="cancelEditingName" class="btn-small cancel-edit" icon="times"}} + {{else}} + {{model.name}} {{fa-icon "pencil"}} + {{/if}} +

    + + {{#if model.remote_theme}} +

    + {{i18n "admin.customize.theme.about_theme"}} +

    + {{#if model.remote_theme.license_url}} +

    + {{i18n "admin.customize.theme.license"}} {{fa-icon "copyright"}} +

    + {{/if}} + {{/if}} + + + {{#if parentThemes}} +

    {{i18n "admin.customize.theme.component_of"}}

    +
      + {{#each parentThemes as |theme|}} +
    • {{#link-to 'adminCustomizeThemes.show' theme replace=true}}{{theme.name}}{{/link-to}}
    • + {{/each}} +
    + {{else}} +

    + {{inline-edit-checkbox action="applyDefault" labelKey="admin.customize.theme.is_default" checked=model.default}} + {{inline-edit-checkbox action="applyUserSelectable" labelKey="admin.customize.theme.user_selectable" checked=model.user_selectable}} +

    + +

    {{i18n "admin.customize.theme.color_scheme"}}

    +

    {{i18n "admin.customize.theme.color_scheme_select"}}

    +

    {{combo-box content=colorSchemes + nameProperty="name" + value=colorSchemeId + selectionIcon="paint-brush" + valueAttribute="id"}} + {{#if colorSchemeChanged}} + {{d-button action="changeScheme" class="btn-primary btn-small submit-edit" icon="check"}} + {{d-button action="cancelChangeScheme" class="btn-small cancel-edit" icon="times"}} + {{/if}} +

    + {{#link-to 'adminCustomize.colors' class="btn edit"}}{{i18n 'admin.customize.colors.edit'}}{{/link-to}} + {{/if}} + +

    {{i18n "admin.customize.theme.css_html"}}

    + {{#if hasEditedFields}} +

    {{i18n "admin.customize.theme.custom_sections"}}

    +
      + {{#each editedDescriptions as |desc|}} +
    • {{desc}}
    • + {{/each}} +
    + {{else}} +

    + {{i18n "admin.customize.theme.edit_css_html_help"}} +

    + {{/if}} +

    + {{#if model.remote_theme}} + {{#if model.remote_theme.commits_behind}} + {{#d-button action="updateToLatest" icon="download" class='btn-primary'}}{{i18n "admin.customize.theme.update_to_latest"}}{{/d-button}} + {{else}} + {{#d-button action="checkForThemeUpdates" icon="refresh"}}{{i18n "admin.customize.theme.check_for_updates"}}{{/d-button}} + {{/if}} + {{/if}} + {{#d-button action="editTheme" class="btn edit"}}{{i18n 'admin.customize.theme.edit_css_html'}}{{/d-button}} + {{#if model.remote_theme}} + + {{#if updatingRemote}} + {{i18n 'admin.customize.theme.updating'}} + {{else}} + {{#if model.remote_theme.commits_behind}} + {{i18n 'admin.customize.theme.commits_behind' count=model.remote_theme.commits_behind}} + {{else}} + {{i18n 'admin.customize.theme.up_to_date'}} {{format-date model.remote_theme.updated_at leaveAgo="true"}} + {{/if}} + {{/if}} + + {{/if}} +

    + + +

    {{i18n "admin.customize.theme.uploads"}}

    + {{#if model.uploads}} +
      + {{#each model.uploads as |upload|}} +
    • + ${{upload.name}}: {{upload.filename}} + + {{d-button action="removeUpload" actionParam=upload class="second btn-small cancel-edit" icon="times"}} + +
    • + {{/each}} +
    + {{else}} +

    {{i18n "admin.customize.theme.no_uploads"}}

    + {{/if}} +

    + {{#d-button action="addUploadModal" icon="plus"}}{{i18n "admin.customize.theme.add"}}{{/d-button}} +

    + + {{#if availableChildThemes}} +

    {{i18n "admin.customize.theme.theme_components"}}

    + {{#unless model.childThemes.length}} +

    + +

    + {{else}} +
      + {{#each model.childThemes as |child|}} +
    • {{#link-to 'adminCustomizeThemes.show' child replace=true class='col'}}{{child.name}}{{/link-to}} {{d-button action="removeChildTheme" actionParam=child class="btn-small cancel-edit col" icon="times"}}
    • + {{/each}} +
    + {{/unless}} + {{#if selectableChildThemes}} +

    {{combo-box content=selectableChildThemes + nameProperty="name" + value=selectedChildThemeId + valueAttribute="id"}} + + {{#d-button action="addChildTheme" icon="plus"}}{{i18n "admin.customize.theme.add"}}{{/d-button}} +

    + {{/if}} + {{/if}} + + {{fa-icon 'desktop'}}{{i18n 'admin.customize.theme.preview'}} + {{fa-icon "download"}} {{i18n 'admin.export_json.button_text'}} + + {{d-button action="destroy" label="admin.customize.delete" icon="trash" class="btn-danger"}} +
    diff --git a/app/assets/javascripts/admin/templates/customize-themes.hbs b/app/assets/javascripts/admin/templates/customize-themes.hbs new file mode 100644 index 0000000000..43129006a5 --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize-themes.hbs @@ -0,0 +1,24 @@ +{{#unless editingTheme}} +
    +

    {{i18n 'admin.customize.theme.long_title'}}

    +
      + {{#each sortedThemes as |theme|}} +
    • + {{#link-to 'adminCustomizeThemes.show' theme replace=true}} + {{theme.name}} + {{#if theme.user_selectable}} + {{fa-icon "user"}} + {{/if}} + {{#if theme.default}} + {{fa-icon "asterisk"}} + {{/if}} + {{/link-to}} +
    • + {{/each}} +
    + + {{d-button label="admin.customize.new" icon="plus" action="newTheme" class="btn-primary"}} + {{d-button action="importModal" icon="upload" label="admin.customize.import"}} +
    +{{/unless}} +{{outlet}} diff --git a/app/assets/javascripts/admin/templates/customize.hbs b/app/assets/javascripts/admin/templates/customize.hbs index 7696b34811..3065c09855 100644 --- a/app/assets/javascripts/admin/templates/customize.hbs +++ b/app/assets/javascripts/admin/templates/customize.hbs @@ -1,7 +1,7 @@
    {{#admin-nav}} + {{nav-item route='adminCustomizeThemes' label='admin.customize.theme.title'}} {{nav-item route='adminCustomize.colors' label='admin.customize.colors.title'}} - {{nav-item route='adminCustomizeCssHtml' label='admin.customize.css_html.title'}} {{nav-item route='adminSiteText' label='admin.site_text.title'}} {{nav-item route='adminCustomizeEmailTemplates' label='admin.customize.email_templates.title'}} {{nav-item route='adminUserFields' label='admin.user_fields.title'}} diff --git a/app/assets/javascripts/admin/templates/dashboard.hbs b/app/assets/javascripts/admin/templates/dashboard.hbs index fbb0eb46b1..c6c4487009 100644 --- a/app/assets/javascripts/admin/templates/dashboard.hbs +++ b/app/assets/javascripts/admin/templates/dashboard.hbs @@ -261,8 +261,8 @@ {{top_referrers.title}} ({{i18n 'admin.dashboard.reports.last_30_days'}}) - {{number top_referrers.ytitles.num_clicks}} - {{number top_referrers.ytitles.num_topics}} + {{top_referrers.ytitles.num_clicks}} + {{top_referrers.ytitles.num_topics}} {{#each top_referrers.data as |r|}} diff --git a/app/assets/javascripts/admin/templates/email-bounced.hbs b/app/assets/javascripts/admin/templates/email-bounced.hbs index e2df9e04bf..9ebbb7d316 100644 --- a/app/assets/javascripts/admin/templates/email-bounced.hbs +++ b/app/assets/javascripts/admin/templates/email-bounced.hbs @@ -28,7 +28,7 @@ {{/if}} {{l.to_address}} - {{l.email_type}} + {{l.email_type}} {{else}} {{i18n 'admin.email.logs.none'}} diff --git a/app/assets/javascripts/admin/templates/email-preview-digest.hbs b/app/assets/javascripts/admin/templates/email-preview-digest.hbs index 80f48717b0..7f31c6e9bf 100644 --- a/app/assets/javascripts/admin/templates/email-preview-digest.hbs +++ b/app/assets/javascripts/admin/templates/email-preview-digest.hbs @@ -43,7 +43,7 @@ {{#if htmlEmpty}}

    {{i18n 'admin.email.no_result'}}

    {{else}} - " + end end end result << "
    " @@ -81,7 +85,7 @@ class TwitterApi end def tweet_uri_for(id) - URI.parse "#{BASE_URL}/1.1/statuses/show.json?id=#{id}" + URI.parse "#{BASE_URL}/1.1/statuses/show.json?id=#{id}&tweet_mode=extended" end unless defined? BASE_URL diff --git a/lib/upload_creator.rb b/lib/upload_creator.rb new file mode 100644 index 0000000000..c47afd4a1f --- /dev/null +++ b/lib/upload_creator.rb @@ -0,0 +1,257 @@ +require "fastimage" +require_dependency "image_sizer" + +class UploadCreator + + TYPES_CONVERTED_TO_JPEG ||= %i{bmp png} + + TYPES_TO_CROP ||= %w{avatar card_background custom_emoji profile_background}.each(&:freeze) + + WHITELISTED_SVG_ELEMENTS ||= %w{ + circle clippath defs ellipse g line linearGradient path polygon polyline + radialGradient rect stop svg text textpath tref tspan use + }.each(&:freeze) + + # Available options + # - type (string) + # - content_type (string) + # - origin (string) + # - is_attachment_for_group_message (boolean) + # - for_theme (boolean) + def initialize(file, filename, opts = {}) + @upload = Upload.new + @file = file + @filename = filename + @opts = opts + end + + def create_for(user_id) + if filesize <= 0 + @upload.errors.add(:base, I18n.t("upload.empty")) + return @upload + end + + DistributedMutex.synchronize("upload_#{user_id}_#{@filename}") do + if FileHelper.is_image?(@filename) + extract_image_info! + return @upload if @upload.errors.present? + + if @filename[/\.svg$/i] + whitelist_svg! + else + convert_to_jpeg! if should_convert_to_jpeg? + downsize! if should_downsize? + + return @upload if is_still_too_big? + + fix_orientation! if should_fix_orientation? + crop! if should_crop? + optimize! if should_optimize? + end + end + + # compute the sha of the file + sha1 = Upload.generate_digest(@file) + + # do we already have that upload? + @upload = Upload.find_by(sha1: sha1) + + # make sure the previous upload has not failed + if @upload && @upload.url.blank? + @upload.destroy + @upload = nil + end + + # return the previous upload if any + return @upload unless @upload.nil? + + # create the upload otherwise + @upload = Upload.new + @upload.user_id = user_id + @upload.original_filename = @filename + @upload.filesize = filesize + @upload.sha1 = sha1 + @upload.url = "" + @upload.origin = @opts[:origin][0...1000] if @opts[:origin] + + if FileHelper.is_image?(@filename) + @upload.width, @upload.height = ImageSizer.resize(*@image_info.size) + end + + if @opts[:is_attachment_for_group_message] + @upload.is_attachment_for_group_message = true + end + + if @opts[:for_theme] + @upload.for_theme = true + end + + return @upload unless @upload.save + + # store the file and update its url + File.open(@file.path) do |f| + url = Discourse.store.store_upload(f, @upload, @opts[:content_type]) + if url.present? + @upload.url = url + @upload.save + else + @upload.errors.add(:url, I18n.t("upload.store_failure", upload_id: @upload.id, user_id: user_id)) + end + end + + if @upload.errors.empty? && FileHelper.is_image?(@filename) && @opts[:type] == "avatar" + Jobs.enqueue(:create_avatar_thumbnails, upload_id: @upload.id, user_id: user_id) + end + + @upload + end + ensure + @file.close! rescue nil + end + + def extract_image_info! + @image_info = FastImage.new(@file) rescue nil + @file.rewind + + if @image_info.nil? + @upload.errors.add(:base, I18n.t("upload.images.not_supported_or_corrupted")) + elsif filesize <= 0 + @upload.errors.add(:base, I18n.t("upload.empty")) + elsif pixels == 0 + @upload.errors.add(:base, I18n.t("upload.images.size_not_found")) + end + end + + def should_convert_to_jpeg? + TYPES_CONVERTED_TO_JPEG.include?(@image_info.type) && + @image_info.size.min > 720 && + SiteSetting.png_to_jpg_quality < 100 + end + + def convert_to_jpeg! + jpeg_tempfile = Tempfile.new(["image", ".jpg"]) + + OptimizedImage.ensure_safe_paths!(@file.path, jpeg_tempfile.path) + Discourse::Utils.execute_command('convert', @file.path, '-quality', SiteSetting.png_to_jpg_quality.to_s, jpeg_tempfile.path) + + # keep the JPEG if it's at least 15% smaller + if File.size(jpeg_tempfile.path) < filesize * 0.85 + @image_info = FastImage.new(jpeg_tempfile) + @file = jpeg_tempfile + @filename = (File.basename(@filename, ".*").presence || I18n.t("image").presence || "image") + ".jpg" + @opts[:content_type] = "image/jpeg" + else + jpeg_tempfile.close! rescue nil + end + end + + def should_downsize? + max_image_size > 0 && filesize >= max_image_size + end + + def downsize! + 3.times do + original_size = filesize + downsized_pixels = [pixels, max_image_pixels].min / 2 + OptimizedImage.downsize(@file.path, @file.path, "#{downsized_pixels}@", filename: @filename, allow_animation: allow_animation) + extract_image_info! + return if filesize >= original_size || pixels == 0 || !should_downsize? + end + end + + def is_still_too_big? + if max_image_pixels > 0 && pixels >= max_image_pixels + @upload.errors.add(:base, I18n.t("upload.images.larger_than_x_megapixels", max_image_megapixels: SiteSetting.max_image_megapixels)) + true + elsif max_image_size > 0 && filesize >= max_image_size + @upload.errors.add(:base, I18n.t("upload.images.too_large", max_size_kb: SiteSetting.max_image_size_kb)) + true + else + false + end + end + + def whitelist_svg! + doc = Nokogiri::XML(@file) + doc.xpath(svg_whitelist_xpath).remove + File.write(@file.path, doc.to_s) + @file.rewind + end + + def should_crop? + TYPES_TO_CROP.include?(@opts[:type]) + end + + def crop! + max_pixel_ratio = Discourse::PIXEL_RATIOS.max + + case @opts[:type] + when "avatar" + width = height = Discourse.avatar_sizes.max + OptimizedImage.resize(@file.path, @file.path, width, height, filename: @filename, allow_animation: allow_animation) + when "profile_background" + max_width = 850 * max_pixel_ratio + width, height = ImageSizer.resize(@image_info.size[0], @image_info.size[1], max_width: max_width, max_height: max_width) + OptimizedImage.downsize(@file.path, @file.path, "#{width}x#{height}\\>", filename: @filename, allow_animation: allow_animation) + when "card_background" + max_width = 590 * max_pixel_ratio + width, height = ImageSizer.resize(@image_info.size[0], @image_info.size[1], max_width: max_width, max_height: max_width) + OptimizedImage.downsize(@file.path, @file.path, "#{width}x#{height}\\>", filename: @filename, allow_animation: allow_animation) + when "custom_emoji" + OptimizedImage.downsize(@file.path, @file.path, "100x100\\>", filename: @filename, allow_animation: allow_animation) + end + end + + def should_fix_orientation? + # orientation is between 1 and 8, 1 being the default + # cf. http://www.daveperrett.com/articles/2012/07/28/exif-orientation-handling-is-a-ghetto/ + @image_info.orientation.to_i > 1 + end + + def fix_orientation! + OptimizedImage.ensure_safe_paths!(@file.path) + Discourse::Utils.execute_command('convert', @file.path, '-auto-orient', @file.path) + end + + def should_optimize? + # GIF is too slow (plus, we'll soon be converting them to MP4) + # Optimizing SVG is useless + return false if @file.path =~ /\.(gif|svg)$/i + # Safeguard for large PNGs + return pixels < 2_000_000 if @file.path =~ /\.png/i + # Everything else is fine! + true + end + + def optimize! + OptimizedImage.ensure_safe_paths!(@file.path) + ImageOptim.new.optimize_image!(@file.path) + rescue ImageOptim::Worker::TimeoutExceeded + Rails.logger.warn("ImageOptim timed out while optimizing #{@filename}") + end + + def filesize + File.size?(@file.path).to_i + end + + def max_image_size + @max_image_size ||= SiteSetting.max_image_size_kb.kilobytes + end + + def max_image_pixels + @max_image_pixels ||= SiteSetting.max_image_megapixels * 1_000_000 + end + + def pixels + @image_info.size&.reduce(:*).to_i + end + + def allow_animation + @allow_animation ||= @opts[:type] == "avatar" ? SiteSetting.allow_animated_avatars : SiteSetting.allow_animated_thumbnails + end + + def svg_whitelist_xpath + @@svg_whitelist_xpath ||= "//*[#{WHITELISTED_SVG_ELEMENTS.map { |e| "name()!='#{e}'" }.join(" and ") }]" + end + +end diff --git a/lib/user_name_suggester.rb b/lib/user_name_suggester.rb index e809be0b41..2069113cfd 100644 --- a/lib/user_name_suggester.rb +++ b/lib/user_name_suggester.rb @@ -21,12 +21,16 @@ module UserNameSuggester name = fix_username(name) i = 1 attempt = name - until attempt == allow_username || User.username_available?(attempt) + until attempt == allow_username || User.username_available?(attempt) || i > 100 suffix = i.to_s max_length = User.username_length.end - suffix.length - 1 attempt = "#{name[0..max_length]}#{suffix}" i += 1 end + until attempt == allow_username || User.username_available?(attempt) || i > 200 + attempt = SecureRandom.hex[1..SiteSetting.max_username_length] + i += 1 + end attempt end diff --git a/lib/validators/censored_words_validator.rb b/lib/validators/censored_words_validator.rb index 3d3c368526..3b566a27ea 100644 --- a/lib/validators/censored_words_validator.rb +++ b/lib/validators/censored_words_validator.rb @@ -1,15 +1,40 @@ class CensoredWordsValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) - if !SiteSetting.censored_words.blank? && value =~ /#{SiteSetting.censored_words}/i + if SiteSetting.censored_words.present? && (censored_words = censor_words(value, censored_words_regexp)).present? record.errors.add( attribute, :contains_censored_words, - censored_words: SiteSetting.censored_words + censored_words: join_censored_words(censored_words) ) - elsif !SiteSetting.censored_pattern.blank? && value =~ /#{SiteSetting.censored_pattern}/i + elsif SiteSetting.censored_pattern.present? && (censored_words = censor_words(value, /#{SiteSetting.censored_pattern}/i)).present? record.errors.add( attribute, :matches_censored_pattern, - censored_pattern: SiteSetting.censored_pattern + censored_words: join_censored_words(censored_words) ) end end + + private + + def censor_words(value, regexp) + censored_words = value.scan(regexp) + censored_words.flatten! + censored_words.compact! + censored_words.map!(&:strip) + censored_words.select!(&:present?) + censored_words.uniq! + censored_words + end + + def join_censored_words(censored_words) + censored_words.map!(&:downcase) + censored_words.uniq! + censored_words.join(", ".freeze) + end + + def censored_words_regexp + Regexp.new( + SiteSetting.censored_words.split('|'.freeze).map! { |w| Regexp.escape(w) }.join('|'.freeze), + true + ) + end end diff --git a/lib/validators/integer_setting_validator.rb b/lib/validators/integer_setting_validator.rb index 2c92f3c20b..185eb0a799 100644 --- a/lib/validators/integer_setting_validator.rb +++ b/lib/validators/integer_setting_validator.rb @@ -1,6 +1,8 @@ class IntegerSettingValidator def initialize(opts={}) @opts = opts + @opts[:min] = 0 unless @opts[:min].present? || @opts[:hidden] + @opts[:max] = 20000 unless @opts[:max].present? || @opts[:hidden] end def valid_value?(val) diff --git a/lib/validators/password_validator.rb b/lib/validators/password_validator.rb index 2ce97f26a8..ced6b2f990 100644 --- a/lib/validators/password_validator.rb +++ b/lib/validators/password_validator.rb @@ -18,6 +18,8 @@ class PasswordValidator < ActiveModel::EachValidator record.errors.add(attribute, :same_as_current) elsif SiteSetting.block_common_passwords && CommonPasswords.common_password?(value) record.errors.add(attribute, :common) + elsif value.chars.uniq.length < SiteSetting.password_unique_characters + record.errors.add(attribute, :unique_characters) end end diff --git a/lib/validators/post_validator.rb b/lib/validators/post_validator.rb index a1836eb8f3..3ed62e3740 100644 --- a/lib/validators/post_validator.rb +++ b/lib/validators/post_validator.rb @@ -30,7 +30,7 @@ class Validators::PostValidator < ActiveModel::Validator end def post_body_validator(post) - return if options[:skip_post_body] + return if options[:skip_post_body] || post.topic&.pm_with_non_human_user? stripped_length(post) raw_quality(post) end diff --git a/lib/validators/upload_validator.rb b/lib/validators/upload_validator.rb index 5612662cb1..cf73111f43 100644 --- a/lib/validators/upload_validator.rb +++ b/lib/validators/upload_validator.rb @@ -24,11 +24,11 @@ class Validators::UploadValidator < ActiveModel::Validator end def is_authorized?(upload, extension) - authorized_extensions(upload, extension, authorized_uploads) + authorized_extensions(upload, extension, authorized_uploads(upload)) end def authorized_image_extension(upload, extension) - authorized_extensions(upload, extension, authorized_images) + authorized_extensions(upload, extension, authorized_images(upload)) end def maximum_image_file_size(upload) @@ -36,7 +36,7 @@ class Validators::UploadValidator < ActiveModel::Validator end def authorized_attachment_extension(upload, extension) - authorized_extensions(upload, extension, authorized_attachments) + authorized_extensions(upload, extension, authorized_attachments(upload)) end def maximum_attachment_file_size(upload) @@ -45,10 +45,12 @@ class Validators::UploadValidator < ActiveModel::Validator private - def authorized_uploads + def authorized_uploads(upload) authorized_uploads = Set.new - SiteSetting.authorized_extensions + extensions = upload.for_theme ? SiteSetting.theme_authorized_extensions : SiteSetting.authorized_extensions + + extensions .gsub(/[\s\.]+/, "") .downcase .split("|") @@ -57,20 +59,21 @@ class Validators::UploadValidator < ActiveModel::Validator authorized_uploads end - def authorized_images - authorized_uploads & FileHelper.images + def authorized_images(upload) + authorized_uploads(upload) & FileHelper.images end - def authorized_attachments - authorized_uploads - FileHelper.images + def authorized_attachments(upload) + authorized_uploads(upload) - FileHelper.images end - def authorizes_all_extensions? - SiteSetting.authorized_extensions.include?("*") + def authorizes_all_extensions?(upload) + extensions = upload.for_theme ? SiteSetting.theme_authorized_extensions : SiteSetting.authorized_extensions + extensions.include?("*") end def authorized_extensions(upload, extension, extensions) - return true if authorizes_all_extensions? + return true if authorizes_all_extensions?(upload) unless authorized = extensions.include?(extension.downcase) message = I18n.t("upload.unauthorized", authorized_extensions: extensions.to_a.join(", ")) diff --git a/lib/version.rb b/lib/version.rb index d02b1f5ee6..2a9c6acd92 100644 --- a/lib/version.rb +++ b/lib/version.rb @@ -3,9 +3,9 @@ module Discourse unless defined? ::Discourse::VERSION module VERSION #:nodoc: MAJOR = 1 - MINOR = 7 - TINY = 10 - PRE = nil + MINOR = 8 + TINY = 0 + PRE = 'beta13' STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.') end diff --git a/lib/wizard.rb b/lib/wizard.rb index ab6c5c33c0..e7a74f190c 100644 --- a/lib/wizard.rb +++ b/lib/wizard.rb @@ -1,14 +1,18 @@ require_dependency 'wizard/step' require_dependency 'wizard/field' require_dependency 'wizard/step_updater' +require_dependency 'wizard/builder' class Wizard + attr_reader :steps, :user + attr_accessor :max_topics_to_require_completion def initialize(user) @steps = [] @user = user @first_step = nil + @max_topics_to_require_completion = 15 end def create_step(step_name) @@ -75,14 +79,20 @@ class Wizard def requires_completion? return false unless SiteSetting.wizard_enabled? + return false if SiteSetting.bypass_wizard_check? + if Topic.limit(@max_topics_to_require_completion + 1).count > @max_topics_to_require_completion + SiteSetting.bypass_wizard_check = true + return false + end - first_admin = User.where(admin: true) - .where.not(id: Discourse.system_user.id) - .where.not(auth_token_updated_at: nil) - .order(:auth_token_updated_at) + first_admin_id = User.where(admin: true) + .human_users + .joins(:user_auth_tokens) + .order('user_auth_tokens.created_at') + .pluck(:id).first - if @user.present? && first_admin.first == @user && (Topic.count < 15) + if @user&.id && first_admin_id == @user.id !Wizard::Builder.new(@user).build.completed? else false diff --git a/lib/wizard/builder.rb b/lib/wizard/builder.rb index 17de742274..867d2a310c 100644 --- a/lib/wizard/builder.rb +++ b/lib/wizard/builder.rb @@ -114,40 +114,41 @@ class Wizard end @wizard.append_step('colors') do |step| - theme_id = ColorScheme.where(via_wizard: true).pluck(:theme_id) - theme_id = theme_id.present? ? theme_id[0] : 'default' + default_theme = Theme.find_by(key: SiteSetting.default_theme_key) + scheme_id = default_theme&.color_scheme&.base_scheme_id || 'default' - themes = step.add_field(id: 'theme_id', type: 'dropdown', required: true, value: theme_id) - ColorScheme.themes.each {|t| themes.add_choice(t[:id], data: t) } + themes = step.add_field(id: 'base_scheme_id', type: 'dropdown', required: true, value: scheme_id) + ColorScheme.base_color_scheme_colors.each do |t| + with_hash = t[:colors].dup + with_hash.map{|k,v| with_hash[k] = "##{v}"} + themes.add_choice(t[:id], data: {colors: with_hash}) + end step.add_field(id: 'theme_preview', type: 'component') step.on_update do |updater| - scheme_name = updater.fields[:theme_id] + scheme_name = updater.fields[:base_scheme_id] - theme = ColorScheme.themes.find {|s| s[:id] == scheme_name } + theme = nil - colors = [] - theme[:colors].each do |name, hex| - colors << {name: name, hex: hex[1..-1] } - end + if scheme_name == "dark" + scheme = ColorScheme.find_by(base_scheme_id: 'dark', via_wizard: true) - attrs = { - enabled: true, - name: I18n.t("wizard.step.colors.fields.theme_id.choices.#{scheme_name}.label"), - colors: colors, - theme_id: scheme_name - } + name = I18n.t("wizard.step.colors.fields.theme_id.choices.dark.label") + scheme ||= ColorScheme.create_from_base(name: name, via_wizard: true, base_scheme_id: "dark") - scheme = ColorScheme.where(via_wizard: true).first - if scheme.present? - attrs[:colors] = colors - revisor = ColorSchemeRevisor.new(scheme, attrs) - revisor.revise + theme = Theme.find_by(color_scheme_id: scheme.id) + name = I18n.t('color_schemes.dark_theme_name') + theme ||= Theme.create(name: name, color_scheme_id: scheme.id, user_id: @wizard.user.id) else - attrs[:via_wizard] = true - scheme = ColorScheme.new(attrs) - scheme.save! + themes = Theme.where(color_scheme_id: nil).order(:id).to_a + theme = themes.find(&:default?) + theme ||= themes.first + + name = I18n.t('color_schemes.light_theme_name') + theme ||= Theme.create(name: name, user_id: @wizard.user.id) end + + theme.set_default! end end @@ -224,7 +225,11 @@ class Wizard users.each do |u| args = {} args[:moderator] = true if u['role'] == 'moderator' - Invite.create_invite_by_email(u['email'], @wizard.user, args) + begin + Invite.create_invite_by_email(u['email'], @wizard.user, args) + rescue => e + updater.errors.add(:invite_list, e.message.concat("
    ")) + end end end end @@ -250,4 +255,3 @@ class Wizard end end end - diff --git a/plugins/discourse-details/config/locales/client.de.yml b/plugins/discourse-details/config/locales/client.de.yml new file mode 100644 index 0000000000..a55b3688bb --- /dev/null +++ b/plugins/discourse-details/config/locales/client.de.yml @@ -0,0 +1,7 @@ +de: + js: + details: + title: Details ausblenden + composer: + details_title: Zusammenfassung + details_text: "Dieser Text wird versteckt" diff --git a/plugins/discourse-details/config/locales/server.de.yml b/plugins/discourse-details/config/locales/server.de.yml new file mode 100644 index 0000000000..f1f54d1be4 --- /dev/null +++ b/plugins/discourse-details/config/locales/server.de.yml @@ -0,0 +1,3 @@ +de: + site_settings: + details_enabled: "Aktiviert das Details-Plugin. Wenn du dies änderst, musst du alle Beiträge neu „backen“ mit: \"rake posts:rebake\"." diff --git a/plugins/discourse-narrative-bot/assets/javascripts/initializers/new-user-narrative.js.es6 b/plugins/discourse-narrative-bot/assets/javascripts/initializers/new-user-narrative.js.es6 new file mode 100644 index 0000000000..40d727b6ae --- /dev/null +++ b/plugins/discourse-narrative-bot/assets/javascripts/initializers/new-user-narrative.js.es6 @@ -0,0 +1,39 @@ +import { withPluginApi } from 'discourse/lib/plugin-api'; + +function initialize(api) { + const messageBus = api.container.lookup('message-bus:main'); + const currentUser = api.getCurrentUser(); + const appEvents = api.container.lookup('app-events:main'); + const SiteHeaderComponent = api.container.lookupFactory('component:site-header'); + + SiteHeaderComponent.reopen({ + didInsertElement() { + this._super(); + this.dispatch('header:search-context-trigger', 'header'); + } + }); + + api.attachWidgetAction('header', 'headerSearchContextTrigger', function() { + if (this.site.mobileView) { + this.state.skipSearchContext = false; + } else { + this.state.contextEnabled = true; + this.state.searchContextType = 'topic'; + } + }); + + if (messageBus && currentUser) { + messageBus.subscribe(`/new_user_narrative/tutorial_search`, () => { + appEvents.trigger('header:search-context-trigger'); + }); + } +} + +export default { + name: "new-user-narratve", + + initialize(container) { + const siteSettings = container.lookup('site-settings:main'); + if (siteSettings.discourse_narrative_bot_enabled) withPluginApi('0.5', initialize); + } +}; diff --git a/plugins/discourse-narrative-bot/config/locales/client.ar.yml b/plugins/discourse-narrative-bot/config/locales/client.ar.yml new file mode 100644 index 0000000000..79ea4de815 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.ar.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +ar: {} diff --git a/plugins/discourse-narrative-bot/config/locales/client.bs_BA.yml b/plugins/discourse-narrative-bot/config/locales/client.bs_BA.yml new file mode 100644 index 0000000000..7b73cd2f22 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.bs_BA.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +bs_BA: {} diff --git a/plugins/discourse-narrative-bot/config/locales/client.cs.yml b/plugins/discourse-narrative-bot/config/locales/client.cs.yml new file mode 100644 index 0000000000..ed70fe3519 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.cs.yml @@ -0,0 +1,13 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +cs: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Zapnout všem novým uživatelům návod pro nové uživatele" + welcome_message: "Poslat všem novým uživatelům uvítací zprávu s návodem jak začit" diff --git a/plugins/discourse-narrative-bot/config/locales/client.da.yml b/plugins/discourse-narrative-bot/config/locales/client.da.yml new file mode 100644 index 0000000000..9ca8189621 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.da.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +da: {} diff --git a/plugins/discourse-narrative-bot/config/locales/client.de.yml b/plugins/discourse-narrative-bot/config/locales/client.de.yml new file mode 100644 index 0000000000..233eaaf57f --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.de.yml @@ -0,0 +1,13 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +de: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Starte bei allen neuen Benutzern das „Tutorial für neue Benutzer“" + welcome_message: "Sende allen neuen Benutzern eine Willkommensnachricht mit einer Kurzanleitung" diff --git a/plugins/discourse-narrative-bot/config/locales/client.en.yml b/plugins/discourse-narrative-bot/config/locales/client.en.yml new file mode 100644 index 0000000000..912895794d --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.en.yml @@ -0,0 +1,6 @@ +en: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Start the new user tutorial for all new users" + welcome_message: "Send all new users a welcome message with a quick start guide" diff --git a/plugins/discourse-narrative-bot/config/locales/client.es.yml b/plugins/discourse-narrative-bot/config/locales/client.es.yml new file mode 100644 index 0000000000..ffbe021243 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.es.yml @@ -0,0 +1,13 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +es: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Comenzar el tutorial de nuevos usuarios para todos los nuevos usuarios" + welcome_message: "Enviar a todos los nuevos usuarios un mensaje de bienvenida con una guía de comienzo rápida" diff --git a/plugins/discourse-narrative-bot/config/locales/client.et.yml b/plugins/discourse-narrative-bot/config/locales/client.et.yml new file mode 100644 index 0000000000..8c601aa1ed --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.et.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +et: {} diff --git a/plugins/discourse-narrative-bot/config/locales/client.fa_IR.yml b/plugins/discourse-narrative-bot/config/locales/client.fa_IR.yml new file mode 100644 index 0000000000..bae00c4791 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.fa_IR.yml @@ -0,0 +1,13 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +fa_IR: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "شروع برنامه آموزشی کاربر جدید برای تمام کاربران جدید" + welcome_message: "برای تمام کاربران جدید پیام خوش آمدید به همراه راهنمای شروع سریع بفرست" diff --git a/plugins/discourse-narrative-bot/config/locales/client.fi.yml b/plugins/discourse-narrative-bot/config/locales/client.fi.yml new file mode 100644 index 0000000000..f9b49d774d --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.fi.yml @@ -0,0 +1,13 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +fi: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Aloita alkeiskurssi kaikkien uusien käyttäjien kanssa" + welcome_message: "Lähetä kaikille uusille käyttäjille tervetuloviesti, jossa on pikaopas palstan käyttöön" diff --git a/plugins/discourse-narrative-bot/config/locales/client.fr.yml b/plugins/discourse-narrative-bot/config/locales/client.fr.yml new file mode 100644 index 0000000000..51560a931b --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.fr.yml @@ -0,0 +1,13 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +fr: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Démarrer le tutoriel nouvel utilisateur pour tous les utilisateurs" + welcome_message: "Envoyer à tous les utilisateurs un message de bienvenue avec un guide de démarrage rapide " diff --git a/plugins/discourse-narrative-bot/config/locales/client.gl.yml b/plugins/discourse-narrative-bot/config/locales/client.gl.yml new file mode 100644 index 0000000000..e1d3372343 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.gl.yml @@ -0,0 +1,13 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +gl: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Comeze o tutorial de novo usuario para todo-los usuarios novos" + welcome_message: "Envíe unha mensaxe de benvida a todo-los usuarios cunha guía de inicio" diff --git a/plugins/discourse-narrative-bot/config/locales/client.he.yml b/plugins/discourse-narrative-bot/config/locales/client.he.yml new file mode 100644 index 0000000000..4bf3fcf275 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.he.yml @@ -0,0 +1,13 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +he: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "התחילו את המדריך למתחילים לכל המשתמשים החדשים" + welcome_message: "שילחו לכל המשתמשים החדשים הודעה עם מדריך להתחלה מהירה" diff --git a/plugins/discourse-narrative-bot/config/locales/client.id.yml b/plugins/discourse-narrative-bot/config/locales/client.id.yml new file mode 100644 index 0000000000..b612993aa7 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.id.yml @@ -0,0 +1,13 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +id: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Mulai panduan anggota baru untuk semua anggota baru" + welcome_message: "Kirim semua anggota ucapan selamat datang dengan panduan awal singkat" diff --git a/plugins/discourse-narrative-bot/config/locales/client.it.yml b/plugins/discourse-narrative-bot/config/locales/client.it.yml new file mode 100644 index 0000000000..ed12933fbd --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.it.yml @@ -0,0 +1,13 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +it: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Avviare il tutorial nuovo utente per tutti i nuovi utenti" + welcome_message: "Inviare a tutti i nuovi utenti un messaggio di benvenuto con una guida di avvio rapido" diff --git a/plugins/discourse-narrative-bot/config/locales/client.ja.yml b/plugins/discourse-narrative-bot/config/locales/client.ja.yml new file mode 100644 index 0000000000..8c2dc00904 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.ja.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +ja: {} diff --git a/plugins/discourse-narrative-bot/config/locales/client.ko.yml b/plugins/discourse-narrative-bot/config/locales/client.ko.yml new file mode 100644 index 0000000000..bf4e05d6f4 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.ko.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +ko: {} diff --git a/plugins/discourse-narrative-bot/config/locales/client.nb_NO.yml b/plugins/discourse-narrative-bot/config/locales/client.nb_NO.yml new file mode 100644 index 0000000000..eb973d4561 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.nb_NO.yml @@ -0,0 +1,13 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +nb_NO: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Start den nye brukerveiledningen for alle nye brukere" + welcome_message: "Send alle nye brukere en velkomstmelding med en rask startguide" diff --git a/plugins/discourse-narrative-bot/config/locales/client.nl.yml b/plugins/discourse-narrative-bot/config/locales/client.nl.yml new file mode 100644 index 0000000000..cc9c39b8b5 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.nl.yml @@ -0,0 +1,12 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +nl: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Start de nieuwe gebruikerstutorial voor alle nieuwe gebruikers" diff --git a/plugins/discourse-narrative-bot/config/locales/client.pl_PL.yml b/plugins/discourse-narrative-bot/config/locales/client.pl_PL.yml new file mode 100644 index 0000000000..c04d371e5a --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.pl_PL.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +pl_PL: {} diff --git a/plugins/discourse-narrative-bot/config/locales/client.pt_BR.yml b/plugins/discourse-narrative-bot/config/locales/client.pt_BR.yml new file mode 100644 index 0000000000..1d8885314a --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.pt_BR.yml @@ -0,0 +1,13 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +pt_BR: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Iniciar o tutorial \"Novo Usuário\" para todos novos usuários" + welcome_message: "Enviar uma mensagem de boas vindas com um guia de início rápido para todos novos usuários" diff --git a/plugins/discourse-narrative-bot/config/locales/client.ro.yml b/plugins/discourse-narrative-bot/config/locales/client.ro.yml new file mode 100644 index 0000000000..31d1c44013 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.ro.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +ro: {} diff --git a/plugins/discourse-narrative-bot/config/locales/client.ru.yml b/plugins/discourse-narrative-bot/config/locales/client.ru.yml new file mode 100644 index 0000000000..8586e91853 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.ru.yml @@ -0,0 +1,13 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +ru: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Включить прохождение нового учебника для всех новых пользователей." + welcome_message: "Отправить всем новым пользователям приветственное сообщение с кратким руководством" diff --git a/plugins/discourse-narrative-bot/config/locales/client.sk.yml b/plugins/discourse-narrative-bot/config/locales/client.sk.yml new file mode 100644 index 0000000000..5773ccd0c8 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.sk.yml @@ -0,0 +1,13 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +sk: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Začnite s novým používateľským návodom pre všetkých nových používateľov" + welcome_message: "Pošlite všetkým nových používateľom uvítaciu správu s rýchlym sprievodcom" diff --git a/plugins/discourse-narrative-bot/config/locales/client.sq.yml b/plugins/discourse-narrative-bot/config/locales/client.sq.yml new file mode 100644 index 0000000000..4526be3162 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.sq.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +sq: {} diff --git a/plugins/discourse-narrative-bot/config/locales/client.sv.yml b/plugins/discourse-narrative-bot/config/locales/client.sv.yml new file mode 100644 index 0000000000..fd54901f75 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.sv.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +sv: {} diff --git a/plugins/discourse-narrative-bot/config/locales/client.te.yml b/plugins/discourse-narrative-bot/config/locales/client.te.yml new file mode 100644 index 0000000000..49141baa04 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.te.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +te: {} diff --git a/plugins/discourse-narrative-bot/config/locales/client.tr_TR.yml b/plugins/discourse-narrative-bot/config/locales/client.tr_TR.yml new file mode 100644 index 0000000000..00efffd670 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.tr_TR.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +tr_TR: {} diff --git a/plugins/discourse-narrative-bot/config/locales/client.uk.yml b/plugins/discourse-narrative-bot/config/locales/client.uk.yml new file mode 100644 index 0000000000..3acfff74e8 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.uk.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +uk: {} diff --git a/plugins/discourse-narrative-bot/config/locales/client.ur.yml b/plugins/discourse-narrative-bot/config/locales/client.ur.yml new file mode 100644 index 0000000000..58bc9735bc --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.ur.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +ur: {} diff --git a/plugins/discourse-narrative-bot/config/locales/client.zh_CN.yml b/plugins/discourse-narrative-bot/config/locales/client.zh_CN.yml new file mode 100644 index 0000000000..040d4d06d9 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.zh_CN.yml @@ -0,0 +1,13 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +zh_CN: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "给所有新用户启动新用户向导" + welcome_message: "给所有新用户发送快速开始指南,作为欢迎消息" diff --git a/plugins/discourse-narrative-bot/config/locales/client.zh_TW.yml b/plugins/discourse-narrative-bot/config/locales/client.zh_TW.yml new file mode 100644 index 0000000000..92c18de9ae --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.zh_TW.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +zh_TW: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.ar.yml b/plugins/discourse-narrative-bot/config/locales/server.ar.yml new file mode 100644 index 0000000000..79ea4de815 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.ar.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +ar: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.bs_BA.yml b/plugins/discourse-narrative-bot/config/locales/server.bs_BA.yml new file mode 100644 index 0000000000..7b73cd2f22 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.bs_BA.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +bs_BA: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.cs.yml b/plugins/discourse-narrative-bot/config/locales/server.cs.yml new file mode 100644 index 0000000000..c0f2fec763 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.cs.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +cs: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.da.yml b/plugins/discourse-narrative-bot/config/locales/server.da.yml new file mode 100644 index 0000000000..9ca8189621 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.da.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +da: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.de.yml b/plugins/discourse-narrative-bot/config/locales/server.de.yml new file mode 100644 index 0000000000..e162c3cdf0 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.de.yml @@ -0,0 +1,421 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +de: + site_settings: + discourse_narrative_bot_enabled: 'Discourse Narrative Bot aktivieren' + disable_discourse_narrative_bot_welcome_post: "Die vom Discourse Narrative Bot gesendete Willkommensnachricht deaktivieren" + discourse_narrative_bot_ignored_usernames: "Benutzernamen, die vom Discourse Narrative Bot ignoriert werden sollen" + discourse_narrative_bot_disable_public_replies: "Öffentliche Antworten von Discourse Narrative Bot deaktivieren" + discourse_narrative_bot_welcome_post_type: "Die Art der Willkommensnachricht, die vom Discourse Narrative Bot versendet werden soll." + badges: + certified: + name: Zertifiziert + description: "Hat das Tutorial für neue Benutzer abgeschlossen" + long_description: | + Das Abzeichen wird verliehen, wenn das interaktive Tutorial für neue Benutzer erfolgreich abgeschlossen wurde. Du hast die Grundlagen für Diskussionen erlernt und bist nun zertifiziert. + licensed: + name: Lizenziert + description: "Hat das Tutorial für fortgeschrittene Benutzer abgeschlossen" + long_description: | + Das Abzeichen wird verliehen, wenn das interaktive Tutorial für fortgeschrittene Benutzer erfolgreich abgeschlossen wurde. Du beherrscht die fortgeschrittenen Werkzeuge für Diskussionen erlernt und besitzt nun die Lizenz zum Diskutieren. + discourse_narrative_bot: + bio: "Hallo! Ich bin keine reale Person. Ich bin ein Bot, der dir etwas über diese Website beibringen kann. Schick mir eine Nachricht oder erwähne irgendwo **`@%{discobot_username}`**, um mit mir zu interagieren." + timeout: + message: |- + Hallo @%{username}! Ich wollte mich nur wieder einmal melden, weil ich schon länger nichts von dir gehört habe. + + - Um fortzusetzen, antworte mir jederzeit. + + - Wenn du diesen Schritt überspringen möchtest, antworte mit `%{skip_trigger}`. + + - Um von vorne zu beginnen, antworte mit `%{reset_trigger}`. + + Wenn du mir lieber nicht schreiben möchtest, ist das auch okay. Ich bin ein Roboter. Du wirst meine Gefühle nicht verletzen. :sob: + dice: + trigger: "würfeln" + invalid: |- + Es tut mir leid, aber das Rollen dieser Kombination von Würfeln ist mathematisch unmöglich. :confounded: + not_enough_dice: |- + Ich habe nur %{num_of_dice} Würfel. [Beschämend](http://www.therobotsvoice.com/2009/04/the_10_most_shameful_rpg_dice.php), ich weiß! + out_of_range: |- + Wusstest du, dass ein fairer Würfel aus [maximal 120 Seiten](http://www.spiegel.de/wissenschaft/mensch/d120-groesster-wuerfel-der-welt-hat-120-seiten-a-1099170.html) besteht? + results: |- + > :game_die: %{results} + quote: + trigger: "Zitat" + '1': + quote: "In der Mitte von Schwierigkeiten liegen die Möglichkeiten." + author: "Albert Einstein" + '2': + quote: "Sei du selbst die Veränderung, die du dir wünschst für diese Welt." + author: "Mahatma Gandhi" + '3': + quote: "Weine nicht, weil es vorbei ist. Lächle, weil es passiert ist." + author: "Dr. Seuss" + '4': + quote: "Gut ist man nur bedient, wenn man sich selbst bedient." + author: "Charles-Guillaume Étienne" + '5': + quote: "Glaube daran, dass du etwas kannst und du hast es schon halb geschafft." + author: "Theodore Roosevelt" + '6': + quote: "Das Leben ist wie eine Schachtel Pralinen. Man weiß nie, was man kriegt." + author: "Forrest Gump" + '7': + quote: "Das ist ein kleiner Schritt für einen Menschen, ein riesiger Sprung für die Menschheit." + author: "Neil Armstrong" + '8': + quote: "Tue jeden Tag etwas, wovor du Angst hast." + author: "Eleanor Roosevelt" + '9': + quote: "Fehler sind immer zu verzeihen, wenn man den Mut hat, diese auch zuzugeben." + author: "Bruce Lee" + '10': + quote: "Was der Verstand eines Menschen begreifen und glauben kann, kann er erreichen." + author: "Napoleon Hill" + results: |- + > :left_speech_bubble: _%{quote}_ — %{author} + magic_8_ball: + trigger: 'Vorhersage' + answers: + '1': "Es ist sicher" + '2': "Es ist entschieden so" + '3': "Ohne einen Zweifel" + '4': "Definitiv ja" + '5': "Darauf kannst du dich verlassen" + '6': "Wie ich es sehe, ja" + '7': "Höchstwahrscheinlich" + '8': "Gute Aussichten" + '9': "Ja" + '10': "Zeichen deuten auf ja" + '11': "Antwort unklar, versuch's nochmal" + '12': "Frag später nochmal" + '13': "Ich sags dir jetzt lieber nicht" + '14': "Kann es jetzt nicht vorhersagen" + '15': "Konzentriere dich und frag nochmal" + '16': "Warts erst mal ab." + '17': "Meine Antwort ist nein" + '18': "Meine Quellen sagen nein" + '19': "Nicht so gute Aussichten" + '20': "Sehr zweifelhaft" + result: |- + > :crystal_ball: %{result} + track_selector: + reset_trigger: 'starte' + skip_trigger: 'überspringen' + help_trigger: 'Hilfe anzeigen' + random_mention: + reply: |- + Hallo! Um herauszufinden, was ich kann, schreibe `@%{discobot_username} %{help_trigger}`. + tracks: |- + Ich weiß derzeit, wie man die folgenden Dinge macht: + + `@%{discobot_username} %{reset_trigger} %{default_track} + > Startet eine der folgenden interaktiven Tutorials: %{tracks}. + bot_actions: |- + `@%{discobot_username} %{dice_trigger} 2d6` + > :game_die: 3, 6 + + `@%{discobot_username} %{quote_trigger}` + > :left_speech_bubble: _Wie angenehm ist es doch, freundlich zu sein! Ein gutes Wort entschlüpft wie ein wohliger Seufzer._ — Bertolt Brecht + + `@%{discobot_username} %{magic_8_ball_trigger}` + > :crystal_ball: Darauf kannst du dich verlassen + do_not_understand: + first_response: |- + Danke für die Antwort! + + Weil ich ein schlecht programmierter Bot bin, habe ich das leider nicht ganz verstanden. :frowning: + track_response: Du kannst es nochmal versuchen, oder, wenn du diesen Schritt überspringen möchtest, antworte mit `%{skip_trigger}`. Falls du von vorne beginnen möchtest, antworte mit `%{reset_trigger}`. + second_response: |- + Es tut mir leid, aber ich verstehe es noch immer nicht. :anguished: + + Ich bin nur ein Bot. Schau dir [unsere Kontaktinformationen](/about) an, falls du dich mit einer echten Person unterhalten möchtest. + + In der Zwischenzeit lass ich dich erst mal in Ruhe. + new_user_narrative: + reset_trigger: "neuer Benutzer" + cert_title: "In Anerkennung deines erfolgreichen Abschlusses eines Tutorials für neue Benutzer" + hello: + title: ":robot: Grüß dich!" + message: |- + Willkommen bei %{title}. Danke, dass du beigetreten bist. + + - Ich bin nur ein Roboter, aber [unser freundliches Team](/about) ist auch da um zu helfen, wenn du eine Person erreichen möchtest. + + - Aus Sicherheitsgründen begrenzen wir vorübergehend, was neue Benutzer tun können. Du wirst neue Fähigkeiten (und [Abzeichen](/badges)) erhalten, während wir uns kennenlernen. + + - Wir glauben an [zivilisiertes Community-Verhalten](/guidelines) zu allen Zeiten. + onebox: + instructions: |- + Als nächstes: Kannst du einen dieser Links mit mir teilen? Antworte mit **einem Link auf einer eigenen Zeile**, und er wird automatisch in eine hübsche, kurze Inhaltsangabe erweitert. + + Um einen Link zu kopieren, tippe und halte auf mobilen Geräten, oder klicke rechts mit deiner Maus: + + - https://de.wikipedia.org/wiki/Antiwitz + - https://de.wikipedia.org/wiki/Tetraphobie + - https://de.wikipedia.org/wiki/Beghilos + reply: |- + Cool! Dies wird mit den meisten Links funktionieren. Denk immer daran, dass der Link _ganz allein_ auf einer Zeile stehen muss; ohne etwas davor oder dahinter. + not_found: |- + Entschuldige bitte, ich konnte keinen Link in deiner Antwort finden! :cry: + + Kannst du versuchen, den folgenden Link auf einer eigenen Zeile in deine nächste Antwort einzufügen? + + - https://de.wikipedia.org/wiki/Hauskatze + images: + reply: |- + Hübsches Bild – ich habe die Like-Schaltfläche :heart: gedrückt, um dich wissen zu lassen, wie sehr es mir gefällt :heart_eyes: + like_not_found: |- + Hast du vergessen meinen [Beitrag](%{url}) mit einem Like :heart: zu markieren? :crying_cat_face: + not_found: |- + Sieht so aus, als hättest du kein Bild hochgeladen. Deshalb habe ich ein Bild ausgesucht, von dem ich _sicher_ bin, dass es dir gefällt. + + `%{image_url}` + + Versuche dieses als nächstes hochzuladen, oder füge den Link auf einer eigenen Zeile ein! + formatting: + instructions: |- + Kannst du einige Wörter in deiner Antwort **fett** oder _kursiv_ markieren? + + - schreibe `**fett**` oder `_kursiv_` + + - oder, drücke die F- oder K-Schaltflächen im Editor + reply: |- + Großartige Leistung! HTML und BBCode funktionieren auch zur Formatierung – um mehr zu erfahren, [probiere dieses Tutorial aus](http://commonmark.org/help) :nerd: + not_found: |- + Ach, ich habe keine Formatierung in deiner Antwort gefunden. :pencil2: + + Kannst du es nochmal versuchen? Verwende die F- (Fett) oder K-Schaltfläche (Kursiv) im Editor, wenn du nicht weiter weißt. + quoting: + instructions: |- + Kannst du versuchen, mich zu zitieren, wenn du antwortest, sodass ich genau weiß, auf welchen Teil du antwortest? + + > Wenn das Kaffee ist, bringe mir bitte Tee; aber wenn das Tee ist, bringe mir bitte Kaffee. + > + > Eine Vorteil von Selbstgesprächen ist, dass du weißt, dass dir wenigstens irgendjemand zuhört. + > + > Manche Menschen können gut mit Worten umgehen, und andere Menschen… ähm, nun ja, nicht so gut. + + Wähle ein beliebiges ↑ Zitat aus, das du bevorzugst, und drücke dann die **Zitat**-Schaltfläche, die über deiner Auswahl erscheint – oder die **Antworten**-Schaltfläche am Ende dieses Beitrags. + + Unter dem Zitat, schreibe ein oder zwei Worte dazu, warum du dieses Zitat gewählt hast, denn das würde mich interessieren :thinking: + reply: |- + Gute Arbeit, du hast mein Lieblingszitat ausgewählt! :left_speech_bubble: + not_found: |- + Hmm, es sieht so aus als hättest du mich in deinem Beitrag nicht zitiert? + + Das Auswählen von beliebigem Text in meinem Beitrag lässt die **Zitat**-Schaltfläche erscheinen. Und das Drücken von **Antworten** mit einem beliebigen ausgewählten Text wird auch funktionieren! Kannst du es nochmal versuchen? + bookmark: + instructions: |- + Wenn du mehr lernen möchtest, wähle unterhalb aus und **füge ein Lesezeichen zu dieser Nachricht hinzu**. Wenn du dies tust, könnte es in der Zukunft ein :gift: geben! + reply: |- + Hervorragend! Jetzt kannst du jederzeit über [den Lesezeichen-Reiter in deinem Profil](%{profile_page_url}/activity/bookmarks) zu unserer Unterhaltung zurückkehren. Gehe dazu einfach auf dein Profilbild oben rechts ↗. + not_found: |- + Oh weh, ich sehe keine Lesezeichen in diesem Thema. Hast du die Lesezeichen-Schaltfläche unter jedem Beitrag gefunden? Verwende die „Mehr anzeigen“-Schaltfläche , um bei Bedarf weitere Aktionen anzuzeigen. + emoji: + instructions: |- + Du hast vielleicht bemerkt, dass ich kleine Bilder in meinen Antworten verwendet habe :blue_car::dash:, die [Emoji](https://de.wikipedia.org/wiki/Emoji) heißen. Kannst du in deiner Antwort **ein Emoji hinzufügen**? Jedes der folgenden wird funktionieren: + + - Gib `:) ;) :D :P :O` ein + + - Gib einen Doppelpunkt : gefolgt vom Emoji-Namen ein `:tada:` + + - Drücke die Emoji-Schaltfläche im Editor oder auf der Tastatur deines mobilen Geräts. + reply: |- + Das ist :sparkles: _emojitastisch!_ :sparkles: + not_found: |- + Hoppla, ich sehe kein Emoji in deinem Beitrag? Oh nein! :sob: + + Versuche einen Doppelpunkt : einzugeben, um damit die Emoji-Auswahl zu öffnen. Dann gib die ersten Buchstaben vom gesuchten Emoji ein; wie zum Beispiel `:bird:` + + Oder drücke die Emoji-Schaltfläche im Editor. + + (Auf einem mobilen Gerät kannst du Emojis auch direkt über deine Tastatur eingeben.) + mention: + instructions: |- + Manchmal möchtest du vielleicht die Aufmerksamkeit einer Person haben, auch wenn du ihr nicht direkt antwortest. Gib `@` ein und vervollständige dann ihren Benutzernamen, um sie zu erwähnen. + + Kannst du **`@%{discobot_username}`** in deiner Antwort erwähnen? + reply: |- + _Hat jemand meinen Namen gesagt!?_ :raised_hand: Ich glaube du warst das! :wave: Nun, hier bin ich! Danke, dass du mich erwähnt hast. :ok_hand: + not_found: |- + Ich sehe meinen Namen darin nicht :frowning: Kannst du nochmal versuchen, mich als `@%{discobot_username}` zu erwähnen? + + (Und ja, mein Benutzername wird wirklich _disco_ geschrieben. Genau wie die Tanzmusik aus den 1970er Jahren. Ich [liebe das Nachtleben!](https://www.youtube.com/watch?v=B_wGI3_sGf8) :dancer:) + flag: + instructions: |- + Wir mögen freundliche Diskussionen, und wir benötigen deine Hilfe, damit [alles zivilisiert bleibt](%{guidelines_url}). Wenn du ein Problem bemerkst, melde dies bitte und lass es diskret den Autor oder [unser hilfreiches Team](%{about_url}) wissen. + + > :imp: Ich habe dir hier etwas Böses geschrieben + + Ich denke, du weißt was zu tun ist. Zögere nicht und **melde diesen Beitrag** als unangemessen! + reply: |- + [Unser Team](/groups/staff) wird diskret über deine Meldung informiert. Wenn genügend Community-Mitglieder einen Beitrag melden, wird er als Vorsichtsmaßnahme automatisch versteckt. (Weil ich nicht wirklich einen bösen Beitrag geschrieben habe :angel:, habe ich mir erlaubt, die Meldung fürs Erste wieder zu löschen.) + not_found: |- + Oh nein, mein böser Beitrag wurde nicht gemeldet. :worried: Kannst du ihn als unangemessen **melden** ? Vergiss nicht, die „Mehr anzeigen“-Schaltfläche zu verwenden, damit für jeden Beitrag weitere Aktionen sichtbar werden. + search: + instructions: |- + _Psst_… ich habe in diesem Thema eine Überraschung versteckt. Wenn du für eine Herausforderung zu haben bist, dann **wähle das Such-Symbol** oben rechts ↗ aus und suche danach. + + Versuche in diesem Thema nach dem Begriff „Capy​bara“ zu suchen. + hidden_message: |- + Wie konntest du das Capybara übersehen? :wink: + + + + Hast du bemerkt, dass du jetzt zurück am Anfang bist? Füttere dieses arme, hungrige Capybara, indem du **mit dem `:herb:`-Emoji antwortest** und du wirst automatisch zum Ende gebracht. + reply: |- + Juhu! Du hast es gefunden :tada: + + - Zum Suchen nach weiteren Details gehe zur [Erweiterten Suche](%{search_url}). + + - Um in einer langen Diskussion an eine beliebige Position zu springen, probiere die Zeitleiste rechts neben dem Thema aus. Auf mobilen Geräten findest du die Zeitleiste rechts unten. + + - Wenn du eine physische Tastatur verwendest, gib ? ein, um eine nützliche Übersicht über Tastenkombinationen anzuzeigen. + not_found: |- + Hm… es sieht so aus, als hättest du Probleme. Tut mir leid. Hast du nach dem Begriff **capy​bara** gesucht ? + end: + message: |- + Danke, dass du mir treu geblieben bist, @%{username}! Ich habe dies für dich gemacht. Ich denke, du hast es verdient: + + %{certificate} + + Das ist erstmal alles für den Augenblick! Aber schau dir doch [**unsere neuesten Diskussionsthemen**](/latest) oder [**Diskussionskategorien**](/categories) an. :sunglasses: + + (Wenn du wieder mit mir sprechen möchtest, um noch mehr zu lernen, schreibe mir einfach eine Nachricht oder erwähne `@%{discobot_username}`!) + certificate: + alt: 'Urkunde' + advanced_user_narrative: + reset_trigger: 'erfahrener Benutzer' + cert_title: "In Anerkennung des erfolgreichen Abschlusses des Tutorials für fortgeschrittene Benutzer" + title: ':arrow_up: Fortgeschrittene Benutzerfunktionen' + start_message: |- + @%{username}, als fortgeschrittener Benutzer hast du dir sicherlich schon die [Benutzer-Einstellungen](/my/preferences) angesehen, oder? Dort gibt es eine Vielzahl an Möglichkeiten, um alles an deine Bedürfnisse anzupassen. Unter anderem kannst du ein helles oder dunkles Design auswählen. + + Aber ich schweife ab. Lass uns beginnen! + edit: + bot_created_post_raw: "@%{discobot_username} ist bei weitem der coolste Bot, den ich kenne :wink:" + instructions: |- + Jeder macht Fehler. Aber keine Sorge, du kannst deine Beiträge immer bearbeiten, um sie zu korrigieren! + + Kannst du beginnen, indem du den Beitrag **bearbeitest**, den ich gerade in deinem Namen erstellt habe? + not_found: |- + Es sieht so aus, als solltest du den [Beitrag](%{url}) noch bearbeiten, den ich für dich erstellt habe. Kannst du es nochmal versuchen? + + Verwende das -Symbol, um den Editor zu öffnen. + reply: |- + Gut gemacht! + + Beachte, dass Änderungen, die nach 5 Minuten gemacht werden, als Überarbeitungen für jeden sichtbar sind. Die Anzahl der Überarbeitungen wird, neben einem kleinen Bleistift-Symbol, am Beitrag rechts oben angezeigt. + delete: + instructions: |- + Wenn du einen von dir erstellten Beitrag zurückziehen möchtest, dann kannst du ihn löschen. + + Leg los und **lösche* einen deiner Beiträge in dieser Unterhaltung, indem du die **Löschen**-Aktion verwendest. Lösche aber nicht den ersten Beitrag! + not_found: |- + Ich sehe noch keine gelöschten Beiträge? Denk daran, dass die „Mehr anzeigen“-Schaltfläche die Löschen-Schaltfläche sichtbar werden lässt. + reply: |- + Oha! :boom: + + Um die Kontinuität deiner Diskussionen zu bewahren, werden Löschungen nicht sofort durchgeführt. Gelöschte Beiträge werden erst nach einer gewissen Zeit entfernt. + recover: + deleted_post_raw: 'Warum hat @%{discobot_username} meinen Beitrag gelöscht? :anguished:' + instructions: |- + Oh nein! Es sieht so aus, als hätte ich versehentlich einen neuen Beitrag gelöscht, den ich gerade für dich erstellt hatte. + + Kannst du mir einen Gefallen tun und ihn **wiederherstellen**? + not_found: |- + Hast du Schwierigkeiten? Denk daran, dass die „Mehr anzeigen“-Schaltfläche die Wiederherstellen-Schaltfläche sichtbar werden lässt. + reply: |- + Puh, das war knapp! Danke, dass du das korrigiert hast :wink: + + Bitte beachte, dass du nur 24 Stunden hast, um einen Beitrag wiederherzustellen. + category_hashtag: + instructions: |- + Wusstest du, dass du auf Kategorien und Schlagwörter in deinen Beiträgen verweisen kannst? Zum Beispiel, hast du die Kategorie %{category} schon gesehen? + + Gib `#` in der Mitte eines Satzes ein und wähle eine Kategorie oder ein Schlagwort aus. + not_found: |- + Hmm, ich sehe darin nirgendwo eine Kategorie. Beachte, dass `#` nicht das erste Zeichen sein darf. Kannst du dies in deine nächste Antwort kopieren? + + ```text + Ich erstelle einen Kategorie-Link per # + ``` + reply: |- + Hervorragend! Denk daran, dass dies sowohl für Kategorien _als auch_ für Schlagwörter funktioniert, sofern Schlagwörter aktiviert sind. + change_topic_notification_level: + instructions: |- + Jedes Thema hat eine Benachrichtigungsstufe. Es beginnt bei „Normal“, was bedeutet, dass du normalerweise nur benachrichtigt wirst, wenn dich jemand direkt anspricht. + + Standardmäßig ist die Benachrichtigungsstufe für Nachrichten auf die höchste Stufe „Beobachten“ gesetzt, was bedeutet, dass du über jede Antwort informiert wirst. Aber du kannst die Benachrichtigungsstufe für _jedes_ Thema auf „Beobachten“, „Verfolgen“ oder „Stummgeschaltet“ setzen. + + Lass uns versuchen, die Benachrichtigungsstufe für dieses Thema zu ändern. Am Ende des Themas wirst du eine Schaltfläche finden, die „Beobachten“ anzeigt. Kannst du die Benachrichtigungsstufe auf **Verfolgen** ändern? + not_found: |- + Es sieht so aus, als würdest du dieses Thema noch beobachten! :eyes: Falls du Probleme hast es zu finden: Die Schaltfläche für die Benachrichtigungsstufe befindet sich am Ende des Themas. + reply: |- + Fantastische Arbeit! Ich hoffe, dass du das Thema stumm geschaltet hast, denn ich kann manchmal ein bisschen gesprächig sein :grin:. + + Beachte, dass die Benachrichtigungsstufe automatisch auf „Verfolgen“ geändert wird, wenn du auf ein Thema antwortest oder ein Thema länger als ein paar Minuten liest. Du kannst dies in [deinen Benutzer-Einstellungen](/my/preferences) ändern. + poll: + instructions: |- + Wusstest du, dass du eine Umfrage zu einem beliebigen Beitrag hinzufügen kannst? Versuche dazu das Zahnrad im Editor zu verwenden und wähle **Umfrage erstellen** aus. + not_found: |- + Hoppla! Da war keine Umfrage in deiner Antwort. + + Verwende das Zahnrad im Editor, oder kopiere diese Umfrage und füge sie in deine nächste Antwort ein: + + ```text + [poll] + * :cat: + * :dog: + [/poll] + ``` + reply: |- + Hey, schöne Umfrage! Wie mache ich mich als Lehrer? + + [poll] + * :+1: + * :-1: + [/poll] + details: + instructions: |- + Manchmal möchtest du vielleicht in deinen Antworten **Details ausblenden**: + + - Wenn du Handlungen eines Films oder einer TV-Serie diskutierst, die als Spoiler betrachtet werden könnten. + + - Wenn dein Beitrag viele optionalen Details benötigt, die überwältigend sein könnten, wenn man sie alle auf einmal liest. + + [details=Wähle dies aus, um zu sehen, wie es funktioniert!] + 1. Wähle das Zahnrad im Editor aus. + 2. Wähle „Details ausblenden“ aus. + 3. Bearbeite die Zusammenfassung der Details und deinen Inhalt. + [/details] + + Kannst du das Zahnrad im Editor benutzen und einen Details-Abschnitt in deiner nächsten Antwort einfügen? + not_found: |- + Hast du Schwierigkeiten, den Details-Abschnitt zu erstellen? Versuche Folgendes in deine nächste Antwort aufzunehmen: + + ```text + [details=Wähle mich aus, um die Details zu sehen] + Hier sind die Details + [/details] + ``` + reply: |- + Großartige Arbeit — deine Aufmerksamkeit fürs _Detail_ ist bewundernswert! + end: + message: |- + Du hast dir in der Tat den Weg hierdurch gebahnt wie ein _fortgeschrittener Benutzer_ :bow: + + %{certificate} + + Das ist alles, was ich für dich habe. + + Bis bald! Wenn du wieder mit mir sprechen möchtest, schicke mir jederzeit eine Nachricht :sunglasses: + certificate: + alt: 'Urkunde für fortgeschrittene Benutzer' diff --git a/plugins/discourse-narrative-bot/config/locales/server.en.yml b/plugins/discourse-narrative-bot/config/locales/server.en.yml new file mode 100644 index 0000000000..a5b6057a7f --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.en.yml @@ -0,0 +1,454 @@ +en: + site_settings: + discourse_narrative_bot_enabled: 'Enable Discourse Narrative Bot' + disable_discourse_narrative_bot_welcome_post: "Disable the welcome post by Discourse Narrative Bot" + discourse_narrative_bot_ignored_usernames: "Usernames that the Discourse Narrative Bot should ignore" + discourse_narrative_bot_disable_public_replies: "Disable public replies by Discourse Narrative Bot" + discourse_narrative_bot_welcome_post_type: "Type of welcome post that the Discourse Narrative Bot should send out" + discourse_narrative_bot_welcome_post_delay: "Wait (n) seconds before sending the Discourse Narrative Bot welcome post." + + badges: + certified: + name: Certified + description: "Completed our new user tutorial" + long_description: | + This badge is granted upon successful completion of the interactive new user tutorial. You’ve taken the initiative to learn the basic tools of discussion, and now you're certified! + licensed: + name: Licensed + description: "Completed our advanced user tutorial" + long_description: | + This badge is granted upon successful completion of the interactive advanced user tutorial. You’ve mastered the advanced tools of discussion — and now you’re fully licensed! + + discourse_narrative_bot: + bio: "Hi, I’m not a real person. I’m a bot that can teach you about this site. To interact with me, send me a message or mention **`@%{discobot_username}`** anywhere." + + timeout: + message: |- + Hey @%{username}, just checking in because I haven’t heard from you in a while. + + - To continue, reply to me any time. + + - If you’d like to skip this step, say `%{skip_trigger}`. + + - To start over, say `%{reset_trigger}`. + + If you’d rather not, that’s OK too. I’m a robot. You won’t hurt my feelings. :sob: + + dice: + trigger: "roll" + invalid: |- + I’m sorry, it is mathematically impossible to roll that combination of dice. :confounded: + not_enough_dice: |- + I only have %{num_of_dice} dice. [Shameful](http://www.therobotsvoice.com/2009/04/the_10_most_shameful_rpg_dice.php), I know! + out_of_range: |- + Did you know that [the maximum number of sides](https://www.wired.com/2016/05/mathematical-challenge-of-designing-the-worlds-most-complex-120-sided-dice) for a mathematically fair die is 120? + results: |- + > :game_die: %{results} + + quote: + trigger: "quote" + "1": + quote: "In the middle of every difficulty lies opportunity" + author: "Albert Einstein" + "2": + quote: "You must be the change you wish to see in the world." + author: "Mahatma Gandhi" + "3": + quote: "Don’t cry because it’s over, smile because it happened." + author: "Dr Seuss" + "4": + quote: "If you want something done right, do it yourself." + author: "Charles-Guillaume Étienne" + "5": + quote: "Believe you can and you’re halfway there." + author: "Theodore Roosevelt" + "6": + quote: "Life is like a box of chocolates. You never know what you’re gonna get." + author: "Forrest Gump’s Mom" + "7": + quote: "That’s one small step for a man, a giant leap for mankind." + author: "Neil Armstrong" + "8": + quote: "Do one thing every day that scares you." + author: "Eleanor Roosevelt" + "9": + quote: "Mistakes are always forgivable, if one has the courage to admit them." + author: "Bruce Lee" + "10": + quote: "Whatever the mind of man can conceive and believe, it can achieve." + author: "Napoleon Hill" + results: |- + > :left_speech_bubble: _%{quote}_ — %{author} + + magic_8_ball: + trigger: 'fortune' + answers: + "1": "It is certain" + "2": "It is decidedly so" + "3": "Without a doubt" + "4": "Yes definitely" + "5": "You may rely on it" + "6": "As I see it, yes" + "7": "Most likely" + "8": "Outlook good" + "9": "Yes" + "10": "Signs point to yes" + "11": "Reply hazy try again" + "12": "Ask again later" + "13": "Better not tell you now" + "14": "Cannot predict now" + "15": "Concentrate and ask again" + "16": "Don't count on it" + "17": "My reply is no" + "18": "My sources say no" + "19": "Outlook not so good" + "20": "Very doubtful" + result: |- + > :crystal_ball: %{result} + + track_selector: + reset_trigger: 'start' + skip_trigger: 'skip' + help_trigger: 'display help' + + random_mention: + reply: |- + Hi! To find out what I can do, say `@%{discobot_username} %{help_trigger}`. + tracks: |- + I currently know how to do the following things: + + `@%{discobot_username} %{reset_trigger} %{default_track}` + > Starts one of the following interactive narratives: %{tracks}. + bot_actions: |- + `@%{discobot_username} %{dice_trigger} 2d6` + > :game_die: 3, 6 + + `@%{discobot_username} %{quote_trigger}` + > :left_speech_bubble: _Carry out a random act of kindness, with no expectation of reward, safe in the knowledge that one day someone might do the same for you_ — Princess Diana + + `@%{discobot_username} %{magic_8_ball_trigger}` + > :crystal_ball: You may rely on it + + do_not_understand: + first_response: |- + Hey, thanks for the reply! + + Unfortunately, as a poorly programmed bot, I can’t quite understand that one. :frowning: + track_response: + You can try again, or if you’d like to skip this step, say `%{skip_trigger}`. Otherwise, to start over, say `%{reset_trigger}`. + second_response: |- + Aw, sorry. I’m still not getting it. :anguished: + + I’m just a bot, but if you’d like to reach a real person, see [our contact page](/about). + + In the meantime, I’ll stay out of your way. + + new_user_narrative: + reset_trigger: "new user" + cert_title: "In recognition of successful completion of the new user tutorial" + + hello: + title: ":robot: Greetings!" + message: |- + Thanks for joining %{title}, and welcome! + + - I’m only a robot, but [our friendly staff](/about) are also here to help if you need to reach a person. + + - For safety reasons, we temporarily limit what new users can do. You’ll gain new abilities (and [badges](/badges)) as we get to know you. + + - We believe in [civilized community behavior](/guidelines) at all times. + + onebox: + instructions: |- + Next, can you share one of these links with me? Reply with **a link on a line by itself**, and it’ll automatically expand to include a nifty summary. + + To copy a link, tap and hold on mobile, or right click your mouse: + + - https://en.wikipedia.org/wiki/Inherently_funny_word + - https://en.wikipedia.org/wiki/Death_by_coconut + - https://en.wikipedia.org/wiki/Calculator_spelling + reply: |- + Cool! This will work for most links. Remember, it must be on a line _all by itself_, with nothing else in front, or behind. + not_found: |- + Sorry, I couldn’t find the link in your reply! :cry: + + Can you try adding the following link, on its own line, in your next reply? + + - https://en.wikipedia.org/wiki/Exotic_Shorthair + + images: + instructions: |- + Here’s a picture of a unicorn: + + + + If you like it (and who wouldn’t!) go ahead and press the like :heart: button below this post to let me know. + + Can you **reply with a picture?** Any picture will do! Drag and drop, press the upload button, or even copy and paste it in. + reply: |- + Nifty picture – I pressed the like :heart: button to let you know how much I appreciated it :heart_eyes: + like_not_found: |- + Did you forget to like :heart: my [post?](%{url}) :crying_cat_face: + not_found: |- + Looks like you didn’t upload an image so I’ve choosen a picture that I’m _sure_ you will enjoy. + + `%{image_url}` + + Try uploading that one next, or pasting the link in on a line by itself! + + formatting: + instructions: |- + Can you make some words **bold** or _italic_ in your reply? + + - type `**bold**` or `_italic_` + + - or, push the B or I buttons in the editor + + reply: |- + Great job! HTML and BBCode also work for formatting – to learn more, [try this tutorial](http://commonmark.org/help) :nerd: + not_found: |- + Aww, I didn’t find any formatting in your reply. :pencil2: + + Can you try again? Use the B bold or I italic buttons in the editor if you get stuck. + + quoting: + instructions: |- + Can you try quoting me when you reply, so I know exactly which part you’re replying to? + + > If this is coffee, please bring me some tea; but if this is tea, please bring me some coffee. + > + > One advantage of talking to yourself is that you know at least somebody’s listening. + > + > Some people have a way with words, and other people… oh, uh, not have way. + + Select the text of whichever ↑ quote you prefer, and then press the **Quote** button that pops up over your selection – or the **Reply** button at the bottom of this post. + + Below the quote, type a word or two about why you picked that one, because I’m curious :thinking: + reply: |- + Nice work, you picked my favorite quote! :left_speech_bubble: + not_found: |- + Hmm it looks like you didn’t quote me in your reply? + + Selecting any text in my post will bring up the **Quote** button. And pressing **Reply** with any text selected will work, too! Can you try again? + + bookmark: + instructions: |- + If you’d like to learn more, select below and **bookmark this private message**. If you do, there may be a :gift: in your future! + reply: |- + Excellent! Now you can easily find your way back to our private conversation any time, right from [the bookmarks tab on your profile](%{profile_page_url}/activity/bookmarks). Just select your profile picture at the upper right ↗ + not_found: |- + Uh oh, I don’t see any bookmarks in this topic. Did you find the bookmark under each post? Use the show more to reveal additional actions if needed. + + emoji: + instructions: |- + You may have seen me use little pictures in my replies :blue_car::dash: those are called [emoji](https://en.wikipedia.org/wiki/Emoji). Can you **add an emoji** to your reply? Any of these will work: + + - Type `:) ;) :D :P :O` + + - Type colon : then complete the emoji name `:tada:` + + - Press the emoji button in the editor, or on your mobile keyboard + reply: |- + That’s :sparkles: _emojitastic!_ :sparkles: + not_found: |- + Oops, I don’t see any Emoji in your reply? Oh no! :sob: + + Try typing a colon : to bring up the emoji picker, then type the first few letters of what you want, such as `:bird:` + + Or, press the emoji button in the editor. + + (If you are on a mobile device, you can also enter Emoji directly from your keyboard, too.) + + mention: + instructions: |- + Sometimes you might want to get a person’s attention, even if you aren’t replying to them directly. Type `@` then complete their user name to mention them. + + Can you mention **`@%{discobot_username}`** in your reply? + reply: |- + _Did someone say my name!?_ :raised_hand: I believe you did! :wave: Well, here I am! Thanks for mentioning me. :ok_hand: + not_found: |- + I don’t see my name in there anywhere :frowning: Can you try mentioning me as `@%{discobot_username}` again? + + (And yes, my user name is spelled _disco_, as in the 1970s dance craze. I do [love the nightlife!](https://www.youtube.com/watch?v=B_wGI3_sGf8) :dancer:) + + flag: + instructions: |- + We like our discussions friendly, and we need your help to [keep things civilized](%{guidelines_url}). If you see a problem, please flag to privately let the author, or [our helpful staff](%{about_url}), know about it. + + > :imp: I wrote something nasty here + + I guess you know what to do. Go ahead and **flag this post** as inappropriate! + reply: |- + [Our staff](/groups/staff) will be privately notified about your flag. If enough community members flag a post, it will also be automatically hidden as a precaution. (Since I didn’t actually write a nasty post :angel:, I’ve gone ahead and removed the flag for now.) + not_found: |- + Oh no, my nasty post hasn’t been flagged yet. :worried: Can you flag it as inappropriate using the **flag** ? Don’t forget to use the show more button to reveal more actions for each post. + + search: + instructions: |- + _psst_ … I’ve hidden a surprise in this topic. If you’re up for the challenge, **select the search icon** at the top right ↗ to search for it. + + Try searching for the term "capy​bara" in this topic + hidden_message: |- + How did you miss this capybara? :wink: + + + + Did you notice you’re now back at the beginning? Feed this poor hungry capybara by **replying with the `:herb:` emoji** and you’ll be automatically taken to the end. + reply: |- + Yay you found it :tada: + + - For more detailed searches, head over to the [full search page](%{search_url}). + + - To jump anywhere in a long discussion, try the topic timeline controls on the right (and bottom, on mobile). + + - If you have a physical :keyboard:, press ? to view our handy keyboard shortcuts. + not_found: |- + Hmm… looks like you might be having trouble. Sorry about that. Did you search for the term **capy​bara**? + + end: + message: |- + Thanks for sticking with me @%{username}! I made this for you, I think you’ve earned it: + + %{certificate} + + That’s all for now! Check out [**our latest discussion topics**](/latest) or [**discussion categories**](/categories). :sunglasses: + + (If you’d like to speak with me again to learn more, just message or mention `@%{discobot_username}` any time!) + + certificate: + alt: 'Certificate of Achievement' + + advanced_user_narrative: + reset_trigger: 'advanced user' + cert_title: "In recognition of successful completion of the advanced user tutorial" + title: ':arrow_up: Advanced user features' + start_message: |- + As an _advanced_ user, have you visited [your preferences page](/my/preferences) yet @%{username}? There are lots of ways to customize your experience, such as selecting a dark or light theme. + + But I digress, let’s begin! + + edit: + bot_created_post_raw: "@%{discobot_username} is, by far, the coolest bot I know :wink:" + instructions: |- + Everyone makes mistakes. But don’t worry, you can always edit your posts to fix them! + + Can you begin by **editing** the post I just created on your behalf? + not_found: |- + It looks like you’ve yet to edit the [post](%{url}) I created for you. Can you try again? + + Use the icon to bring up the editor. + reply: |- + Great work! + + Note that edits made after 5 minutes will show up as public edit revisions, and a little pencil icon will appear at the upper right with the revision count. + + delete: + instructions: |- + If you’d like to withdraw a post you made, you can delete it. + + Go ahead and **delete** any of your posts above by using the **delete** action. Don’t delete the first post, though! + not_found: |- + I don’t see any deleted posts yet? Remember show more will reveal delete. + reply: |- + Whoa! :boom: + + To preserve continuity of discussions, deletes aren’t immediate, so the post will be removed after some time. + + recover: + deleted_post_raw: 'Why did @%{discobot_username} delete my post? :anguished:' + instructions: |- + Oh no! It looks like I accidentally deleted a new post that I just created for you. + + Can you do me a favor and **undelete** it? + not_found: |- + Having trouble? Remember show more will reveal undelete. + reply: |- + Phew, that was a close one! Thanks for fixing that :wink: + + Do note that you only have 24 hours to undelete a post. + + category_hashtag: + instructions: |- + Did you know that you can refer to categories and tags in your post? For example, have you seen the %{category} category? + + Type `#` in the middle of a sentence and select any category or tag. + not_found: |- + Hmm, I don’t see a category in there anywhere. Note that `#` can't be the first character. Can you copy this in your next reply? + + ```text + I can create a category link via # + ``` + reply: |- + Excellent! Remember this works for both categories _and_ tags, if tags are enabled. + + change_topic_notification_level: + instructions: |- + Every topic has a notification level. It starts at 'normal', which means you’ll normally only be notified when someone is speaking directly to you. + + By default, the notification level for a private message is set to the highest level of 'watching', which means you you’ll be notified of every new reply. But you can override the notification level for _any_ topic to 'watch', 'tracking' or 'muted'. + + Let’s try changing the notification level for this topic. At the bottom of the topic, you’ll find a button which shows that you’re **watching** this topic. Can you change the notification level to **tracking**? + not_found: |- + It looks like you’re still watching :eyes: this topic! If you’re having trouble finding it, the notification level button is located at the bottom of the topic. + reply: |- + Awesome work! I hope you didn’t mute this topic since I can be a little talkative at times :grin:. + + Note that when you reply to a topic, or read a topic for more than a few minutes, it is automatically set to a notification level of 'tracking'. You can change this in [your user preferences](/my/preferences). + + poll: + instructions: |- + Did you know you can add a poll to any post? Try using the gear in the editor to **build a poll**. + not_found: |- + Whoops! There wasn’t any poll in your reply. + + Use the gear icon in the editor, or copy and paste this poll in your next reply: + + ```text + [poll] + * :cat: + * :dog: + [/poll] + ``` + reply: |- + Hey, nice poll! How’d I do in teaching you? + + [poll] + * :+1: + * :-1: + [/poll] + details: + instructions: |- + Sometimes you may wish to **hide details** in your replies: + + - When you’re discussing plot points of a movie or TV show that would be a considered a spoiler. + + - When your post needs lots of optional details that may be overwhelming when read all at once. + + [details=Select this to see how it works!] + 1. Select the gear in the editor. + 2. Select "Hide Details". + 3. Edit the details summary and add your content. + [/details] + + Can you use the gear in the editor to add a details section to your next reply? + not_found: |- + Having trouble creating a details widget? Try including the following in your next reply: + + ```text + [details=Select me for details] + Here are the details + [/details] + ``` + reply: |- + Great work — your attention to _detail_ is admirable! + end: + message: |- + You blazed through this like an _advanced user_ indeed :bow: + + %{certificate} + + That’s all I have for you. + + Bye for now! If you’d like to speak with me again, send me a message any time :sunglasses: + certificate: + alt: 'Advanced User Track Certificate of Achievement' diff --git a/plugins/discourse-narrative-bot/config/locales/server.es.yml b/plugins/discourse-narrative-bot/config/locales/server.es.yml new file mode 100644 index 0000000000..937db3462a --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.es.yml @@ -0,0 +1,398 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +es: + site_settings: + discourse_narrative_bot_enabled: 'Habilitar Discourse Narrative Bot' + disable_discourse_narrative_bot_welcome_post: "Deshabilitar el post de bienvenida por Discourse Narrative Bot" + discourse_narrative_bot_ignored_usernames: "Nombres de usuario que el Discourse Narrative Bot debe ignorar" + discourse_narrative_bot_disable_public_replies: "Deshabilitar respuestas públicas del Discourse Narrative Bot" + discourse_narrative_bot_welcome_post_type: "Tipo de post de bienvenida que el Discourse Narrative Bot debería enviar" + badges: + certified: + name: Certificado + description: "Ha completado nuestro tutorial de nuevo usuario" + long_description: | + Este distintivo se otorga a quienes han completado el tutorial de nuevo usuario. Has tomado la iniciativa para aprender las herramientas básicas para el debate, y ahora tú estás certificado! + licensed: + name: Licenciado + description: "Ha completado nuestro tutorial de usuario avanzado" + long_description: | + Este distintivo se otorga a quienes han completado el tutorial de usuario avanzado. Has dominado las herramientas avanzadas de discusión — y ahora tú eres un licenciado! + discourse_narrative_bot: + bio: "Hola, no soy una persona real. Soy un bot que puede enseñarte sobre este sitio. Para interactuar conmigo, me envías un mensaje o me mencionas **`@%{discobot_username}`** en cualquier lugar." + timeout: + message: |- + Hey @%{username}, solo estoy verificando si estás porque no he oído de tí en un tiempo. + - Para continuar, responde en cualquier momento. + + - Si gustas de saltar este paso, dime `%{skip_trigger}`. + + - Para empezar de nuevo, dime `%{reset_trigger}`. + + Si prefieres no continuar, está OK también! Soy un robot. No herirás mis sentimientos. :sob: + dice: + trigger: "tirar" + invalid: |- + Lo siento, es matemáticamente imposible lanzar esa combinación de dados. :confounded: + not_enough_dice: |- + Solo tengo %{num_of_dice} dados para jugar. Es vergonsozo lo sé! + out_of_range: |- + ¿Sabías que el máximo número de lados para que un dado, matemáticamente correcto, es de 120 lados? + results: |- + > :game_die: tirada de dados: %{results} + quote: + trigger: "citar" + '1': + quote: "In the middle of every difficulty lies opportunity" + author: "Albert Einstein" + '2': + quote: "You must be the change you wish to see in the world." + author: "Mahatma Gandhi" + '3': + quote: "Don’t cry because it’s over, smile because it happened." + author: "Dr Seuss" + '4': + quote: "If you want something done right, do it yourself." + author: "Charles-Guillaume Étienne" + '5': + quote: "Believe you can and you’re halfway there." + author: "Theodore Roosevelt" + '6': + quote: "Life is like a box of chocolates. You never know what you’re gonna get." + author: "Forrest Gump’s Mom" + '7': + quote: "That’s one small step for a man, a giant leap for mankind." + author: "Neil Armstrong" + '8': + quote: "Do one thing every day that scares you." + author: "Eleanor Roosevelt" + '9': + quote: "Mistakes are always forgivable, if one has the courage to admit them." + author: "Bruce Lee" + '10': + quote: "Whatever the mind of man can conceive and believe, it can achieve." + author: "Napoleon Hill" + results: |- + > :left_speech_bubble: _%{quote}_ — %{author} + magic_8_ball: + trigger: 'suerte' + answers: + '1': "Es cierto" + '2': "Es decididamente así" + '3': "Sin ninguna duda" + '4': "Definitivamente sí" + '5': "Puedes confiar en ello" + '6': "Como yo lo veo, sí" + '7': "Más probable" + '8': "Buena perspectiva" + '9': "Sí" + '10': "Las señales apuntan a que sí" + '11': "Respuesta confusa, intenta otra vez" + '12': "Pregunta de nuevo más tarde" + '13': "Mejor no te digo ahora" + '14': "No se puede predecir ahora" + '15': "Concentrate y pregunta de nuevo" + '16': "No cuentes con eso" + '17': "Mi respuesta es no" + '18': "Mis fuentes dicen no" + '19': "No son buenas las perspectivas" + '20': "Muy dudoso" + result: |- + > :crystal_ball: %{result} + track_selector: + reset_trigger: 'empezar' + skip_trigger: 'saltar' + help_trigger: 'mostrar ayuda' + random_mention: + reply: |- + ¿Alguien me llamó? Averigua qué puedo hacer con `@%{discobot_username} %{help_trigger}`. + tracks: |- + Hola! Estoy conociendo cómo hacer las siguientes cosas: + + `@%{discobot_username} %{reset_trigger} %{default_track}` + > Inicia una de las siguientes narraciones interactivas: %{tracks}. + bot_actions: |- + `@%{discobot_username} %{dice_trigger} 2d6` + > :game_die: 3, 6 + + `@%{discobot_username} %{quote_trigger}` + > :left_speech_bubble: _Carry out a random act of kindness, with no expectation of reward, safe in the knowledge that one day someone might do the same for you_ — Princess Diana + + `@%{discobot_username} %{magic_8_ball_trigger}` + > :crystal_ball: No se puede predecir ahora + do_not_understand: + first_response: |- + Hey, gracias por tu respuesta! + Lamentablemente, como un robot con programación muy pobre, no he podido entender lo que me has dicho. :frowning: + track_response: Puedes intentarlo de nuevo, o bien para saltar este paso, dime `%{skip_trigger}`. Para empezar de nuevo, dime `%{reset_trigger}`. + second_response: |- + Aw, lo siento. Sigo sin entenderlo. :anguished: + + Soy solo un robot, pero si quieres contactar a una persona real, mira [nuestra página de contacto](/about). + + Mientras tanto, me quedaré fuera de tu camino. + new_user_narrative: + reset_trigger: "usuario nuevo" + cert_title: "En reconocimiento de la finalización exitosa del tutorial de usuario nuevo" + hello: + title: ":robot: Saludos!" + message: |- + Gracias por unirte a %{title}, y bienvenido! + + - Soy solo un robot, pero [nuestro amigable staff](/about) está también aquí para ayudar si necesitas contactar a una persona. + + - Por razones de seguridad, nosotros temporalmente limitamos lo que los nuevos usuarios pueden hacer. Tú podrás ganar nuevas habilidades (y [distintivos](/badges)) cuando te vayamos conociendo. + + - Nosotros creemos en el [comportamiento de una comunidad civilizada](/guidelines) en todo momento. + onebox: + instructions: |- + Ahora, tú puedes compartir uno de estos enlaces conmigo? Responde con **el enlace propiamente dicho**, y automáticamente se expandirá con un breve resumen. + Para copiar un enlace, toca y mantiene presionado en el móvil, o haz clic derecho con tu mouse: + + - https://en.wikipedia.org/wiki/Inherently_funny_word + - https://en.wikipedia.org/wiki/Death_by_coconut + - https://en.wikipedia.org/wiki/Calculator_spelling + reply: |- + Genial! Esto funcionará con la mayoría de los enlaces. Recuerda, deben ser escritos en la linea _todo el enlace_, con nada de texto (ni espacios) delante o detrás. + not_found: |- + Disculpa, no he podido encontrar el enlace en tu respuesta! :cry: + + Puedes intentar agregar el siguiente enlace, en una linea aparte, en tu siguiente respuesta? + + - https://en.wikipedia.org/wiki/Exotic_Shorthair + images: + reply: |- + Elegante imagen – He presionado el botón :heart: "me gusta" para hacerte saber cuánto la aprecio :heart_eyes: + like_not_found: |- + Te olvidaste de dar "me gusta" :heart: a mi [post?](%{url}) :crying_cat_face: + not_found: |- + Parece que no subiste ninguna imagen, así que he elegido una imagen que estoy _seguro_ que disfrutarás. + `%{image_url}` + Trata de subir esta ahora, o bien copia el enlace de la imagen y pega el mismo en una única línea dentro del mensaje! + formatting: + instructions: |- + ¿Puedes hacer algunas letras en **negrita** o _cursiva_ en tu respuesta? + - escribe `**negrita**` o `_cursiva_` + + - o, presiona los botones B o I en el editor + reply: |- + Buen trabajo! HTML y BBCode también funcionan para darle formato al texto – aprende más en, [este tutorial](http://commonmark.org/help) :nerd: + not_found: |- + Aww, no encontré ningún texto con formato en tu respuesta. :pencil2: + + Puedes intentar de nuevo? Usa los botones B para negrita o I para cursiva en el editor si no recuerdas cómo formatear texto. + quoting: + instructions: |- + ¿Puedes intentar citarme cuando me respondes, así sé exactamente qué parte estás respondiendome? + > Si esto es café, por favor trae un poco de té; pero si es té, por favor trae un poco de café. + > + > Una de las ventajas de hablarse a sí mismo, es que tú sabes que al menos alguien está oyendo. + > + > Algunas personas tienen un camino con las palabras, y otras personas ... oh, oh, no tienen camino. + Selecciona la parte del texto ↑ que prefieres citar, y luego presiona el botón **citar** que aparece sobre tu selección del texto – o presiona el botón **Responder** debajo de este post. + Debajo de la cita, escribe una palabra o dos sobre lo que escribí, porque soy curioso :thinking: + reply: |- + Buen trabajo, elegiste mi cita favorita! :left_speech_bubble: + not_found: |- + Hmm me parece que no me has citado en tu respuesta!? + Seleccionando cualquier texto de mi mensaje, aparecerá la palabra **Citar**, como si fuese un botón. Y si decides presionar **Responder** habiendo seleccionado el texto, funcionará igual! ¿Puedes intentarlo de nuevo? + bookmark: + instructions: |- + Si quieres aprender más, selecciona debajo y **guarda como favorito este mensaje privado**. Si lo haces, quizás haya un :gift: en el futuro! + reply: |- + Excelente! Ahora puedes encontrar fácilmente las conversaciones privadas en cualquier momento, desde la [sección de marcadores de tu perfil](%{profile_page_url}/activity/bookmarks). Sólo debes seleccionar la foto de tu perfil en la parte superior derecha de la pantalla ↗ + not_found: |- + Oh oh, no veo ningún mensaje marcado como favorito en esta conversación. No encontraste cómo marcar el mensaje debajo del post? Prueba usando el "ver más" para ver las opciones, y ahí está el botón del marcador. + emoji: + instructions: |- + Habrás notado que uso algunas imagenes pequeñas en mis respuestas por ejemplo: :blue_car::dash: éstos son denominados [emoji](https://es.wikipedia.org/wiki/Emoji). ¿Puedes **agregar un emoji** a tu respuesta? Cualquiera de éstos funcionarán: + + - Escribe `:) ;) :D :P :O` + + - Escribe primero `dos puntos`, así : luego completa con el nombre del emoji, ejemplo `:tada:` + + - Presiona el botón de los emojis en el editor, o a través del teclado de tu móvil. + reply: |- + Eso es :sparkles: _emojitastic!_ :sparkles: + not_found: |- + Oops, no veo ningún Emoji en tu respuesta? Oh no! :sob: + + Prueba escribiendo : y verás que aparece el seleccionador de Emojis, puedes elegir uno, o bien seguir escribiendo el nombre del Emoji que quieras, como `:bird:` + + O, presionar el botón de Emoji en el editor. + + (Si estás usando un celular, también puedes poner los Emojis (smiles, o caritas) desde el teclado de tu celular.) + mention: + instructions: |- + A veces, quieres captar la atención de alguien en un debate, incluso aunque no le estés respondiendo a su mensaje directamente. Para ésto, escribe `@` seguido del nombre del usuario que quieres mencionar. + Puedes mencionar **`@%{discobot_username}`** en tu respuesta? + reply: |- + _Alguien dijo mi nombre!?_ :raised_hand: Creo que tú lo hiciste! :wave: Bien, aquí estoy! Gracias por mencionarme. :ok_hand: + not_found: |- + No veo que hayas dicho mi nombre en ningún lugar. :frowning: ¿Puedes intentar mencionarme como `@%{discobot_username}` de nuevo por favor? + + (Sí! mi nombre de usuario se deletrea _disco_, como el baile de los 70s. Me [encanta la vida nocturna!](https://www.youtube.com/watch?v=B_wGI3_sGf8) :dancer:) + flag: + instructions: |- + Nos gustan nuestras conversaciones amistosas, y necesitamos tu ayuda para [mantener las cosas civilizadas](%{guidelines_url}). Si ves un problema, por favor marca con la bandera y de forma privada se le notificará al autor o bien se notificará al [staff](%{about_url}). + > :imp: Escribí algo desagradable aquí + + Supongo que sabes qué hacer. Sigue adelante y **reporta este mensaje** como inapropiado! + reply: |- + [Nuestro staff](/groups/staff) será notificado por privado sobre tu reporte. Si un número suficiente de miembros reportan un mensaje, será ocultado automáticamente como precaución. (Puesto que no escribí algo realmente desagradable :angel:, he quitado el reporte por ahora.) + not_found: |- + Oh no, mi mensaje desagradable aún no ha sido reportado! :worried: ¿Puedes marcar como inapropiado el mensaje con la **bandera** ? No te olvides de hacer clic en el botón para mostrar todas las opciones: y ver así más acciones por cada mensaje. + search: + instructions: |- + _psst_ … Te he ocultado una sorpresa en este tema. Si aceptas el reto, **selecciona el icono de búsqueda** en la parte superior ↗ y busca lo siguiente. + + Prueba buscar el término "capy​bara" en este tema + hidden_message: |- + Cómo te perdiste este capybara? :wink: + + + + ¿Te fijaste en que estás de vuelta al principio? Alimenta este pobre hambriendo capybara **respondiendo con el emoji `:herb:`** y serás automáticamente enviado al final. + reply: |- + Yay lo encontraste! :tada: + + - Para búsquedas más detalladas, ir a la página de [busqueda completa](%{search_url}). + + - Para saltar donde quieras en una discusión larga, intente los controles de la línea de tiempo del tema a la derecha (o por debajo, en el móvil) + + - Si tienes un :keyboard: físico, presiona la tecla ? para ver nuestros prácticos atajos del teclado. + not_found: |- + Hmm… parece que podrías estar teniendo problemas. Lo siento acerca de ésto. Buscaste el término **capy​bara**? + end: + message: |- + Gracias por quedarte conmigo @%{username}! Hice esto por tí, creo que te lo has ganado: + + %{certificate} + + ¡Eso es todo por ahora! Echa un vistazo a [**nuestros últimos temas de discusión**](/latest) o las [**categorías de debate**](/categories). :sunglasses: + + (Si deseas hablar conmigo de neuvo para aprender más, solo envia un mensaje o me mencionas `@%{discobot_username}` en cualquier momento!) + certificate: + alt: 'Certificado de logro' + advanced_user_narrative: + reset_trigger: 'usuario avanzado' + cert_title: "En reconocimiento de la finalización exitosa del tutorial de usuario avanzado" + title: ':arrow_up: Funciones avanzadas del usuario' + start_message: |- + Como un usuario _avanzado_, ¿no has visitado [tus preferencias](/my/preferences) aún @%{username}? Hay muchas formas de personalizar tu experiencia, por ejemplo podrías elegir un diseño oscuro. + ¡Pero yo divago, comencemos! + edit: + bot_created_post_raw: "@%{discobot_username} es, por lejos, el mejor bot que conozco :wink:" + instructions: |- + Todos cometemos errores. Pero no te preocupes, siempre podrás editar tus mensajes para solucionarlos! + ¿Puedes empezar por **editar** el mensaje que acabo de crear en tu nombre? + not_found: |- + Parece que aún no has editado el [mensaje](%{url}) que creé por tí. ¿Puedes intentar de nuevo? + Usa el icono de lápiz para que aparezca el editor. + reply: |- + ¡Buen trabajo! + Tenga en cuenta que las ediciones realizadas después de 5 minutos aparecerán como revisiones de edición pública y aparecerá un pequeño ícono de lápiz en la parte superior derecha con el recuento de revisiones. + delete: + instructions: |- + Si deseas retirar una publicación que hayas realizado, puedes eliminarla. + Vamos a **borrar** cualquier post que hayas escrito usando la acción para **borrar**. No vayas a querer borrar el primer post! + not_found: |- + ¿Todavía no veo ninguna publicación eliminada? Recuerda presionar para mostrar el botón y borrar. + reply: |- + Whoa! :boom: + + Para preservar la continuidad de las discusiones, las supresiones no son inmediatas, por lo que la publicación se eliminará después de algún tiempo. + recover: + deleted_post_raw: 'Por qué @%{discobot_username} borró mi mensaje? :anguished:' + instructions: |- + Oh no! Parece que accidentalmente borré un nuevo mensaje que tú acababas de crear. + ¿Puedes hacerme el favor y hacer clic en para **recuperar** el mensaje borrado? + not_found: |- + ¿Teniendo problemas? Recuerda muestra más, y aparecerá el botón para recuperar. + reply: |- + Phew, ¡eso estuvo cerca! Gracias por solucionarlo :wink: + + Tenga en cuenta que sólo tiene 24 horas para recuperar una publicación. + category_hashtag: + instructions: |- + ¿Sabía que puede hacer referencia a categorías y etiquetas en su publicación? Por ejemplo, has visto la %{category} categoría? + Escribe `#` en el medio de una oración y selecciona cualquier categoría o etiqueta. + not_found: |- + Hmm, no veo una categoría en ninguna parte. Nota que `#` no puede ser el primer carácter de la oración. ¿Puedes copiar esto en tu próxima respuesta? + + ```text + Yo puedo crear un enlace a la categoría con # + ``` + reply: |- + ¡Excelente! Recuerda que ésto funciona para ambos, categorías _y_ etiquetas, si las etiquetas están disponibles. + change_topic_notification_level: + instructions: |- + Cada tema tiene un nivel de notificación. Comienza en 'normal', lo que significa que normalmente sólo se notificará cuando alguien está hablando directamente con usted. + De forma predeterminada, el nivel de notificación de un mensaje privado se establece en el nivel más alto de "observación", lo que significa que se le notificará cada nueva respuesta. Pero puede sobrescribir el nivel de notificación de _cualquier_ tema a 'vigilar', 'seguir' o 'silenciar'. + Intentemos cambiar el nivel de notificación de este tema. Al final del tema, encontrarás un botón que muestra que estás **vigilando** este tema. ¿Puedes cambiar el nivel de notificación a **seguir**? + not_found: |- + Parece que aún estás vigilando :eyes: este tema! Si tienes problemas para encontrar el botón del nivel de notificación, el mismo está debajo de todo el tema de debate. + reply: |- + ¡Impresionante trabajo! Espero que no silencies este tema ya que puedo ser un poco hablador a veces :grin:. + Tenga en cuenta que cuando responde a un tema o lee un tema durante más de unos minutos, se establece automáticamente en un nivel de notificación de "seguimiento". Puedes cambiar esto en [tus preferencias de usuario](/my/preferences). + poll: + instructions: |- + ¿Sabes que puedes agregar una encuesta en cualquier mensaje? Intenta usando el botón de engranaje en el editor para **armar una encuesta**. + not_found: |- + Whoops! No hubo ninguna encuesta en tu respuesta. + + Usa el ícono de engranaje: en el editor, o bien copia y pega esta encuesta en tu próxima respuesta: + + ```text + [poll] + * :cat: + * :dog: + [/poll] + ``` + reply: |- + Hey, ¡hermosa encuesta! ¿Cómo te enseñé? + [poll] + * :+1: + * :-1: + [/poll] + details: + instructions: |- + A veces, tú puedes decidir **ocultar detalles** en tus respuestas: + + - Cuando estás discutiendo puntos de trama de una película o programa de televisión que sería considerado un spoiler. + + - Cuando su puesto necesita un montón de detalles opcionales que pueden ser abrumador cuando se lee todo a la vez. + + [details=¡Clic aquí para ver cómo funciona!] + 1. Selecciona el botón de engranaje en el editor. + 2. Selecciona "Hide Details". + 3. Edita los detalles, y agrega el contenido que desees ocultar. + [/details] + + ¿Puedes usar el botón de engranaje en el editor para agregar datos ocultos en tu próxima respuesta? + not_found: |- + ¿Tiene problemas para crear un widget de detalles? Trate de incluir lo siguiente en su siguiente respuesta: + + ```text + [details=Seleccionar para ver detalles] + Aquí están los detalles + [/details] + ``` + reply: |- + ¡Gran trabajo — tu atención a los _detalles_ es admirable! + end: + message: |- + Usted ha brillado a través de esto como un _usuario avanzado_ de hecho :bow: + + %{certificate} + + Eso es todo lo que tengo para ti. + + ¡Adiós por ahora! Si desea hablar conmigo de nuevo, envíeme un mensaje en cualquier momento :sunglasses: + certificate: + alt: 'Advanced User Track Certificate of Achievement' diff --git a/plugins/discourse-narrative-bot/config/locales/server.et.yml b/plugins/discourse-narrative-bot/config/locales/server.et.yml new file mode 100644 index 0000000000..8c601aa1ed --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.et.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +et: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.fa_IR.yml b/plugins/discourse-narrative-bot/config/locales/server.fa_IR.yml new file mode 100644 index 0000000000..17cc868595 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.fa_IR.yml @@ -0,0 +1,36 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +fa_IR: + site_settings: + discourse_narrative_bot_enabled: 'ربات سخنگوی دیسکورس را فعال کنید' + disable_discourse_narrative_bot_welcome_post: "پیغام خوشامدگویی ربات دیسکورس را از کار بیاندازید" + discourse_narrative_bot_ignored_usernames: "نام‌های کاربری که بایستی توسط ربات دیسکورس چشمپوشی شوند" + discourse_narrative_bot: + quote: + '9': + author: "بروس لی" + results: |- + > :left_speech_bubble: _%{quote}_ — %{author} + magic_8_ball: + answers: + '3': "بدون شک" + '4': "قطعا بله" + '5': "میتوانی رویش حساب کنی" + '7': "به احتمال زیاد" + '9': "بله" + '12': "بعدا بپرسید" + '13': "بهتر است که الان به شما چیزی نگویم" + '14': "الان نمیتوانم پیشبینی کنم" + '16': "رویش حساب باز نکن" + '17': "پاسخ من خیر است" + result: |- + > :crystal_ball: %{result} + track_selector: + reset_trigger: 'شروع' + new_user_narrative: + reset_trigger: "کاربر جدید" diff --git a/plugins/discourse-narrative-bot/config/locales/server.fi.yml b/plugins/discourse-narrative-bot/config/locales/server.fi.yml new file mode 100644 index 0000000000..7657aa5d78 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.fi.yml @@ -0,0 +1,430 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +fi: + site_settings: + discourse_narrative_bot_enabled: 'Ota Discoursen opastava botti käyttöön' + disable_discourse_narrative_bot_welcome_post: "Estä bottia lähettämästä tervetuloviestejä" + discourse_narrative_bot_ignored_usernames: "Käyttäjänimet, jotka botin tulisi jättää huomiotta" + discourse_narrative_bot_disable_public_replies: "Estä bottia vastaamasta julkisesti ketjuihin" + discourse_narrative_bot_welcome_post_type: "Minkätyyppisen tervetuloviestin botti lähettää" + discourse_narrative_bot_welcome_post_delay: "Odota (n) sekuntia ennen kuin botti lähettää tervetuloviestin." + badges: + certified: + name: Tutkinto plakkarissa + description: "Suoritti peruskurssin" + long_description: | + Tämä ansiomerkki myönnetään, kun suoritat menestyksellä vuorovaikutteisen palstan käytön peruskurssin. Otit tavoitteeksesi hallita tavallisimmat toiminnot, ja nyt sinulla on siitä todistus! + licensed: + name: Pätevyytensä osoittanut + description: "Suoritti jatkokurssin" + long_description: | + Tämä ansiomerkki myönnetään, kun suoritat menestyksellä vuorovaikutteisen palstan käytön jatkokurssin. Olet omaksunut edistyneemmätkin toiminnot, ja olet taitosi osoittanut. + discourse_narrative_bot: + bio: "Moi! En ole ihminen. Olen botti, ja tarjoan opetusta sivuston käyttämisestä. Jos haluat jutella, lähetä yksityisviesti tai mainitse **`@%{discobot_username}`** missä vain." + timeout: + message: |- + Hei @%{username}, kyselen kuulumisia, kun en ole kuullut sinusta hetkeen. + + - Voit jatkaa milloin vain vastaamalla minulle. + + - Voit hypätä tämän vaiheen yli sanomalla `@%{discobot_username} %{skip_trigger}`. + + - Voit aloittaa alusta sanomalla `@%{discobot_username} %{reset_trigger}`. + + Jos et viitsi, sekin on ihan okei. Olen robotti. Minulla ei ole tunteita. :sob: + dice: + trigger: "heitä" + invalid: |- + Pahoittelen, mutta on matemaattinen mahdottomuus heittää noppia sillä tavalla kuin pyysit. :confounded: + not_enough_dice: |- + Minulla on vain %{num_of_dice} noppaa. [Häpeällistä](http://www.therobotsvoice.com/2009/04/the_10_most_shameful_rpg_dice.php), tiedän! + out_of_range: |- + Tiesitkö, että [suurin mahdollinen määrä sivuja](https://www.wired.com/2016/05/mathematical-challenge-of-designing-the-worlds-most-complex-120-sided-dice) matemaattisesti tasapuolisessa nopassa on 120? + results: |- + > :game_die: %{results} + quote: + trigger: "sitaatti" + '1': + quote: "Tutki parhaita kirjailijoita siten avartaaksesi mielikuvituksesi piiriä." + author: "Aleksis Kivi" + '2': + quote: "On ihan mahdollista, että todellisuus on olemassa." + author: "Esko Valtaoja" + '3': + quote: "Jokainen syy, joka estää kuntoilun, on tekosyy." + author: "Urho Kekkonen" + '4': + quote: "Älkää kiinnittäkö huomiota mitä kriitikot sanovat, kriitikoille ei ole koskaan pystytetty yhtään patsasta." + author: "Jean Sibelius" + '5': + quote: "Kahdesta vaihtoehdosta koetan valita aina sen, joka pelottaa enemmän." + author: "Jouko Turkka" + '6': + quote: "Jos Suomessa juodaankin jokunen Koskenkorva liikaa, on siihen hyvä syy: paljon muuta tekemistä ei löydy." + author: "Linus Torvalds" + '7': + quote: "Kaikki lähtee siitä kun sä opit syömään lihapullat haarukalla. Silloin sä osaat tehdä mitä tahansa." + author: "Matti Nykänen" + '8': + quote: "Käsitykseni on, että se, mitä Suomen kansa tällä hetkellä ennen kaikkea kaipaa, on läheistä, asiallisuuteen pohjautuvaa ymmärtämystä eri yhteiskuntalukkien, eri kieliryhmien ja eri puolueiden kesken. Se takaa jatkuvan yhteiskunnallisen rauhan ja siitä riippuu, missä määrin tämä kansa kykenee vapauttansa ja itsenäisyytensä lujittamaan ja tarvittaessa puolustamaan." + author: "Lauri Kristian Relander" + '9': + quote: "Naisen työala tulevaisuudessa on laaja, hänen tehtävänsä tärkeä. Mitä vuosisadat, vuosituhannet ovat rikkoneet ja laiminlyöneet, se kaikki tulee hänen korjata ja parantaa." + author: "Minna Canth" + '10': + quote: "Joskus pitää laittaa viatonkin vankilaan. Joskus täytyy poliisin laittaa viaton lusimaan. Arvovalta säilyy. Pelote säilyy. Aina siellä muutama viaton istuu, viaton pulmunen istuu siellä." + author: "Rauno Repomies televisiosarjassa Pasila" + results: |- + > :left_speech_bubble: _%{quote}_ — %{author} + magic_8_ball: + trigger: 'ennustus' + answers: + '1': "Se on varmaakin varmempaa" + '2': "Niin on tapahtuva" + '3': "Epäilemättä" + '4': "Kyllä, ehdottomasti" + '5': "Voit laskea sen varaan" + '6': "Näkemykseni on, että kyllä" + '7': "Todennäköisesti" + '8': "Siltä näyttäisi" + '9': "Kyllä" + '10': "Merkit viittaavat siihen suuntaan" + '11': "Näky epäselvä, yritä uudelleen" + '12': "Palaa myöhemmin asiaan" + '13': "Parempi, etten kerro vielä" + '14': "En pysty sanomaan juuri nyt" + '15': "Keskity ja kyse sitten uudelleen" + '16': "Älä laske sen varaan" + '17': "Vastaukseni on ei" + '18': "Lähteeni kertovat, että ei" + '19': "Ei siltä vaikuta" + '20': "Epäilen sitä vahvasti" + result: |- + > :crystal_ball: %{result} + track_selector: + reset_trigger: 'aloita' + skip_trigger: 'ohita' + help_trigger: 'apua' + random_mention: + reply: |- + Moi! Jos haluat tietää mitä osaan, sano `@%{discobot_username} %{help_trigger}`. + tracks: |- + Osaan nykyisellään seuraavat jutut: + + `@%{discobot_username} %{reset_trigger} %{default_track}` + > Aloittaa yhden seuraavista opastetuista kierroksista: %{tracks}. + bot_actions: |- + `@%{discobot_username} %{dice_trigger} 2d6` + > :game_die: dice roll: 3, 6 + + `@%{discobot_username} %{quote_trigger}` + > :left_speech_bubble: _Carry out a random act of kindness, with no expectation of reward, safe in the knowledge that one day someone might do the same for you_ — Prinsessa Diana + + `@%{discobot_username} %{magic_8_ball_trigger}` + > :crystal_ball: En pysty sanomaan juuri nyt + do_not_understand: + first_response: |- + Hei, kiitos kun vastasit! + + Valitettavasti olen huonosti ohjelmoitu botti, enkä aivan ymmärtänyt mitä tarkoitit. :frowning: + track_response: Voit yrittää uudelleen, tai jos mieluummin hyppäät vaiheen yli, sano `@%{discobot_username} %{skip_trigger}`. Tai jos haluat aloittaa alusta, sano `@%{discobot_username} %{reset_trigger}`. + second_response: |- + Voi ei, en vieläkään tajua. :anguished: + + Olen kuitenkin vain botti. Jos haluat tavoittaa aidon ihmisen, katsopa [yhteystietoja tältä sivulta](/about). + + En häiritse sinua enempää nyt. + new_user_narrative: + reset_trigger: "peruskurssi" + cert_title: "Tunnustuksena palstan käytön peruskurssin menestyksekkäästä suorittamisesta" + hello: + title: ":robot: Tervehdys!" + message: |- + Kiitos kun liityit sivustollemme %{title}. Tervetuloa! + + - Olen vain robotti, mutta [avulias henkilökunta](/about) auttaa myös mielellään, jos sinulla on asiaa heille. + + - Turvallisuussyistä uusi käyttäjä ei voi tehdä ihan kaikkea. Saat uusia toimintoja käyttöösi (ja [ansiomerkkejä](/badges)) kun tutustumme sinuun. + + - Vannomme [sivistyneen yhteisökäyttäytymisen](/guidelines) nimeen kaikissa tilanteissa. + onebox: + instructions: |- + Voisitko nyt jakaa jonkun seuraavista linkeistä minulle? Laita vastaukseesi **linkin osoite yksin omalle rivilleen**, jolloin siitä muotoillaan automaattisesti kätevä tiivistelmälaatikko. + + Kopioi linkki mobiililaitteella koskettamalla sitä hetken aikaa, tai klikkaa sitä hiiresi oikealla painikkeella: + + - https://en.wikipedia.org/wiki/Inherently_funny_word + - https://en.wikipedia.org/wiki/Death_by_coconut + - https://en.wikipedia.org/wiki/Calculator_spelling + reply: |- + Siistiä! Suurin osa linkeistä käyttäytyy samoin. Muista, että sen tulee olla yksin omalla rivillään; mitään ei saa olla rivillä ennen sitä tai sen jälkeen. + not_found: |- + Voi ei, en löytänyt linkkiä vastauksestasi! :cry: + + Kokeilepa lisätä tämä linkki seuraavaan vastaukseesi, omalle rivilleen. + + - https://fi.wikipedia.org/wiki/Eksoottinen_lyhytkarva + images: + instructions: |- + Tämä on kuva yksisarvisesta: + + + + Jos pidät siitä (kukapa ei pitäisi!), kerro siitä minulle painamalla tykkää-nappia :heart: tämän viestin alla. + + Osaatkohan **vastata kuvalla?** Ihan mikä tahansa kuva kelpaa! Raahaa ja pudota, paina liitä-nappia tai vaikkapa kopioi ja liitä. + reply: |- + Loistava kuva – Painoin tykkää-nappia :heart:, jotta tiedät että pidin siitä kovasti! :heart_eyes: + like_not_found: |- + Unohditko tykätä :heart: [viestistäni?](%{url}) :crying_cat_face: + not_found: |- + Näyttää ettet laittanut kuvaa, joten valitsin kuvan josta _takuulla_ pidät. + + `%{image_url}` + + Kokeilepa liittää tämä kuva tai liittää linkin osoite omalle rivilleen! + formatting: + instructions: |- + **Lihavoi** tai **kursivoi** sanoja seuraavasta vastauksestasi. + + - kirjoita `**lihavoitua**` tai `_kursivoitua_` + + - tai käytä viestieditorin B- tai I-painiketta + reply: |- + Hyvin tehty! HTML ja BBCode käyvät myös muotoiluun. Jos on puhtia, [tästä johdannosta](http://commonmark.org/help) opit lisää. :nerd: + not_found: |- + Pahus, en näe muotoilua viestissäsi. :pencil2: + + Kokeilisitko uudelleen? Käytä editorin B-lihavointipainiketta tai I-kursivointipainiketta, jos muuten ei meinaa onnistua. + quoting: + instructions: |- + Voisitko lainata minua kun vastaat, jotta tiedän mihin kohtaan viittaat? + + > Jos tässä on kahvia, tuo minulle teetä. Jos tässä on teetä, tuo minulle kahvia. + > + > Hyvä puoli itsekseen juttelussa on, että voit olla varma, että joku kuuntelee. + > + > Jotkut ovat briljantteja sanankäyttäjiä ja toiset... öö... tuota... eivät ole. + + Maalaa valitsemasi sitaatin ↑ teksti ja paina sitten Lainaa-painiketta, joka ilmestyy valintasi yhteyteen. Lainaa-napin sijasta voit myös painaa Vastaa-nappia tämän viestin alla. + + Kuvaile parilla sanalla sitaatin perään, miksi valitsit juuri sen, sillä olen kovin utelias. :thinking: + reply: |- + Satuit valitsemaan suosikkisitaattini! :left_speech_bubble: + not_found: |- + Hmm, minusta tuntuu ettet lainannut tekstiäni vastaukseesi? + + **Lainaa**-painike ilmestyy, kun maalaat viestini tekstiä. **Vastaa**-painike käy yhtä hyvin, jos sinulla on tekstiä maalattuna. Kokeilisitko uudelleen? + bookmark: + instructions: |- + Jos haluat oppia lisää, valitse tämän alta ja **lisää tämä yksityisviestiketju kirjanmerkkeihisi**. Jos teet niin, sinua voi odottaa :gift: tulevaisuudessa! + reply: |- + Erinomaista! Löydät yksityiskeskustelumme nyt helposti koska vain [profiilisi kirjanmerkkivälilehdeltä](%{profile_page_url}/activity/bookmarks). Klikkaat vain profiilikuvaasi oikeassa yläkulmasssa ↗ + not_found: |- + Tuota noin, et ole mielestäni kirjanmerkinnyt tämän ketjun viestejä. Löysithän kirjanmerkkikuvakkeen kunkin viestin alta? Voi olla, että sinun täytyy klikata -kuvaketta, jotta näet sen ja muita toimintoja. + emoji: + instructions: |- + Tapaan käyttää pieniä kuvia :blue_car::dash: viesteissäni; ne ovat [emojeja](https://fi.wikipedia.org/wiki/Emoji). **Laitapa joku emoji** vastausviestiisi. On useita tapoja: + + - Näppäile `:) ;) :D :P :O` + + - Laita kaksoispiste : ja sen perään emojin nimi `:tada:` + + - Paina editorin tai mobiililaitteesi näppäimistön emoji-kuvaketta . + reply: |- + :sparkles: _Emojinomaista!_ :sparkles: + not_found: |- + Oho, et tainnut laittaa emojia viestiisi. Nyyh! :sob: + + Saat emojivalitsimen esiin näppäilemällä kaksoispisteen :. Ala kirjoittaa sen perään englanniksi millaisen emojin haluat - esimerkiksi lintu on `:bird:` + + Voit myös painaa editorin emojikuvaketta . + + (Mobiililaitteella voit lisätä emojin suoraan näppäimistöltäsikin.) + mention: + instructions: |- + Voit haluta huomiota joltakulta, vaikket edes olisi vastaamassa hänelle suoraan. Mainitse hänet näppäilemällä `@` ja sen perään käyttäjänimi kokonaisuudessaan. + + Mainitsepa minut **`@%{discobot_username}`** seuraavassa vastauksessasi. + reply: |- + _Kutsuiko joku minua!?_ :raised_hand: Sinäkö se olit? :wave: Tässäpä minä! Kiitos kun mainitsit minut. :ok_hand: + not_found: |- + En näe nimeäni. :frowning: Yrittäisitkö nimeni `@%{discobot_username}` mainitsemista vielä kerran? + + (Huomaa, että nimessäni on kansainvälisessä hengessä _disco_, ihan kuin siinä 1970-luvun tanssityylissä. [Rakastan yöelämää!](https://www.youtube.com/watch?v=B_wGI3_sGf8) :dancer:) + flag: + instructions: |- + Pidämme ystävällishenkisistä keskusteluista, ja tarvitsemme apuasi, jotta [pysytään asialinjalla](%{guidelines_url}). Kun näet ongelman, kerro siitä kirjoittajalle tai [avuliaalle henkilökunnalle](%{about_url}) liputtamalla. + + > :imp: Kirjoitin tähän jotain tuhmaa. + + Tiedät mitä tehdä. **Liputa tämä viesti** sopimattomaksi! + reply: |- + [Henkilökuntamme](/groups/staff) saa ei-julkisen ilmoituksen lipusta. Jos riittävän moni yhteisön jäsen liputtaa viestin, sekin riittää viestin automaattiseen piilottamiseen varotoimena. (Koska en oikeasti kirjoittanut mitään tuhmaa :angel:, menin ja poistin liputuksesi.) + not_found: |- + Oi voi, tuhmaa viestiäni ei ole vielä liputettu. :worried: Voitko liputtaa sen sopimattomaksi **lippupainikkeen** avulla ? Don’t forget to use the show more button to reveal more actions for each post. + search: + instructions: |- + _psst_ … Tein pienen jekun tähän ketjuun. Jos olet valmis ottamaan haasteen vastaan, oikealla ylhäällä ↗ on **hakukuvake** , jolla voit yrittää löytää sen. + + Etsi hakusanaa "kapy​bara" tästä ketjusta + hidden_message: |- + Miten sinulta jäi tämä kapybara huomaamatta? :wink: + + + + Huomasitko, että palasit takaisin alkuun? Ruoki söpöä nälkäistä kapybaraa **vastaamalla `:herb:` -emojilla** niin sinut palautetaan automaattisesti takaisin loppuun. + reply: |- + Jes, löysit sen :tada: + + - Tarkempia hakuja voit tehdä [suurella hakusivulla](%{search_url}). + + - Hyppää mihin kohtaan haluat pitkää keskustelua oikealla olevalla aikajanan avulla (sijaitsee mobiilissa alhaalla). + + - Jos sinulla on fyysinen :keyboard:, tarkastele käteviä näppäinoikoteitä painamalla ?. + not_found: |- + Hm… sinulla vaikuttaa olevan vaikeuksia. Pahoitteluni. Haitko hakusanalla **kapy​bara**? + end: + message: |- + Kiitos kun jaksoit loppuun asti @%{username}! Tein tämän sinulle, minusta olet ansainnut sen: + + %{certificate} + + Siinä kaikki tältä erää! Tsekkaa [**tuoreimmat ketjut**](/latest) tai tutki [**keskustelualueita**](/categories). :sunglasses: + + (Jos haluat vielä jutella kanssani ja oppia lisää, lähetä minulle `@%{discobot_username}` yksityisviesti tai mainitse minut koska vain!) + certificate: + alt: 'Diplomi' + advanced_user_narrative: + reset_trigger: 'jatkokurssi' + cert_title: "Tunnustuksena palstan käytön jatkokurssin menestyksekkäästä suorittamisesta" + title: ':arrow_up: Jatkokurssi' + start_message: |- + _Edistynyt_ käyttäjä kun olet, oletko @%{username} jo käynyt [käyttäasetussivulla](/my/preferences)? Siellä voit mukauttaa sivustoa makusi mukaan monilla tavoin, esimerkiksi valita tumman ja vaalean teeman väliltä. + + Vaan eipä harhauduta asiasta vaan aloitetaan! + edit: + bot_created_post_raw: "@%{discobot_username} on ylivoimaisesti mahtavin botti, jonka tiedän. :wink:" + instructions: |- + Kaikki tekevät virheitä. Vaan älä huoli, voit muokata viestejäsi ja korjata erheesi! + + Aloita **muokkaamalla** viestiä, jonka juuri tein sinun nimissäsi. + not_found: |- + Näyttää, ettet vielä muokannut [viestiä](%{url}), jonka tein puolestasi. Yrittäisitkö uudelleen? + + Palaa viestieditoriin klikkaamalla -kuvaketta. + reply: |- + Hyvin tehty! + + Huomaa, että 5 minuutin jälkeen tehdyt muokkaukset näkyvät kaikille muokkaushistoriassa, joka ilmestyy viestin oikeaan yläkulmaan pienenä kynäsymbolina, jossa näkyy muokkausten määrä. + delete: + instructions: |- + Jos haluat perua viestisi, voit poistaa sen. + + Anna mennä ja **poista** yltä mikä tahansa viestisi poista-toiminnon avulla. Älä kuitenkaan erehdy poistamaan ketjun ensimmäistä viestiä! + not_found: |- + Minusta viestejä ei vielä poistettu? Muista, että näytä lisää -kuvake paljastaa poista-kuvakkeen. + reply: |- + Vau! :boom: + + Keskusteluiden jatkuvuuden vuoksi viesti ei poistu kokonaan välittömästi, vaan se poistuu vasta jonkin ajan kuluttua. + recover: + deleted_post_raw: 'Miksi @%{discobot_username} poisti viestini? :anguished:' + instructions: |- + Voi ei! Taisin vahingossa poistaa viestin, jonka tein sinun nimissäsi. + + Tekisitkö palveluksen ja **palauttaisit** sen? + not_found: |- + Onko ongelmia? Muista, että näytä lisää -kuvake paljastaa palauta-kuvakkeen. + reply: |- + Huh, sydäntä kylmäsi! Kiitos, kun korjasit mokani. :wink: + + Huomioi, että viestin voi palauttaa vain 24 tunnin sisällä sen poistamisesta. + category_hashtag: + instructions: |- + Tiesitkö, että viesteissä voi viitata alueisiin ja tunnisteisiin? Esimerkiksi, oletko käynyt %{category}-alueella? + + Näppäile lauseen keskellä `#` ja valitse mieleinen alue tai tunniste. + not_found: |- + Höh, en näe viittauksia alueisiin. Huomioi, ettei `#` saa olla rivinsä ensimmäinen merkki. Kopioisitko tämän seuraavaan vastaukseesi? + + ```text + I can create a category link via # + ``` + reply: |- + Erinomaista! Muista, että tämä toimii alueiden lisäksi myös tunnisteille, jos ne ovat käytössä täällä. + change_topic_notification_level: + instructions: |- + Sinulla on jokaiselle ketjulle ilmoitustaso. Aluksi se on 'tavallinen', jolloin saat tavalliseen tapaan ilmoituksen vain, jos joku puhuu nimenomaan sinulle. + + Oletuksena yksityisviestiketjujen ilmoitustaso on kaikkein korkein eli 'tarkkaillaan', jolloin saat ilmoituksen joka ikisestä viestistä. Voit kuitenkin valita _mille tahansa_ ketjulle ilmoitustason 'tarkkaillaan', 'seurataan' tai 'vaimennettu'. + + Kokeillaanpa vaihtaa tämän ketjun ilmoitustasoa. Ketjun alla on painike, joka näyttää sinun tarkkailevan tätä ketjua. Vaihdapa ilmoitustasoksi **seurataan**. + not_found: |- + Näytät yhä tarkkailevan :eyes: tätä ketjua! Jollet meinaa löytää sitä, ilmoitustasopainike sijaitsee ketjun alla. + reply: |- + Mahtavaa! Toivottavasti et kuitenkaan vaimentanut tätä ketjua, vaikka saatan välillä olla vähän liiankin puhelias. :grin:. + + Huomioi, että kun vastaat ketjuun tai luet sitä kauemmin kuin muutaman minuutin ajan, se saa automaattisesti ilmoitustasokseen 'seurataan'. Tätä voi säätää [käyttäjäasetuksissa](/my/preferences). + poll: + instructions: |- + Tiesitkö, että viesteihin voi lisätä äänestyksiä? **Luo äänestys** valitsemalla viestieditorin hammasrataskuvake. + not_found: |- + Ups! Vastauksessasi ei ollut äänestystä. + + Käytä editorin hammasrataskuvaketta tai kopioi tämä äänestys ja liitä se seuraavaan vastaukseesi: + + ```text + [poll] + * :cat: + * :dog: + [/poll] + ``` + reply: |- + Hauska äänestys! Miten onnistuin opetuksessa? + + [poll] + * :+1: + * :-1: + [/poll] + details: + instructions: |- + Joskus voit haluta **piilottaa yksityiskohtia** vastauksissasi: + + - Kirjoitat elokuvasta tai TV-ohjelmasta jotakin, minkä voi kokea juonipaljastukseksi. + + - Viestissäsi on suuria määriä yksityiskohtaista tietoa, joka ei ole kaikille olennaista ja joka voisi uuvuttaa tai häiritä lukijaa. + + [details=Select this to see how it works!] + 1. Valitse editorin hammasrataskuvake. + 2. Valitse "Piilota yksityiskohdat". + 3. Muokkaa Yhteenveto-tekstiä, jonka taakse tiedot piilotetaan, ja lisää sisältö. + [/details] + + Piilottaisitko yksityiskohtia editorin hammasrataskuvakkeen avulla seuraavasta vastauksestasi? + not_found: |- + Onko ongelmia yksityiskohtatoiminnon kanssa? Kokeilepa laittaa tämä seuraavaan vastaukseesi: + + ```text + [details=Paina niin saat tarkempaa tietoa] + Yksityiskohtaista tietoa + [/details] + ``` + reply: |- + Hyvin tehty - panostustasi _yksityiskohtiin_ on pakko arvostaa! + end: + message: |- + Selvitit tämän kuin _edistynyt käyttäjä_ konsanaan! :bow: + + %{certificate} + + Siinä oli kaikki, mitä minulla on tarjota. + + Kuulemiin! Jos haluat vielä jutella kanssani, lähetä viesti milloin vain! :sunglasses: + certificate: + alt: 'Diplomi jatkokurssista' diff --git a/plugins/discourse-narrative-bot/config/locales/server.fr.yml b/plugins/discourse-narrative-bot/config/locales/server.fr.yml new file mode 100644 index 0000000000..679d432c30 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.fr.yml @@ -0,0 +1,112 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +fr: + site_settings: + discourse_narrative_bot_enabled: 'Activer l''assistant Discourse' + disable_discourse_narrative_bot_welcome_post: "Désactiver le message de bienvenue de l'assistant Discourse" + discourse_narrative_bot_ignored_usernames: "Noms d'utilisateurs que l'assistant Discourse doit ignorer" + discourse_narrative_bot_disable_public_replies: "Désactiver les réponses publiques de l'assistant Discourse" + discourse_narrative_bot_welcome_post_type: "Type de message de bienvenue envoyé par l'assistant Discourse" + discourse_narrative_bot_welcome_post_delay: "Attendre (n) seconde(s) avant d'envoyer le message de bienvenue de l'Assistant Discourse" + badges: + certified: + name: Certifié + description: "A terminé le tutoriel des nouveaux utilisateurs" + long_description: | + Ce badge est décerné quand vous avez terminé avec succès le tutoriel interactif des nouveaux utilisateurs. Vous avez pris l'initiative d'apprendre les outils de base de la discussion et vous êtes maintenant certifié ! + licensed: + name: Certifié + description: "A terminé le tutoriel des utilisateurs avertis" + long_description: | + Ce badge est décerné quand vous avez terminé avec succès le tutoriel interactif des utilisateurs avancés. Vous avez pris l'initiative d'apprendre les outils avancés de la discussion et vous êtes maintenant certifié à 100% ! + discourse_narrative_bot: + bio: "Bonjour, je ne suis pas une personne réelle. Je suis un robot pour vous faire découvrir ce site. Pour interagir avec moi, envoyez-moi un message ou mentionnez **`@%{discobot_username}`** n'importe où." + dice: + trigger: "Jet" + invalid: |- + Je suis désolé, c'est mathématiquement impossible d'obtenir cette combinaison de dés. :confounded: + not_enough_dice: |- + Je n'ai qu'un dé. [Shameful](http://www.therobotsvoice.com/2009/04/the_10_most_shameful_rpg_dice.php), Je sais! + results: |- + > :game_die: %{results} + quote: + trigger: "Citation" + '1': + quote: "La difficulté est porteuse d'occasions" + author: "Albert Einstein" + '2': + quote: "Vous devez être le changement que vous voulez voir dans ce monde." + author: "Mahatma Gandhi" + '3': + quote: "Ne pleurez pas parce que c'est fini, souriez parce que c'est arrivé !" + author: "Dr Seuss" + '4': + quote: "On n'est jamais si bien servi que par soi-même." + author: "Charles-Guillaume Étienne" + '5': + author: "Theodore Roosevelt" + '6': + quote: "La vie est comme une boîte de chocolats. On ne sait jamais sur quoi on va tomber." + author: "La maman de Forrest Gump" + '7': + quote: "C'est un petit pas pour un homme, un saut géant pour l'humanité." + author: "Neil Armstrong" + '8': + quote: "Fais une chose qui t'effraie chaque jour." + author: "Eleanor Roosevelt" + '9': + author: "Bruce Lee" + '10': + author: "Napoleon Hill" + results: |- + > :left_speech_bubble: %{quote} __ — %{author} + magic_8_ball: + answers: + '1': "C'est certain" + '3': "Sans aucun doute" + '4': "Définitivement oui" + '5': "Vous pouvez compter dessus" + '6': "Comme je le vois, oui" + '7': "Probablement" + '9': "Oui" + '10': "Les signent tendent vers le oui" + '12': "Demander à nouveau plus tard" + '13': "Il vaut mieux ne pas vous le dire maintenant" + '14': "Impossible à prédire pour le moment" + '16': "Ne comptez pas dessus" + '17': "Ma réponse est non" + '18': "Mes sources disent non" + '20': "Très douteux" + result: |- + > :crystal_ball: %{result} + track_selector: + random_mention: + reply: |- + Bonjour ! Pour voir ce que je peux faire, dites `@%{discobot_username} `. + tracks: |- + Actuellement, je sais faire les choses suivantes: + + `@%{discobot_username}%{reset_trigger}%{default_track} ` + > Démarre l'un des récits interactifs suivants: %{tracks}. + new_user_narrative: + hello: + title: ":robot: Bienvenue !" + mention: + instructions: |- + Parfois vous avez peut-être envie d'attirer l'attention, même si vous ne lui répondez pas directement. Tapez `@` et complétez son pseudo pour le mentionner. + + Pouvez vous mentionner **`@%{discobot_username}`** dans votre réponse? + reply: |- + _Quelqu'un a dit mon nom ?_ :raised_hand: Je pense que vous l'avez fait ! :wave: Bien, je suis là ! Merci de m'avoir mentionné. :ok_hand: + advanced_user_narrative: + edit: + bot_created_post_raw: "@%{discobot_username} est, de loin, le robot le plus cool que je connaisse :wink:" + instructions: |- + Tout le monde fait des erreurs. Mais ne vous inquiétez pas, vous pouvez toujours éditer vos messages pour les réparer. + + Pouvez-vous commencer par **éditer** le message que je viens de créer en votre nom? diff --git a/plugins/discourse-narrative-bot/config/locales/server.gl.yml b/plugins/discourse-narrative-bot/config/locales/server.gl.yml new file mode 100644 index 0000000000..695b5cf287 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.gl.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +gl: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.he.yml b/plugins/discourse-narrative-bot/config/locales/server.he.yml new file mode 100644 index 0000000000..3c72d0d3f8 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.he.yml @@ -0,0 +1,101 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +he: + site_settings: + discourse_narrative_bot_enabled: 'אפשרו את בוט הסיפורים של דיסקורס' + disable_discourse_narrative_bot_welcome_post: "נטרלו את פוסט הברוכים הבאים מאת בוט הסיפורים של דיסקורס" + discourse_narrative_bot_ignored_usernames: "שמות משתמשים שבוט הסיפורים של דיסקורס צריך להתעלם מהם" + discourse_narrative_bot_disable_public_replies: "נטרלו תגובות פומביות מאת בוט הסיפורים של דיסקורס" + discourse_narrative_bot_welcome_post_type: "סוג פוסט הברוכים הבאים שבוט הסיפורים של דיסקורס אמור לשלוח" + discourse_narrative_bot_welcome_post_delay: "המתנה של (n) שניות לפני שליחת פוסט ברוכים הבאים של בוט הסיפורים של דיסקורס." + badges: + certified: + description: "סיימו את הדרכת המשתמשים החדשים שלנו" + discourse_narrative_bot: + dice: + trigger: "גלגול" + results: |- + > :game_die: %{results} + quote: + trigger: "ציטוט" + '1': + quote: "במרכזו של כל קושי מונחת הזדמנות" + author: "אלברט אינשטיין" + '2': + quote: "אתם חייבים להיות השינוי שאתם רוצים לראות בעולם." + author: "מהאטמה גנדי" + '3': + quote: "אל תבכו כי זה נגמר, חייכו כי זה קרה." + author: "ד״ר סוס" + '4': + quote: "אם אתם רוצים שמשהו יתבצע כמו שצריך, עשו זאת בעצמכם." + '5': + quote: "האמינו שאתם יכולים ואתם מחצית הדרך לשם." + author: "תאודור רוזוולט" + '6': + quote: "החיים הם כמו קופסת שוקולד. אתם אף פעם לא יודעים מה תקבלו." + author: "האמא של פורסט גאמפ" + '7': + quote: "זהו צעד אחד קטן לאדם, צעד גדול לאנושות." + author: "ניל ארמסטרונג" + '8': + quote: "עשו דבר אחד בכל יום שמפחיד אתכם" + author: "אלינור רוזוולט" + '9': + quote: "טעויות תמיד נסלחות, אם יש את האומץ להודות בהן." + author: "ברוס לי" + '10': + quote: "מה ששכל האדם מסוגל להגות ולהאמין, ניתן להשגה." + author: "נפוליאון היל" + results: |- + > :left_speech_bubble: _%{quote}_ — %{author} + magic_8_ball: + trigger: 'מזלות' + answers: + '1': "זה וודאי" + '3': "ללא ספק" + '4': "כן בהחלט" + '5': "אתם יכולים לסמוך על כך" + '6': "כפי שאני רואה זאת, כן" + '8': "תחזית טובה" + '9': "כן" + '10': "הסימנים מראים שכן" + '12': "תשאלו שוב מאוחר יותר" + '13': "עדיף לא לענות עכשיו" + '14': "קשה לחזות עכשיו" + '15': "תתרכזו ותשאלו שוב" + '16': "אל תסמכו על זה" + '17': "התשובה שלי היא לא" + '18': "המקורות שלי אומרים לא" + '19': "תחזית לא כל כך טובה" + '20': "מאוד בספק" + result: |- + > :crystal_ball: %{result} + track_selector: + reset_trigger: 'התחלה' + skip_trigger: 'דילוג' + help_trigger: 'הצגת עזרה' + do_not_understand: + first_response: |- + הי, תודה על התגובה! + + לצערי, כבוט שתוכנת בצורה גרועה, אני לא ממש מבין את זה. :frowning: + new_user_narrative: + reset_trigger: "משתמש/ת חדש/ה" + cert_title: "כהוקרה על סיום מוצלח של מדריך המשתמשים החדש" + hello: + title: ":robot: ברכות!" + images: + like_not_found: |- + שכחתם לעשות לייק :heart: ל-[פוסט](%{url}) שלי? :crying_cat_face: + certificate: + alt: 'תעודת הישג' + advanced_user_narrative: + reset_trigger: 'משתמש/ת מתקדם/ת' + cert_title: "כהוקרה כל סיום מוצלח של מדריך המשתמשים המתקדמים" + title: ':arrow_up: יכולות משתמשים מתקדמים' diff --git a/plugins/discourse-narrative-bot/config/locales/server.id.yml b/plugins/discourse-narrative-bot/config/locales/server.id.yml new file mode 100644 index 0000000000..90293be524 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.id.yml @@ -0,0 +1,428 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +id: + site_settings: + discourse_narrative_bot_enabled: 'Aktifkan Discourse Narrative Bot' + disable_discourse_narrative_bot_welcome_post: "Matikan kiriman selamat datang dari Discourse Narrative Bot" + discourse_narrative_bot_ignored_usernames: "Nama Pengguna yang diabaikan oleh Discourse Narrative Bot" + discourse_narrative_bot_disable_public_replies: "Matikan balasan publik oleh Discourse Narrative Bot" + discourse_narrative_bot_welcome_post_type: "Ketik kiriman selamat datang yang akan dikirim oleh Discourse Narrative Bot" + discourse_narrative_bot_welcome_post_delay: "Tunggu (n) detik sebelum Discourse Narrative Bot mengirim pos selamat datang." + badges: + certified: + name: Tersertifikasi + description: "Telah menyelesaikan panduan pengguna baru kami" + long_description: | + Lencana ini diberikan atas keberhasilan menyelesaikan panduan interaktif pengguna baru. Anda telah bersedia mempelajari dasar penggunaan diskusi dalam forum, sekarang Anda telah tersertifikat menjadi anggota forum. + licensed: + name: Berlisensi + description: "Telah menyelesaikan panduan pengguna tingkat lanjutan kami" + long_description: | + Lencana ini diberikan atas keberhasilan menyelesaikan panduan interaktif pengguna anggota forum lanjutan. Anda telah ahli menggunakan fasilitas lanjutan dalam diskusi — sekarang Anda telah bersertifikat penuh menjadi anggota forum. + discourse_narrative_bot: + bio: "Salam, saya bukan manusia asli. Saya adalah robot yang akan memandu Anda menggunakan forum ini. Untuk berkomunikasi dengan saya, kirimi saya pesan atau panggil **`@%{discobot_username}`** dimana saja." + timeout: + message: |- + Salam @%{username}, hanya ingin menyapa karena saya tidak melihat Anda beberapa waktu. + + - Untuk melanjutkan, balas saya kapan saja. + + - Jika ingin melewatkan langkah ini, bilang `%{skip_trigger}`. + + - Untuk mulai kembali, bilang `%{reset_trigger}`. + + Jika Anda tidak ingin semuanya, tidak masalah. Saya hanya robot. Saya akan baik-baik saja. :sob: + dice: + trigger: "lempar" + invalid: |- + Maafkan saya, secara matematis tidak mungkin untuk memutar kombinasi dadu. :confounded: + not_enough_dice: |- + Saya hanya punya %{num_of_dice} dadu. [Shameful](http://www.therobotsvoice.com/2009/04/the_10_most_shameful_rpg_dice.php), Saya tahu! + out_of_range: |- + Apakah Anda tahu [the maximum number of sides](https://www.wired.com/2016/05/mathematical-challenge-of-designing-the-worlds-most-complex-120-sided-dice) untuk sebuah dadu seimbang matematis adalah 120? + results: |- + > :game_die: %{results} + quote: + trigger: "kutipan" + '1': + quote: "Setiap kesulitan ada kemudahan" + author: "Albert Einstein" + '2': + quote: "Kamu harus jadi agen perubahan agar dunia mengenalimu." + author: "Mahatma Gandhi" + '3': + quote: "Jangan menangis karena sudah selesai, tersenyumlah karena itu telah terjadi." + author: "Dr Seuss" + '4': + quote: "Jika kamu ingin sesuatu berjalan dengan benar, lakukan sendiri." + author: "Charles-Guillaume Étienne" + '5': + quote: "Percayalah kamu bisa dan kamu sudah menyelesaikannya separuh." + author: "Theodore Roosevelt" + '6': + quote: "Hidup ini seperti sekotak cokelat. Kamu tidak pernah tahu apa yang akan kamu dapati." + author: "Forrest Gump’s Mom" + '7': + quote: "Satu langkah kecil untuk satu orang, lompatan besar untuk umat manusia." + author: "Neil Armstrong" + '8': + quote: "Lakukan satu hal yang kamu takuti untuk satu hari." + author: "Eleanor Roosevelt" + '9': + quote: "Kesalahan selalu dimaafkan, jika seseorang mau berani mengakuinya." + author: "Bruce Lee" + '10': + quote: "Jika pikiran seseorang mau membayangi dan mempercayai sesuatu, hal itu akan tercapai." + author: "Napoleon Hill" + results: |- + > :left_speech_bubble: _%{quote}_ — %{author} + magic_8_ball: + trigger: 'nasib baik' + answers: + '1': "Yakin begini" + '2': "Sudah diputuskan demikian" + '3': "Tidak pakai ragu-ragu" + '4': "Betul sekali" + '5': "Anda dapat percaya dengan itu" + '6': "Sesuai pandangan saya, ya" + '7': "Hampir yakin" + '8': "Tampaknya baik" + '9': "Ya" + '10': "Tandanya iya" + '11': "Balasan tidak jelas coba lagi" + '12': "Nanti tanya lagi" + '13': "Sebaiknya nanti saja diberitahu" + '14': "Tidak bisa memprediksinya sekarang" + '15': "Konsentrasi dan tanya lagi" + '16': "Jangan memikirkan itu" + '17': "Balasan saya adalah tidak" + '18': "Sumber saya bilang tida" + '19': "Sepertinya tidak begitu bagus" + '20': "Ragu-ragu sekali" + result: |- + > :crystal_ball: %{result} + track_selector: + reset_trigger: 'mulai' + skip_trigger: 'lewati' + help_trigger: 'tampilkan bantuan' + random_mention: + reply: |- + Hei! Untuk tahu saya bisa melakukan apa, ketik `@%{discobot_username} %{help_trigger}`. + tracks: |- + Saat ini yang saya tahu saya bisa melakukan ini: + + `@%{discobot_username} %{reset_trigger} %{default_track}` + > Mulai dengan salah satu petunjuk interaktif berikut ini: %{tracks}. + bot_actions: |- + `@%{discobot_username} %{dice_trigger} 2d6` + > :game_die: 3, 6 + + `@%{discobot_username} %{quote_trigger}` + > :left_speech_bubble: _Lakukan kebaikan apa saja tanpa pamrih, ingatlah kalau suatu hari seseorang mungkin melakukan yang sama kepada kamu_ — Princess Diana + + `@%{discobot_username} %{magic_8_ball_trigger}` + > :crystal_ball: Kamu mungkin bergantung padanya + do_not_understand: + first_response: |- + Hei, terima kasih balasannya! + + Sayangnya, sebagai robot yang bodoh, saya tidak paham soal itu. :frowning: + track_response: Bisa dicoba lagi, atau jika ingin melewati ini, ketik `%{skip_trigger}`. Bisa juga jika ingin ulang dari awal, ketik `%{reset_trigger}`. + second_response: |- + Aduh maaf. Saya masih belum paham. :anguished: + + Saya hanya robot, jika ingin berkomunikas dengan manusia asli, buka [our contact page](/about). + + Sementara ini, saya akan mendampingi Anda. + new_user_narrative: + reset_trigger: "pengguna baru" + cert_title: "Tercatat telah berhasil menyelesaikan panduan pengguna baru." + hello: + title: ":robot: Salam!" + message: |- + Selamat datang %{title}! Terima kasih telah bergabung dengan kami. + + - Saya hanyalah sebuah robot, tapi [our friendly staff](/about) siap membantu jika butuh bantuan manusia asli. + + - Demi alasan keamanan, kami membatasi para anggota baru untuk sementara waktu. Anda akan mendapati fasilitas tambahan pengguna (and [badges](/badges)) begitu anda telah memiliki hak. + + - Kami percaya [civilized community behavior](/guidelines) selamanya. + onebox: + instructions: |- + Selanjutnya, bisakah Anda membagikan salah satu tautan ini kepada saya? Balas dengan **a link on a line by itself**, otomatis tautan itu akan menjelaskan ringkasan yang menarik. + + Untuk menyalin sebuah tautan, tekan dan tahan pada ponsel, atau klik kanan pada tetikus: + + - https://en.wikipedia.org/wiki/Inherently_funny_word + - https://en.wikipedia.org/wiki/Death_by_coconut + - https://en.wikipedia.org/wiki/Calculator_spelling + reply: |- + Keren! Cara ini berlaku untuk semua tautan. Ingat, semuanya harus sebaris _all by itself_, tanpa ada sesuatu di depan atau di belakangnya. + not_found: |- + Maaf, Saya tidak bisa menemukan tautan dari balasan Anda! :cry: + + Bisakan coba tautan di bawah ini, di barik tersendiri pada balasan berikutnya? + + - https://en.wikipedia.org/wiki/Exotic_Shorthair + images: + instructions: |- + Ini adalah gambar kuda bertanduk unicorn: + + + + Agar saya tahu kalau Anda suka, (siapa yang tidak suka!) tekan tombol :heart: yang ada di bawah pos ini. + + Bisakah Anda **reply with a picture?** Gambar apa saja bisa! Tarik dan taruh di sini, tekan tombol unggah atau upload, atau bisa juga salin rekan gambarnya ke sini. + reply: |- + Gambar yang menarik – Saya telah menekan tombol :heart: supaya Anda tahu kalau saya menghargainya :heart_eyes: + like_not_found: |- + Apakah Anda lupa menyukai :heart: kiriman saya [post?](%{url}) :crying_cat_face: + not_found: |- + Sepertinya Anda tidak memasukkan sebuah gambar jadi saya pilihkan sebuah gambar yang sepertinya I’m _sure_ you Anda akan suka. + + `%{image_url}` + + Coba selanjutnya unggah gambar lagi, atau tempelkan tautannya dalam sebuah baris tersendiri! + formatting: + instructions: |- + Bisakah Anda menjadikan beberapa kata menjadi **bold** atau _italic_ dalam balasan Anda? + + - Ketik `**bold**` atau `_italic_` + + - atau, tekan tombol B atau I pada editor teks + reply: |- + Percobaan yang bagus! HTML dan BBCode juga bisa digunakan dalam format tulisan – untuk mempelajarinya, [lihat tutorial ini](http://commonmark.org/help) :nerd: + not_found: |- + Ampun deh, saya tidak menemukan format balasan yang sesuai. :pencil2: + + Mau coba lagi? Gunakan tombol B untuk tebal atau I untuk miring yang ada di editor tulisan jika masih bingung. + quoting: + instructions: |- + Coba kutip komentar dalam balasan Anda? Jadi saya paham bagian mana yang dibalas. + + > Jika ini kopi, tolong bawakan teh; tapi jika ini teh, tolong bawakan kopi. + > + > Salah satu kelebihan bicara sendiri yaitu kamu tahu setidaknya ada seseorang yang mendengar ucapanmu. + > + > Beberapa orang punya cara bicara, dan yang lainnya... tidak punya gaya bicara sendiri. + + Pilih salah satu teks di atas ↑ kutip kalimat yang diinginkan, dan tekan tombol **Quote** yang muncul saat tulisan dipilih – atau gunakan tombol **Reply** yang ada di setiap pos. + + Di bawah kutipan, ketik satu atau dua kata atau kenapa kutipan itu dipilih, saya penasaran apa balasa Anda :thinking: + reply: |- + Pilihan yang bagus, Anda memilih kutipan favorit saya! :left_speech_bubble: + not_found: |- + Hmm, sepertinya Anda tidak mengutip salah satu kalimat saya di atas? + + Pilih teks apa saja di pos di atas untuk memunculkan tombol **Quote**. Lalu tekan tombol **Reply** setelah memilih teks apa saja. Bisakah tolong dicoba lagi? + bookmark: + instructions: |- + Jika ingin tahu lebih banyak, pilih di bawah ini dan **bookmark this private message**. Jika dilakukan, akan ada :gift: nanti! + reply: |- + Hebat! Kelak Anda dapat mudah menemukan percakapan pribadi kapan saja, langsung dari [the bookmarks tab on your profile](%{profile_page_url}/activity/bookmarks). Cukup pilih gambar profil Anda di pojok kanan atas ↗ + not_found: |- + Aduh, Saya tidak meliha tanda baca dalam topik ini. Apakah Anda melihat tanda baca atau bookmark yang di bawah setiap pos? Pilih tampilkan lebih banyak untuk melihat fasilitas lain yang dibutuhkan. + emoji: + instructions: |- + Anda telah melihat saya menggunakan gambar dalam balasan saya :blue_car::dash: gambar itu disebut [emoji](https://en.wikipedia.org/wiki/Emoji). Bisa tambahkan **add an emoji** pada balasan Anda? Pilih salah satu cara ini: + + - Ketik `:) ;) :D :P :O` + + - Ketik titik dua : lalu sebut nama emoji `:tada:` + + - Tekan tombol emoji di editor, atau pada papan ketik ponsel Anda. + reply: |- + Hebat :sparkles: _emojitastic!_ :sparkles: + not_found: |- + Uups, Sepertinya tidak ada emoji di balasan Anda? Tidak... :sob: + + Cobalah ketik sebuah titik dua : untuk membuat pilihan emoji, lalu ketik satu kata yang diinginkan, seperti `:bird:` + + Atau, pilih tombol emoji di editor. + + (Jika menggunakan ponsel, anda bisa langsung menggunakan dari papan ketik juga) + mention: + instructions: |- + Terkadang Anda perlu mendapatkan perhatian seseorang, walau Anda tidak membalas langsung komentar mereka. Gunakan `@` lalu ketik nama pengguna untuk memanggil mereka. + + Bisakah coba panggil **`@%{discobot_username}`** dalam balasan selanjutnya? + reply: |- + _Apakah Anda memanggil nama saya!?_ :raised_hand: Saya yakin iya! :wave: Baiklah, saya sudah hadir! Terima kasih sudah menyebut nama saya. :ok_hand: + not_found: |- + Saya tidak melihat nama saya disebut :frowning: Bisa dicoba lagi menyebut `@%{discobot_username}`? + + (Akhirnya, nama saya disebut _disco_, seperti tarian tahun 1970-an. Beneran [love the nightlife!](https://www.youtube.com/watch?v=B_wGI3_sGf8) :dancer:) + flag: + instructions: |- + Kami berharap diskusi yang penuh keakraban, dan kami perlu bantuan Anda untuk [keep things civilized](%{guidelines_url}). Jika melihat sebuah masalah, tolong tandai secara rahasia dan biarkan penulis, atau [pengurus forum](%{about_url}), mengetahuinya. + + > :imp: Saya telah menulis sesuatu yang buruk di sini + + Saya yakin Anda harus melakukan apa. Ayu **peringati pos ini** karena kurang pantas! + reply: |- + [Pengurus forum](/groups/staff) akan mendapatkan pemberitahuan secara rahasia tentang peringatan ini. Jika beberapa orang lainnya melakukan yang sama, secara otomatis kiriman tersebut akan disembunyikan sebagai antisipasi. (Karena saya tidak menulis sesuatu yang tidak baik :angel:, saya bisa terus bediskusi dan menghapus peringatannya sekarang.) + not_found: |- + Tidak..., tulisan buruk saya belum ditandai. :worried: Bisakah Anda menandainya sebagai sesuatu yang kurang pantas dengan **flag** ? Jangan lupa pilih tombol tampilkan lebih banyak untuk melihat tindakan lain untuk setiap kiriman. + search: + instructions: |- + _psst_ … Ada kejutan dalam topik ini. Jika mau mengikuti tantangannya, **pilih tombol pencarian** yang ada di kanan atas ↗ untuk melakukan pencarian. + + Coba cari istilah "capy​bara" dalam topik ini + hidden_message: |- + Bagaimana Anda melewatkan capcaybara? :wink: + + + + Apakah Anda sadar kalau sudah kembali ke diskusi awal? Beri makan si capcaybara yang kelaparan ini **ketik emotikon `:herb:`** dan otomatis Anda kembali ke diskusi paling bawah. + reply: |- + Ketemu juga :tada: + + - Untuk pencarian lebih rinci, buka [halaman pencarian penuh](%{search_url}). + + - Untuk berpindah ke komentar mana saja dalam diskusi yang panjang, coba kontrol linimasa topik yang ada di kanan (jika di ponsel ada di bawah). + + - Jika menggunakan papan ketik fisik :keyboard:, tekan ? untuk melihat pintasan mengetik yang bermanfaat. + not_found: |- + Hmm… sepertinya Anda mengalami kesulitan. Mohon maaf kalau begitu. Apakah sudah mencari untuk istilah **capy​bara**? + end: + message: |- + Terima kasih sudah bersedia saya temani @%{username}! Saya berikan ini untuk Anda, Anda pantas menerima ini: + + %{certificate} + + Panduan singkat ini telah selesai! Ayo mulai lihat [**topik diskusi terbaru forum ini**](/latest) atau lihat apa saja [**kategori diskusi**](/categories). :sunglasses: + + (Jika ingin jumpa dengan saya lagi dan belajar lebih banyak, cukup ketik atau panggil `@%{discobot_username}` kapan saja!) + certificate: + alt: 'Sertifikat Pencapaian' + advanced_user_narrative: + reset_trigger: 'anggota lanjutan' + cert_title: "Tercatat telah berhasil menyelesaikan panduan pengguna lanjutan." + title: ':arrow_up: Fasilitas pengguna lanjutan' + start_message: |- + Sebagai _advanced_ user, sudahkah anda membuka [laman pengaturan anda](/my/preferences) @%{username}? Ada banyak pengaturan demi kenyamanan Anda berforum, seperti memilih tampilan forum warna gelap atau terang. + + Sepertinya saya melantur, mari mulai! + edit: + bot_created_post_raw: "@%{discobot_username} adalah robot keren yang ada sejauh ini :wink:" + instructions: |- + Setiap orang selalu membuat kesalahan. Jangan khawatir, Anda dapat selalu mengubah tulisan Anda nantinya! + + Bisakah Anda mulai melakukan **edit** pos yang sebelumnya sudah Anda buat? + not_found: |- + Tampaknya Anda belum mengedit tulisan [pos](%{url}) yang saya buatkan untuk Anda. Bisa dicoba lagi? + + Gunakan ikon untuk menampilkan editor tulisan. + reply: |- + Bagus sekali! + + Mohon diingat, perubahan yang dilakukan setelah melewati 5 menit akan sebagai revisi tulisan secara publik, dan ikon pensil kecil akan muncul di kanan atas pos dengan jumlah revisi yang dilakukan. + delete: + instructions: |- + Jika Anda ingin menarik pos yang sudah dipublikasi, Anda bisa menghapusnya. + + Ayo coba lakukan dan **hapus** pos yang mana saya dengan menggunakan perintah **hapus**. Tapi jangan menghapus pos yang paling pertama! + not_found: |- + Saya tidak melihat pos yang dihapus? Ingat tombol tampilkan lebih banyak akan membuka tombol untuk menghapus. + reply: |- + Mantap! :boom: + + Agar diskusi bisa terus berjalan, hapus segara komentar atau pos Anda sesegara mungkin. Pos akan terhapus dalam beberapa saat. + recover: + deleted_post_raw: 'Kenapa @%{discobot_username} menghapus pos saya? :anguished:' + instructions: |- + Oh tidak! Sepertinya secara tidak sengaja saya menghapus pos baru yang baru saya buat. + + Bisakah Anda bantu saya dan **batal hapus** pos itu? + not_found: |- + Bingung? Ingat tampilkan lebih banyak akan membuka tombol batal hapus. + reply: |- + Fiuh, itu yang saya maksud! Terima kasih sudah memperbaikinya :wink: + + Ingat, Anda hanya memiliki waktu selama 24 jam untuk menghapus pos Anda. + category_hashtag: + instructions: |- + Apakah Anda tahu kalau bisa mengunjungi kategori atau label dalam pos? Contohnya, sudah melihat kategori %{category}? + + Ketik `#` di tengah kalimat dan pilih kategori atau label yang diinginkan. + not_found: |- + Hmm, saya tidak melihat kategori yang dituliskan di sana. Ingat `#` tidak bisa muncul pada karakter awal. Bisakah Anda menyalin ini di balasan selanjutnya? + + ```text + Saya membuat tautan kategori dengan # + ``` + reply: |- + Hebat! Cara ini bisa digunakan pada categories _and_ tags, jika pelabelan diaktifkan. + change_topic_notification_level: + instructions: |- + Setiap topik memiliki tingkat pemberitahuan. Pengaturan awalnya adalah tingkat tertinggi, yakni 'watching', artinya surel pemberitahuan langsung dikirim setiap ada balasan baru. Tapi Anda bisa mengatur tingkat pemberitahuan untuk _topik_ apa_saja untuk 'watch', 'tracking' atau 'muted'. + + Mari coba ubah tingkat pemberitahuan untuk topik ini. Di topik paling bawah, Anda akan menemukan sebuah tombol yang menyatakan kalau anda **menyimak** atau watching topik ini. Bisakah anda mengubahnya menjadi **tracking** atau mengikuti? + not_found: |- + Sepertinya Anda masih menyimak :eyes: topik ini! Jika kesulitan mencari tombolnya, tombol tingkat pengaturan ada di bawah topik. + reply: |- + Percobaan yang bagus! Semoga Anda tidak membuat senyap atau mute topik ini karena saya adalah robot yang suka bicara :grin:. + + Ingat, saat membalas sebuah topik, atau membaca sebuah topik selama beberapa saat, secara otomatis tingkat pemberitahuan akan menjadi 'tracking'. Anda dapat mengubah pengaturan ini di [pengaturan pengguna Anda](/my/preferences). + poll: + instructions: |- + Tahukah Anda kalau Anda bisa menambahkan pemungutan suara di pos mana saja? Coba gunakan roda gigi yang ada di editor tulis untuk **membuat pemungutan suara**. + not_found: |- + Aduh! Tampaknya tidak ada pemungutan suara dalam balasan Anda. + + Gunakan ikon roda gigi di editor, atau salin dan rekatkan pemungutan suara ini di balasan selanjutnya: + + ```text + [poll] + * :cat: + * :dog: + [/poll] + ``` + reply: |- + Hei, survei yang bagus! Apakah saya pandai mengajarkan Anda? + + [poll] + * :+1: + * :-1: + [/poll] + details: + instructions: |- + Kadang Anda ingin **menyembunyikan rincian** dalam balasan Anda: + + - Saat Anda berdiskusi soal alur cerita sebuah film atau TV bisa saja itu dijadikan sebuah spoiler atau sesuatu yang disembunyikan. + + - Jika pos Anda memiliki banyak pilihan rincian yang tampaknya akan terlalu banyak jika ditampilkan secara keseluruhan. + + [details=Buka ini untuk melihat cara kerja spoiler!] + 1. Pilih roda gigi di editor. + 2. Pilih "Hide Details" atau sembunyikan rincian. + 3. Ubah ringkasan rinciannya dan tambahkan tulisan anda. + [/details] + + Bisakah Anda menggunakan roda gigi dalam editor untuk menambah bagian rincian untuk balasan selanjutnya? + not_found: |- + Kesulitan membuat spoiler? Coba masukkan teks di bawah ini untuk balasan selanjutnya: + + ```text + [details=Buka ini untuk lebih jelasnya] + Ini adalah bagian isi spoile yang disembunyikan. + [/details] + ``` + reply: |- + Bagus sekali — perhatian anda to _detail_ is sangat baik sekali! + end: + message: |- + Anda benar-benar seperti seorang pengguna _sesepuh forum_ :bow: + + %{certificate} + + Itulah yang saya tahu. + + Saya undur diri! Jika kangen dan ingin berbicara dengan saya, panggil saya kapan saja :sunglasses: + certificate: + alt: 'Pencapaian Sertifikat Bukti Pengguna Lanjutan' diff --git a/plugins/discourse-narrative-bot/config/locales/server.it.yml b/plugins/discourse-narrative-bot/config/locales/server.it.yml new file mode 100644 index 0000000000..f9c4a7cce4 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.it.yml @@ -0,0 +1,388 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +it: + site_settings: + discourse_narrative_bot_enabled: 'Abilita Discourse Narrative Bot' + disable_discourse_narrative_bot_welcome_post: "Disabilita il messaggio di benvenuto di Discourse Narrative Bot" + discourse_narrative_bot_ignored_usernames: "Gli username che Discourse Narrative Bot dovrebbe ignorare" + discourse_narrative_bot_disable_public_replies: "Disabilita le risposte pubbliche da Discourse Narrative Bot" + discourse_narrative_bot_welcome_post_type: "Tipo di messaggio di benvenuto che Discourse Narrative Bot dovrebbe inviare" + badges: + certified: + name: Certificato + description: "Completato il tutorial per nuovi utenti" + long_description: | + Questo distintivo è assegnato al completamento del tutorial interattivo per nuovi utenti. Hai voluto imparare gli strumenti base di una discussione, e ora sei certificato! + licensed: + name: Diploma + description: "Completato il tutorial per utenti avanzati" + long_description: | + Questo distintivo è assegnato al completamento del tutorial interattivo per utenti avanzati. Hai imparato gli strumenti avanzati di discussione — e ora sei un diplomato! + discourse_narrative_bot: + bio: "Ciao, io non sono una persona reale. Io sono un bot che ti può insegnare ad usare questo sito. Per interagire con me inviami un messaggio o menziona**`@%{discobot_username}`** ovunque." + timeout: + message: |- + Hey @%{username}, sto solo controllando perchè è da un po' che non ti sento. + - Per continuare, rispondimi in qualsiasi momento. + + - Se vuoi saltare questo passaggio, dimmi `%{skip_trigger}`. + + - Per ricominciare, dimmi `%{reset_trigger}`. + + Se preferisci non continuare, va bene lo stesso. Io sono un robot. Non ferirai i miei sentimenti. :sob: + dice: + trigger: "tira" + invalid: |- + Mi dispiace, è matematicamente impossibile lanciare quella combinazione di dadi. :confounded: + not_enough_dice: |- + Ho solo %{num_of_dice} dadi. [Vergognoso](http://www.therobotsvoice.com/2009/04/the_10_most_shameful_rpg_dice.php), lo so! + out_of_range: |- + Lo sapevi che [il numero massimo di lati](https://www.wired.com/2016/05/mathematical-challenge-of-designing-the-worlds-most-complex-120-sided-dice) per essere matematicamente bilanciato è 120? + results: |- + > :game_die: %{results} + quote: + trigger: "cita" + '1': + quote: "Nel mezzo delle difficoltà nascono le opportunità." + author: "Albert Einstein" + '2': + quote: "Siate il cambiamento che volete vedere nel mondo." + author: "Mahatma Gandhi" + '3': + quote: "Non piangere perchè è finita, sorridi perchè è successo." + author: "Dr Seuss" + '4': + quote: "Se vuoi fare bene una cosa, falla da solo." + author: "Charles-Guillaume Étienne" + '5': + quote: "Se credi che sia possibile sei già a metà strada." + author: "Theodore Roosevelt" + '6': + quote: "La vita è come una scatola di cioccolatini. Non sai mai quello che ti capita." + author: "Madre di Forrest Gump" + '7': + quote: "E' un piccolo passo per l'uomo ma un grande passo per l'umanità." + author: "Neil Armstrong" + '8': + quote: "Almeno una volta al giorno fai qualcosa che ti spaventa." + author: "Eleanor Roosevelt" + '9': + quote: "Gli errori sono sempre perdonabili, se si ha il coraggio di ammetterli." + author: "Bruce Lee" + '10': + quote: "Tutto ciò che la mente umana può concepire e credere, può essere realizzato." + author: "Napoleon Hill" + results: |- + > :left_speech_bubble: _%{quote}_ — %{author} + magic_8_ball: + trigger: 'fortuna' + answers: + '1': "E' certo" + '2': "E' decisamente così" + '3': "Senza alcun dubbio" + '4': "Sì, senza dubbio" + '5': "Ci puoi contare" + '6': "Per quanto posso vedere, sì" + '7': "Molto probabilmente" + '8': "Le prospettive sono buone" + '9': "Sì" + '10': "I segni indicano di sì" + '11': "E' difficile rispondere, prova di nuovo" + '12': "Rifai la domanda più tardi" + '13': "Meglio non risponderti adesso" + '14': "Non posso predirlo ora" + '15': "Concentrati e rifai la domanda" + '16': "Non ci contare" + '17': "La mia risposta è no" + '18': "Le mie fonti dicono di no" + '19': "Le prospettive non sono buone" + '20': "Molto incerto" + result: |- + > :crystal_ball: %{result} + track_selector: + reset_trigger: 'inizia' + skip_trigger: 'salta' + help_trigger: 'mostra la guida' + random_mention: + reply: |- + Ciao! Per sapere cosa posso fare, dimmi `@%{discobot_username} %{help_trigger}`. + tracks: |- + Attualmente so fare le seguenti cose: + + `@%{discobot_username} %{reset_trigger} %{default_track}` + > Avvia una delle seguenti narrazioni interattive: %{tracks}. + bot_actions: |- + `@%{discobot_username} %{dice_trigger} 2d6` + > :game_die: 3, 6 + + `@%{discobot_username} %{quote_trigger}` + > :left_speech_bubble: _Fare un atto di gentilezza casuale, senza nessuna aspettativa di ricompensa, con la certezza che un giorno qualcuno potrebbe fare lo stesso per te_ — Principessa Diana + + `@%{discobot_username} %{magic_8_ball_trigger}` + > :crystal_ball: Ci puoi contare + do_not_understand: + first_response: |- + Hey, grazie per la risposta! + Sfortunatamente, come un bot mal programmato, non posso capire la tua risposta. :frowning: + track_response: Puoi provare di nuovo, o se vuoi saltare questo passaggio, dimmi `%{skip_trigger}`. Altrimenti per ricominciare, dimmi `%{reset_trigger}`. + second_response: |- + Aw, scusa. Non sto ancora capendo. :anguished: + + Sono solo un bot, ma se vuoi contattare una persona reale, guarda [la nostra pagina dei contatti](/about). + + Nel frattempo io me ne starò in disparte. + new_user_narrative: + reset_trigger: "nuovo utente" + cert_title: "In riconoscimento del successo nel completamento del tutorial come nuovo utente" + hello: + title: ":robot: Saluti!" + message: |- + Grazie per esserti unito a %{title}, e benvenuto! + - Io sono solo un robot, ma [il nostro amichevole staff](/about) è qui per aiutarti se hai bisogno di contattare una persona. + + - Per ragioni di sicurezza, abbiamo temporaneamente limitato ciò che i nuovi utenti possono fare. Potrai guadagnare nuove abilità (e [distintivi](/badges)) appena ti conosceremo meglio. + + - Noi crediamo da sempre in una [comunità dal comportamento civile](/guidelines). + onebox: + instructions: |- + Adesso, puoi condividere uno di questi collegamenti con me? Rispondi con **un collegamento su una riga a sè stante**, e verrà automaticamente espanso per includere un bel sommario. + Per copiare un collegamento, toccalo e tienilo premuto su mobile, o fai click col tasto destro del mouse: + + - https://it.wikipedia.org/wiki/Italia + - https://it.wikipedia.org/wiki/Pagina_principale + - https://it.wikipedia.org/wiki/Traduzione + reply: |- + Fantastico! Questo funzionerà per la maggior parte dei collegamenti . Ricorda, deve essere su una linea _da solo_, con nient'altro davanti o dietro. + not_found: |- + Mi dispiace, non riesco a trovare il collegamento nella tua risposta! :cry: + + Puoi provare ad aggiungere il seguente collegamento, su una riga da solo, nella tua prossima risposta? + + - https://it.wikipedia.org/wiki/Exotic_Shorthair + images: + reply: |- + Bell'immagine – Ho premuto il pulsante mi piace :heart: per farti sapere quanto l'ho apprezzata :heart_eyes: + like_not_found: |- + Hai dimenticato di mettere mi piace :heart: al mio [messaggio?](%{url}) :crying_cat_face: + not_found: |- + Sembra che tu non abbia caricato un'immagine così ne ho scelta una io che sono _sicuro_ ti piacerà. + `%{image_url}` + Prova a caricare questa nel prossimo messaggio, o incolla il collegamento su una riga da solo! + formatting: + instructions: |- + Puoi scrivere alcune parole in **grassetto** o _italico_ nella tua risposta? + - digita `**grassetto**` o `_italico_` + + - oppure premi i pulsanti G o I sull'editor + reply: |- + Ottimo lavoro! Anche HTML e BBCode funzionano per la formattazione – per saperne di più [prova questo tutorial](http://commonmark.org/help) :nerd: + not_found: |- + Aww, non ho trovato nessuna formattazione nella tua risposta. :pencil2: + + Puoi provare di nuovo? Usa i pulsanti G grassetto o I italico nell'editor se sei rimasto bloccato. + quoting: + instructions: |- + Puoi provare a citarmi nella tua risposta, così saprò esattamente a quale parte del mio messaggio stai rispondendo? + > Se questo è caffè, per favore portami del thè; ma se questo è thè, per favore portami del caffè. + > + > Un vantaggio del parlare con sè stessi è che almeno sai che qualcuno ti sta ascoltando. + > + > Alcune persone si fanno strada con le parole e altre persone… oh, oh, non hanno una strada. + Seleziona il testo della citazione ↑ che preferisci, e poi premi il pulsante **Cita** che comparirà sopra il testo selezionato – o il pulsante **Rispondi** in fondo a questo messaggio. + Sotto la citazione, digita una o due parole sul motivo per cui hai scelto proprio quella perchè sono curioso :thinking: + reply: |- + Bel lavoro, hai scelto la mia citazione preferita! :left_speech_bubble: + not_found: |- + Hmm sembra che tu non mi abbia citato nella tua risposta! + Selezionare un qualsiasi testo del mio messaggio farà apparire il pulsante **Cita**. E anche premere **Rispondi** con qualsiasi testo selezionato funzionerà! Puoi provare di nuovo? + bookmark: + instructions: |- + Se vuoi saperne di più, seleziona qui sotto e per inserire **questo messaggio privato nei segnalibri**. Se lo farai, ci potrebbe essere un :gift: nel tuo futuro! + reply: |- + Eccellente! Ora potrai tornare facilmente a questa conversazione privata in ogni momento, proprio dalla [scheda segnalibri sul tuo profilo](%{profile_page_url}/activity/bookmarks). Basta selezionare l'immagine del tuo profilo in alto a destra ↗ + not_found: |- + Oh oh, non vedo nessun segnalibro in questo argomento. Hai trovato il pulsante segnalibro sotto ogni messaggio? Usa mostra altro per rivelare pulsanti aggiuntivi se necessario. + emoji: + instructions: |- + Puoi avermi visto utilizzare delle piccole immagini nelle mie risposte :blue_car::dash: che si chiamano [emoji](https://en.wikipedia.org/wiki/Emoji). Puoi **aggiungere una emoji** alla tua risposta? Uno qualsiasi di questi metodi funzionerà: + + - Digita `:) ;) :D :P :O` + + - Digita due punti : poi completa il nome della emoji `:tada:` + + - Premi il pulsante emoji nell'editor, o sulla tastiera mobile + reply: |- + Questo è :sparkles: _emojitastico!_ :sparkles: + not_found: |- + Oops, non vedo nessuna emoji nella tua risposta! Oh no! :sob: + + Prova digitando i due punti : per far apparire il selettore delle emoji, poi digita le prime lettere della emoji che vuoi, ad esempio `:bird:` + + Oppure premi il pulsante emoji nell'editor. + + (Se sei su un dispositivo mobile, puoi anche immettere l'emoji direttamente dalla tastiera.) + mention: + instructions: |- + Qualche volta potresti volere l'attenzione di una persona, anche se non stai rispondendo direttamente a lei. Digita `@` poi completa il suo username per menzionarla. + Puoi menzionare **`@%{discobot_username}`** nella tua risposta? + reply: |- + _Qualcuno ha fatto il mio nome!?_ :raised_hand: Credo che sia stato tu! :wave: Bene, eccomi qui! Grazie per avermi menzionato. :ok_hand: + not_found: |- + Non vedo il mio nome da nessuna parte qui :frowning: Puoi provare a menzionarmi di nuovo come `@%{discobot_username}`? + + (E sì, il mio username inizia con _disco_, come nella mania dance degli anni 70. Io [amo la vita notturna!](https://www.youtube.com/watch?v=B_wGI3_sGf8) :dancer:) + flag: + instructions: |- + Ci piacciono le discussioni amichevoli, e abbiamo bisogno del tuo aiuto per [mantenere le cose civilizzate](%{guidelines_url}). Se vedi un problema, per favore segnalalo privatamente per farlo sapere all'autore o [al nostro staff](%{about_url}). + > :imp: Ho scritto qualcosa di brutto qui + + Credo che tu sappia cosa fare. Vai avanti e **segnala questo messaggio** come inappropriato! + reply: |- + [Il nostro staff](/groups/staff) verrà notificato privatamente della tua segnalazione. Se abbastanza membri della comunità segnalano un messaggio, quest'ultimo verrà automaticamente nascosto per precauzione. (Dal momento che non ho scritto veramente qualcosa di brutto :angel:, ho rimosso la segnalazione per ora.) + not_found: |- + Oh no, il mio brutto messaggio non è ancora stato segnalato. :worried: Puoi segnalarlo come inappropriato usando il pulsante **segnala** ? Non dimenticarti di usare il pulsante mostra altro per rivelare altre azioni possibili su ogni messaggio. + search: + instructions: |- + _psst_ … Ho nascosto una sorpresa in una delle mie risposte precedenti. Se sei pronto alla sfida, **seleziona l'icona cerca** in alto a destra ↗ per cercarla. + Prova a cercare il termine "capi​bara" in questo argomento + hidden_message: |- + Come hai fatto a perdere questo capibara? :wink: + + + + Hai notato che sei tornato all'inizio dell'argomento? Dai da mangiare a questo affamato capibara **rispondendo con l'emoji `:herb:`** e verrai automaticamente riportato alla fine. + reply: |- + Hey l'hai trovato :tada: + + - Per ricerche più dettagliate, vai alla [pagina di ricerca](%{search_url}). + + - Per saltare ovunque in una discussione lunga, prova i controlli temporali sulla destra (e in fondo, su mobile). + + - Se hai una :keyboard: fisica, premi ? per visualizzare delle comode scorciatoie da tastiera. + not_found: |- + Hmm… Sembra che tu abbia qualche problema. Ci dispiace. Hai cercato su il termine **capi​bara**? + end: + message: |- + Grazie per avermi seguito @%{username}! Ho fatto questo per te, penso che te lo sei guadagnato: + + %{certificate} + + E' tutto per ora! Controlla [**gli argomenti delle nostre ultime discussioni**](/latest) or [**le categorie di discussione**](/categories). :sunglasses: + + (Se vuoi parlare con me ancora per saperne di più, inviami un messaggio o menzionami `@%{discobot_username}` quando vuoi!) + certificate: + alt: 'Attestato di Merito' + advanced_user_narrative: + reset_trigger: 'utente avanzato' + cert_title: "In riconoscimento del completamento con successo del tutorial utente avanzato" + title: ':arrow_up: Funzioni utente avanzato' + start_message: |- + Come un utente avanzato, hai già visitato [la pagina delle opzioni sul tuo profilo](/my/preferences) @%{username}? Ci sono molti modi per personalizzare la tua esperienza, ad esempio selezionando un tema scuro o uno chiaro. + Ma sto divagando, cominciamo! + edit: + bot_created_post_raw: "@%{discobot_username} è, di gran lunga, il bot più interessante che conosco :wink:" + instructions: |- + Tutti fanno degli errori. Ma non preoccuparti, puoi sempre modificare i tuoi messaggi per sistemarli! + Puoi iniziare **modificando** il messaggio che ho appena creato al tuo posto? + not_found: |- + Sembra che tu non abbia ancora modificato il [messaggio](%{url}) che ho creato per te. Puoi provare di nuovo? + Usa l'icona per far apparire l'editor. + reply: |- + Ottimo lavoro! + Nota che le modifiche fatte dopo 5 minuti verranno mostrate come revisioni pubbliche, e una piccola icona a forma di matita apparirà in alto a destra sul messaggio con il conteggio delle modifiche fatte. + delete: + instructions: |- + Se lo desideri puoi eliminare un tuo messaggio, cancellandolo. + Vai avanti e **cancella** uno dei tuoi messaggi precedenti usando il pulsante **cancella**. Non cancellare il primo messaggio però! + not_found: |- + Non vedo ancora nessun messaggio cancellato! Ricorda di cliccare su mostra altro per rivelare il pulsante cancella. + reply: |- + Whoa! :boom: + + Per preservare la continuità delle discussioni, le cancellazioni non sono immediate, così i messaggi saranno rimossi dopo un certo periodo di tempo. + recover: + deleted_post_raw: 'Perchè @%{discobot_username} ha cancellato il mio messaggio? :anguished:' + instructions: |- + Oh no! Sembra che io abbia accidentalmente cancellato un nuovo messaggio che avevo creato per te. + Puoi farmi un favore e **ripristinarlo**? + not_found: |- + Stai avendo problemi? Ricorda di cliccare su mostra altro per rivelare il pulsante ripristina. + reply: |- + Pfff, questa è fatta! Grazie per averlo ripristinato :wink: + + Ti faccio notare che hai solo 24 ore di tempo per ripristinare un messaggio. + category_hashtag: + instructions: |- + Lo sapevi che è possibile fare riferimento a categorie ed etichette in un messaggio? Per esempio, hai visto la categoria %{category}? + Digita `#` nel mezzo di una frase e seleziona una categoria o un'etichetta. + not_found: |- + Hmm, non vedo nessuna categoria. Nota che `#` non può essere il primo carattere di una riga. Puoi copiare questo nella tua prossima risposta? + Posso creare un collegamento ad una categoria digitando # + reply: |- + Eccellente! Ricorda che questo funziona per le categorie _e_ le etichette, se le etichette sono abilitate. + change_topic_notification_level: + instructions: |- + Ogni argomento ha un livello di notifica. Parte da 'normale', il che significa che riceverai una notifica quando qualcuno parlerà direttamente con te. + Per impostazione predefinita, il livello di notifica per un messaggio privato è impostato al livello più alto 'in osservazione', il che significa che riceverai una notifica ad ogni nuovo messaggio. Ma puoi sovrascrivere il livello di notifica per _ogni_ argomento su 'in osservazione', 'seguito' o 'silenziato'. + Prova a modificare il livello di notifica per questo argomento. In fondo alla discussione, troverai un pulsante che ti mostrerà che questo argomento è **in osservazione** . Puoi modificare il livello di notifica a **seguito**? + not_found: |- + Sembra che stia ancora in osservazione :eyes: di questo argomento! Se hai problemi a trovarlo, il pulsante relativo al livello della notifica è situato in fondo a questo argomento. + reply: |- + Lavoro impressionante! Spero che non silenzi questo argomento dato che a volte posso essere un po' loquace :grin:. + Nota che quando rispondi ad un argomento, o leggi un argomento per più di qualche minuto, verrà automaticamente impostato il livello di notifica a 'seguito'. Puoi modificare queste impostazioni sulle [tue preferenze utente](/my/preferences). + poll: + instructions: |- + Lo sapevi che puoi aggiungere un sondaggio in qualsiasi messaggio? Prova a usare l'icona ingranaggio sull'editor per **costruire un sondaggio**. + not_found: |- + Whoops! Non c'è nessun sondaggio nella tua risposta. + Usa l'icona ingranaggio sull'editor, oppure copia e incolla questo sondaggio nella tua prossima risposta: + + [poll] + * :cat: + * :dog: + [/poll] + reply: |- + Hey, bel sondaggio! Sono un buon insegnante? + [poll] + * :+1: + * :-1: + [/poll] + details: + instructions: |- + Delle volte desidererai **nascondere dei dettagli** nelle tue risposte: + + - Quando stai discutendo alcuni punti della trama di un film o di uno show televisivo che potrebbero essere considerati spoiler. + + - Quando il tuo messaggio necessita di molti dettagli opzionali che possono essere di intralcio se letti tutti in una volta. + + [details=Seleziona questa opzione per vedere come funziona!] + 1. Seleziona l'icona ingranaggio sull'editor. + 2. Seleziona "Nascondi Dettagli". + 3. Modifica il sommario e aggiungi il tuo contenuto. + [/details] + + Puoi usare l'icona ingranaggio sull'editor per aggiungere una sezione con dei dettagli nella tua prossima risposta? + not_found: |- + Hai problemi a creare un widget con dei dettagli? Prova a includere quello che segue nella tua prossima risposta: + + [details=Selezionami per vedere i dettagli] + Qui ci sono i dettagli + [/details] + reply: |- + Ottimo lavoro — la tua attenzione per i _dettagli_ è ammirevole! + end: + message: |- + Hai affrontato tutto questo come un _utente avanzato_ infatti :bow: + + %{certificate} + + Questo è tutto quello che posso fare per te. + + Arrivederci per adesso! Se desideri parlare di nuovo con me mandami un messaggio in qualsiasi momento :sunglasses: + certificate: + alt: 'Attestato di Merito per Utente Avanzato' diff --git a/plugins/discourse-narrative-bot/config/locales/server.ja.yml b/plugins/discourse-narrative-bot/config/locales/server.ja.yml new file mode 100644 index 0000000000..8c2dc00904 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.ja.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +ja: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.ko.yml b/plugins/discourse-narrative-bot/config/locales/server.ko.yml new file mode 100644 index 0000000000..bf4e05d6f4 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.ko.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +ko: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.nb_NO.yml b/plugins/discourse-narrative-bot/config/locales/server.nb_NO.yml new file mode 100644 index 0000000000..7db05b7d68 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.nb_NO.yml @@ -0,0 +1,30 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +nb_NO: + discourse_narrative_bot: + quote: + trigger: "sitat" + '1': + author: "Albert Einstein" + '2': + author: "Mahatma Gandhi" + '5': + author: "Theodore Roosevelt" + magic_8_ball: + answers: + '9': "Ja" + '12': "Spør igjen senere" + track_selector: + reset_trigger: 'start' + skip_trigger: 'hopp over' + help_trigger: 'vis hjelp' + advanced_user_narrative: + reset_trigger: 'avansert bruker' + details: + reply: |- + Bra jobbet — ditt _detaljarbeid_ er beundringsverdig! diff --git a/plugins/discourse-narrative-bot/config/locales/server.nl.yml b/plugins/discourse-narrative-bot/config/locales/server.nl.yml new file mode 100644 index 0000000000..2674c4c9e6 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.nl.yml @@ -0,0 +1,29 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +nl: + discourse_narrative_bot: + quote: + trigger: "quote" + '1': + author: "Albert Einstein" + '2': + author: "Mahatma Gandhi" + '3': + author: "Dr Seuss" + '4': + author: "Charles-Guillaume Étienne" + '5': + author: "Theodore Roosevelt" + '6': + author: "De moeder van Forrest Gump" + '7': + author: "Neil Armstrong" + '8': + author: "Eleanor Roosevelt" + '9': + author: "Bruce Lee" diff --git a/plugins/discourse-narrative-bot/config/locales/server.pl_PL.yml b/plugins/discourse-narrative-bot/config/locales/server.pl_PL.yml new file mode 100644 index 0000000000..c04d371e5a --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.pl_PL.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +pl_PL: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.pt_BR.yml b/plugins/discourse-narrative-bot/config/locales/server.pt_BR.yml new file mode 100644 index 0000000000..233456f7e0 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.pt_BR.yml @@ -0,0 +1,56 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +pt_BR: + site_settings: + discourse_narrative_bot_enabled: 'Habilitar o Robô de Narrativas do Discourse' + disable_discourse_narrative_bot_welcome_post: "Desabilitar a mensagem de boas vindas do Robô de Narrativas do Discourse" + discourse_narrative_bot_ignored_usernames: "Nomes de Usuário que o Robô de Narrativas do Discourse deve ignorar" + discourse_narrative_bot_disable_public_replies: "Desabilitar respostas públicas do Robô de Narrativas do Discourse" + discourse_narrative_bot_welcome_post_type: "Tipo de mensagem de boas vindas que o Robô de Narrativas do Discourse deve enviar" + badges: + certified: + name: Certificado + description: "Completou o nosso tutorial de \"Novo Usuário\"" + long_description: | + Este emblema é adquirido após finalizar com sucesso o tutorial interativo da nova interface de usuário. Você tomou a iniciativa de aprender as ferramentas básicas de discussão, e agora você está certificado! + licensed: + name: Licenciado + description: "Completou nosso tutorial \"Usuário Avançado\"" + long_description: | + Este emblema é adquirido após finalizar com sucesso o tutorial interativo de usuário avançado. Você dominou as ferramentas avançadas de discussão -e agora você está totalmente licenciado! + discourse_narrative_bot: + dice: + results: |- + > :game_die: %{results} + quote: + trigger: "citação" + '1': + author: "Albert Einstein" + '2': + quote: "Você deve ser a mudança que quer ver no mundo." + author: "Mahatma Gandhi" + '3': + author: "Dr Seuss" + '4': + quote: "Se você quer que algo seja feito corretamente, faça você mesmo." + author: "Charles-Guillaume Étienne" + '5': + author: "Theodore Roosevelt" + '6': + author: "Mãe do Forrest Gump" + '7': + author: "Neil Armstrong" + '8': + author: "Eleanor Roosevelt" + '9': + author: "Bruce Lee" + '10': + author: "Napoleon Hill" + magic_8_ball: + result: |- + > :crystal_ball: %{result} diff --git a/plugins/discourse-narrative-bot/config/locales/server.ro.yml b/plugins/discourse-narrative-bot/config/locales/server.ro.yml new file mode 100644 index 0000000000..31d1c44013 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.ro.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +ro: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.ru.yml b/plugins/discourse-narrative-bot/config/locales/server.ru.yml new file mode 100644 index 0000000000..3e076bde9f --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.ru.yml @@ -0,0 +1,111 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +ru: + site_settings: + discourse_narrative_bot_enabled: 'Включить дискуссионный бот' + disable_discourse_narrative_bot_welcome_post: "Отключить приветственный пост от бота" + discourse_narrative_bot_ignored_usernames: "Имена пользователей, которые следует игнорировать" + discourse_narrative_bot_disable_public_replies: "Отключить общедоступные ответы в боте." + discourse_narrative_bot_welcome_post_type: "Тип приветственного сообщения, которое бот должен отправить" + discourse_narrative_bot_welcome_post_delay: "Подождать (n) секунд перед отправкой ботом приветственного сообщения." + badges: + certified: + name: Проверенный + description: "Руководство пользователя завершено" + long_description: | + Этот значок выдается при успешном завершении интерактивного учебника для нового пользователя. Вы успешно прошли обучение основных инструментов обсуждения, и теперь вы сертифицированы! + licensed: + name: Лицензированный + description: "Завершен наш продвинутый пользовательский учебник" + long_description: | + Этот значок выдается после успешного завершения интерактивного передового руководство пользователя. Вы уже освоили почти все инструменты обсуждения — и теперь ты полностью готов к общению! + discourse_narrative_bot: + dice: + invalid: |- + Извините, даже математически невозможно бросить эту комбинацию костей. :confounded: + quote: + trigger: "цитата" + '1': + quote: "В середине каждой трудности - возможность" + author: "Альберт Эйнштейн" + '2': + quote: "Вы должны быть тем изменением, которое хотите видеть в мире." + author: "Махатма Ганди" + '3': + quote: "Не плачь, потому что это закончилось. Улыбнись, потому что это было." + author: "Доктор Сьюз" + '4': + quote: "Если хочешь сделать что-то хорошо - сделай это сам." + author: "Шарль-Гийом Этьен" + '5': + quote: "Верю, вы можете и вы уже на полпути там." + author: "Теодор Рузвельт" + '6': + quote: "Жизнь как коробка шоколадных конфет: никогда не знаешь, какая начинка тебе попадётся." + author: "Мама Форреста Гампа" + '7': + quote: "Это один маленький шаг для человека, но гигантский скачок для всего человечества." + author: "Нил Армстронг" + '8': + quote: "Делайте каждый день одну вещь, которая вас пугает." + author: "Элеонора Рузвельт" + '9': + quote: "Ошибки всегда простительны, если есть мужество признать их. " + author: "Брюс Ли" + '10': + quote: "Что разум человека способен познать и вообразить, того он способен достичь." + author: "Наполеон Хилл" + magic_8_ball: + answers: + '1': "Это точно" + '2': "Это несомненно так" + '3': "Без сомнения" + '4': "Да, наверняка" + '5': "Вы можете положиться на это" + '6': "Как я вижу, да" + '7': "Скорее всего" + '8': "Хорошие перспективы" + '9': "Да" + '10': "Все признаки указывают, что да" + '11': "Ответ можно снова попробовать" + '12': "Спроси меня позже" + '13': "Лучше не скажу вам сейчас" + '14': "Я не могу предсказать" + '15': "Соберитесь и снова задайте вопрос" + '16': "Не рассчитывай на это" + '17': "Мой ответ - нет" + '18': "Мои источники говорят, что нет" + '19': "Перспективы не очень хорошие" + '20': "Весьма сомнительно" + track_selector: + reset_trigger: 'старт' + skip_trigger: 'пропустить' + help_trigger: 'отображение справки' + random_mention: + reply: |- + Привет! Tтобы узнать, что я могу сделать, наберите: `@%{discobot_username} %{help_trigger}`. + new_user_narrative: + reset_trigger: "новый пользователь" + formatting: + reply: |- + Отличная работа! Формате HTML и BBCode также работа для форматирования – чтобы узнать больше, [попробуйте этот учебник](http://commonmark.org/help) :nerd: + quoting: + reply: |- + Молодец, вы выбрали мою любимую цитату! :left_speech_bubble: + certificate: + alt: 'Свидетельство о достижении' + advanced_user_narrative: + reset_trigger: 'продвинутый пользователь' + cert_title: "В знак признания успешного завершения расширенного руководства пользователя" + title: ':arrow_up: Расширенные функции пользователя' + edit: + bot_created_post_raw: "@%{discobot_username} безусловно, самый лучший бот, которого я знаю :wink:" + recover: + deleted_post_raw: 'Почему @%{discobot_username} удалил моё сообщение? :anguished:' + certificate: + alt: 'Свидетельство о прогрессе для опытных пользователей' diff --git a/plugins/discourse-narrative-bot/config/locales/server.sk.yml b/plugins/discourse-narrative-bot/config/locales/server.sk.yml new file mode 100644 index 0000000000..e8b17f733f --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.sk.yml @@ -0,0 +1,82 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +sk: + badges: + licensed: + description: "Dokončili sme náš tutoriál pre pokročilých používateľov" + discourse_narrative_bot: + dice: + invalid: |- + Je mi to ľúto, ale je matematicky nemožné zahrať túto kombináciu kociek. :confounded: + not_enough_dice: |- + Ja mám len %{num_of_dice} kocky. [Shameful](http://www.therobotsvoice.com/2009/04/the_10_most_shameful_rpg_dice.php), ja viem! + quote: + trigger: "citácia" + '1': + quote: "Uprostred každej prekážky sa nachádza príležitosť" + author: "Albert Einstein" + '2': + quote: "Musíte byť tou zmenou, ktorú chcete vidieť vo svete." + author: "Mahatma Gandhi" + '3': + quote: "Neplačte, lebo to skončilo, usmejte sa, pretože sa to stalo." + author: "Dr Seuss" + '4': + quote: "Ak chcete niečo urobiť správne, urobte to sami." + author: "Charles-Guillaume Étienne" + '5': + quote: "Verte, že to dokážete a ste na polceste." + author: "Theodore Roosevelt" + '6': + quote: "Život je ako bonboniéra. Nikdy nevieš, čo dostaneš." + author: "Matka Forresta Gumpa" + '7': + quote: "Jeden malý krok pre človeka, obrovský skok pre ľudstvo." + author: "Neil Armstrong" + '8': + quote: "Každý deň spravte niečo, čo vás straší." + author: "Eleanor Roosevelt" + '9': + author: "Bruce Lee" + '10': + author: "Napoleon Hill" + magic_8_ball: + trigger: 'šťastie' + answers: + '1': "Je to isté" + '2': "Rozhodne je to tak" + '3': "Bez pochýb" + '4': "Áno jednoznačne" + '5': "Môžete sa na to spoľahnúť" + '6': "Vidím to tak, že áno" + '7': "Pravdepodobne" + '8': "Výhľad je dobrý" + '9': "Áno" + '12': "Opýtajte sa neskôr" + '13': "Radšej ti to teraz nepoviem" + '14': "Teraz to nie je možné predpovedať" + '16': "Nepočítajte s tým" + '17': "Moja odpoveď je nie" + '18': "Moje zdroje hovoria nie" + '19': "Výhľad nie je tak dobrý" + '20': "Veľmi pochybné" + track_selector: + reset_trigger: 'začať' + skip_trigger: 'preskočiť' + help_trigger: 'zobraziť pomoc' + random_mention: + reply: |- + Ahoj! Ak chcete zistiť, čo dokážem urobiť, povedzte `@%{discobot_username} %{help_trigger}`. + new_user_narrative: + reset_trigger: "nový používateľ" + quoting: + reply: |- + Pekná práca, vybrali ste môj obľúbený citát! :left_speech_bubble: + advanced_user_narrative: + reset_trigger: 'pokročilý používateľ' + title: ':arrow_up: Pokročilé funkcie používateľa' diff --git a/plugins/discourse-narrative-bot/config/locales/server.sq.yml b/plugins/discourse-narrative-bot/config/locales/server.sq.yml new file mode 100644 index 0000000000..4526be3162 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.sq.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +sq: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.sv.yml b/plugins/discourse-narrative-bot/config/locales/server.sv.yml new file mode 100644 index 0000000000..fd54901f75 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.sv.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +sv: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.te.yml b/plugins/discourse-narrative-bot/config/locales/server.te.yml new file mode 100644 index 0000000000..49141baa04 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.te.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +te: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.tr_TR.yml b/plugins/discourse-narrative-bot/config/locales/server.tr_TR.yml new file mode 100644 index 0000000000..00efffd670 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.tr_TR.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +tr_TR: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.uk.yml b/plugins/discourse-narrative-bot/config/locales/server.uk.yml new file mode 100644 index 0000000000..3acfff74e8 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.uk.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +uk: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.ur.yml b/plugins/discourse-narrative-bot/config/locales/server.ur.yml new file mode 100644 index 0000000000..58bc9735bc --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.ur.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +ur: {} diff --git a/plugins/discourse-narrative-bot/config/locales/server.zh_CN.yml b/plugins/discourse-narrative-bot/config/locales/server.zh_CN.yml new file mode 100644 index 0000000000..1c87fb7f74 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.zh_CN.yml @@ -0,0 +1,408 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +zh_CN: + site_settings: + discourse_narrative_bot_enabled: '启用 Discourse 代表机器人' + disable_discourse_narrative_bot_welcome_post: "禁止 Discourse 代表机器人发送欢迎帖子" + discourse_narrative_bot_ignored_usernames: "Discourse 代表机器人应该忽略的用户名" + discourse_narrative_bot_disable_public_replies: "禁止 Discourse 代表机器人发布公开回复" + discourse_narrative_bot_welcome_post_type: "Discourse 代表机器人应该发送的欢迎帖子类型" + discourse_narrative_bot_welcome_post_delay: "在 Discourse 代表机器人发送欢迎帖子前等待 (n) 秒。" + badges: + certified: + name: 已合格 + description: "已完成我们的新用户教程" + long_description: | + 该徽章授予完成我们交互式新用户教程的你。你已经有动力学习了讨论的基础工具,你现在已经合格了! + licensed: + name: 已证明 + description: "完成了我们的高级用户教程" + long_description: | + 该徽章授予完成我们交互式高级用户教程的你。你已经掌握了讨论的高级工具——你已经收到了证明! + discourse_narrative_bot: + bio: "你好,我不是真实的人。我是一个教你如何使用站点的机器人。你可以给我发消息或在任意地方提及**`@%{discobot_username}`**与我交互。" + timeout: + message: |- + 你好,@%{username}。好久没见到你了,一切可好? + + - 你可以在任意时候回复我。 + + - 如果你想要跳过这个步骤,说`%{skip_trigger}`。 + + - 要重新开始,说`%{reset_trigger}`。 + + 如果你不想的话也没问题。我是个机器人。你不会伤害到我。 :sob: + dice: + trigger: "投" + invalid: |- + 我很抱歉,数学上证明了不可能投出这种色子组合。 :confounded: + not_enough_dice: |- + 我只有 %{num_of_dice} 个色子。我知道,[太不像话了](http://www.therobotsvoice.com/2009/04/the_10_most_shameful_rpg_dice.php)。 + out_of_range: |- + 你知道数学上[面最多](https://www.wired.com/2016/05/mathematical-challenge-of-designing-the-worlds-most-complex-120-sided-dice)的公平色子有 120 面么? + results: |- + > :game_die: %{results} + quote: + trigger: "引用" + '1': + quote: "undefined" + author: "阿尔伯特·爱因斯坦" + '2': + quote: "成为你想在世界看到的改变。" + author: "默罕默德·甘地" + '3': + quote: "不必为结束而哭泣,而要为曾经拥有而微笑。" + author: "苏斯博士" + '4': + quote: "自己动手,丰衣足食。" + author: "查尔斯·纪尧姆·西安娜" + '5': + quote: "相信自己能成功,你就已经成功了一半。" + author: "西奥多·罗斯福" + '6': + quote: "生命就像一盒巧克力。你永远也不会知道你将拿到什么。" + author: "福雷斯特·甘普的妈妈" + '7': + quote: "这是我个人的一小步,却是全人类的一大步。" + author: "尼尔·阿姆斯特朗" + '8': + quote: "每天做一件让你有畏难情绪的事情。" + author: "埃莉诺·罗斯福" + '9': + quote: "如果一个人敢于承认错误,那么一切的错误都会被原谅。" + author: "李小龙" + '10': + quote: "心想事成。" + author: "拿破仑·希尔" + results: |- + > :left_speech_bubble:_%{quote}_ — %{author} + magic_8_ball: + trigger: '财富' + answers: + '1': "很可能\b" + '2': "一定是" + '3': "绝对是" + '4': "肯定是" + '5': "你可以相信它" + '6': "我觉得是" + '7': "很可能" + '8': "看起来不错" + '9': "是的" + '10': "标志指的是" + '11': "再随便回复试试" + '12': "等等再问我" + '13': "现在最好不告诉你" + '14': "现在不好说" + '15': "专心然后再问一次" + '16': "不要指望它" + '17': "我的回答是不" + '18': "我的情报告诉我说不" + '19': "看起来不是很好" + '20': "很可疑" + result: |- + > :crystal_ball: %{result} + track_selector: + reset_trigger: '开始' + skip_trigger: '跳过' + help_trigger: '显示帮助' + random_mention: + reply: |- + 你好!想看看我能做什么,说`@%{discobot_username} %{help_trigger}`。 + tracks: |- + 我现在知道怎么做这些: + + `@%{discobot_username} %{reset_trigger} %{default_track}` + > 开始以下的交互模式:%{tracks}。 + bot_actions: |- + `@%{discobot_username} %{dice_trigger} 2d6` + > :game_die: 3,6 + + `@%{discobot_username} %{quote_trigger}` + > :left_speech_bubble: _随手做些善举,不要期待任何的回报,肯定能懂得一个道理:总有一天有人会为你做同样的事情。_ — 戴安娜公主 + + `@%{discobot_username} %{magic_8_ball_trigger}` + > :crystal_ball: 你可能需要它 + do_not_understand: + first_response: |- + 你好,感谢回复! + + 不幸的是,我是别人随手写的机器人,我其实不太懂这个。:fronwing: + track_response: 你可以再试一次,或者你想要跳过这个步骤,说`%{skip_trigger}`。不然的话,说`%{reset_trigger}`重新开始。 + second_response: |- + 啊,抱歉。我还是没懂。 :anguished: + + 我只是个机器人,但是如果你想要找到真人,看看[我们的联系页面](/about)。 + + 同时,我不会再烦你。 + new_user_narrative: + reset_trigger: "新用户" + cert_title: "你已经成功完成新用户教程了" + hello: + title: ":robot: 祝贺!" + message: |- + 感谢你加入%{title},然后欢迎! + + - 我只是个机器人,但是需要的话[我们友善的管理人员](/about)也能帮助你。 + + - 出于安全原因,我们临时限制了新用户能做的事。随着我们对你的了解,你将获得新能力(和[徽章](/badges))。 + + - 我们一直相信[文明的讨论行为](/guidelines)。 + onebox: + instructions: |- + 接下来,你能分享下列链接给我么?回复**单独一行的链接**,然后它会自动展开显示一个有用的摘要。 + + 要复制链接,在移动端点击并且保持,或者用你的鼠标右键: + + - https://en.wikipedia.org/wiki/Inherently_funny_word + - https://en.wikipedia.org/wiki/Death_by_coconut + - https://en.wikipedia.org/wiki/Calculator_spelling + reply: |- + 酷!这对大多数链接都有用。记住,链接必须要**占一行**,前后都没东西。 + not_found: |- + 抱歉,我没在你的回复中找到链接! :cry: + + 你可以试着添加下面的链接吗?在你的下个回复中,单独一行。 + + - https://en.wikipedia.org/wiki/Exotic_Shorthair + images: + instructions: |- + 这是一只独角兽的照片: + + + + 如果你喜欢它(谁不呢!),在帖子的下方赞 :heart: 一下,这样我就知道你喜欢它了。 + + 你可以**用图片回复**吗?任何图片都行!拖进来后释放,点击上传按钮,或者复制粘贴都行。 + reply: |- + 好赞的图片—我点了赞 :heart: ,你知道我很乐意看见它的 :heart_eyes: + like_not_found: |- + 你忘记赞 :heart: 我的[帖子](%{url})了吗? :crying_cat_face: + not_found: |- + 看起来你没有上传我**肯定**会喜欢的图片。 + + `%{image_url}` + + 试试再传一个,或者复制一个占一行的链接! + formatting: + instructions: |- + 你可以在下一个回复中加粗或斜体这些词语么? + + - 输入`**粗体**`或`_斜体_` + + - 或者,按编辑器上的按钮BI + reply: |- + 干得漂亮!HTML 和 BBCode 也可以用来格式化—想学更多,[试试这个教程](http://commonmark.org/help) :nerd: + not_found: |- + 啊,我在你的回复中没找到任何格式。 :pencil2: + + 你能再试试吗?如果你不知道怎么办了,按编辑器上的按钮 B 加粗或者I 斜体。 + quoting: + instructions: |- + 你能在回复的时候引用我的话吗,这样我就知道你在回复我什么了。 + + > 如果这是咖啡,请给我些茶;但是如果这是茶,请给我些咖啡。 + > + > 和你自己说话一个优点是至少你知道有人在听。 + > + > 有些人知道怎么说话,而另一些人...啊,哦,不知道。 + + 选择你喜欢的文字,然后在旁边弹出的地方按下**引用**—或者点击帖子底下的**回复**按钮。 + + 在引用下面,输入一两个为什么你这么选的原因,我有点好奇 :thinking: + reply: |- + 干得好,你选择了我最喜欢的一段!:left_speech_bubble: + not_found: |- + 哈,看起来你没有在回复中引用我的话? + + 选择我帖子中的任何文字都会弹出**引用**按钮。然后选中文字的同时按下**回复**按钮也可以!你可以再试一次吗? + bookmark: + instructions: |- + 如果你想到了解更多,选择下方的和**收藏这个私信**。如果你做了,未来你可能收到一份 :gift: ! + reply: |- + 太棒了!现在你可以轻松地找到我们的私信了。它就在[你个人页面的收藏栏](%{profile_page_url}/activity/bookmarks)中。选择右上方你的个人头像 ↗ + not_found: "啊哦,我没有在这个主题中看到任何收藏。\b你在每个帖子下找到了收藏吗?如果需要的话,使用显示更多展开更多操作。" + emoji: + instructions: |- + 你可能看到我在回复中用了些小图片 :blue_car::dash: 他们叫[Emoji](https://zh.wikipedia.org/wiki/Emoji)。你能在你的回复中**添加Emoji**吗?以下任意一个都可以: + + - 输入 `:) ;) :D :P :O` + + - 输入半角冒号 : 然后是 emoji 的名字 `:tada:` + + - 在编辑器中按下 emoji 按钮 ,或者用你移动端的键盘 + reply: |- + 这是 :sparkles: **Emoji 精神!** :sparkles: + not_found: "啊,我没有在你的回复中看到任何 Emoji?\b哦不! :sob:\n\n试试输入一个半角冒号:打开 emoji 选择器,然后输入你想要的几个字母,比如 `:bird`。\n\n或者,点击你编辑器里的 emoji 按钮。\n\n(如果你正在用移动设备,你也可以试试直接在你的键盘里输入 Emoji)" + mention: + instructions: |- + 有时你可能想要在回复别人的时候提醒另一个人。输入`@`然后是他们的用户名提及他们。 + + 你能在回复中提及**@%{discobot_username}**吗? + reply: |- + **谁在找我!?** :raised_hand: 我觉得是你! :wave: 好吧,我在这儿!感谢你提到我。 :ok_hand: + not_found: |- + 我没有在任何地方看到我的名字 :frowning: 你可以再`@%{discobot_username}` 我试试? + + (是的,我的用户名是**disco**,和 1970 年流行的舞蹈同名) + flag: + instructions: |- + 我们都喜欢友善的讨论,我们也需要你的帮助[一起保持文明](%{guidelines_url})。如果你看到了某些问题,请用标记让作者知道,或者让[我们友善的管理人员](%{about_url})来处理。 + + > :imp: 我在这写了些脏话 + + 我猜你知道要做什么了。点击**标记该贴**然后标记其为不合适的! + reply: |- + [我们的管理人员](/groups/staff)将会私下里通知关于你标记的后续。如果许多社区成员标记了帖子,出于谨慎,帖子将自动被隐藏。(我没有真的写很糟糕的帖子 :angel:,我先把这个标记删除了) + not_found: |- + 啊不,我糟糕的帖子还没被标记。 :worried: 你能用**标记**标记其为不合适的吗?不要忘记每个帖子后都有显示更多按钮,这样你能看到更多操作。 + search: + instructions: "呼…我已经隐藏了这个主题中的一个惊喜。如果你想挑战的话,点击右上角↗的**搜索按钮** 来搜索。\n\n试试搜索\x1D在这个主题中搜索关键词“capy​bara”" + hidden_message: |- + 你找到这个 capybara 啦 :wink: + + + + 你注意到你回到了主题开头么?**回复`:herb:` 这个 emoji**给这帖,你会自动跳转到结尾。 + reply: |- + 你找到啦 :tada: + + - 想用更复杂的条件搜索,前往[全文搜索页](%{search_url})。 + + - 想在长讨论中任意跳转的话,试试右侧(移动端在下方)的主题时间线控制器。 + + - 如果你有 :keyboard:,按下?查看有用的键盘快捷键列表。 + not_found: |- + 哈...看起来你碰到了些问题。抱歉。你搜索过**capy​bara**了吗? + end: + message: |- + 感谢你@%{username}一直和我相处!我给你准备了一个东西,这是你应得的: + + %{certificate} + + 现在就是这样了!查看[**我们最新的讨论主题**](/latest)或者[**讨论分类**](/categories)。:sunglasses: + + (如果你还想我和我聊以了解更多,可以随时私信我或者提及`@%{discobot_username}`我!) + certificate: + alt: '成就证明' + advanced_user_narrative: + reset_trigger: '高级用户' + cert_title: "你已经成功完成高级用户教程了" + title: ':arrow_up: 高级用户特性' + start_message: |- + %{username} + edit: + bot_created_post_raw: "@%{discobot_username}是目前我了解的最酷的机器人 :wink:" + instructions: |- + 每个人都会犯错。但是不要担心,你永远可以编辑你的帖子修正他们! + + 你可以开始**编辑**我替你创建的帖子么? + not_found: |- + 看起来你还没有编辑我为你创建的[帖子](%{url})。你能再试试么? + + 试试图标来召唤编辑器。 + reply: |- + 干得漂亮! + + 注意下,你初次编辑后 5 分钟后的修改将会公开显示,一个小小的铅笔图标将出现在编辑次数的右上角。 + delete: + instructions: |- + 如果你想要撤回你的帖子,你可以删除它。 + + + 前往并**删除**你的任意一个帖子。不过不要删除你的第一帖! + not_found: |- + 我没有看到你删除了任何一帖。记住点击显示更多按钮后你可以看到删除按钮。 + reply: |- + 哇! :boom: + + 为了讨论的连贯性,删除不会立即发生,所以帖子将在之后移除。 + recover: + deleted_post_raw: '为什么@%{discobot_username}删除了我的帖子?:anguished:' + instructions: |- + 哦不!看起来我不小心删除了我给你创建的帖子。 + + 你可以帮我**恢复**它么? + not_found: |- + 碰到麻烦了?记住点击显示更多按钮后你可以看到恢复按钮。 + reply: |- + 呼,差点啊!感谢你帮忙 :wink: + + 注意你只有 24 小时时间恢复一个帖子。 + category_hashtag: + instructions: |- + 你知道你可以在你的帖子中提到分类和标签么?例如,你看到过%{category}分类了吗? + + 输入`#`然后选择一个分类或标签。 + not_found: "哈,我没有看到分类啊。注意`#`不能是第一个字符。你能在下一个回复中\b拷贝它吗?\n\n```text\n我能用#创建一个分类链接\n```" + reply: |- + 漂亮!记住这对分类和标签(如果站点启用了它的话)都有用。 + change_topic_notification_level: + instructions: "每个主题都有通知等级。默认为“普通”,只有别人直接回复你的时候才会提醒你。\n\n默认情况下,私信中的通知等级被设为最高级“监看”,即你将收到每个新回复的提醒。但是你可以覆盖任意一个主题的通知等级至“监看”、“追踪”或“静音”。\n\n让我们试试改变这个主题的通知等级。在主题最下方,你可以看到一个**监看**的按钮。\x1C你可以把通知等级改到**追踪**吗?" + not_found: |- + 看起来你仍在监看 :eyes: 这个主题!没找到按钮?通知等级就在主题页面的最下方。 + reply: |- + 干得漂亮!我希望你没有把该主题静音。我有时话比较多 :grin:。 + + 注意当你回复主题时,或者花了几分钟读了一个主题,它将被自动设为“追踪”。你可以在[你的用户设置](/my/preferences)中修改。 + poll: + instructions: |- + 你知道你可以在任何帖子中添加投票吗?试试编辑器中的齿轮图标**设置投票**。 + not_found: |- + 啊哦!你的回复中没有任何投票。 + + 使用编辑器中的齿轮图标或者复制以下投票: + + ```text + [poll] + * :cat: + * :dog: + [/poll] + ``` + reply: |- + 棒!你看我是怎么教你的? + + [poll] + * :+1: + * :-1: + [/poll] + details: + instructions: |- + 有时你可能想要**隐藏**你回复中的细节: + + - 当你讨论电影或者电视剧剧情的时候,这有可能剧透。 + + - 当你帖子中有大量可选的内容,一次读完可能太困难了。 + + [details=点击它看看!] + 1. 选择编辑器中的齿轮图标。 + 2. 选择“隐藏详情”。 + 3. 编辑详情信息并增加内容。 + [/details] + + 你可以用编辑器中的齿轮图标并在下次回复中添加详情一栏。 + not_found: |- + 添加详情控件时碰到麻烦了?试试在下个回复中包含下列文字: + + ```text + [details=点击我查看详情] + 这儿是详情 + [/details] + ``` + reply: |- + 干得好—你很**注意**细节! + end: + message: |- + 你像个高级用户一样做完了教程。真的 :bow: + + %{certificate} + + 这是我能给你做的最多的事情了。 + + 再见啦!如果你想要继续和我交流,随时给我发消息 :sunglasses: + certificate: + alt: '高级用户终身成就证明' diff --git a/plugins/discourse-narrative-bot/config/locales/server.zh_TW.yml b/plugins/discourse-narrative-bot/config/locales/server.zh_TW.yml new file mode 100644 index 0000000000..92c18de9ae --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.zh_TW.yml @@ -0,0 +1,8 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +zh_TW: {} diff --git a/plugins/discourse-narrative-bot/config/settings.yml b/plugins/discourse-narrative-bot/config/settings.yml new file mode 100644 index 0000000000..e36e06f8a4 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/settings.yml @@ -0,0 +1,23 @@ +plugins: + discourse_narrative_bot_enabled: + default: + default: true + test: false + client: true + disable_discourse_narrative_bot_welcome_post: + default: false + discourse_narrative_bot_welcome_post_type: + default: 'new_user_track' + enum: 'DiscourseNarrativeBot::WelcomePostTypeSiteSetting' + discourse_narrative_bot_welcome_post_delay: + default: 0 + discourse_narrative_bot_ignored_usernames: + default: 'discourse' + type: list + discourse_narrative_bot_disable_public_replies: + default: false + +uncategorized: + send_welcome_message: + default: false + hidden: true diff --git a/plugins/discourse-narrative-bot/db/fixtures/001_discobot.rb b/plugins/discourse-narrative-bot/db/fixtures/001_discobot.rb new file mode 100644 index 0000000000..c81ac87fc4 --- /dev/null +++ b/plugins/discourse-narrative-bot/db/fixtures/001_discobot.rb @@ -0,0 +1,43 @@ +discobot_username ='discobot' +user = User.find_by(id: -2) + +if !user + suggested_username = UserNameSuggester.suggest(discobot_username) + + User.seed do |u| + u.id = -2 + u.name = discobot_username + u.username = suggested_username + u.username_lower = suggested_username.downcase + u.email = "discobot_email" + u.password = SecureRandom.hex + u.active = true + u.approved = true + u.trust_level = TrustLevel[4] + end + + # TODO Pull the user avatar from that thread for now. In the future, pull it from a local file or from some central discobot repo. + if !Rails.env.test? + UserAvatar.import_url_for_user( + "https://cdn.discourse.org/dev/uploads/default/original/2X/e/edb63d57a720838a7ce6a68f02ba4618787f2299.png", + User.find(-2), + override_gravatar: true + ) + end +end + +bot = User.find(-2) +bot.update!(admin:true, moderator: false) + +bot.user_option.update!( + email_private_messages: false, + email_direct: false +) + +if !bot.user_profile.bio_raw + bot.user_profile.update!( + bio_raw: I18n.t('discourse_narrative_bot.bio', site_title: SiteSetting.title, discobot_username: bot.username) + ) +end + +Group.user_trust_level_change!(-2, TrustLevel[4]) diff --git a/plugins/discourse-narrative-bot/db/fixtures/002_badges.rb b/plugins/discourse-narrative-bot/db/fixtures/002_badges.rb new file mode 100644 index 0000000000..263e9dccb7 --- /dev/null +++ b/plugins/discourse-narrative-bot/db/fixtures/002_badges.rb @@ -0,0 +1,39 @@ +Badge + .where(name: 'Complete New User Track') + .update_all(name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME) + +Badge + .where(name: 'Complete Discobot Advanced User Track') + .update_all(name: DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME) + +new_user_narrative_badge = Badge.find_by(name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME) + +unless new_user_narrative_badge + new_user_narrative_badge = Badge.create!( + name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME, + badge_type_id: 3 + ) +end + +advanced_user_narrative_badge = Badge.find_by(name: DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME) + +unless advanced_user_narrative_badge + advanced_user_narrative_badge = Badge.create!( + name: DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME, + badge_type_id: 2 + ) +end + +badge_grouping = BadgeGrouping.find(1) + +[ + [new_user_narrative_badge, I18n.t('badges.certified.description')], + [advanced_user_narrative_badge, I18n.t('badges.licensed.description')] +].each do |badge, description| + + badge.update!( + badge_grouping: badge_grouping, + description: description, + system: true + ) +end diff --git a/plugins/discourse-narrative-bot/jobs/bot_input.rb b/plugins/discourse-narrative-bot/jobs/bot_input.rb new file mode 100644 index 0000000000..06ac33d7fc --- /dev/null +++ b/plugins/discourse-narrative-bot/jobs/bot_input.rb @@ -0,0 +1,17 @@ +module Jobs + class BotInput < Jobs::Base + + sidekiq_options queue: 'critical', retry: false + + def execute(args) + return unless user = User.find_by(id: args[:user_id]) + + I18n.with_locale(user.effective_locale) do + ::DiscourseNarrativeBot::TrackSelector.new(args[:input].to_sym, user, + post_id: args[:post_id], + topic_id: args[:topic_id] + ).select + end + end + end +end diff --git a/plugins/discourse-narrative-bot/jobs/narrative_init.rb b/plugins/discourse-narrative-bot/jobs/narrative_init.rb new file mode 100644 index 0000000000..e2e9219bee --- /dev/null +++ b/plugins/discourse-narrative-bot/jobs/narrative_init.rb @@ -0,0 +1,13 @@ +module Jobs + class NarrativeInit < Jobs::Base + sidekiq_options queue: 'critical' + + def execute(args) + if user = User.find_by(id: args[:user_id]) + I18n.with_locale(user.effective_locale) do + args[:klass].constantize.new.input(:init, user) + end + end + end + end +end diff --git a/plugins/discourse-narrative-bot/jobs/narrative_timeout.rb b/plugins/discourse-narrative-bot/jobs/narrative_timeout.rb new file mode 100644 index 0000000000..0e8b5bc80b --- /dev/null +++ b/plugins/discourse-narrative-bot/jobs/narrative_timeout.rb @@ -0,0 +1,11 @@ +module Jobs + class NarrativeTimeout < Jobs::Base + def execute(args) + if user = User.find_by(id: args[:user_id]) + I18n.with_locale(user.effective_locale) do + args[:klass].constantize.new.notify_timeout(user) + end + end + end + end +end diff --git a/plugins/discourse-narrative-bot/jobs/onceoff/grant_badges.rb b/plugins/discourse-narrative-bot/jobs/onceoff/grant_badges.rb new file mode 100644 index 0000000000..bbc995fb3a --- /dev/null +++ b/plugins/discourse-narrative-bot/jobs/onceoff/grant_badges.rb @@ -0,0 +1,35 @@ +module Jobs + module DiscourseNarrativeBot + class GrantBadges < ::Jobs::Onceoff + def execute_onceoff(args) + new_user_track_badge = Badge.find_by( + name: ::DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME + ) + + advanced_user_track_badge = Badge.find_by( + name: ::DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME + ) + + PluginStoreRow.where( + plugin_name: ::DiscourseNarrativeBot::PLUGIN_NAME, + type_name: 'JSON' + ).find_each do |row| + + value = JSON.parse(row.value) + completed = value["completed"] + user = User.find_by(id: row.key) + + if user && completed + if completed.include?(::DiscourseNarrativeBot::NewUserNarrative.to_s) + BadgeGranter.grant(new_user_track_badge, user) + end + + if completed.include?(::DiscourseNarrativeBot::AdvancedUserNarrative.to_s) + BadgeGranter.grant(advanced_user_track_badge, user) + end + end + end + end + end + end +end diff --git a/plugins/discourse-narrative-bot/jobs/send_default_welcome_message.rb b/plugins/discourse-narrative-bot/jobs/send_default_welcome_message.rb new file mode 100644 index 0000000000..fcf3efcd5a --- /dev/null +++ b/plugins/discourse-narrative-bot/jobs/send_default_welcome_message.rb @@ -0,0 +1,25 @@ +module Jobs + class SendDefaultWelcomeMessage < Jobs::Base + def execute(args) + if user = User.find_by(id: args[:user_id]) + type = user.invited_by ? 'welcome_invite' : 'welcome_user' + params = SystemMessage.new(user).defaults + + title = I18n.t("system_messages.#{type}.subject_template", params) + raw = I18n.t("system_messages.#{type}.text_body_template", params) + discobot_user = User.find(-2) + + post = PostCreator.create!( + discobot_user, + title: title, + raw: raw, + archetype: Archetype.private_message, + target_usernames: user.username, + skip_validations: true + ) + + post.topic.update_status('closed', true, discobot_user) + end + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/actions.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/actions.rb new file mode 100644 index 0000000000..a005e6b4ec --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/actions.rb @@ -0,0 +1,82 @@ +module DiscourseNarrativeBot + module Actions + def discobot_user + @discobot ||= User.find(-2) + end + + private + + def reply_to(post, raw, opts = {}) + if post + default_opts = { + raw: raw, + topic_id: post.topic_id, + reply_to_post_number: post.post_number, + skip_validations: true + } + + new_post = PostCreator.create!(self.discobot_user, default_opts.merge(opts)) + reset_rate_limits(post) if new_post + new_post + else + PostCreator.create!(self.discobot_user, { raw: raw, skip_validations: true }.merge(opts)) + end + end + + def reset_rate_limits(post) + user = post.user + data = DiscourseNarrativeBot::Store.get(user.id.to_s) + + return unless data + + key = "#{DiscourseNarrativeBot::PLUGIN_NAME}:reset-rate-limit:#{post.topic_id}:#{data['state']}" + + if !(count = $redis.get(key)) + count = 0 + + duration = + if user && user.new_user? + SiteSetting.rate_limit_new_user_create_post + else + SiteSetting.rate_limit_create_post + end + + $redis.setex(key, duration, count) + end + + if count.to_i < 2 + post.default_rate_limiter.rollback! + post.limit_posts_per_day&.rollback! + $redis.incr(key) + end + end + + def fake_delay + sleep(rand(2..3)) if Rails.env.production? + end + + def bot_mentioned?(post) + doc = Nokogiri::HTML.fragment(post.cooked) + + valid = false + + doc.css(".mention").each do |mention| + valid = true if mention.text == "@#{self.discobot_user.username}" + end + + valid + end + + def reply_to_bot_post?(post) + post&.reply_to_post && post.reply_to_post.user_id == -2 + end + + def pm_to_bot?(post) + topic = post.topic + return false if !topic + + topic.pm_with_non_human_user? && + topic.topic_allowed_users.where(user_id: -2).exists? + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/advanced_user_narrative.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/advanced_user_narrative.rb new file mode 100644 index 0000000000..1c3b67f520 --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/advanced_user_narrative.rb @@ -0,0 +1,379 @@ +module DiscourseNarrativeBot + class AdvancedUserNarrative < Base + I18N_KEY = "discourse_narrative_bot.advanced_user_narrative".freeze + BADGE_NAME = 'Licensed'.freeze + + TRANSITION_TABLE = { + begin: { + next_state: :tutorial_edit, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.edit.instructions") }, + init: { + action: :start_advanced_track + } + }, + + tutorial_edit: { + next_state: :tutorial_delete, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.delete.instructions") }, + edit: { + action: :reply_to_edit + }, + reply: { + next_state: :tutorial_edit, + action: :missing_edit + } + }, + + tutorial_delete: { + next_state: :tutorial_recover, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.recover.instructions") }, + delete: { + action: :reply_to_delete + }, + reply: { + next_state: :tutorial_delete, + action: :missing_delete + } + }, + + tutorial_recover: { + next_state: :tutorial_category_hashtag, + next_instructions: Proc.new do + category = Category.secured.last + slug = category.slug + + if parent_category = category.parent_category + slug = "#{parent_category.slug}#{CategoryHashtag::SEPARATOR}#{slug}" + end + + I18n.t("#{I18N_KEY}.category_hashtag.instructions", + category: "##{slug}" + ) + end, + recover: { + action: :reply_to_recover + }, + reply: { + next_state: :tutorial_recover, + action: :missing_recover + } + }, + + tutorial_category_hashtag: { + next_state: :tutorial_change_topic_notification_level, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.change_topic_notification_level.instructions") }, + reply: { + action: :reply_to_category_hashtag + } + }, + + tutorial_change_topic_notification_level: { + next_state: :tutorial_poll, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.poll.instructions") }, + topic_notification_level_changed: { + action: :reply_to_topic_notification_level_changed + }, + reply: { + next_state: :tutorial_notification_level, + action: :missing_topic_notification_level_change + } + }, + + tutorial_poll: { + next_state: :tutorial_details, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.details.instructions") }, + reply: { + action: :reply_to_poll + } + }, + + tutorial_details: { + next_state: :end, + reply: { + action: :reply_to_details + } + } + } + + def self.reset_trigger + I18n.t('discourse_narrative_bot.advanced_user_narrative.reset_trigger') + end + + def reset_bot(user, post) + if pm_to_bot?(post) + reset_data(user, { topic_id: post.topic_id }) + else + reset_data(user) + end + + Jobs.enqueue_in(2.seconds, :narrative_init, user_id: user.id, klass: self.class.to_s) + end + + private + + def init_tutorial_edit + data = get_data(@user) + + fake_delay + + post = PostCreator.create!(@user, { + raw: I18n.t( + "#{I18N_KEY}.edit.bot_created_post_raw", + discobot_username: self.discobot_user.username + ), + topic_id: data[:topic_id], + skip_bot: true + }) + + set_state_data(:post_id, post.id) + post + end + + def init_tutorial_recover + data = get_data(@user) + + post = PostCreator.create!(@user, { + raw: I18n.t( + "#{I18N_KEY}.recover.deleted_post_raw", + discobot_username: self.discobot_user.username + ), + topic_id: data[:topic_id], + skip_bot: true + }) + + set_state_data(:post_id, post.id) + PostDestroyer.new(@user, post, skip_bot: true).destroy + end + + def start_advanced_track + raw = I18n.t("#{I18N_KEY}.start_message", username: @user.username) + + raw = <<~RAW + #{raw} + + #{instance_eval(&@next_instructions)} + RAW + + opts = { + title: I18n.t("#{I18N_KEY}.title"), + target_usernames: @user.username, + archetype: Archetype.private_message + } + + if @post && + @post.archetype == Archetype.private_message && + @post.topic.topic_allowed_users.pluck(:user_id).include?(@user.id) + + opts = opts.merge(topic_id: @post.topic_id) + end + + if @data[:topic_id] + opts = opts.merge(topic_id: @data[:topic_id]) + end + post = reply_to(@post, raw, opts) + + @data[:topic_id] = post.topic_id + @data[:track] = self.class.to_s + post + end + + def reply_to_edit + return unless valid_topic?(@post.topic_id) + + fake_delay + + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.edit.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + reply_to(@post, raw) + end + + def missing_edit + post_id = get_state_data(:post_id) + return unless valid_topic?(@post.topic_id) && post_id != @post.id + + fake_delay + + unless @data[:attempted] + reply_to(@post, I18n.t("#{I18N_KEY}.edit.not_found", + url: Post.find_by(id: post_id).url + )) + end + + enqueue_timeout_job(@user) + false + end + + def reply_to_delete + return unless valid_topic?(@topic_id) + + fake_delay + + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.delete.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + PostCreator.create!(self.discobot_user, + raw: raw, + topic_id: @topic_id + ) + end + + def missing_delete + return unless valid_topic?(@post.topic_id) + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.delete.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + + def reply_to_recover + return unless valid_topic?(@post.topic_id) + + fake_delay + + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.recover.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + PostCreator.create!(self.discobot_user, + raw: raw, + topic_id: @post.topic_id + ) + end + + def missing_recover + return unless valid_topic?(@post.topic_id) && + post_id = get_state_data(:post_id) && @post.id != post_id + + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.recover.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + + def reply_to_category_hashtag + topic_id = @post.topic_id + return unless valid_topic?(topic_id) + + if Nokogiri::HTML.fragment(@post.cooked).css('.hashtag').size > 0 + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.category_hashtag.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + reply_to(@post, raw) + else + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.category_hashtag.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def missing_topic_notification_level_change + return unless valid_topic?(@post.topic_id) + + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.change_topic_notification_level.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + + def reply_to_topic_notification_level_changed + return unless valid_topic?(@topic_id) + + fake_delay + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.change_topic_notification_level.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + + post = PostCreator.create!(self.discobot_user, + raw: raw, + topic_id: @topic_id + ) + enqueue_timeout_job(@user) + post + end + + def reply_to_poll + topic_id = @post.topic_id + return unless valid_topic?(topic_id) + + if Nokogiri::HTML.fragment(@post.cooked).css(".poll").size > 0 + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.poll.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + reply_to(@post, raw) + else + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.poll.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def reply_to_details + topic_id = @post.topic_id + return unless valid_topic?(topic_id) + + fake_delay + + if Nokogiri::HTML.fragment(@post.cooked).css("details").size > 0 + reply_to(@post, I18n.t("#{I18N_KEY}.details.reply")) + else + reply_to(@post, I18n.t("#{I18N_KEY}.details.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def reply_to_wiki + topic_id = @post.topic_id + return unless valid_topic?(topic_id) + + fake_delay + + if @post.wiki + reply_to(@post, I18n.t("#{I18N_KEY}.wiki.reply")) + else + reply_to(@post, I18n.t("#{I18N_KEY}.wiki.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def end_reply + fake_delay + + reply_to(@post, I18n.t("#{I18N_KEY}.end.message", + certificate: certificate('advanced') + )) + end + + def synchronize(user) + if Rails.env.test? + yield + else + DistributedMutex.synchronize("advanced_user_narrative_#{user.id}") { yield } + end + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/base.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/base.rb new file mode 100644 index 0000000000..b454e826cc --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/base.rb @@ -0,0 +1,192 @@ +module DiscourseNarrativeBot + class Base + include Actions + + TIMEOUT_DURATION = 900 # 15 mins + + class InvalidTransitionError < StandardError; end + + def input(input, user, post: nil, topic_id: nil, skip: false) + new_post = nil + @post = post + @topic_id = topic_id + @skip = skip + + synchronize(user) do + @user = user + @data = get_data(user) || {} + @state = (@data[:state] && @data[:state].to_sym) || :begin + @input = input + opts = {} + + begin + opts = transition + rescue InvalidTransitionError + # For given input, no transition for current state + return + end + + next_state = opts[:next_state] + action = opts[:action] + + if next_instructions = opts[:next_instructions] + @next_instructions = next_instructions + end + + begin + old_data = @data.dup + new_post = (@skip && @state != :end) ? skip_tutorial(next_state) : self.send(action) + + if new_post + old_state = old_data[:state] + state_changed = (old_state.to_s != next_state.to_s) + clean_up_state(old_state) if state_changed + + @state = @data[:state] = next_state + @data[:last_post_id] = new_post.id + set_data(@user, @data) + + init_state(next_state) if state_changed + + if next_state == :end + end_reply + cancel_timeout_job(user) + + BadgeGranter.grant( + Badge.find_by(name: self.class::BADGE_NAME), + user + ) + + set_data(@user, + topic_id: new_post.topic_id, + state: :end, + track: self.class.to_s + ) + end + end + rescue => e + @data = old_data + set_data(@user, @data) + raise e + end + end + + new_post + end + + def reset_bot + not_implemented + end + + def set_data(user, value) + DiscourseNarrativeBot::Store.set(user.id, value) + end + + def get_data(user) + DiscourseNarrativeBot::Store.get(user.id) + end + + def notify_timeout(user) + @data = get_data(user) || {} + + if post = Post.find_by(id: @data[:last_post_id]) + reply_to(post, I18n.t("discourse_narrative_bot.timeout.message", + username: user.username, + skip_trigger: TrackSelector.skip_trigger, + reset_trigger: "#{TrackSelector.reset_trigger} #{self.class.reset_trigger}", + )) + end + end + + def certificate(type = nil) + options = { + user_id: @user.id, + date: Time.zone.now.strftime('%b %d %Y'), + host: Discourse.base_url, + format: :svg + } + + options.merge!(type: type) if type + src = DiscourseNarrativeBot::Engine.routes.url_helpers.certificate_url(options) + "#{I18n.t("#{self.class::I18N_KEY}.certificate.alt")}" + end + + protected + + def set_state_data(key, value) + @data[@state] ||= {} + @data[@state][key] = value + set_data(@user, @data) + end + + def get_state_data(key) + @data[@state] ||= {} + @data[@state][key] + end + + def reset_data(user, additional_data = {}) + old_data = get_data(user) + new_data = additional_data + set_data(user, new_data) + new_data + end + + def transition + options = self.class::TRANSITION_TABLE.fetch(@state).dup + input_options = options.fetch(@input) + options.merge!(input_options) unless @skip + options + rescue KeyError + raise InvalidTransitionError.new + end + + def skip_tutorial(next_state) + return unless valid_topic?(@post.topic_id) + + fake_delay + + if next_state != :end + reply = reply_to(@post, instance_eval(&@next_instructions)) + enqueue_timeout_job(@user) + reply + else + @post + end + end + + def valid_topic?(topic_id) + topic_id == @data[:topic_id] + end + + def cancel_timeout_job(user) + Jobs.cancel_scheduled_job(:narrative_timeout, user_id: user.id, klass: self.class.to_s) + end + + def enqueue_timeout_job(user) + return if Rails.env.test? + + cancel_timeout_job(user) + + Jobs.enqueue_in(TIMEOUT_DURATION, :narrative_timeout, + user_id: user.id, + klass: self.class.to_s + ) + end + + def not_implemented + raise 'Not implemented.' + end + + private + + def clean_up_state(state) + clean_up_method = "clean_up_#{state}" + self.send(clean_up_method) if self.class.private_method_defined?(clean_up_method) + end + + def init_state(state) + init_method = "init_#{state}" + self.send(init_method) if self.class.private_method_defined?(init_method) + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/certificate_generator.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/certificate_generator.rb new file mode 100644 index 0000000000..57bef91255 --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/certificate_generator.rb @@ -0,0 +1,583 @@ +module DiscourseNarrativeBot + class CertificateGenerator + def initialize(user, date) + @user = user + @date = I18n.l(Date.parse(date), format: :date_only) + @discobot_user = User.find(-2) + end + + def new_user_track + width = 538.583 # Default width for the SVG + + svg = <<~SVG + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + test_cert + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #{I18n.t('discourse_narrative_bot.new_user_narrative.cert_title')} + + + #{@discobot_user.username} + + + #{@date} + + + + + + + + + #{name} + + #{logo_group(40, width, 280)} + + + + + + + + + + + + + + SVG + end + + def advanced_user_track + width = 722.8 # Default width for the SVG + + <<~SVG + + + + + + + + + + + + + + + + + + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.cert_title')} + + + + + + + + + + + + + + #{@date} + + + + + + + + + #{@discobot_user.username} + + + #{name} + + #{logo_group(55, width, 350)} + + + + + + + + SVG + end + + private + + def name + (@user.name && !@user.name.blank? ? @user.name : @user.username).titleize + end + + def logo_group(size, width, height) + begin + uri = URI(SiteSetting.logo_small_url) + + logo_uri = + if uri.host.blank? || uri.scheme.blank? + URI("#{Discourse.base_url}/#{uri.path}") + else + uri + end + + <<~URL + + + + URL + rescue URI::InvalidURIError + '' + end + end + + def avatar_url + UrlHelper.absolute(@user.avatar_template.gsub('{size}', '250')) + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/dice.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/dice.rb new file mode 100644 index 0000000000..de5cb4b377 --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/dice.rb @@ -0,0 +1,32 @@ +module DiscourseNarrativeBot + class Dice + MAXIMUM_NUM_OF_DICE = 20 + MAXIMUM_RANGE_OF_DICE = 120 + + def self.roll(num_of_dice, range_of_dice) + if num_of_dice == 0 || range_of_dice == 0 + return I18n.t('discourse_narrative_bot.dice.invalid') + end + + output = '' + + if num_of_dice > MAXIMUM_NUM_OF_DICE + output << I18n.t('discourse_narrative_bot.dice.not_enough_dice', + num_of_dice: MAXIMUM_NUM_OF_DICE + ) + output << "\n\n" + num_of_dice = MAXIMUM_NUM_OF_DICE + end + + if range_of_dice > MAXIMUM_RANGE_OF_DICE + output << I18n.t('discourse_narrative_bot.dice.out_of_range') + output << "\n\n" + range_of_dice = MAXIMUM_RANGE_OF_DICE + end + + output << I18n.t('discourse_narrative_bot.dice.results', + results: num_of_dice.times.map { rand(1..range_of_dice) }.join(", ") + ) + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/magic_8_ball.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/magic_8_ball.rb new file mode 100644 index 0000000000..583dcfe3a1 --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/magic_8_ball.rb @@ -0,0 +1,9 @@ +module DiscourseNarrativeBot + class Magic8Ball + def self.generate_answer + I18n.t("discourse_narrative_bot.magic_8_ball.result", result: I18n.t( + "discourse_narrative_bot.magic_8_ball.answers.#{rand(1..20)}" + )) + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/new_user_narrative.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/new_user_narrative.rb new file mode 100644 index 0000000000..202f0c5058 --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/new_user_narrative.rb @@ -0,0 +1,519 @@ +require 'distributed_mutex' + +module DiscourseNarrativeBot + class NewUserNarrative < Base + I18N_KEY = "discourse_narrative_bot.new_user_narrative".freeze + BADGE_NAME = 'Certified'.freeze + + TRANSITION_TABLE = { + begin: { + init: { + next_state: :tutorial_bookmark, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.bookmark.instructions") }, + action: :say_hello + } + }, + + tutorial_bookmark: { + next_state: :tutorial_onebox, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.onebox.instructions") }, + + bookmark: { + action: :reply_to_bookmark + }, + + reply: { + next_state: :tutorial_bookmark, + action: :missing_bookmark + } + }, + + tutorial_onebox: { + next_state: :tutorial_emoji, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.emoji.instructions") }, + + reply: { + action: :reply_to_onebox + } + }, + + tutorial_emoji: { + next_state: :tutorial_mention, + next_instructions: Proc.new { + I18n.t("#{I18N_KEY}.mention.instructions", discobot_username: self.discobot_user.username) + }, + reply: { + action: :reply_to_emoji + } + }, + + tutorial_mention: { + next_state: :tutorial_formatting, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.formatting.instructions") }, + + reply: { + action: :reply_to_mention + } + }, + + tutorial_formatting: { + next_state: :tutorial_quote, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.quoting.instructions") }, + + reply: { + action: :reply_to_formatting + } + }, + + tutorial_quote: { + next_state: :tutorial_images, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.images.instructions") }, + + reply: { + action: :reply_to_quote + } + }, + + tutorial_images: { + next_state: :tutorial_flag, + next_instructions: Proc.new { + I18n.t("#{I18N_KEY}.flag.instructions", + guidelines_url: url_helpers(:guidelines_url), + about_url: url_helpers(:about_index_url)) + }, + reply: { + action: :reply_to_image + }, + like: { + action: :track_like + } + }, + + tutorial_flag: { + next_state: :tutorial_search, + next_instructions: Proc.new { I18n.t("#{I18N_KEY}.search.instructions") }, + flag: { + action: :reply_to_flag + }, + reply: { + next_state: :tutorial_flag, + action: :missing_flag + } + }, + + tutorial_search: { + next_state: :end, + reply: { + action: :reply_to_search + } + } + } + + SEARCH_ANSWER = ':herb:'.freeze + + def self.reset_trigger + I18n.t('discourse_narrative_bot.new_user_narrative.reset_trigger') + end + + def reset_bot(user, post) + if pm_to_bot?(post) + reset_data(user, { topic_id: post.topic_id }) + else + reset_data(user) + end + + Jobs.enqueue_in(2.seconds, :narrative_init, user_id: user.id, klass: self.class.to_s) + end + + private + + def synchronize(user) + if Rails.env.test? + yield + else + DistributedMutex.synchronize("new_user_narrative_#{user.id}") { yield } + end + end + + def init_tutorial_search + topic = @post.topic + post = topic.first_post + + MessageBus.publish('/new_user_narrative/tutorial_search', {}, user_ids: [@user.id]) + + raw = <<~RAW + #{post.raw} + + #{I18n.t("#{I18N_KEY}.search.hidden_message")} + RAW + + PostRevisor.new(post, topic).revise!( + self.discobot_user, + { raw: raw }, + { skip_validations: true, force_new_version: true } + ) + + set_state_data(:post_version, post.reload.version || 0) + end + + def clean_up_tutorial_search + first_post = @post.topic.first_post + first_post.revert_to(get_state_data(:post_version) - 1) + first_post.save! + first_post.publish_change_to_clients!(:revised) + end + + def say_hello + raw = I18n.t( + "#{I18N_KEY}.hello.message", + username: @user.username, + title: SiteSetting.title + ) + + raw = <<~RAW + #{raw} + + #{instance_eval(&@next_instructions)} + RAW + + opts = { + title: I18n.t("#{I18N_KEY}.hello.title", title: SiteSetting.title), + target_usernames: @user.username, + archetype: Archetype.private_message + } + + if @post && + @post.archetype == Archetype.private_message && + @post.topic.topic_allowed_users.pluck(:user_id).include?(@user.id) + + opts = opts.merge(topic_id: @post.topic_id) + end + + if @data[:topic_id] + opts = opts.merge(topic_id: @data[:topic_id]) + end + + post = reply_to(@post, raw, opts) + @data[:topic_id] = post.topic.id + @data[:track] = self.class.to_s + post + end + + def missing_bookmark + return unless valid_topic?(@post.topic_id) + return if @post.user_id == self.discobot_user.id + + fake_delay + enqueue_timeout_job(@user) + reply_to(@post, I18n.t("#{I18N_KEY}.bookmark.not_found")) unless @data[:attempted] + false + end + + def reply_to_bookmark + return unless valid_topic?(@post.topic_id) + return unless @post.user_id == self.discobot_user.id + + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.bookmark.reply", profile_page_url: url_helpers(:user_url, username: @user.username))} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + + reply = reply_to(@post, raw) + enqueue_timeout_job(@user) + reply + end + + def reply_to_onebox + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + + @post.post_analyzer.cook(@post.raw, {}) + + if @post.post_analyzer.found_oneboxes? + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.onebox.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + + reply = reply_to(@post, raw) + enqueue_timeout_job(@user) + reply + else + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.onebox.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def track_like + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + + post_liked = PostAction.find_by( + post_action_type_id: PostActionType.types[:like], + post_id: @data[:last_post_id], + user_id: @user.id + ) + + if post_liked + set_state_data(:liked, true) + + if (post_id = get_state_data(:post_id)) && (post = Post.find_by(id: post_id)) + fake_delay + like_post(post) + + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.images.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + reply = reply_to(@post, raw) + enqueue_timeout_job(@user) + return reply + end + end + + false + end + + def reply_to_image + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + + @post.post_analyzer.cook(@post.raw, {}) + transition = true + attempted_count = get_state_data(:attempted) || 0 + + if attempted_count < 2 + @data[:skip_attempted] = true + @data[:attempted] = false + else + @data[:skip_attempted] = false + end + + if @post.post_analyzer.image_count > 0 + set_state_data(:post_id, @post.id) + + if get_state_data(:liked) + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.images.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + like_post(@post) + else + raw = I18n.t( + "#{I18N_KEY}.images.like_not_found", + url: Post.find_by(id: @data[:last_post_id]).url + ) + + transition = false + end + else + raw = I18n.t( + "#{I18N_KEY}.images.not_found", + image_url: "#{Discourse.base_url}/images/dog-walk.gif" + ) + + transition = false + end + + fake_delay + + set_state_data(:attempted, attempted_count + 1) if !transition + reply = reply_to(@post, raw) unless @data[:attempted] && !transition + enqueue_timeout_job(@user) + transition ? reply : false + end + + def reply_to_formatting + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + + if Nokogiri::HTML.fragment(@post.cooked).css("b", "strong", "em", "i", ".bbcode-i", ".bbcode-b").size > 0 + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.formatting.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + + reply = reply_to(@post, raw) + enqueue_timeout_job(@user) + reply + else + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.formatting.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def reply_to_quote + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + + doc = Nokogiri::HTML.fragment(@post.cooked) + + if doc.css(".quote").size > 0 + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.quoting.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + + reply = reply_to(@post, raw) + enqueue_timeout_job(@user) + reply + else + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.quoting.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def reply_to_emoji + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + + doc = Nokogiri::HTML.fragment(@post.cooked) + + if doc.css(".emoji").size > 0 + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.emoji.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + + reply = reply_to(@post, raw) + enqueue_timeout_job(@user) + reply + else + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.emoji.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def reply_to_mention + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + + if bot_mentioned?(@post) + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.mention.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + + reply = reply_to(@post, raw) + enqueue_timeout_job(@user) + reply + else + fake_delay + + unless @data[:attempted] + reply_to(@post, I18n.t( + "#{I18N_KEY}.mention.not_found", + username: @user.username, + discobot_username: self.discobot_user.username + )) + end + + enqueue_timeout_job(@user) + false + end + end + + def missing_flag + return unless valid_topic?(@post.topic_id) + return if @post.user_id == -2 + + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.flag.not_found")) unless @data[:attempted] + false + end + + def reply_to_flag + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + return unless @post.user.id == -2 + + raw = <<~RAW + #{I18n.t("#{I18N_KEY}.flag.reply")} + + #{instance_eval(&@next_instructions)} + RAW + + fake_delay + + reply = reply_to(@post, raw) + @post.post_actions.where(user_id: @user.id).destroy_all + + enqueue_timeout_job(@user) + reply + end + + def reply_to_search + post_topic_id = @post.topic_id + return unless valid_topic?(post_topic_id) + + if @post.raw.match(/#{SEARCH_ANSWER}/) + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.search.reply", search_url: url_helpers(:search_url))) + else + fake_delay + reply_to(@post, I18n.t("#{I18N_KEY}.search.not_found")) unless @data[:attempted] + enqueue_timeout_job(@user) + false + end + end + + def end_reply + fake_delay + + reply_to( + @post, + I18n.t("#{I18N_KEY}.end.message", + username: @user.username, + base_url: Discourse.base_url, + certificate: certificate, + discobot_username: self.discobot_user.username, + advanced_trigger: AdvancedUserNarrative.reset_trigger + ), + topic_id: @data[:topic_id] + ) + end + + def like_post(post) + PostAction.act(self.discobot_user, post, PostActionType.types[:like]) + end + + def welcome_topic + Topic.find_by(slug: 'welcome-to-discourse', archetype: Archetype.default) || + Topic.recent(1).first + end + + def url_helpers(url, opts = {}) + Rails.application.routes.url_helpers.send(url, opts.merge(host: Discourse.base_url)) + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/quote_generator.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/quote_generator.rb new file mode 100644 index 0000000000..729a3a6256 --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/quote_generator.rb @@ -0,0 +1,27 @@ +require 'excon' + +module DiscourseNarrativeBot + class QuoteGenerator + API_ENDPOINT = 'http://api.forismatic.com/api/1.0/'.freeze + + def self.generate(user) + quote, author = + if user.effective_locale != 'en' + translation_key = "discourse_narrative_bot.quote.#{rand(1..10)}" + + [ + I18n.t("#{translation_key}.quote"), + I18n.t("#{translation_key}.author") + ] + else + connection = Excon.new("#{API_ENDPOINT}?lang=en&format=json&method=getQuote") + response = connection.request(expects: [200, 201], method: :Get) + + response_body = JSON.parse(response.body) + [response_body["quoteText"].strip, response_body["quoteAuthor"].strip] + end + + I18n.t('discourse_narrative_bot.quote.results', quote: quote, author: author) + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/track_selector.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/track_selector.rb new file mode 100644 index 0000000000..94fdfd9f83 --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/track_selector.rb @@ -0,0 +1,257 @@ +module DiscourseNarrativeBot + class TrackSelector + include Actions + + GENERIC_REPLIES_COUNT_PREFIX = 'discourse-narrative-bot:track-selector-count:'.freeze + PUBLIC_DISPLAY_BOT_HELP_KEY = 'discourse-narrative-bot:track-selector:display-bot-help'.freeze + + TRACKS = [ + NewUserNarrative, + AdvancedUserNarrative + ] + + TOPIC_ACTIONS = [ + :delete, + :topic_notification_level_changed + ].each(&:freeze) + + RESET_TRIGGER_EXACT_MATCH_LENGTH = 200 + + def initialize(input, user, post_id:, topic_id: nil) + @input = input + @user = user + @post_id = post_id + @topic_id = topic_id + @post = Post.find_by(id: post_id) + end + + def select + data = Store.get(@user.id) + + if @post && !is_topic_action? + is_reply = @input == :reply + return if is_reply && reset_track + + topic_id = @post.topic_id + + if (data && data[:topic_id] == topic_id) + state = data[:state] + klass = (data[:track] || NewUserNarrative.to_s).constantize + + if is_reply && like_user_post + Store.set(@user.id, data.merge!(state: nil, topic_id: nil)) + elsif state&.to_sym == :end && is_reply + bot_commands(bot_mentioned?) || generic_replies(klass.reset_trigger) + elsif is_reply + previous_status = data[:attempted] + current_status = klass.new.input(@input, @user, post: @post, skip: skip_track?) + data = Store.get(@user.id) + data[:attempted] = !current_status + + if previous_status && data[:attempted] == previous_status && !data[:skip_attempted] + generic_replies(klass.reset_trigger, state) + else + $redis.del(generic_replies_key(@user)) + end + + Store.set(@user.id, data) + else + klass.new.input(@input, @user, post: @post, skip: skip_track?) + end + elsif is_reply && (pm_to_bot?(@post) || public_reply?) + like_user_post + bot_commands + end + elsif data && data.dig(:state)&.to_sym != :end && is_topic_action? + klass = (data[:track] || NewUserNarrative.to_s).constantize + klass.new.input(@input, @user, post: @post, topic_id: @topic_id) + end + end + + def self.reset_trigger + I18n.t(i18n_key("reset_trigger")) + end + + def self.skip_trigger + I18n.t(i18n_key("skip_trigger")) + end + + def self.help_trigger + I18n.t(i18n_key("help_trigger")) + end + + def self.quote_trigger + I18n.t("discourse_narrative_bot.quote.trigger") + end + + def self.dice_trigger + I18n.t("discourse_narrative_bot.dice.trigger") + end + + def self.magic_8_ball_trigger + I18n.t("discourse_narrative_bot.magic_8_ball.trigger") + end + + private + + def is_topic_action? + @is_topic_action ||= TOPIC_ACTIONS.include?(@input) + end + + def reset_track + reset = false + + TRACKS.each do |klass| + if selected_track(klass) + klass.new.reset_bot(@user, @post) + reset = true + break + end + end + + reset + end + + def selected_track(klass) + post_raw = @post.raw + trigger = "#{self.class.reset_trigger} #{klass.reset_trigger}" + + if post_raw.length < RESET_TRIGGER_EXACT_MATCH_LENGTH && pm_to_bot?(@post) + post_raw.match(Regexp.new("\\b\\W\?#{trigger}\\W\?\\b", 'i')) + else + match_trigger?(trigger) + end + end + + def bot_commands(hint = true) + raw = + if match_data = match_trigger?("#{self.class.dice_trigger} (\\d+)d(\\d+)") + DiscourseNarrativeBot::Dice.roll(match_data[1].to_i, match_data[2].to_i) + elsif match_trigger?(self.class.quote_trigger) + DiscourseNarrativeBot::QuoteGenerator.generate(@user) + elsif match_trigger?(self.class.magic_8_ball_trigger) + DiscourseNarrativeBot::Magic8Ball.generate_answer + elsif match_trigger?(self.class.help_trigger) + help_message + elsif hint + message = I18n.t(self.class.i18n_key('random_mention.reply'), + discobot_username: self.discobot_user.username, + help_trigger: self.class.help_trigger + ) + + if public_reply? + key = "#{PUBLIC_DISPLAY_BOT_HELP_KEY}:#{@post.topic_id}" + last_bot_help_post_number = $redis.get(key) + + if !last_bot_help_post_number || + (last_bot_help_post_number && + @post.post_number - 10 > last_bot_help_post_number.to_i && + (1.day.to_i - $redis.ttl(key)) > 6.hours.to_i) + + $redis.setex(key, 1.day.to_i, @post.post_number) + message + end + else + message + end + end + + if raw + fake_delay + reply_to(@post, raw, skip_validations: true) + end + end + + def help_message + discobot_username = self.discobot_user.username + + message = I18n.t( + self.class.i18n_key('random_mention.tracks'), + discobot_username: discobot_username, + reset_trigger: self.class.reset_trigger, + default_track: NewUserNarrative.reset_trigger, + tracks: [NewUserNarrative.reset_trigger, AdvancedUserNarrative.reset_trigger].join(', ') + ) + + message << "\n\n#{I18n.t(self.class.i18n_key('random_mention.bot_actions'), + discobot_username: discobot_username, + dice_trigger: self.class.dice_trigger, + quote_trigger: self.class.quote_trigger, + magic_8_ball_trigger: self.class.magic_8_ball_trigger + )}" + end + + def generic_replies_key(user) + "#{GENERIC_REPLIES_COUNT_PREFIX}#{user.id}" + end + + def generic_replies(track_reset_trigger, state = nil) + reset_trigger = "#{self.class.reset_trigger} #{track_reset_trigger}" + key = generic_replies_key(@user) + count = ($redis.get(key) || $redis.setex(key, 900, 0)).to_i + + case count + when 0 + raw = I18n.t(self.class.i18n_key('do_not_understand.first_response')) + + if state && state.to_sym != :end + raw = "#{raw}\n\n#{I18n.t(self.class.i18n_key('do_not_understand.track_response'), reset_trigger: reset_trigger, skip_trigger: self.class.skip_trigger)}" + end + + reply_to(@post, raw) + when 1 + reply_to(@post, I18n.t(self.class.i18n_key('do_not_understand.second_response'), + reset_trigger: self.class.reset_trigger + )) + else + # Stay out of the user's way + end + + $redis.incr(key) + end + + def self.i18n_key(key) + "discourse_narrative_bot.track_selector.#{key}" + end + + def skip_track? + if pm_to_bot?(@post) + post_raw = @post.raw + + post_raw.match(/^@#{self.discobot_user.username} #{self.class.skip_trigger}/i) || + post_raw.strip == self.class.skip_trigger + else + false + end + end + + def match_trigger?(trigger) + discobot_username = self.discobot_user.username + regexp = Regexp.new("@#{discobot_username} #{trigger}", 'i') + match = @post.cooked.match(regexp) + + if pm_to_bot?(@post) + match || @post.raw.strip.match(Regexp.new("^#{trigger}$", 'i')) + else + match + end + end + + def like_user_post + if @post.raw.match(/thank/i) + PostAction.act(self.discobot_user, @post, PostActionType.types[:like]) + end + end + + def bot_mentioned? + @bot_mentioned ||= PostAnalyzer.new(@post.raw, @post.topic_id).raw_mentions.include?( + self.discobot_user.username + ) + end + + def public_reply? + !SiteSetting.discourse_narrative_bot_disable_public_replies && + (bot_mentioned? || reply_to_bot_post?(@post)) + end + end +end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/welcome_post_type_site_setting.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/welcome_post_type_site_setting.rb new file mode 100644 index 0000000000..7ab39db316 --- /dev/null +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/welcome_post_type_site_setting.rb @@ -0,0 +1,18 @@ +module DiscourseNarrativeBot + class WelcomePostTypeSiteSetting + def self.valid_value?(val) + values.any? { |v| v[:value] == val.to_s } + end + + def self.values + @values ||= [ + { name: 'discourse_narrative_bot.welcome_post_type.new_user_track', value: 'new_user_track' }, + { name: 'discourse_narrative_bot.welcome_post_type.welcome_message', value: 'welcome_message' } + ] + end + + def self.translate_names? + true + end + end +end diff --git a/plugins/discourse-narrative-bot/plugin.rb b/plugins/discourse-narrative-bot/plugin.rb new file mode 100644 index 0000000000..fa652aecd5 --- /dev/null +++ b/plugins/discourse-narrative-bot/plugin.rb @@ -0,0 +1,223 @@ +# name: discourse-narrative-bot +# about: Introduces staff to Discourse +# version: 0.0.1 +# authors: Nick Sahler (@nicksahler) + +enabled_site_setting :discourse_narrative_bot_enabled + +if Rails.env.development? + Rails.application.config.before_initialize do |app| + app.middleware.insert_before( + ::ActionDispatch::Static, + ::ActionDispatch::Static, + Rails.root.join("plugins/discourse-narrative-bot/public").to_s + ) + end +end + +require_relative 'lib/discourse_narrative_bot/welcome_post_type_site_setting.rb' + +after_initialize do + SeedFu.fixture_paths << Rails.root.join("plugins", "discourse-narrative-bot", "db", "fixtures").to_s + + Mime::Type.register "image/svg+xml", :svg + + [ + '../jobs/bot_input.rb', + '../jobs/narrative_timeout.rb', + '../jobs/narrative_init.rb', + '../jobs/send_default_welcome_message.rb', + '../jobs/onceoff/grant_badges.rb', + '../lib/discourse_narrative_bot/actions.rb', + '../lib/discourse_narrative_bot/base.rb', + '../lib/discourse_narrative_bot/new_user_narrative.rb', + '../lib/discourse_narrative_bot/advanced_user_narrative.rb', + '../lib/discourse_narrative_bot/track_selector.rb', + '../lib/discourse_narrative_bot/certificate_generator.rb', + '../lib/discourse_narrative_bot/dice.rb', + '../lib/discourse_narrative_bot/quote_generator.rb', + '../lib/discourse_narrative_bot/magic_8_ball.rb', + '../lib/discourse_narrative_bot/welcome_post_type_site_setting.rb' + ].each { |path| load File.expand_path(path, __FILE__) } + + # Disable welcome message because that is what the bot is supposed to replace. + SiteSetting.send_welcome_message = false + + module ::DiscourseNarrativeBot + PLUGIN_NAME = "discourse-narrative-bot".freeze + + class Engine < ::Rails::Engine + engine_name PLUGIN_NAME + isolate_namespace DiscourseNarrativeBot + + if Rails.env.production? + Dir[Rails.root.join("plugins/discourse-narrative-bot/public/images/*")].each do |src| + dest = Rails.root.join("public/images/#{File.basename(src)}") + File.symlink(src, dest) if !File.exists?(dest) + end + end + end + + class Store + def self.set(key, value) + ::PluginStore.set(PLUGIN_NAME, key, value) + end + + def self.get(key) + ::PluginStore.get(PLUGIN_NAME, key) + end + + def self.remove(key) + ::PluginStore.remove(PLUGIN_NAME, key) + end + end + + class CertificatesController < ::ApplicationController + layout :false + skip_before_filter :check_xhr + + def generate + raise Discourse::InvalidParameters.new('user_id must be present') unless params[:user_id]&.present? + + user = User.find_by(id: params[:user_id]) + raise Discourse::NotFound if user.blank? + + raise Discourse::InvalidParameters.new('date must be present') unless params[:date]&.present? + + generator = CertificateGenerator.new(user, params[:date]) + + svg = + case params[:type] + when 'advanced' + generator.advanced_user_track + else + generator.new_user_track + end + + respond_to do |format| + format.svg { render inline: svg} + end + end + end + end + + DiscourseNarrativeBot::Engine.routes.draw do + get "/certificate" => "certificates#generate", format: :svg + end + + Discourse::Application.routes.append do + mount ::DiscourseNarrativeBot::Engine, at: "/discobot" + end + + self.add_model_callback(User, :after_destroy) do + DiscourseNarrativeBot::Store.remove(self.id) + end + + self.add_model_callback(User, :after_commit, on: :create) do + return if SiteSetting.disable_discourse_narrative_bot_welcome_post + + delay = SiteSetting.discourse_narrative_bot_welcome_post_delay + + case SiteSetting.discourse_narrative_bot_welcome_post_type + when 'new_user_track' + if enqueue_narrative_bot_job? + Jobs.enqueue_in(delay, :narrative_init, + user_id: self.id, + klass: DiscourseNarrativeBot::NewUserNarrative.to_s + ) + end + when 'welcome_message' + Jobs.enqueue_in(delay, :send_default_welcome_message, user_id: self.id) + end + end + + require_dependency "user" + + User.class_eval do + def enqueue_narrative_bot_job? + SiteSetting.discourse_narrative_bot_enabled && + self.id > 0 && + !self.anonymous? && + !self.user_option.mailing_list_mode && + !self.staged && + !SiteSetting.discourse_narrative_bot_ignored_usernames.split('|'.freeze).include?(self.username) + end + end + + self.on(:post_created) do |post, options| + user = post.user + + if user.enqueue_narrative_bot_job? && !options[:skip_bot] + Jobs.enqueue(:bot_input, + user_id: user.id, + post_id: post.id, + input: :reply + ) + end + end + + self.on(:post_edited) do |post| + if post.user.enqueue_narrative_bot_job? + Jobs.enqueue(:bot_input, + user_id: post.user.id, + post_id: post.id, + input: :edit + ) + end + end + + self.on(:post_destroyed) do |post, options, user| + if user.enqueue_narrative_bot_job? && !options[:skip_bot] + Jobs.enqueue(:bot_input, + user_id: user.id, + post_id: post.id, + topic_id: post.topic_id, + input: :delete + ) + end + end + + self.on(:post_recovered) do |post, _, user| + if user.enqueue_narrative_bot_job? + Jobs.enqueue(:bot_input, + user_id: user.id, + post_id: post.id, + input: :recover + ) + end + end + + self.add_model_callback(PostAction, :after_commit, on: :create) do + if self.user.enqueue_narrative_bot_job? + input = + case self.post_action_type_id + when *PostActionType.flag_types.values + :flag + when PostActionType.types[:like] + :like + when PostActionType.types[:bookmark] + :bookmark + end + + if input + Jobs.enqueue(:bot_input, + user_id: self.user.id, + post_id: self.post.id, + input: input + ) + end + end + end + + self.on(:topic_notification_level_changed) do |_, user_id, topic_id| + user = User.find_by(id: user_id) + + if user && user.enqueue_narrative_bot_job? + Jobs.enqueue(:bot_input, + user_id: user_id, + topic_id: topic_id, + input: :topic_notification_level_changed + ) + end + end +end diff --git a/plugins/discourse-narrative-bot/public/images/capybara-eating.gif b/plugins/discourse-narrative-bot/public/images/capybara-eating.gif new file mode 100644 index 0000000000..ef32566b6f Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/capybara-eating.gif differ diff --git a/plugins/discourse-narrative-bot/public/images/dog-walk.gif b/plugins/discourse-narrative-bot/public/images/dog-walk.gif new file mode 100644 index 0000000000..cf575ac056 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/dog-walk.gif differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-bookmark.png b/plugins/discourse-narrative-bot/public/images/font-awesome-bookmark.png new file mode 100644 index 0000000000..7e274ce6f2 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-bookmark.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-ellipsis.png b/plugins/discourse-narrative-bot/public/images/font-awesome-ellipsis.png new file mode 100644 index 0000000000..15890f217b Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-ellipsis.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-flag.png b/plugins/discourse-narrative-bot/public/images/font-awesome-flag.png new file mode 100644 index 0000000000..84dea7c346 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-flag.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-gear.png b/plugins/discourse-narrative-bot/public/images/font-awesome-gear.png new file mode 100644 index 0000000000..73eec571f8 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-gear.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-link.png b/plugins/discourse-narrative-bot/public/images/font-awesome-link.png new file mode 100644 index 0000000000..daa81336e3 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-link.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-pencil.png b/plugins/discourse-narrative-bot/public/images/font-awesome-pencil.png new file mode 100644 index 0000000000..444c7620d9 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-pencil.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-rotate-left.png b/plugins/discourse-narrative-bot/public/images/font-awesome-rotate-left.png new file mode 100644 index 0000000000..d2046c653c Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-rotate-left.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-search.png b/plugins/discourse-narrative-bot/public/images/font-awesome-search.png new file mode 100644 index 0000000000..87568be2f5 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-search.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-smile.png b/plugins/discourse-narrative-bot/public/images/font-awesome-smile.png new file mode 100644 index 0000000000..579525498c Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-smile.png differ diff --git a/plugins/discourse-narrative-bot/public/images/font-awesome-trash.png b/plugins/discourse-narrative-bot/public/images/font-awesome-trash.png new file mode 100644 index 0000000000..ab10804845 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/font-awesome-trash.png differ diff --git a/plugins/discourse-narrative-bot/public/images/unicorn.png b/plugins/discourse-narrative-bot/public/images/unicorn.png new file mode 100644 index 0000000000..21312064c3 Binary files /dev/null and b/plugins/discourse-narrative-bot/public/images/unicorn.png differ diff --git a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb new file mode 100644 index 0000000000..9eb7248636 --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb @@ -0,0 +1,648 @@ +require 'rails_helper' + +RSpec.describe DiscourseNarrativeBot::AdvancedUserNarrative do + let(:discobot_user) { User.find(-2) } + let(:first_post) { Fabricate(:post, user: discobot_user) } + let(:user) { Fabricate(:user) } + + let(:topic) do + Fabricate(:private_message_topic, first_post: first_post, + topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: discobot_user), + Fabricate.build(:topic_allowed_user, user: user), + ] + ) + end + + let(:post) { Fabricate(:post, topic: topic, user: user) } + let(:narrative) { described_class.new } + let(:other_topic) { Fabricate(:topic) } + let(:other_post) { Fabricate(:post, topic: other_topic) } + let(:skip_trigger) { DiscourseNarrativeBot::TrackSelector.skip_trigger } + let(:reset_trigger) { DiscourseNarrativeBot::TrackSelector.reset_trigger } + + before do + SiteSetting.discourse_narrative_bot_enabled = true + end + + describe '#notify_timeout' do + before do + narrative.set_data(user, + state: :tutorial_poll, + topic_id: topic.id, + last_post_id: post.id + ) + end + + it 'should create the right message' do + expect { narrative.notify_timeout(user) }.to change { Post.count }.by(1) + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.timeout.message', + username: user.username, + skip_trigger: skip_trigger, + reset_trigger: "#{reset_trigger} #{described_class.reset_trigger}", + )) + end + end + + describe '#reset_bot' do + before do + narrative.set_data(user, state: :tutorial_images, topic_id: topic.id) + end + + context 'when trigger is initiated in a PM' do + let(:user) { Fabricate(:user) } + + let(:topic) do + topic_allowed_user = Fabricate.build(:topic_allowed_user, user: user) + bot = Fabricate.build(:topic_allowed_user, user: discobot_user) + Fabricate(:private_message_topic, topic_allowed_users: [topic_allowed_user, bot]) + end + + let(:post) { Fabricate(:post, topic: topic) } + + it 'should reset the bot' do + narrative.reset_bot(user, post) + + expected_raw = I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.start_message', + username: user.username + ) + + expected_raw = <<~RAW + #{expected_raw} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.edit.instructions')} + RAW + + new_post = Post.offset(1).last + + expect(narrative.get_data(user)).to eq({ + "topic_id" => topic.id, + "state" => "tutorial_edit", + "last_post_id" => new_post.id, + "track" => described_class.to_s, + "tutorial_edit" => { + "post_id" => Post.last.id + } + }) + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(new_post.topic.id).to eq(topic.id) + end + end + + context 'when trigger is not initiated in a PM' do + it 'should start the new track in a PM' do + narrative.reset_bot(user, other_post) + + expected_raw = I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.start_message', + username: user.username + ) + + expected_raw = <<~RAW + #{expected_raw} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.edit.instructions')} + RAW + + new_post = Post.offset(1).last + + expect(narrative.get_data(user)).to eq({ + "topic_id" => new_post.topic.id, + "state" => "tutorial_edit", + "last_post_id" => new_post.id, + "track" => described_class.to_s, + "tutorial_edit" => { + "post_id" => Post.last.id + } + }) + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(new_post.topic.id).to_not eq(topic.id) + end + end + end + + describe "#input" do + context 'edit tutorial' do + before do + narrative.set_data(user, + state: :tutorial_edit, + topic_id: topic.id, + track: described_class.to_s, + tutorial_edit: { + post_id: first_post.id + } + ) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_edit) + end + end + + describe 'when user replies to the post' do + it 'should create the right reply' do + post + narrative.expects(:enqueue_timeout_job).with(user).once + + expect { narrative.input(:reply, user, post: post) } + .to change { Post.count }.by(1) + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.edit.not_found', + url: first_post.url + )) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: "@#{discobot_user.username} #{skip_trigger}") + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.delete.instructions') + ) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_delete) + end + end + end + + describe 'when user edits the right post' do + let(:post_2) { Fabricate(:post, user: post.user, topic: post.topic) } + + it 'should create the right reply' do + post_2 + + expect do + PostRevisor.new(post_2).revise!(post_2.user, raw: 'something new') + end.to change { Post.count }.by(1) + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.edit.reply')} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.delete.instructions')} + RAW + + expect(Post.last.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_delete) + end + end + end + + context 'delete tutorial' do + before do + narrative.set_data(user, + state: :tutorial_delete, + topic_id: topic.id, + track: described_class.to_s + ) + end + + describe 'when user replies to the topic' do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user).once + + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.delete.not_found' + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_delete) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.offset(1).last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.recover.instructions') + ) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_recover) + end + end + end + + describe 'when user destroys a post in a different topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + PostDestroyer.new(user, other_post).destroy + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_delete) + end + end + + describe 'when user deletes a post in the right topic' do + it 'should create the right reply' do + post + + expect { PostDestroyer.new(user, post).destroy } + .to change { Post.count }.by(2) + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.delete.reply')} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.recover.instructions')} + RAW + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_recover) + expect(Post.offset(1).last.raw).to eq(expected_raw.chomp) + end + + context 'when user is an admin' do + it 'should create the right reply' do + post + user.update!(admin: true) + + expect { PostDestroyer.new(user, post).destroy } + .to_not change { Post.count } + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.delete.reply')} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.recover.instructions')} + RAW + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_recover) + expect(Post.last.raw).to eq(expected_raw.chomp) + end + end + end + end + + context 'undelete post tutorial' do + before do + narrative.set_data(user, + state: :tutorial_recover, + topic_id: topic.id, + track: described_class.to_s + ) + end + + describe 'when user replies to the topic' do + it 'should create the right reply' do + narrative.set_data(user, narrative.get_data(user).merge( + tutorial_recover: { post_id: '1' } + )) + + narrative.expects(:enqueue_timeout_job).with(user).once + + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.recover.not_found' + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_recover) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + parent_category = Fabricate(:category, name: 'a') + category = Fabricate(:category, parent_category: parent_category, name: 'b') + + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.category_hashtag.instructions', + category: "#a:b" + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_category_hashtag) + end + end + end + + describe 'when user recovers a post in a different topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + PostDestroyer.new(user, other_post).destroy + PostDestroyer.new(user, other_post).recover + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_recover) + end + end + + describe 'when user recovers a post in the right topic' do + it 'should create the right reply' do + parent_category = Fabricate(:category, name: 'a') + category = Fabricate(:category, parent_category: parent_category, name: 'b') + post + + PostDestroyer.new(user, post).destroy + + expect { PostDestroyer.new(user, post).recover } + .to change { Post.count }.by(1) + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.recover.reply')} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.category_hashtag.instructions', category: "#a:b")} + RAW + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_category_hashtag) + expect(Post.last.raw).to eq(expected_raw.chomp) + end + end + end + + context 'category hashtag tutorial' do + before do + narrative.set_data(user, + state: :tutorial_category_hashtag, + topic_id: topic.id, + track: described_class.to_s + ) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) } + .to_not change { Post.count } + + expect(narrative.get_data(user)[:state].to_sym) + .to eq(:tutorial_category_hashtag) + end + end + + describe 'when user replies to the topic' do + it 'should create the right reply' do + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.category_hashtag.not_found' + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_category_hashtag) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.change_topic_notification_level.instructions' + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_change_topic_notification_level) + end + end + end + + it 'should create the right reply' do + category = Fabricate(:category) + + post.update!(raw: "Check out this ##{category.slug}") + narrative.input(:reply, user, post: post) + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.category_hashtag.reply')} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.change_topic_notification_level.instructions')} + RAW + + expect(Post.last.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_change_topic_notification_level) + end + end + + context 'topic notification level tutorial' do + before do + narrative.set_data(user, + state: :tutorial_change_topic_notification_level, + topic_id: topic.id, + track: described_class.to_s + ) + end + + describe 'when notification level is changed for another topic' do + it 'should not do anything' do + other_topic + user + narrative.expects(:enqueue_timeout_job).with(user).never + + expect do + TopicUser.change( + user.id, + other_topic.id, + notification_level: TopicUser.notification_levels[:tracking] + ) + end.to_not change { Post.count } + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_change_topic_notification_level) + end + end + + describe 'when user replies to the topic' do + it 'should create the right reply' do + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.change_topic_notification_level.not_found' + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_change_topic_notification_level) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.poll.instructions') + ) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_poll) + end + end + end + + describe 'when user changed the topic notification level' do + it 'should create the right reply' do + TopicUser.change( + user.id, + topic.id, + notification_level: TopicUser.notification_levels[:tracking] + ) + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.change_topic_notification_level.reply')} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.poll.instructions')} + RAW + + expect(Post.last.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_poll) + end + end + end + + context 'poll tutorial' do + before do + narrative.set_data(user, + state: :tutorial_poll, + topic_id: topic.id, + track: described_class.to_s + ) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_poll) + end + end + + describe 'when user replies to the topic' do + it 'should create the right reply' do + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t('discourse_narrative_bot.advanced_user_narrative.poll.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_poll) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.details.instructions') + ) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_details) + end + end + end + + it 'should create the right reply' do + post.update!(raw: "[poll]\n* 1\n* 2\n[/poll]\n") + narrative.input(:reply, user, post: post) + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.poll.reply')} + + #{I18n.t('discourse_narrative_bot.advanced_user_narrative.details.instructions')} + RAW + + expect(Post.last.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_details) + end + end + + context "details tutorial" do + before do + narrative.set_data(user, + state: :tutorial_details, + topic_id: topic.id, + track: described_class.to_s + ) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_details) + end + end + + describe 'when user replies to the topic' do + it 'should create the right reply' do + narrative.input(:reply, user, post: post) + + expect(Post.last.raw).to eq(I18n.t('discourse_narrative_bot.advanced_user_narrative.details.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_details) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + + expect do + DiscourseNarrativeBot::TrackSelector.new( + :reply, user, post_id: post.id + ).select + end.to change { Post.count }.by(1) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:end) + end + end + end + + it 'should create the right reply' do + post.update!(raw: "[details=\"This is a test\"]wooohoo[/details]") + narrative.input(:reply, user, post: post) + + expect(Post.offset(1).last.raw).to eq(I18n.t( + 'discourse_narrative_bot.advanced_user_narrative.details.reply' + )) + + expect(narrative.get_data(user)).to eq({ + "state" => "end", + "topic_id" => topic.id, + "track" => described_class.to_s + }) + + expect(user.badges.where(name: DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME).exists?) + .to eq(true) + end + end + end +end diff --git a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb new file mode 100644 index 0000000000..f484b27556 --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb @@ -0,0 +1,895 @@ +require 'rails_helper' + +describe DiscourseNarrativeBot::NewUserNarrative do + let!(:welcome_topic) { Fabricate(:topic, title: 'Welcome to Discourse') } + let(:discobot_user) { User.find(-2) } + let(:first_post) { Fabricate(:post, user: discobot_user) } + let(:user) { Fabricate(:user) } + + let(:topic) do + Fabricate(:private_message_topic, first_post: first_post, + topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: discobot_user), + Fabricate.build(:topic_allowed_user, user: user), + ] + ) + end + + let(:post) { Fabricate(:post, topic: topic, user: user) } + let(:narrative) { described_class.new } + let(:other_topic) { Fabricate(:topic) } + let(:other_post) { Fabricate(:post, topic: other_topic) } + let(:profile_page_url) { "#{Discourse.base_url}/users/#{user.username}" } + let(:skip_trigger) { DiscourseNarrativeBot::TrackSelector.skip_trigger } + let(:reset_trigger) { DiscourseNarrativeBot::TrackSelector.reset_trigger } + + before do + SiteSetting.discourse_narrative_bot_enabled = true + end + + describe '#notify_timeout' do + before do + narrative.set_data(user, + state: :tutorial_images, + topic_id: topic.id, + last_post_id: post.id + ) + end + + it 'should create the right message' do + expect { narrative.notify_timeout(user) }.to change { Post.count }.by(1) + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.timeout.message', + username: user.username, + skip_trigger: skip_trigger, + reset_trigger: "#{reset_trigger} #{described_class.reset_trigger}", + )) + end + end + + describe '#reset_bot' do + before do + narrative.set_data(user, state: :tutorial_images, topic_id: topic.id) + end + + context 'when trigger is initiated in a PM' do + let(:user) { Fabricate(:user) } + + let(:topic) do + topic_allowed_user = Fabricate.build(:topic_allowed_user, user: user) + bot = Fabricate.build(:topic_allowed_user, user: discobot_user) + Fabricate(:private_message_topic, topic_allowed_users: [topic_allowed_user, bot]) + end + + let(:post) { Fabricate(:post, topic: topic) } + + it 'should reset the bot' do + narrative.reset_bot(user, post) + + expected_raw = I18n.t('discourse_narrative_bot.new_user_narrative.hello.message', + username: user.username, title: SiteSetting.title + ) + + expected_raw = <<~RAW + #{expected_raw} + + #{I18n.t('discourse_narrative_bot.new_user_narrative.bookmark.instructions', profile_page_url: profile_page_url)} + RAW + + new_post = Post.last + + expect(narrative.get_data(user)).to eq({ + "topic_id" => topic.id, + "state" => "tutorial_bookmark", + "last_post_id" => new_post.id, + "track" => described_class.to_s + }) + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(new_post.topic.id).to eq(topic.id) + end + end + + context 'when trigger is not initiated in a PM' do + it 'should start the new track in a PM' do + narrative.reset_bot(user, other_post) + + expected_raw = I18n.t('discourse_narrative_bot.new_user_narrative.hello.message', + username: user.username, title: SiteSetting.title + ) + + expected_raw = <<~RAW + #{expected_raw} + + #{I18n.t('discourse_narrative_bot.new_user_narrative.bookmark.instructions', profile_page_url: profile_page_url)} + RAW + + new_post = Post.last + + expect(narrative.get_data(user)).to eq({ + "topic_id" => new_post.topic.id, + "state" => "tutorial_bookmark", + "last_post_id" => new_post.id, + "track" => described_class.to_s + }) + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(new_post.topic.id).to_not eq(topic.id) + end + end + end + + describe '#input' do + before do + SiteSetting.title = "This is an awesome site!" + narrative.set_data(user, state: :begin) + end + + describe 'when an error occurs' do + before do + narrative.set_data(user, state: :tutorial_flag, topic_id: topic.id) + end + + it 'should revert to the previous state' do + narrative.expects(:send).with('init_tutorial_search').raises(StandardError.new('some error')) + narrative.expects(:send).with(:reply_to_flag).returns(post) + + expect { narrative.input(:flag, user, post: post) }.to raise_error(StandardError, 'some error') + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_flag) + end + end + + describe 'when input does not have a valid transition from current state' do + before do + narrative.set_data(user, state: :begin) + end + + it 'should raise the right error' do + expect(narrative.input(:something, user, post: post)).to eq(nil) + expect(narrative.get_data(user)[:state].to_sym).to eq(:begin) + end + end + + describe 'when [:begin, :init]' do + it 'should create the right post' do + narrative.expects(:enqueue_timeout_job).never + + narrative.input(:init, user, post: nil) + new_post = Post.last + + expected_raw = I18n.t('discourse_narrative_bot.new_user_narrative.hello.message', + username: user.username, title: SiteSetting.title + ) + + expected_raw = <<~RAW + #{expected_raw} + + #{I18n.t('discourse_narrative_bot.new_user_narrative.bookmark.instructions', profile_page_url: profile_page_url)} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + + expect(narrative.get_data(user)[:state].to_sym) + .to eq(:tutorial_bookmark) + end + end + + describe "bookmark tutorial" do + before do + narrative.set_data(user, state: :tutorial_bookmark, topic_id: topic.id) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post.update!(user_id: -2) + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:bookmark, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_bookmark) + end + end + + describe "when bookmark is not on bot's post" do + it 'should not do anything' do + narrative.expects(:enqueue_timeout_job).with(user).never + post + + expect { narrative.input(:bookmark, user, post: post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_bookmark) + end + end + + describe 'when user replies to the topic' do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user).once + + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t('discourse_narrative_bot.new_user_narrative.bookmark.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_bookmark) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: "@#{discobot_user.username} #{skip_trigger}") + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.onebox.instructions') + ) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_onebox) + end + end + end + + it 'should create the right reply' do + post.update!(user: discobot_user) + narrative.expects(:enqueue_timeout_job).with(user) + + narrative.input(:bookmark, user, post: post) + new_post = Post.last + profile_page_url = "#{Discourse.base_url}/u/#{user.username}" + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.bookmark.reply', profile_page_url: profile_page_url)} + + #{I18n.t('discourse_narrative_bot.new_user_narrative.onebox.instructions')} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_onebox) + end + end + + describe 'onebox tutorial' do + before do + narrative.set_data(user, state: :tutorial_onebox, topic_id: topic.id) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_onebox) + end + end + + describe 'when post does not contain onebox' do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t('discourse_narrative_bot.new_user_narrative.onebox.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_onebox) + end + end + + describe "when user has not liked bot's post" do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t('discourse_narrative_bot.new_user_narrative.onebox.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_onebox) + end + end + + describe 'when user replies to the topic' do + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.emoji.instructions') + ) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_emoji) + end + end + end + + it 'should create the right reply' do + post.update!(raw: 'https://en.wikipedia.org/wiki/ROT13') + + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + new_post = Post.last + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.onebox.reply')} + + #{I18n.t('discourse_narrative_bot.new_user_narrative.emoji.instructions')} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_emoji) + end + end + + describe 'images tutorial' do + let(:post_2) { Fabricate(:post, topic: topic) } + + before do + narrative.set_data(user, + state: :tutorial_images, + topic_id: topic.id, + last_post_id: post_2.id, + track: described_class.to_s + ) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_images) + end + end + + describe 'when user replies to the topic' do + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.flag.instructions', + guidelines_url: Discourse.base_url + '/guidelines', + about_url: Discourse.base_url + '/about' + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_flag) + end + end + end + + context 'when image is not found' do + it 'should create the right replies' do + PostAction.act(user, post_2, PostActionType.types[:like]) + + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.images.not_found', + image_url: "#{Discourse.base_url}/images/dog-walk.gif" + )) + + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + new_post = Fabricate(:post, + user: user, + topic: topic, + raw: "" + ) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: new_post.id).select + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.images.reply')} + + #{I18n.t( + 'discourse_narrative_bot.new_user_narrative.flag.instructions', + guidelines_url: "#{Discourse.base_url}/guidelines", + about_url: "#{Discourse.base_url}/about" + )} + RAW + + expect(Post.last.raw).to eq(expected_raw.chomp) + + post_action = PostAction.last + + expect(post_action.post_action_type_id).to eq(PostActionType.types[:like]) + expect(post_action.user).to eq(discobot_user) + expect(post_action.post).to eq(new_post) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_flag) + end + end + + it 'should create the right replies' do + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.images.not_found', + image_url: "#{Discourse.base_url}/images/dog-walk.gif" + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_images) + + new_post = Fabricate(:post, + user: user, + topic: topic, + raw: "" + ) + + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: new_post.id).select + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.images.like_not_found', + url: post_2.url + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_images) + + expect(narrative.get_data(user)[:tutorial_images][:post_id]) + .to eq(new_post.id) + + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + PostAction.act(user, post_2, PostActionType.types[:like]) + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.images.reply')} + + #{I18n.t( + 'discourse_narrative_bot.new_user_narrative.flag.instructions', + guidelines_url: "#{Discourse.base_url}/guidelines", + about_url: "#{Discourse.base_url}/about" + )} + RAW + + expect(Post.last.raw).to eq(expected_raw.chomp) + + post_action = PostAction.last + + expect(post_action.post_action_type_id).to eq(PostActionType.types[:like]) + expect(post_action.user).to eq(discobot_user) + expect(post_action.post).to eq(new_post) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_flag) + end + end + + describe 'fomatting tutorial' do + before do + narrative.set_data(user, state: :tutorial_formatting, topic_id: topic.id) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_formatting) + end + end + + describe 'when post does not contain any formatting' do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + + expect(Post.last.raw).to eq(I18n.t('discourse_narrative_bot.new_user_narrative.formatting.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_formatting) + end + end + + describe 'when user replies to the topic' do + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.quoting.instructions', + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_quote) + end + end + end + + ["**bold**", "__italic__", "[b]bold[/b]", "[i]italic[/i]"].each do |raw| + it 'should create the right reply' do + post.update!(raw: raw) + + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + new_post = Post.last + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.formatting.reply')} + + #{I18n.t('discourse_narrative_bot.new_user_narrative.quoting.instructions')} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_quote) + end + end + end + + describe 'quote tutorial' do + before do + narrative.set_data(user, state: :tutorial_quote, topic_id: topic.id) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_quote) + end + end + + describe 'when post does not contain any quotes' do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + + expect(Post.last.raw).to eq(I18n.t('discourse_narrative_bot.new_user_narrative.quoting.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_quote) + end + end + + describe 'when user replies to the topic' do + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.images.instructions', + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_images) + end + end + end + + it 'should create the right reply' do + post.update!( + raw: '[quote="#{post.user}, post:#{post.post_number}, topic:#{topic.id}"]\n:monkey: :fries:\n[/quote]' + ) + + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + new_post = Post.last + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.quoting.reply')} + + #{I18n.t('discourse_narrative_bot.new_user_narrative.images.instructions')} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_images) + end + end + + describe 'emoji tutorial' do + before do + narrative.set_data(user, state: :tutorial_emoji, topic_id: topic.id) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_emoji) + end + end + + describe 'when post does not contain any emoji' do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + + expect(Post.last.raw).to eq(I18n.t('discourse_narrative_bot.new_user_narrative.emoji.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_emoji) + end + end + + describe 'when user replies to the topic' do + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.mention.instructions', + discobot_username: discobot_user.username + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_mention) + end + end + end + + it 'should create the right reply' do + post.update!( + raw: ':monkey: :fries:' + ) + + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + new_post = Post.last + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.emoji.reply')} + + #{I18n.t('discourse_narrative_bot.new_user_narrative.mention.instructions', + discobot_username: discobot_user.username + )} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_mention) + end + end + + describe 'mention tutorial' do + before do + narrative.set_data(user, state: :tutorial_mention, topic_id: topic.id) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_mention) + end + end + + describe 'when post does not contain any mentions' do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.mention.not_found', + username: user.username, + discobot_username: discobot_user.username + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_mention) + end + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.formatting.instructions', + discobot_username: discobot_user.username + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_formatting) + end + end + + it 'should create the right reply' do + post.update!( + raw: '@discobot hello how are you doing today?' + ) + + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + new_post = Post.last + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.mention.reply')} + + #{I18n.t( + 'discourse_narrative_bot.new_user_narrative.formatting.instructions' + )} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_formatting) + end + end + + describe 'flag tutorial' do + let(:post) { Fabricate(:post, user: discobot_user, topic: topic) } + let(:flag) { Fabricate(:flag, post: post, user: user) } + let(:other_post) { Fabricate(:post, user: user, topic: topic) } + + before do + flag + narrative.set_data(user, state: :tutorial_flag, topic_id: topic.id) + end + + describe 'when post flagged is not for the right topic' do + it 'should not do anything' do + narrative.expects(:enqueue_timeout_job).with(user).never + flag.update!(post: other_post) + + expect { narrative.input(:flag, user, post: flag.post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_flag) + end + end + + describe 'when post being flagged does not belong to discobot ' do + it 'should not do anything' do + narrative.expects(:enqueue_timeout_job).with(user).never + flag.update!(post: other_post) + + expect { narrative.input(:flag, user, post: flag.post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_flag) + end + end + + describe 'when user replies to the topic' do + it 'should create the right reply' do + narrative.input(:reply, user, post: other_post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t('discourse_narrative_bot.new_user_narrative.flag.not_found')) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_flag) + end + + describe 'when reply contains the skip trigger' do + it 'should create the right reply' do + other_post.update!(raw: skip_trigger) + described_class.any_instance.expects(:enqueue_timeout_job).with(user) + + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: other_post.id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.search.instructions' + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_search) + end + end + end + + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user) + + expect { narrative.input(:flag, user, post: flag.post) }.to change { PostAction.count }.by(-1) + + new_post = Post.last + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.new_user_narrative.flag.reply')} + + #{I18n.t( + 'discourse_narrative_bot.new_user_narrative.search.instructions' + )} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_search) + end + end + + describe 'search tutorial' do + before do + narrative.set_data(user, state: :tutorial_search, topic_id: topic.id) + end + + describe 'when post is not in the right topic' do + it 'should not do anything' do + other_post + narrative.expects(:enqueue_timeout_job).with(user).never + + expect { narrative.input(:reply, user, post: other_post) }.to_not change { Post.count } + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_search) + end + end + + describe 'when post does not contain the right answer' do + it 'should create the right reply' do + narrative.expects(:enqueue_timeout_job).with(user) + narrative.input(:reply, user, post: post) + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.search.not_found' + )) + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_search) + end + end + + describe 'when post contain the right answer' do + let(:post) { Fabricate(:post, user: discobot_user, topic: topic) } + let(:flag) { Fabricate(:flag, post: post, user: user) } + + before do + narrative.set_data(user, + state: :tutorial_flag, + topic_id: topic.id + ) + + DiscourseNarrativeBot::TrackSelector.new(:flag, user, post_id: flag.post_id).select + + expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_search) + + expect(post.reload.topic.first_post.raw).to include(I18n.t( + "discourse_narrative_bot.new_user_narrative.search.hidden_message" + )) + end + + it 'should clean up if the tutorial is skipped' do + post.update!(raw: skip_trigger) + + expect do + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + end.to change { Post.count }.by(1) + + expect(first_post.reload.raw).to eq('Hello world') + expect(narrative.get_data(user)[:state].to_sym).to eq(:end) + end + + it 'should create the right reply' do + post.update!( + raw: "#{described_class::SEARCH_ANSWER} this is a capybara" + ) + + expect do + DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select + end.to change { Post.count }.by(2) + + new_post = Post.offset(1).last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.search.reply', + search_url: "#{Discourse.base_url}/search" + ).chomp) + + expect(first_post.reload.raw).to eq('Hello world') + + expect(narrative.get_data(user)).to include({ + "state" => "end", + "topic_id" => new_post.topic_id, + "track" => described_class.to_s, + }) + + expect(user.badges.where(name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME).exists?) + .to eq(true) + end + end + end + end +end diff --git a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/store_spec.rb b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/store_spec.rb new file mode 100644 index 0000000000..7d435279a6 --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/store_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +describe DiscourseNarrativeBot::Store do + describe '.set' do + it 'should set the right value in the plugin store' do + key = 'somekey' + described_class.set(key, 'yay') + plugin_store_row = PluginStoreRow.last + + expect(plugin_store_row.value).to eq('yay') + expect(plugin_store_row.plugin_name).to eq(DiscourseNarrativeBot::PLUGIN_NAME) + expect(plugin_store_row.key).to eq(key) + end + end + + describe '.get' do + it 'should get the right value from the plugin store' do + PluginStoreRow.create!( + plugin_name: DiscourseNarrativeBot::PLUGIN_NAME, + key: 'somekey', + value: 'yay', + type_name: 'string' + ) + + expect(described_class.get('somekey')).to eq('yay') + end + end +end diff --git a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/track_selector_spec.rb b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/track_selector_spec.rb new file mode 100644 index 0000000000..63b224770c --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/track_selector_spec.rb @@ -0,0 +1,671 @@ +require 'rails_helper' + +describe DiscourseNarrativeBot::TrackSelector do + let(:user) { Fabricate(:user) } + let(:discobot_user) { User.find(-2) } + let(:narrative) { DiscourseNarrativeBot::NewUserNarrative.new } + + let(:random_mention_reply) do + I18n.t('discourse_narrative_bot.track_selector.random_mention.reply', + discobot_username: discobot_user.username, + help_trigger: described_class.help_trigger + ) + end + + let(:help_message) do + discobot_username = discobot_user.username + + end_message = <<~RAW + #{I18n.t( + 'discourse_narrative_bot.track_selector.random_mention.tracks', + discobot_username: discobot_username, + default_track: DiscourseNarrativeBot::NewUserNarrative.reset_trigger, + reset_trigger: described_class.reset_trigger, + tracks: "#{DiscourseNarrativeBot::NewUserNarrative.reset_trigger}, #{DiscourseNarrativeBot::AdvancedUserNarrative.reset_trigger}" + )} + + #{I18n.t( + 'discourse_narrative_bot.track_selector.random_mention.bot_actions', + discobot_username: discobot_username, + dice_trigger: described_class.dice_trigger, + quote_trigger: described_class.quote_trigger, + magic_8_ball_trigger: described_class.magic_8_ball_trigger + )} + RAW + + end_message.chomp + end + + describe '#select' do + context 'in a PM with discobot' do + let(:first_post) { Fabricate(:post, user: discobot_user) } + + let(:topic) do + Fabricate(:private_message_topic, first_post: first_post, + topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: discobot_user), + Fabricate.build(:topic_allowed_user, user: user), + ] + ) + end + + let(:post) { Fabricate(:post, topic: topic, user: user) } + + context 'during a tutorial track' do + before do + narrative.set_data(user, + state: :tutorial_formatting, + topic_id: topic.id, + track: "DiscourseNarrativeBot::NewUserNarrative" + ) + end + + context 'when bot is mentioned' do + it 'should select the right track' do + post.update!(raw: '@discobot show me what you can do') + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + "discourse_narrative_bot.new_user_narrative.formatting.not_found" + )) + end + end + + context 'when bot is replied to' do + it 'should select the right track' do + post.update!( + raw: 'show me what you can do', + reply_to_post_number: first_post.post_number + ) + + described_class.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(I18n.t( + "discourse_narrative_bot.new_user_narrative.formatting.not_found" + )) + + described_class.new(:reply, user, post_id: post.id).select + + expected_raw = <<~RAW + #{I18n.t( + 'discourse_narrative_bot.track_selector.do_not_understand.first_response', + reset_trigger: "#{described_class.reset_trigger} #{DiscourseNarrativeBot::NewUserNarrative.reset_trigger}", + )} + + #{I18n.t( + 'discourse_narrative_bot.track_selector.do_not_understand.track_response', + reset_trigger: "#{described_class.reset_trigger} #{DiscourseNarrativeBot::NewUserNarrative.reset_trigger}", + skip_trigger: described_class.skip_trigger + )} + RAW + + expect(Post.last.raw).to eq(expected_raw.chomp) + end + end + + describe 'when user thank the bot' do + it 'should like the post' do + post.update!(raw: 'thanks!') + + expect { described_class.new(:reply, user, post_id: post.id).select } + .to change { PostAction.count }.by(1) + + post_action = PostAction.last + + expect(post_action.post).to eq(post) + expect(post_action.post_action_type_id).to eq(PostActionType.types[:like]) + + post = Post.last + + expect(Post.last).to eq(post) + + expect(DiscourseNarrativeBot::NewUserNarrative.new.get_data(user)['state']) + .to eq(nil) + end + end + + context 'when reply contains a reset trigger' do + it 'should reset the track' do + post.update!( + raw: "#{described_class.reset_trigger} #{DiscourseNarrativeBot::NewUserNarrative.reset_trigger}" + ) + + described_class.new(:reply, user, post_id: post.id).select + + expect(DiscourseNarrativeBot::NewUserNarrative.new.get_data(user)['state']) + .to eq("tutorial_bookmark") + end + + describe 'reset trigger in surrounded by quotes' do + it 'should reset the track' do + post.update!( + raw: "'#{described_class.reset_trigger} #{DiscourseNarrativeBot::NewUserNarrative.reset_trigger}'" + ) + + described_class.new(:reply, user, post_id: post.id).select + + expect(DiscourseNarrativeBot::NewUserNarrative.new.get_data(user)['state']) + .to eq("tutorial_bookmark") + end + end + + describe 'reset trigger in a middle of a sentence' do + describe 'when post is less than reset trigger exact match limit' do + it 'should reset the track' do + post.update!( + raw: "I would like to #{described_class.reset_trigger} #{DiscourseNarrativeBot::NewUserNarrative.reset_trigger} now" + ) + + described_class.new(:reply, user, post_id: post.id).select + + expect(DiscourseNarrativeBot::NewUserNarrative.new.get_data(user)['state']) + .to eq("tutorial_bookmark") + end + end + + describe 'when post exceeds reset trigger exact match limit' do + it 'should not reset the track' do + post.update!( + raw: "I would like to #{described_class.reset_trigger} #{DiscourseNarrativeBot::NewUserNarrative.reset_trigger} now #{'a' * described_class::RESET_TRIGGER_EXACT_MATCH_LENGTH}" + ) + + expect { described_class.new(:reply, user, post_id: post.id).select } + .to change { Post.count }.by(1) + + expect(DiscourseNarrativeBot::NewUserNarrative.new.get_data(user)['state']) + .to eq("tutorial_formatting") + end + end + end + end + end + + context 'at the end of a tutorial track' do + before do + narrative.set_data(user, + state: :end, + topic_id: topic.id, + track: "DiscourseNarrativeBot::NewUserNarrative" + ) + end + + context 'generic replies' do + after do + $redis.del("#{described_class::GENERIC_REPLIES_COUNT_PREFIX}#{user.id}") + end + + it 'should create the right generic do not understand responses' do + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.track_selector.do_not_understand.first_response', + reset_trigger: "#{described_class.reset_trigger} #{DiscourseNarrativeBot::NewUserNarrative.reset_trigger}", + )) + + described_class.new(:reply, user, post_id: Fabricate(:post, + topic: new_post.topic, + user: user, + reply_to_post_number: new_post.post_number + ).id).select + + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.track_selector.do_not_understand.second_response', + reset_trigger: "#{described_class.reset_trigger} #{DiscourseNarrativeBot::NewUserNarrative.reset_trigger}", + )) + + new_post = Fabricate(:post, + topic: new_post.topic, + user: user, + reply_to_post_number: new_post.post_number + ) + + expect { described_class.new(:reply, user, post_id: new_post.id).select } + .to_not change { Post.count } + end + end + + context 'when discobot is mentioned at the end of a track' do + it 'should create the right reply' do + post.update!(raw: 'Show me what you can do @discobot') + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to eq(random_mention_reply) + end + + describe 'when asking discobot for help' do + it 'should create the right reply' do + post.update!(raw: 'show me what you can do @discobot display help') + described_class.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to include(help_message) + end + + describe 'as an admin or moderator' do + it 'should include the commands to start the advanced user track' do + user.update!(moderator: true) + post.update!(raw: 'Show me what you can do @discobot display help') + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to include( + DiscourseNarrativeBot::AdvancedUserNarrative.reset_trigger + ) + end + end + + describe 'as a user that has completed the new user track' do + it 'should include the commands to start the advanced user track' do + narrative.set_data(user, + state: :end, + topic_id: post.topic.id, + track: "DiscourseNarrativeBot::NewUserNarrative", + ) + + BadgeGranter.grant( + Badge.find_by(name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME), + user + ) + + post.update!(raw: 'Show me what you can do @discobot display help') + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to include( + DiscourseNarrativeBot::AdvancedUserNarrative.reset_trigger + ) + end + end + end + + describe 'when discobot is asked to roll dice' do + before do + narrative.set_data(user, + state: :end, + topic_id: topic.id + ) + end + + it 'should create the right reply' do + post.update!(raw: 'roll 2d1') + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + "discourse_narrative_bot.dice.results", results: '1, 1' + )) + end + + describe 'when range of dice request is too high' do + before do + srand(1) + end + + it 'should create the right reply' do + stub_request(:get, "https://www.wired.com/2016/05/mathematical-challenge-of-designing-the-worlds-most-complex-120-sided-dice") + .to_return(status: 200, body: "", headers: {}) + + post.update!(raw: "roll 1d#{DiscourseNarrativeBot::Dice::MAXIMUM_RANGE_OF_DICE + 1}") + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.dice.out_of_range')} + + #{I18n.t('discourse_narrative_bot.dice.results', results: '38')} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + end + end + + describe 'when number of dice to roll is too high' do + it 'should create the right reply' do + post.update!(raw: "roll #{DiscourseNarrativeBot::Dice::MAXIMUM_NUM_OF_DICE + 1}d1") + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expected_raw = <<~RAW + #{I18n.t('discourse_narrative_bot.dice.not_enough_dice', num_of_dice: DiscourseNarrativeBot::Dice::MAXIMUM_NUM_OF_DICE)} + + #{I18n.t('discourse_narrative_bot.dice.results', results: '1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1')} + RAW + + expect(new_post.raw).to eq(expected_raw.chomp) + end + end + + describe 'when dice combination is invalid' do + it 'should create the right reply' do + post.update!(raw: "roll 0d1") + described_class.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(I18n.t( + 'discourse_narrative_bot.dice.invalid' + )) + end + end + end + end + end + + context 'when in a normal PM with discobot' do + describe 'when discobot is replied to' do + it 'should create the right reply' do + SiteSetting.discourse_narrative_bot_disable_public_replies = true + post.update!(raw: 'Show me what you can do @discobot') + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to eq(random_mention_reply) + end + + it 'should not rate limit help message' do + post.update!(raw: '@discobot') + other_post = Fabricate(:post, raw: 'discobot', topic: post.topic) + + [post, other_post].each do |reply| + described_class.new(:reply, user, post_id: reply.id).select + expect(Post.last.raw).to eq(random_mention_reply) + end + end + end + end + end + + context 'random discobot mentions' do + let(:topic) { Fabricate(:topic) } + let(:post) { Fabricate(:post, topic: topic, user: user) } + + describe 'when discobot public replies are disabled' do + before do + SiteSetting.discourse_narrative_bot_disable_public_replies = true + end + + describe 'when discobot is mentioned' do + it 'should not reply' do + post.update!(raw: 'Show me what you can do @discobot') + + expect do + described_class.new(:reply, user, post_id: post.id).select + end.to_not change { Post.count } + end + end + end + + describe 'when discobot is mentioned' do + it 'should create the right reply' do + post.update!(raw: 'Show me what you can do @discobot') + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + expect(new_post.raw).to eq(random_mention_reply) + end + + describe 'rate limiting random reply message in public topic' do + let(:topic) { Fabricate(:topic) } + let(:other_post) { Fabricate(:post, raw: '@discobot show me something', topic: topic) } + let(:post) { Fabricate(:post, topic: topic) } + + after do + $redis.flushall + end + + describe 'when random reply massage has been displayed in the last 6 hours' do + it 'should not do anything' do + $redis.set( + "#{described_class::PUBLIC_DISPLAY_BOT_HELP_KEY}:#{other_post.topic_id}", + post.post_number - 11 + ) + + $redis.class.any_instance.expects(:ttl).returns(19.hours.to_i) + + user + post.update!(raw: "Show me what you can do @discobot") + + expect { described_class.new(:reply, user, post_id: post.id).select } + .to_not change { Post.count } + end + end + + describe 'when random reply message has not been displayed in the last 6 hours' do + it 'should create the right reply' do + $redis.set( + "#{described_class::PUBLIC_DISPLAY_BOT_HELP_KEY}:#{other_post.topic_id}", + post.post_number - 11 + ) + + $redis.class.any_instance.expects(:ttl).returns(7.hours.to_i) + + user + post.update!(raw: "Show me what you can do @discobot") + + described_class.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(random_mention_reply) + end + end + + describe 'when random reply message has been displayed in the last 10 replies' do + it 'should not do anything' do + described_class.new(:reply, user, post_id: other_post.id).select + expect(Post.last.raw).to eq(random_mention_reply) + + expect($redis.get( + "#{described_class::PUBLIC_DISPLAY_BOT_HELP_KEY}:#{other_post.topic_id}" + ).to_i).to eq(other_post.post_number.to_i) + + user + post.update!(raw: "Show me what you can do @discobot") + + expect do + described_class.new(:reply, user, post_id: post.id).select + end.to_not change { Post.count } + end + end + end + + describe 'when asking discobot for help' do + it 'should create the right reply' do + post.update!(raw: '@discobot display help') + described_class.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(help_message) + end + end + + describe 'when asking discobot to start new user track' do + describe 'invalid text' do + it 'should not trigger the bot' do + post.update!(raw: '`@discobot start new user track`') + + expect { described_class.new(:reply, user, post_id: post.id).select } + .to_not change { Post.count } + end + end + end + + describe 'when discobot is asked to roll dice' do + it 'should create the right reply' do + post.update!(raw: '@discobot roll 2d1') + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to eq( + I18n.t("discourse_narrative_bot.dice.results", + results: '1, 1' + )) + end + + describe 'when dice roll is requested incorrectly' do + it 'should create the right reply' do + post.update!(raw: 'roll 2d1 @discobot') + described_class.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(random_mention_reply) + end + end + + describe 'when roll dice command is present inside a quote' do + it 'should ignore the command' do + user + post.update!(raw: '[quote="Donkey, post:6, topic:1"]@discobot roll 2d1[/quote]') + + expect { described_class.new(:reply, user, post_id: post.id).select } + .to_not change { Post.count } + end + end + end + + describe 'when a quote is requested' do + it 'should create the right reply' do + stub_request(:get, "http://api.forismatic.com/api/1.0/?format=json&lang=en&method=getQuote"). + to_return(status: 200, body: "{\"quoteText\":\"Be Like Water\",\"quoteAuthor\":\"Bruce Lee\"}") + + ['@discobot quote', 'hello @discobot quote there'].each do |raw| + post.update!(raw: raw) + described_class.new(:reply, user, post_id: post.id).select + new_post = Post.last + + expect(new_post.raw).to eq( + I18n.t("discourse_narrative_bot.quote.results", + quote: "Be Like Water", author: "Bruce Lee" + )) + end + end + + describe 'when quote is requested incorrectly' do + it 'should create the right reply' do + post.update!(raw: 'quote @discobot') + described_class.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(random_mention_reply) + end + end + + describe 'when quote command is present inside a onebox or quote' do + it 'should ignore the command' do + user + post.update!(raw: '[quote="Donkey, post:6, topic:1"]@discobot quote[/quote]') + + expect { described_class.new(:reply, user, post_id: post.id).select } + .to_not change { Post.count } + end + end + + describe 'when user requesting quote has a preferred locale' do + before do + SiteSetting.allow_user_locale = true + user.update!(locale: 'it') + srand(1) + end + + it 'should create the right reply' do + post.update!(raw: '@discobot quote') + described_class.new(:reply, user, post_id: post.id).select + key = "discourse_narrative_bot.quote.6" + + expect(Post.last.raw).to eq(I18n.t('discourse_narrative_bot.quote.results', + quote: I18n.t("#{key}.quote"), author: I18n.t("#{key}.author") + )) + end + end + end + + describe 'when magic 8 ball is requested' do + before do + srand(1) + end + + it 'should create the right reply' do + post.update!(raw: '@discobot fortune') + described_class.new(:reply, user, post_id: post.id).select + + expect(Post.last.raw).to eq(I18n.t('discourse_narrative_bot.magic_8_ball.result', + result: I18n.t("discourse_narrative_bot.magic_8_ball.answers.6") + )) + end + end + + describe 'when bot is asked to reset/start a track' do + describe 'when user likes a post containing a reset trigger' do + it 'should not start the track' do + another_post = Fabricate(:post, + user: Fabricate(:user), + topic: topic, + raw: "@discobot start new user" + ) + + user + + expect do + PostAction.act(user, another_post, PostActionType.types[:like]) + end.to_not change { Post.count } + end + end + end + end + end + + context 'pm to self' do + let(:other_topic) do + topic_allowed_user = Fabricate.build(:topic_allowed_user, user: user) + Fabricate(:private_message_topic, topic_allowed_users: [topic_allowed_user]) + end + + let(:other_post) { Fabricate(:post, topic: other_topic) } + + describe 'when a new message is made' do + it 'should not do anything' do + other_post + + expect { described_class.new(:reply, user, post_id: other_post.id).select } + .to_not change { Post.count } + end + end + end + + context 'pms to bot' do + let(:other_topic) do + topic_allowed_user = Fabricate.build(:topic_allowed_user, user: user) + bot = Fabricate.build(:topic_allowed_user, user: discobot_user) + Fabricate(:private_message_topic, topic_allowed_users: [topic_allowed_user, bot]) + end + + let(:other_post) { Fabricate(:post, topic: other_topic) } + + describe 'when a new like is made' do + it 'should not do anything' do + other_post + expect { described_class.new(:like, user, post_id: other_post.id).select } + .to_not change { Post.count } + end + end + + describe 'when a new message is made' do + it 'should create the right reply' do + described_class.new(:reply, user, post_id: other_post.id).select + + expect(Post.last.raw).to eq(random_mention_reply) + end + end + + describe 'when user thanks the bot' do + it 'should like the post' do + other_post.update!(raw: 'thanks!') + + expect { described_class.new(:reply, user, post_id: other_post.id).select } + .to change { PostAction.count }.by(1) + + post_action = PostAction.last + + expect(post_action.post).to eq(other_post) + expect(post_action.post_action_type_id).to eq(PostActionType.types[:like]) + end + end + end + end +end diff --git a/plugins/discourse-narrative-bot/spec/integration/discobot_certificate_spec.rb b/plugins/discourse-narrative-bot/spec/integration/discobot_certificate_spec.rb new file mode 100644 index 0000000000..394be887e6 --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/integration/discobot_certificate_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +describe "Discobot Certificate" do + let(:user) { Fabricate(:user, name: 'Jeff Atwood') } + + describe 'when viewing the certificate' do + it 'should return the right text' do + params = { + date: Time.zone.now.strftime("%b %d %Y"), + user_id: user.id + } + + stub_request(:get, /letter_avatar_proxy/).to_return(status: 200) + + stub_request(:get, "http://test.localhost//images/d-logo-sketch-small.png") + .to_return(status: 200) + + xhr :get, '/discobot/certificate.svg', params + + expect(response.status).to eq(200) + end + + describe 'when params are missing' do + it "should raise the right errors" do + params = { + date: Time.zone.now.strftime("%b %d %Y"), + user_id: user.id + } + + params.each do |key, _| + expect { xhr :get, '/discobot/certificate.svg', params.except(key) } + .to raise_error(Discourse::InvalidParameters) + end + end + end + + describe 'when date is invalid' do + it 'should raise the right error' do + expect do + xhr :get, '/discobot/certificate.svg', + name: user.name, + date: "", + avatar_url: 'https://somesite.com/someavatar', + user_id: user.id + end.to raise_error(ArgumentError, 'invalid date') + end + end + end +end diff --git a/plugins/discourse-narrative-bot/spec/jobs/onceoff/grant_badges_spec.rb b/plugins/discourse-narrative-bot/spec/jobs/onceoff/grant_badges_spec.rb new file mode 100644 index 0000000000..355a51e6d8 --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/jobs/onceoff/grant_badges_spec.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +RSpec.describe Jobs::DiscourseNarrativeBot::GrantBadges do + let(:user) { Fabricate(:user) } + let(:other_user) { Fabricate(:user) } + + before do + DiscourseNarrativeBot::Store.set(user.id, completed: [ + DiscourseNarrativeBot::NewUserNarrative.to_s, + DiscourseNarrativeBot::AdvancedUserNarrative.to_s + ]) + end + + it 'should grant the right badges' do + described_class.new.execute_onceoff({}) + + expect(user.badges.count).to eq(2) + + expect(user.badges.map(&:name)).to eq([ + DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME, + DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME, + ]) + + expect(other_user.badges.count).to eq(0) + end +end diff --git a/plugins/discourse-narrative-bot/spec/jobs/send_default_welcome_message_spec.rb b/plugins/discourse-narrative-bot/spec/jobs/send_default_welcome_message_spec.rb new file mode 100644 index 0000000000..7f8a1c5c12 --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/jobs/send_default_welcome_message_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +RSpec.describe Jobs::SendDefaultWelcomeMessage do + let(:user) { Fabricate(:user) } + + it 'should send the right welcome message' do + described_class.new.execute(user_id: user.id) + + topic = Topic.last + + expect(topic.title).to eq(I18n.t( + "system_messages.welcome_user.subject_template", + site_name: SiteSetting.title + )) + + expect(topic.first_post.raw).to eq(I18n.t( + "system_messages.welcome_user.text_body_template", + SystemMessage.new(user).defaults + ).chomp) + + expect(topic.closed).to eq(true) + end + + describe 'for an invited user' do + let(:invite) { Fabricate(:invite, user: user, redeemed_at: Time.zone.now) } + + it 'should send the right welcome message' do + described_class.new.execute(user_id: invite.user_id) + + topic = Topic.last + + expect(topic.title).to eq(I18n.t( + "system_messages.welcome_invite.subject_template", + site_name: SiteSetting.title + )) + + expect(topic.first_post.raw).to eq(I18n.t( + "system_messages.welcome_invite.text_body_template", + SystemMessage.new(user).defaults + ).chomp) + + expect(topic.closed).to eq(true) + end + end +end diff --git a/plugins/discourse-narrative-bot/spec/user_spec.rb b/plugins/discourse-narrative-bot/spec/user_spec.rb new file mode 100644 index 0000000000..a18d48837c --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/user_spec.rb @@ -0,0 +1,132 @@ +require 'rails_helper' + +describe User do + let(:user) { Fabricate(:user) } + let(:profile_page_url) { "#{Discourse.base_url}/users/#{user.username}" } + + before do + SiteSetting.discourse_narrative_bot_enabled = true + end + + describe 'when a user is created' do + it 'should initiate the bot' do + user + + expected_raw = I18n.t('discourse_narrative_bot.new_user_narrative.hello.message', + username: user.username, title: SiteSetting.title + ) + + expect(Post.last.raw).to include(expected_raw.chomp) + end + + describe 'welcome post' do + context 'disabled' do + before do + SiteSetting.disable_discourse_narrative_bot_welcome_post = true + end + + it 'should not initiate the bot' do + expect { user }.to_not change { Post.count } + end + end + + describe 'enabled' do + before do + SiteSetting.disable_discourse_narrative_bot_welcome_post = false + end + + it 'initiate the bot' do + expect { user }.to change { Topic.count }.by(1) + + expect(Topic.last.title).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.hello.title' + )) + end + + describe "when send welcome message is selected" do + before do + SiteSetting.discourse_narrative_bot_welcome_post_type = 'welcome_message' + end + + it 'should send the right welcome message' do + expect { user }.to change { Topic.count }.by(1) + + expect(Topic.last.title).to eq(I18n.t( + "system_messages.welcome_user.subject_template", + site_name: SiteSetting.title + )) + end + end + + describe 'when welcome message is delayed' do + before do + SiteSetting.discourse_narrative_bot_welcome_post_delay = 100 + SiteSetting.queue_jobs = true + end + + it 'should delay the initialization of the new user track' do + Timecop.freeze do + user + + expect(Jobs::NarrativeInit.jobs.first['at']) + .to be_within(1.second).of(Time.zone.now.to_f + 100) + end + end + + it 'should delay sending the welcome message' do + SiteSetting.discourse_narrative_bot_welcome_post_type = 'welcome_message' + + Timecop.freeze do + user + + expect(Jobs::SendDefaultWelcomeMessage.jobs.first['at']) + .to be_within(1.second).of(Time.zone.now.to_f + 100) + end + end + end + end + end + + context 'when user is staged' do + let(:user) { Fabricate(:user, staged: true) } + + it 'should not initiate the bot' do + expect { user }.to_not change { Post.count } + end + end + + context 'when user is anonymous?' do + let(:anonymous_user) { Fabricate(:anonymous) } + + it 'should not initiate the bot' do + SiteSetting.allow_anonymous_posting = true + + expect { anonymous_user }.to_not change { Post.count } + end + end + + context "when user's username should be ignored" do + let(:user) { Fabricate.build(:user) } + + before do + SiteSetting.discourse_narrative_bot_ignored_usernames = 'discourse|test' + end + + ['discourse', 'test'].each do |username| + it 'should not initiate the bot' do + expect { user.update!(username: username) }.to_not change { Post.count } + end + end + end + end + + describe 'when a user has been destroyed' do + it "should clean up plugin's store" do + DiscourseNarrativeBot::Store.set(user.id, 'test') + + user.destroy! + + expect(DiscourseNarrativeBot::Store.get(user.id)).to eq(nil) + end + end +end diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.zn_CN.yml b/plugins/discourse-nginx-performance-report/config/locales/server.zn_CN.yml index 485126f6ca..dd6ab31956 100644 --- a/plugins/discourse-nginx-performance-report/config/locales/server.zn_CN.yml +++ b/plugins/discourse-nginx-performance-report/config/locales/server.zn_CN.yml @@ -1,3 +1,3 @@ -zh_CN: +zn_CN: site_settings: daily_performance_report: "每日分析 NGINX 日志并且发布详情主题到管理人员才能看到的主题" diff --git a/plugins/lazyYT/assets/stylesheets/lazyYT.css b/plugins/lazyYT/assets/stylesheets/lazyYT.css index 732735138a..38d6bbff69 100644 --- a/plugins/lazyYT/assets/stylesheets/lazyYT.css +++ b/plugins/lazyYT/assets/stylesheets/lazyYT.css @@ -13,6 +13,7 @@ padding: 0 0 56.25% 0; overflow: hidden; background-color: #000000; + margin-bottom: 12px; } .lazyYT-container iframe { diff --git a/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 b/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 index 391a5d76ac..6458c65028 100644 --- a/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 +++ b/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 @@ -2,6 +2,7 @@ import { default as computed, observes } from 'ember-addons/ember-computed-decor import InputValidation from 'discourse/models/input-validation'; export default Ember.Controller.extend({ + regularPollType: 'regular', numberPollType: 'number', multiplePollType: 'multiple', @@ -10,16 +11,22 @@ export default Ember.Controller.extend({ this._setupPoll(); }, - @computed("numberPollType", "multiplePollType") - pollTypes(numberPollType, multiplePollType) { + @computed("regularPollType", "numberPollType", "multiplePollType") + pollTypes(regularPollType, numberPollType, multiplePollType) { let types = []; + types.push({ name: I18n.t("poll.ui_builder.poll_type.regular"), value: regularPollType }); types.push({ name: I18n.t("poll.ui_builder.poll_type.number"), value: numberPollType }); types.push({ name: I18n.t("poll.ui_builder.poll_type.multiple"), value: multiplePollType }); return types; }, + @computed("pollType", "regularPollType") + isRegular(pollType, regularPollType) { + return pollType === regularPollType; + }, + @computed("pollType", "pollOptionsCount", "multiplePollType") isMultiple(pollType, count, multiplePollType) { return (pollType === multiplePollType) && count > 0; @@ -30,9 +37,9 @@ export default Ember.Controller.extend({ return pollType === numberPollType; }, - @computed("isNumber", "isMultiple") - showMinMax(isNumber, isMultiple) { - return isNumber || isMultiple; + @computed("isRegular") + showMinMax(isRegular) { + return !isRegular; }, @computed("pollOptions") @@ -61,9 +68,9 @@ export default Ember.Controller.extend({ } }, - @computed("isMultiple", "isNumber", "pollOptionsCount") - pollMinOptions(isMultiple, isNumber, count) { - if (!isMultiple && !isNumber) return; + @computed("isRegular", "isMultiple", "isNumber", "pollOptionsCount") + pollMinOptions(isRegular, isMultiple, isNumber, count) { + if (isRegular) return; if (isMultiple) { return this._comboboxOptions(1, count + 1); @@ -72,9 +79,9 @@ export default Ember.Controller.extend({ } }, - @computed("isMultiple", "isNumber", "pollOptionsCount", "pollMin", "pollStep") - pollMaxOptions(isMultiple, isNumber, count, pollMin, pollStep) { - if (!isMultiple && !isNumber) return; + @computed("isRegular", "isMultiple", "isNumber", "pollOptionsCount", "pollMin", "pollStep") + pollMaxOptions(isRegular, isMultiple, isNumber, count, pollMin, pollStep) { + if (isRegular) return; const pollMinInt = parseInt(pollMin) || 1; if (isMultiple) { @@ -120,9 +127,20 @@ export default Ember.Controller.extend({ return output; }, - @computed("pollOptionsCount", "isNumber") - disableInsert(count, isNumber) { - return isNumber ? false : (count < 2); + @computed("pollOptionsCount", "isRegular", "isMultiple", "isNumber", "pollMin", "pollMax") + disableInsert(count, isRegular, isMultiple, isNumber, pollMin, pollMax) { + return (isRegular && count < 2) || (isMultiple && count < pollMin && pollMin >= pollMax) || (isNumber ? false : (count < 2)); + }, + + @computed("pollMin", "pollMax") + minMaxValueValidation(pollMin, pollMax) { + let options = { ok: true }; + + if (pollMin >= pollMax) { + options = { failed: true, reason: I18n.t("poll.ui_builder.help.invalid_values") }; + } + + return InputValidation.create(options); }, @computed("disableInsert") diff --git a/plugins/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs b/plugins/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs index a3e7566f58..81a25f4b04 100644 --- a/plugins/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs +++ b/plugins/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs @@ -4,35 +4,35 @@ {{combo-box content=pollTypes value=pollType - valueAttribute="value" - none="poll.ui_builder.poll_type.regular"}} + valueAttribute="value"}}
    {{#if showMinMax}}
    - {{combo-box content=pollMinOptions - value=pollMin - valueAttribute="value" - class="poll-options-min"}} + {{input type='number' + value=pollMin + valueAttribute="value" + class="poll-options-min"}} + {{input-tip validation=minMaxValueValidation}}
    - {{combo-box content=pollMaxOptions - value=pollMax - valueAttribute="value" - class="poll-options-max"}} + {{input type='number' + value=pollMax + valueAttribute="value" + class="poll-options-max"}}
    {{#if isNumber}}
    - {{combo-box content=pollStepOptions - value=pollStep - valueAttribute="value" - class="poll-options-step"}} + {{input type='number' + value=pollStep + valueAttribute="value" + class="poll-options-step"}}
    {{/if}} {{/if}} diff --git a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 index 09b8fdfa31..9fa2577af6 100644 --- a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 +++ b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 @@ -43,6 +43,7 @@ function initializePolls(api) { } }); this.set("pollsObject", this._polls); + _glued.forEach(g => g.queueRerender()); } } }); diff --git a/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 b/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 index e6d961a562..5bac8d63b8 100644 --- a/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 +++ b/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 @@ -11,6 +11,15 @@ function optionHtml(option) { return new RawHtml({ html: `${option.html}` }); } +function fetchVoters(payload) { + return ajax("/polls/voters.json", { + type: "get", + data: payload + }).catch(() => { + bootbox.alert(I18n.t('poll.error_while_fetching_voters')); + }); +} + createWidget('discourse-poll-option', { tagName: 'li', @@ -71,8 +80,7 @@ createWidget('discourse-poll-voters', { return { loaded: 'new', pollVoters: [], - offset: 0, - canLoadMore: false + offset: 1, }; }, @@ -80,37 +88,32 @@ createWidget('discourse-poll-voters', { const { attrs, state } = this; if (state.loaded === 'loading') { return; } - const { voterIds } = attrs; - - if (!voterIds.length) { return; } - - const windowSize = Math.round(($('.poll-container:eq(0)').width() / 25) * 2); - const index = state.offset * windowSize; - const ids = voterIds.slice(index, index + windowSize); - state.loaded = 'loading'; - return ajax("/polls/voters.json", { - type: "get", - data: { user_ids: ids } + + return fetchVoters({ + post_id: attrs.postId, + poll_name: attrs.pollName, + option_id: attrs.optionId, + offset: state.offset }).then(result => { state.loaded = 'loaded'; - state.pollVoters = state.pollVoters.concat(result.users); - state.canLoadMore = state.pollVoters.length < attrs.totalVotes; + state.offset += 1; + + const pollResult = result[attrs.pollName]; + const newVoters = attrs.pollType === 'number' ? pollResult : pollResult[attrs.optionId]; + state.pollVoters = state.pollVoters.concat(newVoters); + this.scheduleRerender(); - }).catch(() => { - bootbox.alert(I18n.t('poll.error_while_fetching_voters')); }); }, loadMore() { - this.state.offset += 1; return this.fetchVoters(); }, html(attrs, state) { - if (state.loaded === 'new') { - this.fetchVoters(); - return; + if (attrs.pollVoters && state.loaded === 'new') { + state.pollVoters = attrs.pollVoters; } const contents = state.pollVoters.map(user => { @@ -120,7 +123,7 @@ createWidget('discourse-poll-voters', { }), ' ']); }); - if (state.canLoadMore) { + if (state.pollVoters.length < attrs.totalVotes) { contents.push(this.attach('discourse-poll-load-more', { id: attrs.id() })); } @@ -131,14 +134,38 @@ createWidget('discourse-poll-voters', { createWidget('discourse-poll-standard-results', { tagName: 'ul.results', + buildKey: attrs => `${attrs.id}-standard-results`, - html(attrs) { + defaultState() { + return { + loaded: 'new' + }; + }, + + fetchVoters() { + const { attrs, state } = this; + + if (state.loaded === 'new') { + fetchVoters({ + post_id: attrs.post.id, + poll_name: attrs.poll.get('name') + }).then(result => { + state.voters = result[attrs.poll.get('name')]; + state.loaded = 'loaded'; + this.scheduleRerender(); + }); + } + }, + + html(attrs, state) { const { poll } = attrs; const options = poll.get('options'); - if (options) { + if (options) { const voters = poll.get('voters'); - const ordered = options.sort((a, b) => { + const isPublic = poll.get('public'); + + const ordered = _.clone(options).sort((a, b) => { if (a.votes < b.votes) { return 1; } else if (a.votes === b.votes) { @@ -158,10 +185,13 @@ createWidget('discourse-poll-standard-results', { const rounded = attrs.isMultiple ? percentages.map(Math.floor) : evenRound(percentages); + if (isPublic) this.fetchVoters(); + return ordered.map((option, idx) => { const contents = []; - const per = rounded[idx].toString(); + const chosen = (attrs.vote || []).includes(option.id); + contents.push(h('div.option', h('p', [ h('span.percentage', `${per}%`), optionHtml(option) ]) )); @@ -170,23 +200,51 @@ createWidget('discourse-poll-standard-results', { h('div.bar', { attributes: { style: `width:${per}%` }}) )); - if (poll.get('public')) { + if (isPublic) { contents.push(this.attach('discourse-poll-voters', { id: () => `poll-voters-${option.id}`, + postId: attrs.post.id, + optionId: option.id, + pollName: poll.get('name'), totalVotes: option.votes, - voterIds: option.voter_ids + pollVoters: (state.voters && state.voters[option.id]) || [] })); } - return h('li', contents); + return h('li', { className: `${chosen ? 'chosen' : ''}` }, contents); }); } } }); createWidget('discourse-poll-number-results', { - html(attrs) { + buildKey: attrs => `${attrs.id}-number-results`, + + defaultState() { + return { + loaded: 'new' + }; + }, + + fetchVoters() { + const { attrs, state } = this; + + if (state.loaded === 'new') { + + fetchVoters({ + post_id: attrs.post.id, + poll_name: attrs.poll.get('name') + }).then(result => { + state.voters = result[attrs.poll.get('name')]; + state.loaded = 'loaded'; + this.scheduleRerender(); + }); + } + }, + + html(attrs, state) { const { poll } = attrs; + const isPublic = poll.get('public'); const totalScore = poll.get('options').reduce((total, o) => { return total + parseInt(o.html, 10) * parseInt(o.votes, 10); @@ -198,12 +256,16 @@ createWidget('discourse-poll-number-results', { const results = [h('div.poll-results-number-rating', new RawHtml({ html: `${averageRating}` }))]; - if (poll.get('public')) { - const options = poll.get('options'); + if (isPublic) { + this.fetchVoters(); + results.push(this.attach('discourse-poll-voters', { id: () => `poll-voters-${poll.get('name')}`, totalVotes: poll.get('voters'), - voterIds: [].concat(...(options.map(option => option.voter_ids))) + pollVoters: state.voters || [], + postId: attrs.post.id, + pollName: poll.get('name'), + pollType: poll.get('type') })); } @@ -426,7 +488,6 @@ export default createWidget('discourse-poll', { }, toggleStatus() { - const { state, attrs } = this; const { poll } = attrs; const isClosed = poll.get('status') === 'closed'; diff --git a/plugins/poll/assets/stylesheets/common/poll.scss b/plugins/poll/assets/stylesheets/common/poll.scss index 762ce7638f..d52c6e1429 100644 --- a/plugins/poll/assets/stylesheets/common/poll.scss +++ b/plugins/poll/assets/stylesheets/common/poll.scss @@ -77,7 +77,7 @@ div.poll { display: inline; } - margin-top: 10px; + margin-top: 4px; } .poll-voters-toggle-expand { @@ -107,7 +107,11 @@ div.poll { .bar { height: 10px; - background: $primary; + background: dark-light-diff($primary, $secondary, 50%, -25%);; + } + + .chosen .bar { + background: $tertiary; } } diff --git a/plugins/poll/assets/stylesheets/mobile/poll.scss b/plugins/poll/assets/stylesheets/mobile/poll.scss index 1dc4c95caa..1c35f64bb3 100644 --- a/plugins/poll/assets/stylesheets/mobile/poll.scss +++ b/plugins/poll/assets/stylesheets/mobile/poll.scss @@ -6,4 +6,8 @@ div.poll { margin: 4px 2px; } } + + .poll-info { + .info-text:before {content:"\00a0";} // nbsp + } } diff --git a/plugins/poll/config/locales/client.bs_BA.yml b/plugins/poll/config/locales/client.bs_BA.yml index 48f762a3cd..f300bc34eb 100644 --- a/plugins/poll/config/locales/client.bs_BA.yml +++ b/plugins/poll/config/locales/client.bs_BA.yml @@ -46,5 +46,29 @@ bs_BA: open: title: "Otvori anketu" label: "Otvori" + confirm: "Da li ste sigurni da želite da otvorite ovu anketu?" close: + title: "Zatvori anketu" label: "Zatvori" + confirm: "Da li ste sigurni da želite da zatvorite ovu anketu?" + error_while_toggling_status: "Izvinjavamo se, pojavio se problem u prebacivanju statutusa ove ankete" + error_while_casting_votes: "Izvinjavamo se, pojavila se greška prikazivanja vaših glasova" + error_while_fetching_voters: "Izvinjavamo se, pojavila se greška pri prikazivanju glasača" + ui_builder: + title: Izgradi anketu + insert: Umetni anketu + help: + options_count: Unesite bar 2. opcije + poll_type: + label: Tip + regular: Jedan izbor + multiple: Višestruki izbor + number: Rejting broja + poll_config: + max: Maksimalno + min: Minimalno + step: Korak + poll_public: + label: Pokaži ko je glasao + poll_options: + label: Upišite jednu opciju ankete po liniji diff --git a/plugins/poll/config/locales/client.da.yml b/plugins/poll/config/locales/client.da.yml index 48183b6883..1576928ec8 100644 --- a/plugins/poll/config/locales/client.da.yml +++ b/plugins/poll/config/locales/client.da.yml @@ -54,6 +54,7 @@ da: insert: Indsæt afstemning help: options_count: Vælg mindst 2 muligheder + invalid_values: Minimumsværdien skal være mindre end maksimumværdien. poll_type: label: Type regular: Enkelt valg @@ -66,4 +67,4 @@ da: poll_public: label: Vis hvem der stemte poll_options: - label: Indtast en afstemning pr. linje + label: Indtast en valgmulighed per linje diff --git a/plugins/poll/config/locales/client.de.yml b/plugins/poll/config/locales/client.de.yml index 00a1d5ce62..744a66fa8f 100644 --- a/plugins/poll/config/locales/client.de.yml +++ b/plugins/poll/config/locales/client.de.yml @@ -23,12 +23,12 @@ de: one: "Du musst mindestens 1 Option auswählen." other: "Du musst mindestens %{count} Optionen auswählen." up_to_max_options: - one: "Du kannst bis zu eine Option auswählen." + one: "Du kannst bis zu 1 Option auswählen." other: "Du kannst bis zu %{count} Optionen auswählen." x_options: - one: "Bitte wähle 1 Option" - other: "Bitte wähle %{count} Optionen" - between_min_and_max_options: "Bitte wähle zwischen %{min} und %{max} Optionen" + one: "Bitte wähle 1 Option." + other: "Bitte wähle %{count} Optionen." + between_min_and_max_options: "Bitte wähle zwischen %{min} und %{max} Optionen." cast-votes: title: "Gib deine Stimmen ab" label: "Jetzt abstimmen!" @@ -46,14 +46,15 @@ de: title: "Umfrage beenden" label: "Beenden" confirm: "Möchtest du diese Umfrage wirklich beenden?" - error_while_toggling_status: "Entschuldige, es gab einen Fehler beim Wechseln des Status dieser Umfrage." - error_while_casting_votes: "Entschuldige, es gab einen Fehler beim Abgeben deiner Stimme." - error_while_fetching_voters: "Entschuldige, es gab einen Fehler beim Anzeigen der Teilnehmer." + error_while_toggling_status: "Entschuldige, beim Wechseln des Status dieser Umfrage ist ein Fehler aufgetreten." + error_while_casting_votes: "Entschuldige, beim Abgeben deiner Stimme ist ein Fehler aufgetreten." + error_while_fetching_voters: "Entschuldige, beim Anzeigen der Teilnehmer ist ein Fehler aufgetreten." ui_builder: title: Umfrage erstellen insert: Umfrage einfügen help: options_count: Gib mindestens 2 Optionen ein + invalid_values: Der Minimalwert muss kleiner sein als der Maximalwert. poll_type: label: Art regular: Einfachauswahl @@ -64,6 +65,6 @@ de: min: min. step: Schrittweite poll_public: - label: Anzeigen wer abgestimmt hat + label: Anzeigen, wer abgestimmt hat poll_options: label: Bitte gib eine Umfrageoption pro Zeile ein diff --git a/plugins/poll/config/locales/client.el.yml b/plugins/poll/config/locales/client.el.yml new file mode 100644 index 0000000000..a76d40e090 --- /dev/null +++ b/plugins/poll/config/locales/client.el.yml @@ -0,0 +1,69 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +el: + js: + poll: + voters: + one: "ψηφοφόρος" + other: "ψηφοφόροι" + total_votes: + one: "συνολική ψήφος" + other: "συνολικές ψήφοι" + average_rating: "Μέση βαθμολογία: %{average}." + public: + title: "Οι ψηφοφορίες είναι δημόσιες " + multiple: + help: + at_least_min_options: + one: "Επιλέξτε τουλάχιστον 1 επιλογή" + other: "Επιλέξτε τουλάχιστον %{count} επιλογές" + up_to_max_options: + one: "Επιλέξτε μέχρι 1 επιλογή" + other: "Επιλέξτε μέχρι %{count} επιλογές" + x_options: + one: "Επιλέξτε 1 επιλογή" + other: "Επιλέξτε %{count} επιλογές" + between_min_and_max_options: "Επιλέξτε ανάμεσα σε %{min} και %{max} επιλογές" + cast-votes: + title: "Δώσε τις ψήφους σου" + label: "Ψήφισε τώρα!" + show-results: + title: "Εμφάνισε τα αποτελέσματα της ψηφοφορίας" + label: "Εμφάνισε τα αποτελέσματα" + hide-results: + title: "Πίσω στην ψηφοφορία" + label: "Κρύψε τα αποτελέσματα" + open: + title: "Να ξεκινήσει η ψηφοφορία" + label: "Ξεκίνημα" + confirm: "Σίγουρα θες να ξεκινήσεις αυτή την ψηφοφορία;" + close: + title: "Να κλείσει η ψηφοφορία" + label: "Κλείσιμο" + confirm: "Σίγουρα θες να κλείσεις αυτή την ψηφοφορία;" + error_while_toggling_status: "Λυπούμαστε, παρουσιάστηκε ένα σφάλμα σχετικά με την εναλλαγή της κατάστασης αυτής της δημοσκόπησης." + error_while_casting_votes: "Λυπούμαστε, παρουσιάστηκε ένα σφάλμα με τις ψήφους σας." + error_while_fetching_voters: "Συγνώμη, παρουσιάστηκε ένα σφάλμα κατά την διαδικασία εμφάνισης των ψηφοφόρων." + ui_builder: + title: Δημιουργία Ψηφοφορίας + insert: Εισαγωγή Ψηφοφορίας + help: + options_count: Εισάγετε τουλάχιστόν 2 επιλογές + poll_type: + label: Τύπος + regular: Μία επιλογή + multiple: Πολλαπλές επιλογές + number: Αξιολόγηση αριθμoύ + poll_config: + max: Μέγιστο + min: Ελάχιστο + step: Βήμα + poll_public: + label: Δείξε ποιοι ψηφίσαν. + poll_options: + label: Εισάγετε μία επιλογή ψηφοφορίας ανα γραμμή diff --git a/plugins/poll/config/locales/client.en.yml b/plugins/poll/config/locales/client.en.yml index d70520a375..f2d8cb01d2 100644 --- a/plugins/poll/config/locales/client.en.yml +++ b/plugins/poll/config/locales/client.en.yml @@ -74,6 +74,7 @@ en: insert: Insert Poll help: options_count: Enter at least 2 options + invalid_values: Minimum value must be smaller than the maximum value. poll_type: label: Type regular: Single Choice diff --git a/plugins/poll/config/locales/client.es.yml b/plugins/poll/config/locales/client.es.yml index d31ecc9903..fa46fb0aa5 100644 --- a/plugins/poll/config/locales/client.es.yml +++ b/plugins/poll/config/locales/client.es.yml @@ -20,14 +20,14 @@ es: multiple: help: at_least_min_options: - one: "Elige al menos 1 opción" - other: "Elige al menos %{count} opciones" + one: "Escoge al menos 1 opción" + other: "Escoge al menos %{count} opciones" up_to_max_options: - one: "Elige 1 opción" - other: "Elige hasta %{count} opciones" + one: "Escoge 1 opción" + other: "Escoge hasta %{count} opciones" x_options: - one: "Elige 1 opción" - other: "Elige %{count} opciones" + one: "Escoge 1 opción" + other: "Escoge %{count} opciones" between_min_and_max_options: "Elige entre %{min} y %{max} opciones" cast-votes: title: "Votar" @@ -39,21 +39,22 @@ es: title: "Volver a los votos" label: "Ocultar resultados" open: - title: "Abrir encuesta" + title: "Abrir la encuesta" label: "Abrir" confirm: "¿Seguro que quieres abrir esta encuesta?" close: title: "Cerrar la encuesta" label: "Cerrar" confirm: "¿Seguro que quieres cerrar esta encuesta?" - error_while_toggling_status: "Lo sentimos, hubo un error al cambiar el estado de esta encuesta." - error_while_casting_votes: "Lo sentimos, hubo un error al registrar tus votos." - error_while_fetching_voters: "Lo sentimos, hubo un error al mostrar los votantes." + error_while_toggling_status: "Lo sentimos, ha ocurrido un error al cambiar el estado de esta encuesta." + error_while_casting_votes: "Lo sentimos, ha ocurrido un error al registrar tus votos." + error_while_fetching_voters: "Lo sentimos, ha ocurrido un error al mostrar los votantes." ui_builder: - title: Crear Encuesta - insert: Insertar Encuesta + title: Crear encuesta + insert: Insertar encuesta help: - options_count: Introducir al menos 2 opciones + options_count: Introduce al menos 2 opciones + invalid_values: El valor mínimo debe ser menor que el valor máximo. poll_type: label: Tipo regular: Una opción @@ -66,4 +67,4 @@ es: poll_public: label: Mostrar quién votó poll_options: - label: Introducir una opción de la encuesta por línea + label: Introduce una opción de la encuesta por línea diff --git a/plugins/poll/config/locales/client.fa_IR.yml b/plugins/poll/config/locales/client.fa_IR.yml index 064c381502..a3888c8d13 100644 --- a/plugins/poll/config/locales/client.fa_IR.yml +++ b/plugins/poll/config/locales/client.fa_IR.yml @@ -15,6 +15,15 @@ fa_IR: average_rating: "میانگین امتیاز: %{average}." public: title: "آرا عمومی هستند" + multiple: + help: + at_least_min_options: + other: "حداقل %{count} گزینه انتخاب کنید." + up_to_max_options: + other: "حداکثر %{count} گزینه انتخاب کنید" + x_options: + other: "%{count} گزینه انتخاب کنید" + between_min_and_max_options: "بین %{min} تا %{max} گزینه انتخاب کنید" cast-votes: title: "انداختن رأی شما" label: "رای بدهید!" @@ -30,17 +39,27 @@ fa_IR: confirm: "آیا از باز کردن این نظرسنجی اطمینان دارید ؟ " close: title: "نظرسنجی را ببند" - label: "بسته " + label: "بستن" confirm: "آیا از بستن این نظرسنجی اطمینان دارید ؟ " + error_while_toggling_status: "متاسفانه در تغییر وضعیت این نظرسنجی، خطایی رخ داده است." + error_while_casting_votes: "متاسفانه در ثبت رای شما خطایی رخ داده است." + error_while_fetching_voters: "متاسفانه در نمایش رای دهندگان خطایی رخ داده است." ui_builder: title: ساخت نظرسنجی + insert: درج نظر سنجی + help: + options_count: حداقل 2 گزینه وارد کنید + invalid_values: مقدار حداقل می بایست کمتر از مقدار حداکثر باشد. poll_type: label: نوع - regular: یک انتخاب - multiple: انتخاب چندگانه + regular: یک انتخابی + multiple: چند انتخابی + number: امتیاز عددی poll_config: max: بیشترین min: کمترین step: مرحله poll_public: - label: نشان دادن رای دهندگان + label: نمایش رای دهندگان + poll_options: + label: در هر خط یک گزینه نظرسنجی را وارد کنید diff --git a/plugins/poll/config/locales/client.fi.yml b/plugins/poll/config/locales/client.fi.yml index f21a64f1a9..6d3159eda8 100644 --- a/plugins/poll/config/locales/client.fi.yml +++ b/plugins/poll/config/locales/client.fi.yml @@ -54,6 +54,7 @@ fi: insert: Lisää äänestys help: options_count: Valitse vähintään 2 vaihtoehtoa + invalid_values: Vähimmäisarvon täytyy olla enimmäisarvoa pienempi. poll_type: label: Tyyppi regular: Valitaan yksi diff --git a/plugins/poll/config/locales/client.fr.yml b/plugins/poll/config/locales/client.fr.yml index 32dc6ec804..dfad51e304 100644 --- a/plugins/poll/config/locales/client.fr.yml +++ b/plugins/poll/config/locales/client.fr.yml @@ -54,6 +54,7 @@ fr: insert: Insérer le sondage help: options_count: Entrez au moins 2 options + invalid_values: La valeur minimum doit être plus petite que la valeur maximum. poll_type: label: Type regular: Choix unique diff --git a/plugins/poll/config/locales/client.he.yml b/plugins/poll/config/locales/client.he.yml index 026fa9f55c..452e3b3ed3 100644 --- a/plugins/poll/config/locales/client.he.yml +++ b/plugins/poll/config/locales/client.he.yml @@ -54,6 +54,7 @@ he: insert: הכנסת סקר help: options_count: הכניסו לפחות 2 אפשרויות + invalid_values: ערך מינימלי חייב להיות קטן מערך מקסימלי. poll_type: label: סוג regular: בחירה בודדת diff --git a/plugins/poll/config/locales/client.id.yml b/plugins/poll/config/locales/client.id.yml index 4aa3990d1e..3c8ab5af7c 100644 --- a/plugins/poll/config/locales/client.id.yml +++ b/plugins/poll/config/locales/client.id.yml @@ -13,6 +13,17 @@ id: total_votes: other: "jumlah suara" average_rating: "Rata-rata rating: %{average}." + public: + title: "Polling ini publik" + multiple: + help: + at_least_min_options: + other: "Pilih setidaknya %{count} pilihan" + up_to_max_options: + other: "Pilih sampai pilihan" + x_options: + other: "Pilih %{count} pilihan" + between_min_and_max_options: "Pilih antara %{min} dan %{max} pilihan" cast-votes: title: "Gunakan suaramu" label: "Pilih sekarang!" @@ -30,3 +41,4 @@ id: title: "Tutup polling" label: "Tutup" confirm: "Apakah Anda yakin ingin menutup akun ini?" + error_while_toggling_status: "Maaf, telah terjadi kesalahan pengalihan status dari pemilihan ini." diff --git a/plugins/poll/config/locales/client.it.yml b/plugins/poll/config/locales/client.it.yml index 26c9513d18..34d953edbf 100644 --- a/plugins/poll/config/locales/client.it.yml +++ b/plugins/poll/config/locales/client.it.yml @@ -54,6 +54,7 @@ it: insert: Inserisci Sondaggio help: options_count: Inserisci almeno 2 opzioni + invalid_values: Il valore minimo deve essere minore del valore massimo. poll_type: label: Tipo regular: Scelta Singola diff --git a/plugins/poll/config/locales/client.ko.yml b/plugins/poll/config/locales/client.ko.yml index d2041eaea1..bad2adb3cb 100644 --- a/plugins/poll/config/locales/client.ko.yml +++ b/plugins/poll/config/locales/client.ko.yml @@ -13,6 +13,8 @@ ko: total_votes: other: "전체 투표" average_rating: "평균: %{average}." + public: + title: "투표는 공개됩니다." multiple: help: at_least_min_options: diff --git a/plugins/poll/config/locales/client.nb_NO.yml b/plugins/poll/config/locales/client.nb_NO.yml index c7b43bab0c..57987100a5 100644 --- a/plugins/poll/config/locales/client.nb_NO.yml +++ b/plugins/poll/config/locales/client.nb_NO.yml @@ -54,6 +54,7 @@ nb_NO: insert: 'Sett inn avstemning ' help: options_count: Må inneholde minst 2 alternativer + invalid_values: Minimumsverdien må være mindre enn maksimumsverdien. poll_type: label: Type regular: Et valg diff --git a/plugins/poll/config/locales/client.nl.yml b/plugins/poll/config/locales/client.nl.yml index 91024d5099..b35d6656cd 100644 --- a/plugins/poll/config/locales/client.nl.yml +++ b/plugins/poll/config/locales/client.nl.yml @@ -14,14 +14,14 @@ nl: total_votes: one: "totale stem" other: "totale stemmen" - average_rating: "Gemiddeld cijfer: %{average}." + average_rating: "Gemiddelde beoordeling: %{average}." public: title: "Stemmen zijn openbaar." multiple: help: at_least_min_options: - one: "Kies tenminste 1 optie" - other: "Kies tenminste %{count} opties" + one: "Kies minstens 1 optie" + other: "Kies minstens %{count} opties" up_to_max_options: one: "Kies maximaal 1 optie" other: "Kies maximaal %{count} opties" @@ -33,32 +33,33 @@ nl: title: "Breng je stemmen uit" label: "Stem nu!" show-results: - title: "Bekijk de resultaten van de poll" - label: "Bekijk resultaten" + title: "De pollresultaten weergeven" + label: "Resultaten tonen" hide-results: title: "Terug naar je stemmen" - label: "Verberg resultaten" + label: "Resultaten verbergen" open: - title: "Open de poll" - label: "Open" + title: "De poll openen" + label: "Openen" confirm: "Weet je zeker dat je deze poll wil openen?" close: - title: "Sluit de poll" - label: "Sluit" + title: "De poll sluiten" + label: "Sluiten" confirm: "Weet je zeker dat je deze poll wil sluiten?" - error_while_toggling_status: "Sorry, er is iets misgegaan bij het aanpassen van de status van deze poll." - error_while_casting_votes: "Sorry, er is iets misgegaan bij het uitbrengen van je stemmen." + error_while_toggling_status: "Sorry, er is een fout opgetreden bij het omschakelen van de status van deze poll." + error_while_casting_votes: "Sorry, er is een fout opgetreden bij uitbrengen van je stemmen." error_while_fetching_voters: "Sorry, er is iets misgegaan bij het weergeven van de stemmers." ui_builder: - title: Maak Poll - insert: Poll Invoegen + title: Poll aanmaken + insert: Poll invoegen help: options_count: Geef ten minste 2 opties + invalid_values: Minimale waarde moet kleiner zijn dan de maximale waarde. poll_type: label: Type - regular: Eén Keuze - multiple: Multiple Choice - number: Numerieke Beoordeling + regular: Eén keuze + multiple: Meerkeuze + number: Numerieke beoordeling poll_config: max: Max min: Min diff --git a/plugins/poll/config/locales/client.pl_PL.yml b/plugins/poll/config/locales/client.pl_PL.yml index f59ca7ea03..8bc698e026 100644 --- a/plugins/poll/config/locales/client.pl_PL.yml +++ b/plugins/poll/config/locales/client.pl_PL.yml @@ -11,10 +11,12 @@ pl_PL: voters: one: "głosujący" few: "głosujących" + many: "głosujących" other: "głosujących" total_votes: one: "oddanych głosów" few: "oddanych głosów" + many: "oddanych głosów" other: "oddanych głosów" average_rating: "Średnia ocena: %{average}." public: @@ -24,14 +26,17 @@ pl_PL: at_least_min_options: one: "Wybierz co najmniej 1 opcję" few: "Wybierz co najmniej %{count} opcji" + many: "Wybierz co najmniej %{count} opcji" other: "Wybierz co najmniej %{count} opcji" up_to_max_options: one: "Wybierz co najwyżej 1 opcję" few: "Wybierz co najwyżej %{count} opcji" + many: "Wybierz co najwyżej %{count} opcji" other: "Wybierz co najwyżej %{count} opcji" x_options: one: "Wybierz 1 opcję" few: "Wybierz %{count} opcji" + many: "Wybierz %{count} opcji" other: "Wybierz %{count} opcji" between_min_and_max_options: "Wybierz pomiędzy %{min} a %{max} opcjami" cast-votes: @@ -59,6 +64,7 @@ pl_PL: insert: Wstaw ankietę help: options_count: Wprowadź przynajmniej 2 opcje + invalid_values: Wartość minimalna musi być mniejsza niż maksymalna. poll_type: label: Typ regular: Pojedynczy wybór diff --git a/plugins/poll/config/locales/client.pt_BR.yml b/plugins/poll/config/locales/client.pt_BR.yml index 421c19a380..0a6fedd298 100644 --- a/plugins/poll/config/locales/client.pt_BR.yml +++ b/plugins/poll/config/locales/client.pt_BR.yml @@ -54,6 +54,7 @@ pt_BR: insert: Inserir resposta help: options_count: Enquetes devem ter no mínimo 2 opções + invalid_values: O menor valor valor deve ser menor que o valor máximo. poll_type: label: Tipo regular: Única escolha diff --git a/plugins/poll/config/locales/client.sk.yml b/plugins/poll/config/locales/client.sk.yml index eec84edce3..5424aff098 100644 --- a/plugins/poll/config/locales/client.sk.yml +++ b/plugins/poll/config/locales/client.sk.yml @@ -11,7 +11,7 @@ sk: voters: one: "volič" few: "voliči" - other: "voliči" + other: "účastníci" total_votes: one: "hlas celkom" few: "hlasy celkom" @@ -38,11 +38,11 @@ sk: title: "Hlasovať" label: "Hlasuj teraz!" show-results: - title: "Zobraiť výsledky hlasovania" + title: "Zobraziť výsledky hlasovania" label: "Zobraz výsledky" hide-results: title: "Návrat na odovzdané hlasy" - label: "Skyť výsledky" + label: "Skryť výsledky" open: title: "Zahájiť hlasovanie" label: "Zahájiť" @@ -53,12 +53,13 @@ sk: confirm: "Ste si istý, že chcete uzavrieť toto hlasovanie?" error_while_toggling_status: "Ľutujeme, nastala chyba pri prepínaní stavu tohto hlasovania." error_while_casting_votes: "Ľutujeme, nastala chyba pri prideľovaní Vašich hlasov." - error_while_fetching_voters: "Ľutujeme, nastala chyba pri zobrazovaní voličov." + error_while_fetching_voters: "Ľutujeme, nastala chyba pri zobrazení účastníkov." ui_builder: title: Vytvoriť hlasovanie insert: Vložiť hlasovanie help: options_count: Zadajte aspoň 2 možnosti + invalid_values: Minimálna hodnota musí byť menšia ako maximálna hodnota. poll_type: label: Typ regular: Jedna možnosť @@ -71,4 +72,4 @@ sk: poll_public: label: Ukázať kto hlasoval poll_options: - label: Zadajte jednu možnosť voľby na riadok + label: Zadajte každú možnosť voľby do nového riadka diff --git a/plugins/poll/config/locales/client.sv.yml b/plugins/poll/config/locales/client.sv.yml index 49e9f0e49e..f620cfdde7 100644 --- a/plugins/poll/config/locales/client.sv.yml +++ b/plugins/poll/config/locales/client.sv.yml @@ -54,6 +54,7 @@ sv: insert: Lägg till omröstning help: options_count: Ange minst 2 alternativ + invalid_values: Minimivärde måste vara mindre än det maximala värdet. poll_type: label: Typ regular: Ett val diff --git a/plugins/poll/config/locales/client.ur.yml b/plugins/poll/config/locales/client.ur.yml index 58bc9735bc..e0ae607df4 100644 --- a/plugins/poll/config/locales/client.ur.yml +++ b/plugins/poll/config/locales/client.ur.yml @@ -5,4 +5,65 @@ # To work with us on translations, join this project: # https://www.transifex.com/projects/p/discourse-org/ -ur: {} +ur: + js: + poll: + voters: + one: "1 ووٹر" + other: "ووٹرز" + total_votes: + one: "کُل ووٹ" + other: "کُل ووٹ" + average_rating: "اوسط ریٹینگ: %{اوسط}۔" + public: + title: "ووٹ پبلک ہیں۔" + multiple: + help: + at_least_min_options: + one: "کم از کم 1 آپشن کا انتخاب کریں" + other: "کم از کم %{شمار} آپشنس کا انتخاب کریں" + up_to_max_options: + one: "1 آپشن کا انتخاب کریں" + other: "%{شمار} تک آپشنس کا انتخاب کریں" + x_options: + one: "1 آپشن انتخاب کریں" + other: "%{شمار} آپشنس انتخاب کریں" + between_min_and_max_options: "%{کم از کم} سے %{زیادہ سے زیادہ} آپشنس کا انتخاب کریں" + cast-votes: + title: "اپنے ووٹ دیں۔" + label: "ابھی ووٹ دیں۔" + show-results: + title: "سروے کے نتائج ظاہر کریں" + label: "نتائج دکھائیں" + hide-results: + title: "وآپس اپنے ووٹس پر" + label: "نتائج چھپائیں" + open: + title: "پول کھولیں" + label: "کھولیں" + confirm: "کیا آپ واقعی یہ پول کھولنا چاہتے ہیں؟" + close: + title: "پول بند کریں" + label: "بند کریں" + confirm: "کیا آپ واقعی یہ پول بند کرنا چاہتے ہیں؟" + error_while_toggling_status: "معذرت، اِس پول کا سٹیٹس تبدیل کرتے وقت ایک تکنیکی خرابی کا سامنا کرنا پڑا۔" + error_while_casting_votes: "معذرت، آپ کا ووٹ ڈالتے وقت ایک تکنیکی خرابی کا سامنا کرنا پڑا" + error_while_fetching_voters: "معذرت، ووٹرز کو دکھاتے وقت ایک تکنیکی خرابی کا سامنا کرنا پڑا" + ui_builder: + title: پول شروع کریے + insert: پول شامل کریے + help: + options_count: کم از کم 2 آپشنس اینٹر کریں + poll_type: + label: قِسم + regular: اکیلا انتخاب + multiple: ایک سے زیادہ انتخاب + number: نمبر کی درجہ بندی + poll_config: + max: زیادہ سے زیادہ + min: کم از کم + step: قدم + poll_public: + label: دکھایں جنہوں نے ووٹ دیا + poll_options: + label: فی سطر پول کی ہر ایک آپشن درج کریں diff --git a/plugins/poll/config/locales/client.vi.yml b/plugins/poll/config/locales/client.vi.yml index 6e397ca3d8..9600865c4f 100644 --- a/plugins/poll/config/locales/client.vi.yml +++ b/plugins/poll/config/locales/client.vi.yml @@ -12,42 +12,54 @@ vi: other: "người bình chọn" total_votes: other: "tổng số bình chọn" - average_rating: "Đánh giá trung bình: %{average}." + average_rating: "Trung bình: %{average}." public: - title: "Bình chọn công khai." + title: "Thăm dò này là công khai." multiple: help: at_least_min_options: - other: "Chọn ít nhất %{count} tùy chọn" + other: "Chọn ít nhất %{count} lựa chọn" up_to_max_options: - other: "Chọn tối đa %{count} tùy chọn" + other: "Chọn tối đa %{count} lựa chọn" x_options: - other: "Chọn %{count} tùy chọn" + other: "Chọn %{count} lựa chọn" + between_min_and_max_options: "Chọn giữa %{min}%{max} lựa chọn." cast-votes: - title: "Bỏ phiếu của bạn" + title: "Thay đổi bình chọn của bạn" label: "Bình chọn ngay!" show-results: - title: "Hiển thị kết quả cuộc thăm dò" + title: "Hiển thị kết quả thăm dò" label: "Hiện kết quả" hide-results: title: "Trở lại bình chọn của bạn" label: "Ẩn kết quả" open: - title: "Mở bình chọn" + title: "Mở thăm dò" label: "Mở" - confirm: "Bạn có chắc bạn muốn mở bình chọn này?" + confirm: "Bạn có muốn mở thăm dò này?" close: - title: "Đóng bình chọn" - label: "Đóng lại" - confirm: "Bạn có chắc chắn muốn đóng bình chọn này?" + title: "Đóng thăm dò" + label: "Đóng" + confirm: "Bạn có muốn đóng thăm dò này ?" + error_while_toggling_status: "Đã có lỗi xảy ra khi chuyển trạng thái của thăm dò." + error_while_casting_votes: "Đã có lỗi xảy ra làm ảnh hưởng đến bình chọn của bạn." + error_while_fetching_voters: "Đã có lỗi xảy ra khi hiển thị những người tham gia bình chọn." ui_builder: + title: Tạo thăm dò + insert: Chèn thăm dò help: - options_count: Nhập vào ít nhất 2 tùy chọn + options_count: Nhập vào ít nhất 2 lựa chọn + invalid_values: Giá trị nhỏ nhất phải nhỏ hơn giá trị lớn nhất. poll_type: label: Loại + regular: Một lựa chọn + multiple: Nhiều lựa chọn + number: Xếp hạng poll_config: max: Tối đa min: Tối thiểu step: Bước poll_public: - label: Hiển thị ai đã bình chọn + label: Hiển thị người đã bình chọn + poll_options: + label: Nhập mỗi lựa chọn trong thăm dò vào một dòng diff --git a/plugins/poll/config/locales/client.zh_CN.yml b/plugins/poll/config/locales/client.zh_CN.yml index 995cd722df..28bf1e387f 100644 --- a/plugins/poll/config/locales/client.zh_CN.yml +++ b/plugins/poll/config/locales/client.zh_CN.yml @@ -49,6 +49,7 @@ zh_CN: insert: 插入投票 help: options_count: 输入至少 2 个选项 + invalid_values: 最小值必须小于最大值。 poll_type: label: 类型 regular: 单选 diff --git a/plugins/poll/config/locales/server.bs_BA.yml b/plugins/poll/config/locales/server.bs_BA.yml index 8bf826b822..37f7bcbf51 100644 --- a/plugins/poll/config/locales/server.bs_BA.yml +++ b/plugins/poll/config/locales/server.bs_BA.yml @@ -33,3 +33,13 @@ bs_BA: edit_window_expired: cannot_change_polls: "Možete dodati, ukloniti ili preimenovati ankete nakon prvih %{minutes} minuta." op_cannot_edit_options: "Ne možete dodati ili ukloniti opcije ankete ankon prvih %{minutes} minuta. Molimo kontaktirajte moderatora ako morate urediti opciju ankete." + staff_cannot_add_or_remove_options: "Ne možete dodati ili ukloniti opcije ankete nakon prvih %{minutes} minuta. Umjesto toga trebali bi prvo zatvoriti ovu temu i kreirati novu." + no_polls_associated_with_this_post: "Nijedna anketa nije povezana sa ovim postom." + no_poll_with_this_name: "Ne postoji anketa %{name} koja je povezana sa ovim postom." + post_is_deleted: "Nije moguće djelovati na obrisan post." + topic_must_be_open_to_vote: "Tema mora biti otvorena kako bi se moglo glasati." + poll_must_be_open_to_vote: "Anketa mora biti otvorena kako bi se moglo glasati." + topic_must_be_open_to_toggle_status: "Tema mora biti otvorena za izmjenu statusa." + only_staff_or_op_can_toggle_status: "Samo pripadnik moderatora/administratora ili autor posta može izmijeniti status ankete." + email: + link_to_poll: "Kliknite za prikaz ankete." diff --git a/plugins/poll/config/locales/server.da.yml b/plugins/poll/config/locales/server.da.yml index a644ab921e..64c27602d4 100644 --- a/plugins/poll/config/locales/server.da.yml +++ b/plugins/poll/config/locales/server.da.yml @@ -38,6 +38,6 @@ da: topic_must_be_open_to_vote: "Emnet skal være åbent for at kunne stemme." poll_must_be_open_to_vote: "Afstemning skal være åben for at kunne stemme." topic_must_be_open_to_toggle_status: "Emnet skal være åbent for at ændre status." - only_staff_or_op_can_toggle_status: "Kun personalet eller emnets opretter kan ændre status for en afstemning" + only_staff_or_op_can_toggle_status: "Kun medlemmer af hjælperteamet eller emnets opretter kan ændre status for en afstemning." email: link_to_poll: "Klik for at se afstemningen." diff --git a/plugins/poll/config/locales/server.el.yml b/plugins/poll/config/locales/server.el.yml new file mode 100644 index 0000000000..1d51d34891 --- /dev/null +++ b/plugins/poll/config/locales/server.el.yml @@ -0,0 +1,43 @@ +# encoding: utf-8 +# +# Never edit this file. It will be overwritten when translations are pulled from Transifex. +# +# To work with us on translations, join this project: +# https://www.transifex.com/projects/p/discourse-org/ + +el: + site_settings: + poll_enabled: "Να επιτρέπεται σε χρήστες να δημιουργούν ψηφοφορίες;" + poll_maximum_options: "Μέγιστος αριθμός επιλογών σε μια ψηφοφορία." + poll_edit_window_mins: "Αριθμός λεπτών μετά την δημιουργία ανάρτησης που οι ψηφοφορίες μπορούν να αλλαχθούν " + poll: + multiple_polls_without_name: "Υπάρχουν πολλές ψηφοφορίες χωρίς όνομα. Χρησιμοποίησε την ιδιότητα 'name' για να κάνεις τις ψηφοφορίες σου μοναδικά αναγνωρίσιμες." + multiple_polls_with_same_name: "Υπάρχουν πολλές ψηφοφορίες με το ίδιο όνομα: %{name}. Χρησιμοποίησε την ιδιότητα 'name' για να κάνεις τις ψηφοφορίες σου μοναδικά αναγνωρίσιμες." + default_poll_must_have_at_least_2_options: "Μια ψηφοφορία πρέπει να έχει τουλάχιστον 2 επιλογές." + named_poll_must_have_at_least_2_options: "Η ψηφοφορία %{name} πρέπει να έχει τουλάχιστον 2 επιλογές." + default_poll_must_have_less_options: + one: "Η Δημοσκόπηση πρέπει να έχει λιγότερη από 1 επιλογή." + other: "Η Δημοσκόπηση πρέπει να έχει λιγότερες από %{count} επιλογές." + named_poll_must_have_less_options: + one: "Η Ψηφοφορία με το όνομα %{name} πρέπει να έχει λιγότερο απο 1 επιλογή. " + other: "Η Ψηφοφορία με το όνομα %{name} πρέπει να έχει λιγότερες απο %{count} επιλογές. " + default_poll_must_have_different_options: "Η ψηφοφορία πρέπει να έχει διαφορετικές επιλογές." + named_poll_must_have_different_options: "Η ψηφοφορία %{name} πρέπει να έχει διαφορετικές επιλογές." + default_poll_with_multiple_choices_has_invalid_parameters: "Δημοσκόπηση με πολλαπλές επιλογές έχει μη έγκυρες παραμέτρους." + named_poll_with_multiple_choices_has_invalid_parameters: "Η Ψηφοφορία με το όνομα %{name} με πολλαπλές επιλογές έχει μη έγγυρες παραμέτρους" + requires_at_least_1_valid_option: "Πρέπει να κάνεις τουλάχιστον 1 έγκυρη επιλογή." + default_cannot_be_made_public: "Δημοσκόπηση με ψήφους δεν μπορεί να γίνει δημόσια." + named_cannot_be_made_public: "Η Ψηφοφορία με το όνομα %{name} κατέχει ψήφους που δεν μπορούν να δημοσιοποιηθούν." + edit_window_expired: + cannot_change_polls: "Δεν μπορείτε να προσθέσετε, να καταργήσετε ή να μετονομάσετε δημοσκοπήσεις μετά τα πρώτα %{minutes} λεπτά." + op_cannot_edit_options: "Δεν μπορείτε να προσθέσετε ή να αφαιρέσετε επιλογές για την δημοσκόπηση μετά τα πρώτα %{minutes} λεπτά. Παρακαλούμε επικοινωνήστε με έναν συντονιστή αν χρειάζεται να επεξεργαστείτε μια επιλογή για την δημοσκόπηση." + staff_cannot_add_or_remove_options: "Δεν μπορείτε να προσθέσετε ή να αφαιρέσετε επιλογές για την δημοσκόπηση μετά τα πρώτα %{minutes} λεπτά. Θα πρέπει να κλείσετε αυτό το νήμα και να δημιουργήσετε ένα νέο αντ 'αυτού." + no_polls_associated_with_this_post: "Σε αυτή την ανάρτηση δεν υπάρχουν ψηφοφορίες." + no_poll_with_this_name: "Σε αυτή την ανάρτηση δεν υπάρχουν ψηφοφορίες με το όνομα %{name}." + post_is_deleted: "Δεν μπορείτε να ενεργήσετε σε διεγραμένη ανάρτηση." + topic_must_be_open_to_vote: "Το νήμα πρέπει να είναι ανοιχτό για ψηφοφορίες." + poll_must_be_open_to_vote: "Η ψηφοφορία πρέπει να έχει ξεκινήσει πρώτα." + topic_must_be_open_to_toggle_status: "Για να μπορεί να αλλάξει η κατάσταση του νήματος, πρέπει να είναι ανοιχτό." + only_staff_or_op_can_toggle_status: "Η κατάσταση της ψηφοφορίας μπορεί να αλλάξει μόνο από συντονιστές ή από τον δημιουργό του νήματος." + email: + link_to_poll: "Κάντε κλικ για να δείτε την δημοσκόπηση." diff --git a/plugins/poll/config/locales/server.es.yml b/plugins/poll/config/locales/server.es.yml index 4a75b376c7..f7059dddb6 100644 --- a/plugins/poll/config/locales/server.es.yml +++ b/plugins/poll/config/locales/server.es.yml @@ -26,18 +26,18 @@ es: default_poll_with_multiple_choices_has_invalid_parameters: "La encuesta con múltiples opciones tiene parámetros no válidos." named_poll_with_multiple_choices_has_invalid_parameters: "La encuesta llamada %{name} con múltiples opciones tiene párametros no válidos." requires_at_least_1_valid_option: "Debes escoger al menos 1 opción válida." - default_cannot_be_made_public: "La encuesta con votos ya registrados no puede hacerse pública." - named_cannot_be_made_public: "La encuesta llamada %{name} tiene votos ya registrados por lo que no puede hacerse pública." + default_cannot_be_made_public: "Una encuesta con votos ya registrados no puede hacerse pública." + named_cannot_be_made_public: "La encuesta llamada %{name} tiene votos ya registrados, por lo que no puede hacerse pública." edit_window_expired: - cannot_change_polls: "No se puede modificar, quitar o renombrar encuestas transcurridos %{minutes} minutos." - op_cannot_edit_options: "No se puede modificar o eliminar las opciones de la encuesta transcurridos %{minutes} minutos. Por favor, contacta con un moderador si necesitas editar las opciones de la encuesta." - staff_cannot_add_or_remove_options: "No se puede modificar o eliminar las opciones de la encuesta transcurridos %{minutes} minutos. En su lugar, deberías cerrar este tema y abrir uno nuevo." + cannot_change_polls: "No se pueden modificar, quitar o renombrar encuestas transcurridos %{minutes} minutos." + op_cannot_edit_options: "No se pueden modificar o eliminar las opciones de la encuesta transcurridos %{minutes} minutos. Por favor, contacta con un moderador si necesitas editar las opciones de la encuesta." + staff_cannot_add_or_remove_options: "No se pueden modificar o eliminar las opciones de la encuesta transcurridos %{minutes} minutos. En su lugar, deberías cerrar este tema y abrir uno nuevo." no_polls_associated_with_this_post: "No hay encuestas asociadas a este post." - no_poll_with_this_name: "No hay ninguna encuesta llamada %{name} asociada con este post." + no_poll_with_this_name: "No hay ninguna encuesta llamada %{name} asociada a este post." post_is_deleted: "No se puede actuar en un post eliminado." topic_must_be_open_to_vote: "Este tema debe estar abierto para votar." poll_must_be_open_to_vote: "La encuesta debe estar abierta para votar." - topic_must_be_open_to_toggle_status: "Este tema debe estar abierto para cambiar entre estados." + topic_must_be_open_to_toggle_status: "Este tema debe estar abierto para cambiar entre estados de la encuesta." only_staff_or_op_can_toggle_status: "Solo un moderador, administrador o el autor original del post puede cambiar el estado de una encuesta." email: link_to_poll: "Haz clic para ver la encuesta." diff --git a/plugins/poll/config/locales/server.fa_IR.yml b/plugins/poll/config/locales/server.fa_IR.yml index a13b8de965..69023ef5cb 100644 --- a/plugins/poll/config/locales/server.fa_IR.yml +++ b/plugins/poll/config/locales/server.fa_IR.yml @@ -7,11 +7,12 @@ fa_IR: site_settings: - poll_enabled: "به کاربران اجازه ساخت نظرسنجی ها را بده ؟ " - poll_maximum_options: "Maximum number of options allowed in a poll." + poll_enabled: "به کاربران اجازه ساخت نظرسنجی داده شود؟ " + poll_maximum_options: "حداکثر تعداد گزینه برای نظرسنجی‌ها." + poll_edit_window_mins: "چند دقیقه بعد از ارسال نظر می توان آن را ویرایش کرد" poll: - multiple_polls_without_name: "چند نظرسنجی متفاوت بدون اسم است. استفاده کن از 'اسم/code>' ویژگی ٬ تا نظرسنجی منحصر به فرد را تشخیص دهد." - multiple_polls_with_same_name: "چند نظرسنجی با اسم برابر وجود دارند: %{name}.استفاده کن از 'اسم/code>' ویژگی ٬ تا نظرسنجی منحصر به فرد را تشخیص دهد." + multiple_polls_without_name: "چند نظرسنجی متفاوت بدون اسم وچود دارد. از ویژگی 'اسم' برای شناسایی منحصر به فرد رای‌گیری‌های خود استفاده کنید. " + multiple_polls_with_same_name: "چند نظرسنجی با اسم مشابه وجود دارند: %{name}. از ویژگی 'اسم' برای شناسایی منحصر به فرد رای‌گیری‌های خود استفاده کنید." default_poll_must_have_at_least_2_options: "نظرسنجی باید حداقل 2 گزینه داشته باشد." named_poll_must_have_at_least_2_options: "اسم نظرسنجی %{name} باید حداقل 2 گزینه داشته باشد. " default_poll_must_have_less_options: @@ -22,13 +23,19 @@ fa_IR: named_poll_must_have_different_options: "Poll named %{name} must have different options." default_poll_with_multiple_choices_has_invalid_parameters: "در نظرسنجی چند گزینه‌ای پارامترهای نامعتبری وجود دارد." named_poll_with_multiple_choices_has_invalid_parameters: "در نظرسنجی چند گزینه‌ای با نام %{name} پارامترهای نامعتبری وجود دارد." - requires_at_least_1_valid_option: "You must select at least 1 valid option." - no_polls_associated_with_this_post: "هیچ نظرسنجی با این نوشته در تماس نیستند. " - no_poll_with_this_name: "هیچ نظرسنجی اسم گزاری نشده %{name} در تماس به این نوشته نیست. " + requires_at_least_1_valid_option: "باید حداقل یک گزینه معتبر انتخاب کنید." + default_cannot_be_made_public: "نظر سنجی با رای را نمی توان عمومی کرد" + named_cannot_be_made_public: "تعداد آرای نظر‌سنجی %{name} قابل نمایش عمومی نیست." + edit_window_expired: + cannot_change_polls: "نمی‌توانید بعد از %{minutes} دقیقه نظرسنجی‌ها را اضافه و حذف کنید یا نام آن‌ها را تغییر دهید." + op_cannot_edit_options: "بعد از %{minutes} دقیقه اول نمی‌توانید گزینه‌های نظر‌سنجی را حذف یا اضافه کنید. برای ویرایش نظرسنجی با مدیر‌ها ارتباط برقرار کنید." + staff_cannot_add_or_remove_options: "بعد از %{minutes} دقیقه اول نمی‌توانید گزینه‌های نظر‌سنجی را حذف یا اضافه کنید. باید این موضوع را ببندید و یک موضوع جدید ایجاد کنید." + no_polls_associated_with_this_post: "هیچ نظرسنجی‌ای با این نوشته در تماس نیستن. " + no_poll_with_this_name: "هیچ نظرسنجی اسم گزاری شده با اسم %{name} با این نوشته مرتبط نیست." post_is_deleted: "این کار را نمی‌توان روی یک نوشته حذف شده انجام داد." - topic_must_be_open_to_vote: "جستار باید برای رای گزاری باز باشد. " + topic_must_be_open_to_vote: "موضوع باید برای رای گزاری باز باشد. " poll_must_be_open_to_vote: "نظرسنجی باید باز باشد برای رای گیری." - topic_must_be_open_to_toggle_status: "جستار باید برای تغییر وضیعت باز باشد. " - only_staff_or_op_can_toggle_status: "فقط کارمندان یا ارسال کنندگان پست می توانند وضعیت نظرسنجی را تغییر دهند. " + topic_must_be_open_to_toggle_status: "موضوع باید برای تغییر وضیعت باز باشد. " + only_staff_or_op_can_toggle_status: "فقط همکاران یا ارسال کنندگان پست می توانند وضعیت نظرسنجی را تغییر دهند. " email: link_to_poll: "برای دیدن نظرسنجی کلیک کنید." diff --git a/plugins/poll/config/locales/server.ko.yml b/plugins/poll/config/locales/server.ko.yml index 3a1c0d7882..b11c90e4e1 100644 --- a/plugins/poll/config/locales/server.ko.yml +++ b/plugins/poll/config/locales/server.ko.yml @@ -9,6 +9,7 @@ ko: site_settings: poll_enabled: "유저에게 설문조사 작성 허용" poll_maximum_options: "최대 설문조사 항목이 허용됨." + poll_edit_window_mins: "포스트 생성 이후 설문조사가 편집될 수 있는 시간(분)" poll: multiple_polls_without_name: "이름없는 설문조사가 여러개 있습니다. 'name' 속성을 사용하여 설문조사를 개별적으로 구분해 보세요." multiple_polls_with_same_name: "같은 이름 %{name} 의 설문조사가 여러개 있습니다. 'name' 속성을 사용하여 설문조사를 개별적으로 구분해 보세요." diff --git a/plugins/poll/config/locales/server.nl.yml b/plugins/poll/config/locales/server.nl.yml index 735e14eb8d..5542279560 100644 --- a/plugins/poll/config/locales/server.nl.yml +++ b/plugins/poll/config/locales/server.nl.yml @@ -7,37 +7,37 @@ nl: site_settings: - poll_enabled: "Toestaan dat gebruikers polls mogen maken?" - poll_maximum_options: "Maximum aantal opties toegestaan in een poll." - poll_edit_window_mins: "Aantal minuten na het aanmaken van een bericht waarin polls bewerkt kunnen worden." + poll_enabled: "Toestaan dat gebruikers polls maken?" + poll_maximum_options: "Maximale aantal toegestane opties in een poll." + poll_edit_window_mins: "Aantal minuten na het aanmaken van een bericht waarin polls kunnen worden bewerkt." poll: - multiple_polls_without_name: "Er zijn meerdere polls zonder naam. Gebruik het 'naam' attribuut om je polls te identificeren." - multiple_polls_with_same_name: "Er zijn meerdere polls met dezelfde naam : %{name}. Gebruik het 'naam' attribuut om je polls te identificeren." + multiple_polls_without_name: "Er zijn meerdere polls zonder naam. Gebruik het attribuut 'naam' om uw polls uniek te identificeren." + multiple_polls_with_same_name: "Er zijn meerdere polls met dezelfde naam : %{name}. Gebruik het attribuut 'naam' om uw polls uniek te identificeren." default_poll_must_have_at_least_2_options: "De poll moet minimaal 2 opties hebben." - named_poll_must_have_at_least_2_options: "De poll genaamd %{name} moet minimaal 2 opties hebben." + named_poll_must_have_at_least_2_options: "De poll met de naam %{name} moet minimaal 2 opties hebben." default_poll_must_have_less_options: - one: "Poll dient minder dan 1 optie te hebben." - other: "Poll dient minder dan %{count} opties te hebben." + one: "De poll moet minder dan 1 optie hebben." + other: "De poll moet minder dan %{count} opties hebben." named_poll_must_have_less_options: - one: "De poll genaamd %{name} moet minder dan 1 optie hebben." - other: "De poll met de naam %{name} moet minder dan %{count} opties bevatten." - default_poll_must_have_different_options: "Polls moeten verschillende opties hebben." - named_poll_must_have_different_options: "De poll genaamd %{name} moet verschillende opties hebben." + one: "De poll met de naam %{name} moet minder dan 1 optie hebben." + other: "De poll met de naam %{name} moet minder dan %{count} opties hebben." + default_poll_must_have_different_options: "De poll moet verschillende opties hebben." + named_poll_must_have_different_options: "De poll met de naam %{name} moet verschillende opties hebben." default_poll_with_multiple_choices_has_invalid_parameters: "De poll met meerdere keuzes heeft ongeldige parameters." named_poll_with_multiple_choices_has_invalid_parameters: "De poll met de naam %{name} met meerdere keuzes heeft ongeldige parameters." - requires_at_least_1_valid_option: "Kies ten minste 1 geldige optie." - default_cannot_be_made_public: "Poll met stemmen kan niet openbaar gemaakt worden." - named_cannot_be_made_public: "De poll genaamd %{name} heeft stemmen die niet openbaar gemaakt kunnen worden" + requires_at_least_1_valid_option: "Kies minstens 1 geldige optie." + default_cannot_be_made_public: "De poll met stemmen kan niet openbaar worden gemaakt." + named_cannot_be_made_public: "De poll met de naam %{name} heeft stemmen die niet openbaar kunnen worden gemaakt." edit_window_expired: - cannot_change_polls: "Je kunt na %{minutes} minuten geen polls meer toevoegen, verwijderen of hernoemen." - op_cannot_edit_options: "Je kunt na %{minutes} minuten geen polls meer toevoegen, verwijderen of hernoemen. Neem contact op met een moderator als je een poll optie wilt wijzigen." - staff_cannot_add_or_remove_options: "Je kunt na %{minutes} minuten geen polls meer toevoegen, verwijderen of hernoemen. Sluit deze topic en maak een nieuwe." - no_polls_associated_with_this_post: "Er bestaan geen polls voor dit bericht." - no_poll_with_this_name: "Er bestaat geen poll met de naam %{name} voor dit bericht." + cannot_change_polls: "U kunt na %{minutes} minuten geen polls meer toevoegen, verwijderen of hernoemen." + op_cannot_edit_options: "U kunt na %{minutes} minuten geen pollopties meer toevoegen of verwijderen. Neem contact op met een moderator als u een polloptie dient te bewerken." + staff_cannot_add_or_remove_options: "U kunt na %{minutes} minuten geen pollopties meer toevoegen of verwijderen. U dient daarom dit topic te sluiten en een nieuw topic aan te maken." + no_polls_associated_with_this_post: "Er zijn geen polls aan dit bericht gekoppeld." + no_poll_with_this_name: "Er is geen poll met de naam %{name} aan dit bericht gekoppeld." post_is_deleted: "Dit kan niet bij een verwijderd bericht." topic_must_be_open_to_vote: "Het topic moet geopend zijn om te kunnen stemmen." poll_must_be_open_to_vote: "De poll moet geopend zijn om te kunnen stemmen." - topic_must_be_open_to_toggle_status: "Het topic moet geopend zijn om de status te kunnen veranderen." - only_staff_or_op_can_toggle_status: "Alleen een staflid of de oorspronkelijke plaatser van een bericht kan de status van een poll veranderen." + topic_must_be_open_to_toggle_status: "Het topic moet geopend zijn om de status te kunnen omschakelen." + only_staff_or_op_can_toggle_status: "Alleen een staflid of de oorspronkelijke plaatser van een bericht kan de status van een poll omschakelen." email: link_to_poll: "Klik om de poll te bekijken." diff --git a/plugins/poll/config/locales/server.pl_PL.yml b/plugins/poll/config/locales/server.pl_PL.yml index d420ddcdc9..4e02ce454e 100644 --- a/plugins/poll/config/locales/server.pl_PL.yml +++ b/plugins/poll/config/locales/server.pl_PL.yml @@ -18,10 +18,12 @@ pl_PL: default_poll_must_have_less_options: one: "Ankieta musi posiadać mniej niż 1 opcję do wyboru." few: "Ankieta musi posiadać co najmniej %{count} pozycje." + many: "Ankieta musi posiadać co najmniej %{count} pozycji." other: "Ankieta musi posiadać co najmniej %{count} pozycji." named_poll_must_have_less_options: one: "Ankieta %{name} musi posiadać mniej niż 1 opcję." few: "Ankieta %{name} musi posiadać mniej niż %{count} opcje." + many: "Ankieta %{name} musi posiadać mniej niż %{count} opcji." other: "Ankieta %{name} musi posiadać mniej niż %{count} opcji." default_poll_must_have_different_options: "Ankieta musi posiadać kilka różnych opcji do wyboru." named_poll_must_have_different_options: "Ankieta %{name} musi posiadać kilka różnych opcji do wyboru." diff --git a/plugins/poll/config/locales/server.ur.yml b/plugins/poll/config/locales/server.ur.yml index 58bc9735bc..3fdf49a7c0 100644 --- a/plugins/poll/config/locales/server.ur.yml +++ b/plugins/poll/config/locales/server.ur.yml @@ -5,4 +5,39 @@ # To work with us on translations, join this project: # https://www.transifex.com/projects/p/discourse-org/ -ur: {} +ur: + site_settings: + poll_enabled: "کیا صارفین کو پول بنانے کی اجازت دی جاے؟" + poll_maximum_options: "ایک پول میں رکھے جانے والی آپشنز کی زیادہ سے زیادہ تعداد۔" + poll_edit_window_mins: "پوسٹ بنانے کے بعد کتنے منت تک پولز کی ترمیم کی جاسکتی ہے۔" + poll: + multiple_polls_without_name: "نام کے بغیر ایک سے زیادہ پولزموجود ہیں۔ اپنے پولز کو منفرد شناخت دینے کیلیے 'نام' صفت کا استعمال کریں۔" + multiple_polls_with_same_name: "ایک ہی نام والے ایک سے زیادہ پولزموجود ہیں: %{نام}۔ اپنے پولز کو منفرد شناخت دینے کیلیے 'نام' صفت کا استعمال کریں۔" + default_poll_must_have_at_least_2_options: "پول کی کم ازکم 2 آپشنز ہونا ضروری ہے۔" + named_poll_must_have_at_least_2_options: "پول جس کا نام %{نام} ہے، کی کم ازکم 2 آپشنز ہونا ضروری ہے۔" + default_poll_must_have_less_options: + one: "پول کی 1 سے کم آپشن ہونا ضروری ہے۔" + other: "پول کی %{گنتی} سے کم آپشنز ہونا ضروری ہے۔" + named_poll_must_have_less_options: + one: "پول جس کا نام %{نام} ہے، کی 1 سے کم آپشن ہونا ضروری ہے۔" + other: "پول جس کا نام %{نام} ہے، کی %{count} سے کم آپشنز ہونا ضروری ہے۔" + default_poll_must_have_different_options: "پول کی مختلف آپشنز ہونا ضروری ہے۔" + named_poll_must_have_different_options: "پول جس کا نام %{نام} ہے، کی آپشنز مختلف ہونا ضروری ہے۔" + default_poll_with_multiple_choices_has_invalid_parameters: "ایک سے زیادہ انتخاب والے پول کے غلط پیرامیٹرز ہیں۔" + named_poll_with_multiple_choices_has_invalid_parameters: "ایک سے زیادہ انتخاب والے پول جس کا نام %{نام} ہے، کے غلط پیرامیٹرز ہیں۔" + requires_at_least_1_valid_option: "آپ کو کم از کم 1 جائز آپشن منتخب کرنا ضروری ہے۔" + default_cannot_be_made_public: "ووٹ کے ساتھ والے پول کو پبلک نہیں کیا جا سکتا۔" + named_cannot_be_made_public: "%{نام} نام والے پول کے ووٹ ہیں، جو کہ پبلک نہیں کیے جا سکتے۔" + edit_window_expired: + cannot_change_polls: "آپ پہلے %{منٹ} منٹ بعد پولز میں اضافہ، کمی یا ان کے نام میں تبدیلی نہیں کر سکتے۔" + op_cannot_edit_options: "پہلے %{منٹ} منٹ بعد آپ پول کی آپشنز میں اضافہ یا کمی نہیں کر سکتے۔ اگر آپ نے کسی پول آپشن میں ترمیم کرنی ہے تو براہ مہربانی کسی ماڈریٹر سے رابطہ کریں۔" + staff_cannot_add_or_remove_options: "پہلے %{منٹ} منٹ بعد آپ پول کی آپشنز میں اضافہ یا کمی نہیں کر سکتے۔ اِس کے بجاے، آپ کو یہ ٹاپک بند کر کہ ایک نیا ٹاپک بنا لینا چاہیے۔" + no_polls_associated_with_this_post: "کوئی پول اس پوسٹ کے ساتھ منسلک نہیں ہیں۔" + no_poll_with_this_name: "کوئی پول جس کا نام %{نام} ہو، اس پوسٹ کے ساتھ منسلک نہیں ہے۔" + post_is_deleted: "حذف کر دی گی پوست پر کام نہیں کیا جا سکتا۔" + topic_must_be_open_to_vote: "ووٹ کرنے کیلیے ٹاپک کا کھلا ہونا ضروری ہے۔" + poll_must_be_open_to_vote: "ووٹ کرنے کیلیے پول کا کھلا ہونا ضروری ہے۔" + topic_must_be_open_to_toggle_status: "سٹیٹس بدلنے کیلیے ٹاپک کا کھلا ہونا ضروری ہے۔" + only_staff_or_op_can_toggle_status: "صرف عملے کے رکن یا اصل پوسٹر ایک پول کا سٹیٹس تبدیل کر سکتے ہیں۔" + email: + link_to_poll: "پول دیکھنے کیلیے کلک کریں۔" diff --git a/plugins/poll/config/locales/server.vi.yml b/plugins/poll/config/locales/server.vi.yml index 6e34c3a8d6..7295cb59cc 100644 --- a/plugins/poll/config/locales/server.vi.yml +++ b/plugins/poll/config/locales/server.vi.yml @@ -8,27 +8,34 @@ vi: site_settings: poll_enabled: "Cho phép người dùng tạo các cuộc thăm dò?" - poll_maximum_options: "Số lượng tối đa tùy chọn trong một cuộc thăm dò." + poll_maximum_options: "Số lượng tối đa tùy chọn trong một cuộc thăm dò. " + poll_edit_window_mins: "Số phút tình từ lúc tạo bài viết mà người dùng có quyền sửa thăm dò." poll: - multiple_polls_without_name: "Có nhiều cuộc thăm dò mà không có tên. Sử dụng thuộc tính 'name' để xác định cuộc thăm dò của bạn." + multiple_polls_without_name: "Có nhiều cuộc thăm dò chưa được đặt tên. Sử dụng thuộc tính 'name' để xác định cuộc thăm dò của bạn." multiple_polls_with_same_name: "Có nhiều cuộc thăm dò có cùng tên: %{name}. Sử dụng thuộc tính 'name' để xác định cuộc thăm dò của bạn." - default_poll_must_have_at_least_2_options: "Thăm dò ý kiến ​​phải có ít nhất 2 lựa chọn." - named_poll_must_have_at_least_2_options: "Thăm dò có tên %{name} phải có ít nhất 2 lựa chọn." + default_poll_must_have_at_least_2_options: "Thăm dò ​​phải có ít nhất 2 lựa chọn." + named_poll_must_have_at_least_2_options: "Thăm dò %{name} phải có ít nhất 2 lựa chọn." default_poll_must_have_less_options: other: "Thăm dò phải có ít hơn %{count} lựa chọn." named_poll_must_have_less_options: - other: "Thăm dò %{name} phải có ít hơn %{count} tùy chọn." - default_poll_must_have_different_options: "Thăm dò ý kiến ​​phải có các tùy chọn khác nhau." - named_poll_must_have_different_options: "Thăm dò %{name} ​​phải có các tùy chọn khác nhau." - default_poll_with_multiple_choices_has_invalid_parameters: "Thăm dò ý kiến ​​với nhiều sự lựa chọn có các tham số không hợp lệ." - named_poll_with_multiple_choices_has_invalid_parameters: "Thăm dò %{name} ​​với nhiều sự lựa chọn có các tham số không hợp lệ. " + other: "Thăm dò %{name} phải có ít hơn %{count} lựa chọn." + default_poll_must_have_different_options: "Thăm dò ​​phải có các lựa chọn khác nhau." + named_poll_must_have_different_options: "Thăm dò %{name} ​​phải có các lựa chọn khác nhau." + default_poll_with_multiple_choices_has_invalid_parameters: "Thăm dò ​​với nhiều lựa chọn có các tham số không hợp lệ." + named_poll_with_multiple_choices_has_invalid_parameters: "Thăm dò %{name} ​​với nhiều lựa chọn có các tham số không hợp lệ. " requires_at_least_1_valid_option: "Bạn phải chọn ít nhất 1 lựa chọn hợp lệ." - no_polls_associated_with_this_post: "Không có cuộc thăm dò được liên kết với bài này." + default_cannot_be_made_public: "Thăm dò đã tồn tại các biểu quyết không thể chuyển sang chế độ công khai." + named_cannot_be_made_public: "Thăm dò %{name} đã tồn tại các biểu quyết không thể chuyển sang chế độ công khai." + edit_window_expired: + cannot_change_polls: "Bạn không thể thêm, xoá hay đổi tên thăm dò sau %{minutes} phút đầu tiên." + op_cannot_edit_options: "Bạn không thể thêm, xoá hay đổi tên thăm dò sau %{minutes} phút đầu tiên. Liên hệ người quản lí nếu cần để sửa đổi thăm dò." + staff_cannot_add_or_remove_options: "Bạn không thể thêm, xoá hay đổi tên thăm dò sau %{minutes} phút đầu tiên. Bạn nên đóng chủ đề này và tạo lại chủ đề mới." + no_polls_associated_with_this_post: "Không có thăm dò nào liên kết với bài này." no_poll_with_this_name: "Không có thăm dò có tên %{name} liên kết với bài viết này." post_is_deleted: "Không thể thực hiện trên bài viết đã xóa." - topic_must_be_open_to_vote: "Các chủ đề phải được mở để bầu chọn." - poll_must_be_open_to_vote: "Thăm dò ý kiến ​​phải được mở để bầu chọn." - topic_must_be_open_to_toggle_status: "Các chủ đề phải được mở để chuyển trạng thái." - only_staff_or_op_can_toggle_status: "Chỉ có một BQT hoặc các người đăng bài có thể chuyển đổi một trạng thái thăm dò ý kiến" + topic_must_be_open_to_vote: "Chủ đề phải được mở để bầu chọn." + poll_must_be_open_to_vote: "Thăm dò ​​phải được mở để tiến hành bầu chọn." + topic_must_be_open_to_toggle_status: "Chủ đề phải được mở để chuyển trạng thái." + only_staff_or_op_can_toggle_status: "Chỉ có người quản lí hoặc người đăng bài mới có quyền chuyển trạng thái của một thăm dò." email: - link_to_poll: "Nhấn để hiển thị." + link_to_poll: "Click để xem thăm dò." diff --git a/plugins/poll/lib/polls_updater.rb b/plugins/poll/lib/polls_updater.rb index 42d67f219d..696a62eed1 100644 --- a/plugins/poll/lib/polls_updater.rb +++ b/plugins/poll/lib/polls_updater.rb @@ -64,7 +64,10 @@ module DiscoursePoll end polls[poll_name]["voters"] = previous_polls[poll_name]["voters"] - polls[poll_name]["anonymous_voters"] = previous_polls[poll_name]["anonymous_voters"] if previous_polls[poll_name].has_key?("anonymous_voters") + + if previous_polls[poll_name].has_key?("anonymous_voters") + polls[poll_name]["anonymous_voters"] = previous_polls[poll_name]["anonymous_voters"] + end previous_options = previous_polls[poll_name]["options"] public_poll = polls[poll_name]["public"] == "true" @@ -72,7 +75,19 @@ module DiscoursePoll polls[poll_name]["options"].each_with_index do |option, index| previous_option = previous_options[index] option["votes"] = previous_option["votes"] - option["anonymous_votes"] = previous_option["anonymous_votes"] if previous_option.has_key?("anonymous_votes") + + if previous_option["id"] != option["id"] + if votes_fields = post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD] + votes_fields.each do |key, value| + index = value[poll_name].index(previous_option["id"]) + votes_fields[key][poll_name][index] = option["id"] if index + end + end + end + + if previous_option.has_key?("anonymous_votes") + option["anonymous_votes"] = previous_option["anonymous_votes"] + end if public_poll && previous_option.has_key?("voter_ids") option["voter_ids"] = previous_option["voter_ids"] diff --git a/plugins/poll/plugin.rb b/plugins/poll/plugin.rb index 5a193b1ace..3b764043b6 100644 --- a/plugins/poll/plugin.rb +++ b/plugins/poll/plugin.rb @@ -211,13 +211,61 @@ after_initialize do end def voters - user_ids = params.require(:user_ids) + post_id = params.require(:post_id) + poll_name = params.require(:poll_name) - users = User.where(id: user_ids).map do |user| - UserNameSerializer.new(user).serializable_hash + post = Post.find_by(id: post_id) + raise Discourse::InvalidParameters.new("post_id is invalid") if !post + + poll = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD][poll_name] + raise Discourse::InvalidParameters.new("poll_name is invalid") if !poll + + user_ids = [] + options = poll["options"] + + if poll["type"] != "number" + options.each do |option| + if (params[:option_id]) + next unless option["id"] == params[:option_id].to_s + end + + next unless option["voter_ids"] + user_ids << option["voter_ids"].slice((params[:offset].to_i || 0) * 25, 25) + end + + user_ids.flatten! + user_ids.uniq! + + poll_votes = post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD] + + result = {} + + User.where(id: user_ids).map do |user| + user_hash = UserNameSerializer.new(user).serializable_hash + + poll_votes[user.id.to_s][poll_name].each do |option_id| + if (params[:option_id]) + next unless option_id == params[:option_id].to_s + end + + result[option_id] ||= [] + result[option_id] << user_hash + end + end + else + user_ids = options.map { |option| option["voter_ids"] }.sort! + user_ids.flatten! + user_ids.uniq! + user_ids = user_ids.slice((params[:offset].to_i || 0) * 25, 25) + + result = [] + + users = User.where(id: user_ids).map do |user| + result << UserNameSerializer.new(user).serializable_hash + end end - render json: { users: users } + render json: { poll_name => result } end end @@ -247,11 +295,11 @@ after_initialize do end end - validate(:post, :validate_polls) do + validate(:post, :validate_polls) do |force=nil| return if !SiteSetting.poll_enabled? && (self.user && !self.user.staff?) # only care when raw has changed! - return unless self.raw_changed? + return unless self.raw_changed? || force validator = DiscoursePoll::PollsValidator.new(self) return unless (polls = validator.validate_polls) @@ -268,6 +316,29 @@ after_initialize do true end + NewPostManager.add_handler(1) do |manager| + post = Post.new(raw: manager.args[:raw]) + + if !DiscoursePoll::PollsValidator.new(post).validate_polls + result = NewPostResult.new(:poll, false) + + post.errors.full_messages.each do |message| + result.errors[:base] << message + end + + result + else + manager.args["is_poll"] = true + nil + end + end + + on(:approved_post) do |queued_post, created_post| + if queued_post.post_options["is_poll"] + created_post.validate_polls(true) + end + end + Post.register_custom_field_type(DiscoursePoll::POLLS_CUSTOM_FIELD, :json) Post.register_custom_field_type(DiscoursePoll::VOTES_CUSTOM_FIELD, :json) @@ -294,7 +365,16 @@ after_initialize do polls: post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]}) end - add_to_serializer(:post, :polls, false) { post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] } + add_to_serializer(:post, :polls, false) do + polls = post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].dup + + polls.each do |_, poll| + poll["options"].each do |option| + option.delete("voter_ids") + end + end + end + add_to_serializer(:post, :include_polls?) { post_custom_fields.present? && post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].present? } add_to_serializer(:post, :polls_votes, false) do diff --git a/plugins/poll/spec/integration/poll_endpoints_spec.rb b/plugins/poll/spec/integration/poll_endpoints_spec.rb index 6aeb65cd30..d2c5a4caac 100644 --- a/plugins/poll/spec/integration/poll_endpoints_spec.rb +++ b/plugins/poll/spec/integration/poll_endpoints_spec.rb @@ -1,18 +1,118 @@ require "rails_helper" describe "DiscoursePoll endpoints" do - describe "fetch voters from user_ids" do + describe "fetch voters for a poll" do let(:user) { Fabricate(:user) } + let(:post) { Fabricate(:post, raw: "[poll public=true]\n- A\n- B\n[/poll]") } it "should return the right response" do - get "/polls/voters.json", { user_ids: [user.id] } + DiscoursePoll::Poll.vote( + post.id, + DiscoursePoll::DEFAULT_POLL_NAME, + ["5c24fc1df56d764b550ceae1b9319125"], + user + ) + + get "/polls/voters.json", { + post_id: post.id, + poll_name: DiscoursePoll::DEFAULT_POLL_NAME + } expect(response.status).to eq(200) - json = JSON.parse(response.body)["users"].first + poll = JSON.parse(response.body)[DiscoursePoll::DEFAULT_POLL_NAME] + option = poll["5c24fc1df56d764b550ceae1b9319125"] - expect(json["name"]).to eq(user.name) - expect(json["title"]).to eq(user.title) + expect(option.length).to eq(1) + expect(option.first["id"]).to eq(user.id) + expect(option.first["username"]).to eq(user.username) + end + + it 'should return the right response for a single option' do + DiscoursePoll::Poll.vote( + post.id, + DiscoursePoll::DEFAULT_POLL_NAME, + ["5c24fc1df56d764b550ceae1b9319125", "e89dec30bbd9bf50fabf6a05b4324edf"], + user + ) + + get "/polls/voters.json", { + post_id: post.id, + poll_name: DiscoursePoll::DEFAULT_POLL_NAME, + option_id: 'e89dec30bbd9bf50fabf6a05b4324edf' + } + + expect(response.status).to eq(200) + + poll = JSON.parse(response.body)[DiscoursePoll::DEFAULT_POLL_NAME] + + expect(poll['5c24fc1df56d764b550ceae1b9319125']).to eq(nil) + + option = poll['e89dec30bbd9bf50fabf6a05b4324edf'] + + expect(option.length).to eq(1) + expect(option.first["id"]).to eq(user.id) + expect(option.first["username"]).to eq(user.username) + end + + describe 'when post_id is blank' do + it 'should raise the right error' do + expect { get "/polls/voters.json", { poll_name: DiscoursePoll::DEFAULT_POLL_NAME } } + .to raise_error(ActionController::ParameterMissing) + end + end + + describe 'when post_id is not valid' do + it 'should raise the right error' do + expect do + get "/polls/voters.json", { + post_id: -1, + poll_name: DiscoursePoll::DEFAULT_POLL_NAME + } + end.to raise_error(Discourse::InvalidParameters, 'post_id is invalid') + end + end + + describe 'when poll_name is blank' do + it 'should raise the right error' do + expect { get "/polls/voters.json", { post_id: post.id } } + .to raise_error(ActionController::ParameterMissing) + end + end + + describe 'when poll_name is not valid' do + it 'should raise the right error' do + expect do + get "/polls/voters.json", post_id: post.id, poll_name: 'wrongpoll' + end.to raise_error(Discourse::InvalidParameters, 'poll_name is invalid') + end + end + + context "number poll" do + let(:post) { Fabricate(:post, raw: '[poll type=number min=1 max=20 step=1 public=true][/poll]') } + + it 'should return the right response' do + post + + DiscoursePoll::Poll.vote( + post.id, + DiscoursePoll::DEFAULT_POLL_NAME, + ["4d8a15e3cc35750f016ce15a43937620"], + user + ) + + get "/polls/voters.json", { + post_id: post.id, + poll_name: DiscoursePoll::DEFAULT_POLL_NAME + } + + expect(response.status).to eq(200) + + poll = JSON.parse(response.body)[DiscoursePoll::DEFAULT_POLL_NAME] + + expect(poll.first["id"]).to eq(user.id) + expect(poll.first["username"]).to eq(user.username) + end end end end diff --git a/plugins/poll/spec/lib/new_post_manager_spec.rb b/plugins/poll/spec/lib/new_post_manager_spec.rb new file mode 100644 index 0000000000..8f1a843d0b --- /dev/null +++ b/plugins/poll/spec/lib/new_post_manager_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +describe NewPostManager do + let(:user) { Fabricate(:newuser) } + let(:admin) { Fabricate(:admin) } + + describe 'when new post containing a poll is queued for approval' do + it 'should render the poll upon approval' do + params = { + raw: "[poll]\n* 1\n* 2\n* 3\n[/poll]", + archetype: "regular", + category: "", + typing_duration_msecs: "2700", + composer_open_duration_msecs: "12556", + visible: true, + image_sizes: nil, + is_warning: false, + title: "This is a test post with a poll", + ip_address: "127.0.0.1", + user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 Safari/537.36", + referrer: "http://localhost:3000/", + first_post_checks: true + } + + expect { NewPostManager.new(user, params).perform } + .to change { QueuedPost.count }.by(1) + + queued_post = QueuedPost.last + queued_post.approve!(admin) + + expect(Post.last.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]) + .to_not eq(nil) + end + end +end diff --git a/plugins/poll/spec/lib/polls_updater_spec.rb b/plugins/poll/spec/lib/polls_updater_spec.rb index 4210b12d5e..3d679d071a 100644 --- a/plugins/poll/spec/lib/polls_updater_spec.rb +++ b/plugins/poll/spec/lib/polls_updater_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' describe DiscoursePoll::PollsUpdater do + let(:user) { Fabricate(:user) } + let(:post_with_two_polls) do raw = <<-RAW.strip_heredoc [poll] @@ -127,8 +129,6 @@ describe DiscoursePoll::PollsUpdater do DiscoursePoll::PollsValidator.new(Fabricate(:post, raw: raw)).validate_polls end - let(:user) { Fabricate(:user) } - before do DiscoursePoll::Poll.vote(post.id, "poll", ["5c24fc1df56d764b550ceae1b9319125"], user) post.reload @@ -222,9 +222,16 @@ describe DiscoursePoll::PollsUpdater do let(:another_post) { Fabricate(:post, created_at: Time.zone.now - poll_edit_window_mins.minutes) } before do - polls.each { |key, value| value["voters"] = 2 } described_class.update(another_post, polls) + another_post.reload SiteSetting.poll_edit_window_mins = poll_edit_window_mins + + DiscoursePoll::Poll.vote( + another_post.id, + "poll", + [polls["poll"]["options"].first["id"]], + user + ) end it "should not allow new polls to be added" do @@ -254,6 +261,8 @@ describe DiscoursePoll::PollsUpdater do end context "staff" do + let(:another_user) { Fabricate(:user) } + it "should not allow staff to add options if votes have been casted" do another_post.update_attributes!(last_editor_id: User.staff.first.id) @@ -284,8 +293,15 @@ describe DiscoursePoll::PollsUpdater do expect(message.data[:polls]).to eq(polls_with_3_options) end - it "should allow staff to edit options if votes have been casted" do - another_post.update_attributes!(last_editor_id: User.staff.first.id) + it "should allow staff to edit options even if votes have been casted" do + another_post.update!(last_editor_id: User.staff.first.id) + + DiscoursePoll::Poll.vote( + another_post.id, + "poll", + [polls["poll"]["options"].first["id"]], + another_user + ) raw = <<-RAW.strip_heredoc [poll] @@ -301,9 +317,16 @@ describe DiscoursePoll::PollsUpdater do described_class.update(another_post, different_polls) end.first - different_polls.each { |key, value| value["voters"] = 2 } + custom_fields = another_post.reload.custom_fields + + expect(custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]) + .to eq(different_polls) + + [user, another_user].each do |u| + expect(custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD][u.id.to_s]["poll"]) + .to eq(["68b434ff88aeae7054e42cd05a4d9056"]) + end - expect(another_post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]).to eq(different_polls) expect(message.data[:post_id]).to eq(another_post.id) expect(message.data[:polls]).to eq(different_polls) end diff --git a/plugins/poll/test/javascripts/acceptance/polls-test.js.es6 b/plugins/poll/test/javascripts/acceptance/polls-test.js.es6 index 35aec81b5c..66c56b24b7 100644 --- a/plugins/poll/test/javascripts/acceptance/polls-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/polls-test.js.es6 @@ -2,23 +2,18 @@ import { acceptance } from "helpers/qunit-helpers"; acceptance("Rendering polls", { loggedIn: true, - settings: { poll_enabled: true }, - setup() { - const response = object => { - return [ - 200, - { "Content-Type": "application/json" }, - object - ]; - }; - - server.get('/t/13.json', () => { - return response({"post_stream":{"posts":[{"id":19,"name":null,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png","created_at":"2016-12-01T02:39:49.199Z","cooked":"
    \n
    \n
      \n
    • test
    • \n
    • haha
    • \n
    \n

    0voters

    \n
    \n\n
    \n\n
    \n
    \n
      \n
    • donkey
    • \n
    • kong
    • \n
    \n

    0voters

    \n
    \n\n
    ","post_number":1,"post_type":1,"updated_at":"2016-12-01T02:47:18.317Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":13,"topic_slug":"this-is-a-test-topic-for-polls","display_username":null,"primary_group_name":null,"primary_group_flair_url":null,"primary_group_flair_bg_color":null,"primary_group_flair_color":null,"version":2,"can_edit":true,"can_delete":false,"can_recover":true,"can_wiki":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false,"polls":{"poll":{"options":[{"id":"57ddd734344eb7436d64a7d68a0df444","html":"test","votes":0},{"id":"b5b78d79ab5b5d75d4d33d8b87f5d2aa","html":"haha","votes":0}],"voters":2,"status":"open","name":"poll"},"test":{"options":[{"id":"c26ad90783b0d80936e5fdb292b7963c","html":"donkey","votes":0},{"id":"99f2b9ac452ba73b115fcf3556e6d2d4","html":"kong","votes":0}],"voters":3,"status":"open","name":"test"}}}],"stream":[19]},"timeline_lookup":[[1,0]],"id":13,"title":"This is a test topic for polls","fancy_title":"This is a test topic for polls","posts_count":1,"created_at":"2016-12-01T02:39:48.055Z","views":1,"reply_count":0,"participant_count":1,"like_count":0,"last_posted_at":"2016-12-01T02:39:49.199Z","visible":true,"closed":false,"archived":false,"has_summary":false,"archetype":"regular","slug":"this-is-a-test-topic-for-polls","category_id":1,"word_count":10,"deleted_at":null,"user_id":1,"draft":null,"draft_key":"topic_13","draft_sequence":4,"posted":true,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"pinned_until":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"},"last_poster":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"},"participants":[{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png","post_count":1}],"suggested_topics":[{"id":8,"title":"Welcome to Discourse","fancy_title":"Welcome to Discourse","slug":"welcome-to-discourse","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2016-11-24T02:10:54.328Z","last_posted_at":"2016-11-24T02:10:54.393Z","bumped":true,"bumped_at":"2016-11-24T02:10:54.393Z","unseen":false,"pinned":true,"unpinned":null,"excerpt":"The first paragraph of this pinned topic will be visible as a welcome message to all new visitors on your homepage. It's important! \n\nEdit this into a brief description of your community: \n\n\nWho is it for?\nWhat can they …","visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":0,"views":0,"category_id":1,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":-1,"username":"system","avatar_template":"/letter_avatar_proxy/v2/letter/s/bcef8e/{size}.png"}}]},{"id":12,"title":"Some testing topic testing","fancy_title":"Some testing topic testing","slug":"some-testing-topic-testing","posts_count":4,"reply_count":0,"highest_post_number":4,"image_url":null,"created_at":"2016-11-24T08:36:08.773Z","last_posted_at":"2016-12-01T01:15:52.008Z","bumped":true,"bumped_at":"2016-12-01T01:15:52.008Z","unseen":false,"last_read_post_number":4,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":2,"category_id":1,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"}}]},{"id":11,"title":"Some testing topic","fancy_title":"Some testing topic","slug":"some-testing-topic","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2016-11-24T08:35:26.758Z","last_posted_at":"2016-11-24T08:35:26.894Z","bumped":true,"bumped_at":"2016-11-24T08:35:26.894Z","unseen":false,"last_read_post_number":1,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":0,"category_id":1,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"}}]}],"notification_level":3,"notifications_reason_id":1,"can_move_posts":true,"can_edit":true,"can_delete":true,"can_recover":true,"can_remove_allowed_users":true,"can_invite_to":true,"can_create_post":true,"can_reply_as_new_topic":true,"can_flag_topic":true},"highest_post_number":1,"last_read_post_number":1,"last_read_post_id":19,"deleted_by":null,"has_deleted":false,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":true},{"id":7,"count":0,"hidden":false,"can_act":true},{"id":8,"count":0,"hidden":false,"can_act":true}],"chunk_size":20,"bookmarked":false}); - }); - } + settings: { poll_enabled: true } }); test("Single Poll", () => { + server.get('/t/13.json', () => { + return [ + 200, + { "Content-Type": "application/json" }, + {"post_stream":{"posts":[{"id":19,"name":null,"username":"tgx","avatar_template":"/images/avatar.png","created_at":"2016-12-01T02:39:49.199Z","cooked":"
    \n
    \n
      \n
    • test
    • \n
    • haha
    • \n
    \n

    0voters

    \n
    \n\n
    \n\n
    \n
    \n
      \n
    • donkey
    • \n
    • kong
    • \n
    \n

    0voters

    \n
    \n\n
    ","post_number":1,"post_type":1,"updated_at":"2016-12-01T02:47:18.317Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":13,"topic_slug":"this-is-a-test-topic-for-polls","display_username":null,"primary_group_name":null,"primary_group_flair_url":null,"primary_group_flair_bg_color":null,"primary_group_flair_color":null,"version":2,"can_edit":true,"can_delete":false,"can_recover":true,"can_wiki":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false,"polls":{"poll":{"options":[{"id":"57ddd734344eb7436d64a7d68a0df444","html":"test","votes":0},{"id":"b5b78d79ab5b5d75d4d33d8b87f5d2aa","html":"haha","votes":0}],"voters":2,"status":"open","name":"poll"},"test":{"options":[{"id":"c26ad90783b0d80936e5fdb292b7963c","html":"donkey","votes":0},{"id":"99f2b9ac452ba73b115fcf3556e6d2d4","html":"kong","votes":0}],"voters":3,"status":"open","name":"test"}}}],"stream":[19]},"timeline_lookup":[[1,0]],"id":13,"title":"This is a test topic for polls","fancy_title":"This is a test topic for polls","posts_count":1,"created_at":"2016-12-01T02:39:48.055Z","views":1,"reply_count":0,"participant_count":1,"like_count":0,"last_posted_at":"2016-12-01T02:39:49.199Z","visible":true,"closed":false,"archived":false,"has_summary":false,"archetype":"regular","slug":"this-is-a-test-topic-for-polls","category_id":1,"word_count":10,"deleted_at":null,"user_id":1,"draft":null,"draft_key":"topic_13","draft_sequence":4,"posted":true,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"pinned_until":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"},"last_poster":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"},"participants":[{"id":1,"username":"tgx","avatar_template":"/images/avatar.png","post_count":1}],"suggested_topics":[{"id":8,"title":"Welcome to Discourse","fancy_title":"Welcome to Discourse","slug":"welcome-to-discourse","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2016-11-24T02:10:54.328Z","last_posted_at":"2016-11-24T02:10:54.393Z","bumped":true,"bumped_at":"2016-11-24T02:10:54.393Z","unseen":false,"pinned":true,"unpinned":null,"excerpt":"The first paragraph of this pinned topic will be visible as a welcome message to all new visitors on your homepage. It's important! \n\nEdit this into a brief description of your community: \n\n\nWho is it for?\nWhat can they …","visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":0,"views":0,"category_id":1,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":-1,"username":"system","avatar_template":"/images/avatar.png"}}]},{"id":12,"title":"Some testing topic testing","fancy_title":"Some testing topic testing","slug":"some-testing-topic-testing","posts_count":4,"reply_count":0,"highest_post_number":4,"image_url":null,"created_at":"2016-11-24T08:36:08.773Z","last_posted_at":"2016-12-01T01:15:52.008Z","bumped":true,"bumped_at":"2016-12-01T01:15:52.008Z","unseen":false,"last_read_post_number":4,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":2,"category_id":1,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"}}]},{"id":11,"title":"Some testing topic","fancy_title":"Some testing topic","slug":"some-testing-topic","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2016-11-24T08:35:26.758Z","last_posted_at":"2016-11-24T08:35:26.894Z","bumped":true,"bumped_at":"2016-11-24T08:35:26.894Z","unseen":false,"last_read_post_number":1,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":0,"category_id":1,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"}}]}],"notification_level":3,"notifications_reason_id":1,"can_move_posts":true,"can_edit":true,"can_delete":true,"can_recover":true,"can_remove_allowed_users":true,"can_invite_to":true,"can_create_post":true,"can_reply_as_new_topic":true,"can_flag_topic":true},"highest_post_number":1,"last_read_post_number":1,"last_read_post_id":19,"deleted_by":null,"has_deleted":false,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":true},{"id":7,"count":0,"hidden":false,"can_act":true},{"id":8,"count":0,"hidden":false,"can_act":true}],"chunk_size":20,"bookmarked":false} + ] + }); + visit("/t/this-is-a-test-topic-for-polls/13"); andThen(() => { @@ -29,3 +24,97 @@ test("Single Poll", () => { equal(find('.info-number', polls[1]).text(), '3', 'it should display the right number of votes'); }); }); + +test("Public poll", () => { + server.get('/t/12.json', () => { + return [ + 200, + { "Content-Type": "application/json" }, + {"post_stream":{"posts":[{"id":15,"name":null,"username":"tgx","avatar_template":"/images/avatar.png","created_at":"2017-01-31T08:39:06.237Z","cooked":"
    \n
    \n
      \n
    • 1
    • \n
    • 2
    • \n
    • 3
    • \n
    \n
    \n

    0voters

    \n

    Choose up to 3 options

    \n

    Votes are public.

    \n
    \n
    \n\n
    ","post_number":1,"post_type":1,"updated_at":"2017-01-31T08:39:06.237Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":12,"topic_slug":"this-is-a-topic-created-for-testing","display_username":null,"primary_group_name":null,"primary_group_flair_url":null,"primary_group_flair_bg_color":null,"primary_group_flair_color":null,"version":1,"can_edit":true,"can_delete":false,"can_recover":true,"can_wiki":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false,"polls":{"poll":{"options":[{"id":"4d8a15e3cc35750f016ce15a43937620","html":"1","votes":29},{"id":"cd314db7dfbac2b10687b6f39abfdf41","html":"2","votes":29},{"id":"68b434ff88aeae7054e42cd05a4d9056","html":"3","votes":42}],"voters":100,"status":"open","name":"poll","type":"multiple","min":"1","max":"3","public":"true"}}}],"stream":[15]},"timeline_lookup":[[1,0]],"id":12,"title":"This is a topic created for testing","fancy_title":"This is a topic created for testing","posts_count":1,"created_at":"2017-01-31T08:39:06.094Z","views":1,"reply_count":0,"participant_count":1,"like_count":0,"last_posted_at":"2017-01-31T08:39:06.237Z","visible":true,"closed":false,"archived":false,"has_summary":false,"archetype":"regular","slug":"this-is-a-topic-created-for-testing","category_id":1,"word_count":13,"deleted_at":null,"user_id":1,"draft":null,"draft_key":"topic_12","draft_sequence":1,"posted":true,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"pinned_until":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"},"last_poster":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"},"participants":[{"id":1,"username":"tgx","avatar_template":"/images/avatar.png","post_count":1,"primary_group_name":null,"primary_group_flair_url":null,"primary_group_flair_color":null,"primary_group_flair_bg_color":null}],"suggested_topics":[{"id":8,"title":"Welcome to Discourse","fancy_title":"Welcome to Discourse","slug":"welcome-to-discourse","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2017-01-31T07:53:45.363Z","last_posted_at":"2017-01-31T07:53:45.439Z","bumped":true,"bumped_at":"2017-01-31T07:53:45.439Z","unseen":false,"pinned":true,"unpinned":null,"excerpt":"The first paragraph of this pinned topic will be visible as a welcome message to all new visitors on your homepage. It's important! \n\nEdit this into a brief description of your community: \n\n\nWho is it for?\nWhat can they …","visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":0,"views":0,"category_id":1,"featured_link":null,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":-1,"username":"system","avatar_template":"/images/avatar.png"}}]},{"id":11,"title":"This is a test post to try out posts","fancy_title":"This is a test post to try out posts","slug":"this-is-a-test-post-to-try-out-posts","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2017-01-31T07:55:58.407Z","last_posted_at":"2017-01-31T07:55:58.634Z","bumped":true,"bumped_at":"2017-01-31T07:55:58.634Z","unseen":false,"last_read_post_number":1,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":1,"category_id":1,"featured_link":null,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"}}]}],"notification_level":3,"notifications_reason_id":1,"can_move_posts":true,"can_edit":true,"can_delete":true,"can_recover":true,"can_remove_allowed_users":true,"can_invite_to":true,"can_create_post":true,"can_reply_as_new_topic":true,"can_flag_topic":true},"highest_post_number":1,"last_read_post_number":1,"last_read_post_id":15,"deleted_by":null,"has_deleted":false,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":true},{"id":7,"count":0,"hidden":false,"can_act":true},{"id":8,"count":0,"hidden":false,"can_act":true}],"chunk_size":20,"bookmarked":false,"featured_link":null} + ] + }); + + server.get('/polls/voters.json', request => { + let body = {}; + + if (_.isEqual(request.queryParams, { post_id: "15", poll_name: "poll" })) { + body = {"poll":{"68b434ff88aeae7054e42cd05a4d9056":[{"id":402,"username":"bruce400","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":409,"username":"bruce407","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":410,"username":"bruce408","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":411,"username":"bruce409","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":421,"username":"bruce419","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":422,"username":"bruce420","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":423,"username":"bruce421","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":426,"username":"bruce424","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":429,"username":"bruce427","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":437,"username":"bruce435","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":440,"username":"bruce438","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":442,"username":"bruce440","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":443,"username":"bruce441","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":445,"username":"bruce443","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":450,"username":"bruce448","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":451,"username":"bruce449","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":453,"username":"bruce451","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":455,"username":"bruce453","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":461,"username":"bruce459","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":466,"username":"bruce464","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":468,"username":"bruce466","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":477,"username":"bruce475","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":478,"username":"bruce476","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":498,"username":"bruce496","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":501,"username":"bruce499","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null}],"cd314db7dfbac2b10687b6f39abfdf41":[{"id":403,"username":"bruce401","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":404,"username":"bruce402","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":405,"username":"bruce403","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":408,"username":"bruce406","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":413,"username":"bruce411","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":414,"username":"bruce412","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":416,"username":"bruce414","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":418,"username":"bruce416","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":419,"username":"bruce417","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":433,"username":"bruce431","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":434,"username":"bruce432","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":435,"username":"bruce433","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":439,"username":"bruce437","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":441,"username":"bruce439","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":448,"username":"bruce446","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":449,"username":"bruce447","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":452,"username":"bruce450","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":462,"username":"bruce460","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":464,"username":"bruce462","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":465,"username":"bruce463","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":470,"username":"bruce468","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":471,"username":"bruce469","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":474,"username":"bruce472","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":476,"username":"bruce474","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":486,"username":"bruce484","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null}],"4d8a15e3cc35750f016ce15a43937620":[{"id":406,"username":"bruce404","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":407,"username":"bruce405","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":412,"username":"bruce410","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":415,"username":"bruce413","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":417,"username":"bruce415","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":420,"username":"bruce418","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":424,"username":"bruce422","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":425,"username":"bruce423","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":427,"username":"bruce425","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":428,"username":"bruce426","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":430,"username":"bruce428","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":431,"username":"bruce429","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":432,"username":"bruce430","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":436,"username":"bruce434","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":438,"username":"bruce436","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":444,"username":"bruce442","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":446,"username":"bruce444","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":447,"username":"bruce445","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":454,"username":"bruce452","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":458,"username":"bruce456","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":459,"username":"bruce457","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":481,"username":"bruce479","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":492,"username":"bruce490","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":494,"username":"bruce492","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":500,"username":"bruce498","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null}]}}; + } else if (_.isEqual(request.queryParams, { post_id: "15", poll_name: "poll", offset: "1", option_id: "68b434ff88aeae7054e42cd05a4d9056" })) { + body = {"poll":{"68b434ff88aeae7054e42cd05a4d9056":[{"id":402,"username":"bruce400","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":409,"username":"bruce407","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":410,"username":"bruce408","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":411,"username":"bruce409","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":421,"username":"bruce419","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":422,"username":"bruce420","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":423,"username":"bruce421","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":426,"username":"bruce424","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":429,"username":"bruce427","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":437,"username":"bruce435","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":440,"username":"bruce438","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":442,"username":"bruce440","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":443,"username":"bruce441","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":445,"username":"bruce443","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":450,"username":"bruce448","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":451,"username":"bruce449","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":453,"username":"bruce451","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":455,"username":"bruce453","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":456,"username":"bruce454","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":461,"username":"bruce459","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":466,"username":"bruce464","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":468,"username":"bruce466","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":477,"username":"bruce475","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":478,"username":"bruce476","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":498,"username":"bruce496","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null}]}}; + } + + return [200, { "Content-Type": "application/json" }, body]; + }); + + visit("/t/this-is-a-topic-created-for-testing/12"); + + andThen(() => { + const polls = find('.poll'); + equal(polls.length, 1, 'it should render the poll correctly'); + }); + + click('button.toggle-results'); + + andThen(() => { + equal( + find('.poll-voters:first li').length, 25, + 'it should display the right number of voters' + ); + }); + + click('.poll-voters-toggle-expand:first a'); + + andThen(() => { + equal( + find('.poll-voters:first li').length, 50, + 'it should display the right number of voters' + ) + }); +}); + +test("Public number poll", () => { + server.get('/t/13.json', () => { + return [ + 200, + { "Content-Type": "application/json" }, + {"post_stream":{"posts":[{"id":16,"name":null,"username":"tgx","avatar_template":"/images/avatar.png","created_at":"2017-01-31T09:11:11.281Z","cooked":"
    \n
    \n
      \n
    • 1
    • \n
    • 2
    • \n
    • 3
    • \n
    • 4
    • \n
    • 5
    • \n
    • 6
    • \n
    • 7
    • \n
    • 8
    • \n
    • 9
    • \n
    • 10
    • \n
    • 11
    • \n
    • 12
    • \n
    • 13
    • \n
    • 14
    • \n
    • 15
    • \n
    • 16
    • \n
    • 17
    • \n
    • 18
    • \n
    • 19
    • \n
    • 20
    • \n
    \n
    \n

    0voters

    \n

    Votes are public.

    \n
    \n
    \n\n
    ","post_number":1,"post_type":1,"updated_at":"2017-01-31T09:11:11.281Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":13,"topic_slug":"this-is-a-topic-for-testing-number-poll","display_username":null,"primary_group_name":null,"primary_group_flair_url":null,"primary_group_flair_bg_color":null,"primary_group_flair_color":null,"version":1,"can_edit":true,"can_delete":false,"can_recover":true,"can_wiki":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false,"polls":{"poll":{"options":[{"id":"4d8a15e3cc35750f016ce15a43937620","html":"1","votes":2},{"id":"cd314db7dfbac2b10687b6f39abfdf41","html":"2","votes":1},{"id":"68b434ff88aeae7054e42cd05a4d9056","html":"3","votes":1},{"id":"aa2393b424f2f395abb63bf785760a3b","html":"4","votes":0},{"id":"8b2f2930cac0574c3450f5db9a6fb7f9","html":"5","votes":1},{"id":"60cad69e0cfcb3fa77a68d11d3758002","html":"6","votes":0},{"id":"9ab1070dec27185440cdabb4948a5e9a","html":"7","votes":1},{"id":"99944bf07088f815a966d585daed6a7e","html":"8","votes":3},{"id":"345a83050400d78f5fac98d381b45e23","html":"9","votes":3},{"id":"46c01f638a50d86e020f47469733b8be","html":"10","votes":3},{"id":"07f7f85b2a3809faff68a35e81a664eb","html":"11","votes":2},{"id":"b3e8c14e714910cb8dd7089f097be133","html":"12","votes":4},{"id":"b4f15431e07443c372d521e4ed131abe","html":"13","votes":2},{"id":"a77bc9a30933e5af327211db2da46e17","html":"14","votes":2},{"id":"303d7c623da1985e94a9d27d43596934","html":"15","votes":2},{"id":"4e885ead68ff4456f102843df9fbbd7f","html":"16","votes":1},{"id":"cbf6e2b72e403b12d7ee63a138f32647","html":"17","votes":2},{"id":"9364fa2d67fbd62c473165441ad69571","html":"18","votes":2},{"id":"eb8661f072794ea57baa7827cd8ffc88","html":"19","votes":1},{"id":"b373436e858c0821135f994a5ff3345f","html":"20","votes":2}],"voters":35,"status":"open","name":"poll","type":"number","min":"1","max":"20","step":"1","public":"true"}}}],"stream":[16]},"timeline_lookup":[[1,0]],"id":13,"title":"This is a topic for testing number poll","fancy_title":"This is a topic for testing number poll","posts_count":1,"created_at":"2017-01-31T09:11:11.161Z","views":1,"reply_count":0,"participant_count":1,"like_count":0,"last_posted_at":"2017-01-31T09:11:11.281Z","visible":true,"closed":false,"archived":false,"has_summary":false,"archetype":"regular","slug":"this-is-a-topic-for-testing-number-poll","category_id":1,"word_count":12,"deleted_at":null,"user_id":1,"draft":null,"draft_key":"topic_13","draft_sequence":1,"posted":true,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"pinned_until":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"},"last_poster":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"},"participants":[{"id":1,"username":"tgx","avatar_template":"/images/avatar.png","post_count":1,"primary_group_name":null,"primary_group_flair_url":null,"primary_group_flair_color":null,"primary_group_flair_bg_color":null}],"suggested_topics":[{"id":8,"title":"Welcome to Discourse","fancy_title":"Welcome to Discourse","slug":"welcome-to-discourse","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2017-01-31T07:53:45.363Z","last_posted_at":"2017-01-31T07:53:45.439Z","bumped":true,"bumped_at":"2017-01-31T07:53:45.439Z","unseen":false,"pinned":true,"unpinned":null,"excerpt":"The first paragraph of this pinned topic will be visible as a welcome message to all new visitors on your homepage. It's important! \n\nEdit this into a brief description of your community: \n\n\nWho is it for?\nWhat can they …","visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":0,"views":0,"category_id":1,"featured_link":null,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":-1,"username":"system","avatar_template":"/images/avatar.png"}}]},{"id":11,"title":"This is a test post to try out posts","fancy_title":"This is a test post to try out posts","slug":"this-is-a-test-post-to-try-out-posts","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2017-01-31T07:55:58.407Z","last_posted_at":"2017-01-31T07:55:58.634Z","bumped":true,"bumped_at":"2017-01-31T07:55:58.634Z","unseen":false,"last_read_post_number":1,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":1,"category_id":1,"featured_link":null,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"}}]},{"id":12,"title":"This is a topic created for testing","fancy_title":"This is a topic created for testing","slug":"this-is-a-topic-created-for-testing","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2017-01-31T08:39:06.094Z","last_posted_at":"2017-01-31T08:39:06.237Z","bumped":true,"bumped_at":"2017-01-31T09:10:46.528Z","unseen":false,"last_read_post_number":1,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":1,"category_id":1,"featured_link":null,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"}}]}],"notification_level":3,"notifications_reason_id":1,"can_move_posts":true,"can_edit":true,"can_delete":true,"can_recover":true,"can_remove_allowed_users":true,"can_invite_to":true,"can_create_post":true,"can_reply_as_new_topic":true,"can_flag_topic":true},"highest_post_number":1,"last_read_post_number":1,"last_read_post_id":16,"deleted_by":null,"has_deleted":false,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":true},{"id":7,"count":0,"hidden":false,"can_act":true},{"id":8,"count":0,"hidden":false,"can_act":true}],"chunk_size":20,"bookmarked":false,"featured_link":null} + ] + }); + + server.get('/polls/voters.json', request => { + let body = {}; + + if (_.isEqual(request.queryParams, { post_id: "16", poll_name: "poll" })) { + body = {"poll":[{"id":402,"username":"bruce400","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":403,"username":"bruce401","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":404,"username":"bruce402","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":405,"username":"bruce403","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":406,"username":"bruce404","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":407,"username":"bruce405","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":408,"username":"bruce406","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":409,"username":"bruce407","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":410,"username":"bruce408","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":411,"username":"bruce409","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":412,"username":"bruce410","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":413,"username":"bruce411","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":414,"username":"bruce412","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":415,"username":"bruce413","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":416,"username":"bruce414","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":417,"username":"bruce415","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":419,"username":"bruce417","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":421,"username":"bruce419","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":422,"username":"bruce420","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":424,"username":"bruce422","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":425,"username":"bruce423","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":427,"username":"bruce425","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":430,"username":"bruce428","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":431,"username":"bruce429","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":435,"username":"bruce433","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null}]}; + } else if (_.isEqual(request.queryParams, { post_id: "16", poll_name: "poll", offset: "1" })) { + body = {"poll":[{"id":418,"username":"bruce416","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":420,"username":"bruce418","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":423,"username":"bruce421","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":426,"username":"bruce424","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":428,"username":"bruce426","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":429,"username":"bruce427","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":432,"username":"bruce430","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":433,"username":"bruce431","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":434,"username":"bruce432","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null},{"id":436,"username":"bruce434","avatar_template":"/images/avatar.png","name":"Bruce Wayne","title":null}]}; + } + + return [200, { "Content-Type": "application/json" }, body]; + }); + + visit("/t/this-is-a-topic-for-testing-number-poll/13"); + + andThen(() => { + const polls = find('.poll'); + equal(polls.length, 1, 'it should render the poll correctly'); + }); + + click('button.toggle-results'); + + andThen(() => { + equal( + find('.poll-voters:first li').length, 25, + 'it should display the right number of voters' + ); + }); + + click('.poll-voters-toggle-expand:first a'); + + andThen(() => { + equal( + find('.poll-voters:first li').length, 35, + 'it should display the right number of voters' + ) + }); +}); diff --git a/plugins/poll/test/javascripts/controllers/poll-ui-builder-test.js.es6 b/plugins/poll/test/javascripts/controllers/poll-ui-builder-test.js.es6 index 60c50ca58f..61373127de 100644 --- a/plugins/poll/test/javascripts/controllers/poll-ui-builder-test.js.es6 +++ b/plugins/poll/test/javascripts/controllers/poll-ui-builder-test.js.es6 @@ -59,6 +59,14 @@ test("showMinMax", function() { }); equal(controller.get("showMinMax"), true, "it should be true"); + + controller.setProperties({ + isNumber: false, + isMultiple: false, + isRegular: true + }); + + equal(controller.get("showMinMax"), false, "it should be false"); }); test("pollOptionsCount", function() { @@ -147,6 +155,14 @@ test("disableInsert", function() { const controller = this.subject(); controller.siteSettings = Discourse.SiteSettings; + controller.setProperties({ isRegular: true }); + + equal(controller.get("disableInsert"), true, "it should be true"); + + controller.setProperties({ isRegular: true, pollOptionsCount: 2 }); + + equal(controller.get("disableInsert"), false, "it should be false"); + controller.setProperties({ isNumber: true }); equal(controller.get("disableInsert"), false, "it should be false"); @@ -188,12 +204,16 @@ test("regular pollOutput", function() { controller.siteSettings.poll_maximum_options = 20; controller.set("pollOptions", "1\n2"); + controller.setProperties({ + pollOptions: "1\n2", + pollType: controller.get("regularPollType") + }); - equal(controller.get("pollOutput"), "[poll]\n* 1\n* 2\n[/poll]", "it should return the right output"); + equal(controller.get("pollOutput"), "[poll type=regular]\n* 1\n* 2\n[/poll]", "it should return the right output"); controller.set("publicPoll", "true"); - equal(controller.get("pollOutput"), "[poll public=true]\n* 1\n* 2\n[/poll]", "it should return the right output"); + equal(controller.get("pollOutput"), "[poll type=regular public=true]\n* 1\n* 2\n[/poll]", "it should return the right output"); }); diff --git a/public/403.cs.html b/public/403.cs.html index 1558630fb1..f9d2d1fc6d 100644 --- a/public/403.cs.html +++ b/public/403.cs.html @@ -20,7 +20,7 @@

    403

    Nemůžete zobrazit tento zdroj!

    -

    Toto bude nahrazeno zvláštní 403 stránkou Discourse.

    +

    Toto bude nahrazeno zvláštní Discourse stránkou 403.

    diff --git a/public/403.el.html b/public/403.el.html new file mode 100644 index 0000000000..3a9905a252 --- /dev/null +++ b/public/403.el.html @@ -0,0 +1,26 @@ + + +Αυτό δεν επιτρέπετε να το κάνεις (403) + + + + +
    +

    403

    +

    Δεν μπορείς να δείς αυτό το περιεχόμενο!

    + +

    Αυτή θα αντικατασταθεί με μια προσαρμοσμένη σελίδα 403 του Discourse.

    +
    + + diff --git a/public/403.nl.html b/public/403.nl.html index 6b3999f78e..7b03290e9e 100644 --- a/public/403.nl.html +++ b/public/403.nl.html @@ -18,9 +18,9 @@

    403

    -

    Je mag dit onderdeel niet bekijken!

    +

    Je kan dat onderdeel niet bekijken!

    -

    Dit zal vervangen worden door een eigen Discourse 403 pagina.

    +

    Dit zal worden vervangen door een eigen Discourse 403-pagina.

    diff --git a/public/403.sv.html b/public/403.sv.html index cb78010cc2..e042091983 100644 --- a/public/403.sv.html +++ b/public/403.sv.html @@ -20,7 +20,7 @@

    403

    Den resursen får du inte se!

    -

    Detta kommer att ersättas med en skräddarsydd sida för Discourse 403.

    +

    Detta kommer att ersättas med en skräddarsydd 403-sida för Discourse.

    diff --git a/public/422.cs.html b/public/422.cs.html index 9975c415c2..ae0439e5a1 100644 --- a/public/422.cs.html +++ b/public/422.cs.html @@ -18,8 +18,8 @@
    -

    Změna, kterou jste chtěl(a) byla odmítnuta.

    -

    Možná jste se pokusil(a) změnit něco k čemu jste neměl(a) přístup.

    +

    Požadovaná změna byla odmítnuta.

    +

    Možná jste se pokusil(a) změnit něco, k čemu jste neměl(a) přístup.

    diff --git a/public/422.el.html b/public/422.el.html new file mode 100644 index 0000000000..f8ffc181d3 --- /dev/null +++ b/public/422.el.html @@ -0,0 +1,25 @@ + + +Η αλλαγή που ζήτησες απορρίφθηκε (422) + + + + + +
    +

    Η αλλαγή που ζήτησες απορρίφθηκε.

    +

    Ίσως προσπάθησες να αλλάξεις κάτι για το οποίο δεν έχεις πρόσβαση.

    +
    + + diff --git a/public/422.fa_IR.html b/public/422.fa_IR.html index 074ff49512..b08b4263a5 100644 --- a/public/422.fa_IR.html +++ b/public/422.fa_IR.html @@ -19,7 +19,7 @@

    تغییری که خواستید، رد شد.

    -

    شاید شما تلاش کردید چیزی را تغییر دهید که پروانهٔ دسترسی به آن را نداشتید.

    +

    شاید شما تلاش کردید چیزی را تغییر دهید که دسترسی به آن را نداشتید.

    diff --git a/public/422.nl.html b/public/422.nl.html index 0050dcf272..d654bc4c9e 100644 --- a/public/422.nl.html +++ b/public/422.nl.html @@ -19,7 +19,7 @@

    De geplande wijziging is geweigerd.

    -

    Misschien probeerde je iets te doen waar je geen rechten voor hebt.

    +

    Misschien probeerde je iets te wijzigen waarvoor je geen toegang hebt.

    diff --git a/public/500.ar.html b/public/500.ar.html index 99d27d4ae0..72323ee844 100644 --- a/public/500.ar.html +++ b/public/500.ar.html @@ -5,7 +5,7 @@

    آخ

    -

    البرمجية وراء منصة المناقشة هذه واجهت مشكلة غير متوقعة. نعتذر عن الإزعاج.

    +

    البرمجية المشغّلة لمنصة المناقشة هذه واجهت مشكلة غير متوقعة. نعتذر على الإزعاج.

    خُزّنت معلومات مفصلة عن الخطأ، ووُلّد إشعار آلي. سنفحص المعلومات ونرى ما المشكلة.

    لا حاجة لاتخاذ أي إجراء آخر. ولكن لو استمر حدوث الخطأ، فيمكنك توفير تفاصيل إضافية أهمها الخطوات المتبعة لتكرار حدوث الخطأ، ونشرها بعد ذلك في موضوع في فئة المشاكل والأخطاء داخل الموقع.

    diff --git a/public/500.el.html b/public/500.el.html new file mode 100644 index 0000000000..216aaf6d26 --- /dev/null +++ b/public/500.el.html @@ -0,0 +1,12 @@ + + +Ωχ - Σφάλμα 500 + + + +

    Ωχ

    +

    Προέκυψε ένα απρόσμενο πρόβλημα στο λογισμικό αυτού του φόρουμ. Λυπούμαστε για την ταλαιπωρία.

    +

    Καταγράφηκαν λεπτομερείς πληροφορίες σχετικά με το σφάλμα και δημιουργήθηκε μια αυτόματη ειδοποίηση. Θα το εξετάσουμε σύντομα.

    +

    Καμία περαιτέρω ενέργεια δεν είναι απαραίτητη. Ωστόσο, αν το πρόβλημα παραμένει, μπορείτε να κάνετε μια ανάρτηση στο νήμα Site Feedback για να μας δώσετε περισσότερες λεπτομέρειες, συμπεριλαμβανομένης και της διαδικασίας για να αναπαράγουμε το πρόβλημα.

    + + diff --git a/public/500.fa_IR.html b/public/500.fa_IR.html index cb0254098b..7ec5303935 100644 --- a/public/500.fa_IR.html +++ b/public/500.fa_IR.html @@ -1,12 +1,14 @@ -اِی بابا - خطای 500 + اوه - خطای 500 -

    اِی بابا

    -

    نرم‌افزاری که ا به ین انجمن گفتگو را قدرت می‌دهد، با مشکلی غیرمنتظره روبرو شده است. از اینکه به زحمت افتادید پوزش میخواهیم.

    +

    اوه

    +

    نرم‌افزاری که به این انجمن گفتگو قدرت می‌دهد، با مشکلی غیرمنتظره روبرو شده است. از اینکه به زحمت افتادید پوزش میخواهیم.

    اطلاعات کامل در مورد این خطا، به سیستم ارسال شد و اطلاع رسانی به صورت خودکار تولید شد. ما آن را برسی می نماییم.

    -

    No further action is necessary. However, if the error condition persists, you can provide additional detail, including steps to reproduce the error, by posting a discussion topic in the site's feedback category.

    +

    هیچ اقدام دیگری لازم نیست انجام دهید . با این وجود اگر خطا + همچنان باقی است , شما می توانید جزئیات آن را به همراه گام +های لازم برای تولید خطایی مثل این ,در یک موضوع جدید در دسته بازخوردهای سایت ارسال نمایید.

    diff --git a/public/500.nl.html b/public/500.nl.html index 976f08a58f..7a092cd2a1 100644 --- a/public/500.nl.html +++ b/public/500.nl.html @@ -5,8 +5,8 @@

    Oeps

    -

    Er is een onverwacht probleem opgetreden met de software waar dit forum op draait. Excuses voor het ongemak.

    +

    Er is een onverwacht probleem opgetreden met de software waarmee dit forum werkt. Excuses voor het ongemak.

    Gedetailleerde informatie over de fout is in het logboek opgeslagen en de beheerders zijn automatisch ingelicht. We zullen er naar kijken.

    -

    Er is geen verdere actie nodig. Als de fout blijft voorkomen, geef dan verdere uitleg in de metacategorie, zoals de stappen om de fout te reproduceren.

    +

    Er is geen verdere actie nodig. Als de fout echter blijft voorkomen, kan je aanvullende details opgeven, waaronder stappen om de fout te reproduceren, door een discussietopic in de feedbackcategorie van de website te openen.

    diff --git a/public/500.vi.html b/public/500.vi.html index d82e1248a5..9f4adf6470 100644 --- a/public/500.vi.html +++ b/public/500.vi.html @@ -7,6 +7,6 @@

    Ối

    Phần mềm chạy diễn đàn thảo luận này bất ngờ gặp sự cố không mong muốn. Chúng tôi rất xin lỗi vì sự bất tiện này.

    Thông tin chi tiết về lỗi đã được ghi lại và thông báo tự động đã được tạo. Chúng tôi sẽ xem xét lỗi này.

    -

    Không cần tiến hành bất cứ hành động nào. Tuy nhiên, nếu lỗi này vẫn tiếp tục, bạn có thể cung cấp thêm thông tin chi tiết bao gồm các bước để tái tạo lỗi bằng cách tạo một thảo luận trên chuyên mục site feedback.

    +

    Không cần tiến hành bất cứ hành động nào. Tuy nhiên, nếu lỗi này vẫn tồn tại, hãy cung cấp thêm thông tin chi tiết bao gồm các bước để phát sinh lại lỗi và chủ đề tại meta.discourse.org/c/support.

    diff --git a/public/503.el.html b/public/503.el.html new file mode 100644 index 0000000000..c87180b824 --- /dev/null +++ b/public/503.el.html @@ -0,0 +1,11 @@ + + +Ο ιστότοπος βρίσκεται σε κατάσταση συντήρησης - Discourse.org + + + +

    Ο ιστότοπος είναι εκτός λειτουργίας λόγω προγραμματισμένης συντήρησης.

    +

    Δοκιμάστε πάλι σε λίγα λεπτά.

    +

    Λυπούμαστε για την ταλαιπωρία!

    + + diff --git a/public/503.fa_IR.html b/public/503.fa_IR.html index c584976cd5..6d14e0c683 100644 --- a/public/503.fa_IR.html +++ b/public/503.fa_IR.html @@ -5,7 +5,7 @@

    طبق برنامه‌ ریزی، هم‌اکنون سایت برای تعمیر و نگهداری در دسترس نیست.

    -

    لطفاً دوباره برسی کنید چند دقیقهٔ دیگر.

    +

    لطفاً چند دقیقهٔ دیگر دوباره برسی کنید.

    پوزش برای این آزردگی!

    diff --git a/public/503.fr.html b/public/503.fr.html index 61aeb6bbe8..6a14566b77 100644 --- a/public/503.fr.html +++ b/public/503.fr.html @@ -5,7 +5,7 @@

    Nous sommes actuellement indisponible pour une maintenance planifiée.

    -

    Merci de revenir dans quelques minutes</ span>.

    +

    Merci de revenir dans quelques minutes.

    Désolé pour le dérangement !

    diff --git a/public/503.nl.html b/public/503.nl.html index 0c84dea9dc..1953a6a250 100644 --- a/public/503.nl.html +++ b/public/503.nl.html @@ -1,10 +1,10 @@ -Er wordt momenteel aan de site gewerkt - Discourse.org +Er wordt aan de website gewerkt - Discourse.org -

    Je kunt ons momenteel niet bereiken door gepland onderhoud van de site

    +

    De website is momenteel niet bereikbaar vanwege gepland onderhoud

    Kom over een paar minuten terug.

    Excuses voor het ongemak!

    diff --git a/public/503.ur.html b/public/503.ur.html index d9a0dbe7ed..c6b8726fc9 100644 --- a/public/503.ur.html +++ b/public/503.ur.html @@ -1,11 +1,11 @@ -Site Is Undergoing Maintenance - Discourse.org +ویب سائٹ کی محافظت ہو رہی ہے - Discourse.org -

    We are currently down for planned site maintenance

    -

    Please check back in a few minutes.

    -

    Sorry for the inconvenience!

    +

    ویب سائٹ ابھی وقتی محافظت کے لیے ڈاون ہے۔

    +

    کچھ منٹ بعد چیک کریں۔

    +

    تکلیف کے لئے معذرت!

    diff --git a/public/503.vi.html b/public/503.vi.html index a952fd3a5a..0216e90242 100644 --- a/public/503.vi.html +++ b/public/503.vi.html @@ -1,10 +1,10 @@ -Trang đang được bảo trì- Discourse.org +Trang đang được bảo trì -

    Trang tạm dừng dịch vụ để bảo trì dịch vụ

    +

    Chúng tôi đang tạm ngưng dịch vụ để bảo trì

    Vui lòng quay lại sau vài phút.

    Xin lỗi về sự bất tiện này!

    diff --git a/public/images/envelope.svg b/public/images/envelope.svg new file mode 100644 index 0000000000..6400f5af46 --- /dev/null +++ b/public/images/envelope.svg @@ -0,0 +1 @@ +image/svg+xml diff --git a/public/images/lock.svg b/public/images/lock.svg new file mode 100644 index 0000000000..05de608a7d --- /dev/null +++ b/public/images/lock.svg @@ -0,0 +1 @@ +image/svg+xml diff --git a/public/javascripts/embed.js b/public/javascripts/embed.js index 16f7e05f27..024dacb61e 100644 --- a/public/javascripts/embed.js +++ b/public/javascripts/embed.js @@ -11,6 +11,10 @@ var queryParams = {}; if (DE.discourseEmbedUrl) { + if (DE.discourseEmbedUrl.indexOf('/') === 0) { + console.error("discourseEmbedUrl must be a full URL, not a relative path"); + } + queryParams.embed_url = encodeURIComponent(DE.discourseEmbedUrl); } diff --git a/public/javascripts/jquery.magnific-popup-min.js b/public/javascripts/jquery.magnific-popup-min.js deleted file mode 100644 index 3124853033..0000000000 --- a/public/javascripts/jquery.magnific-popup-min.js +++ /dev/null @@ -1,3 +0,0 @@ -// Magnific Popup v0.9.9 by Dmitry Semenov -// http://bit.ly/magnific-popup#build=image -(function(a){var b="Close",c="BeforeClose",d="AfterClose",e="BeforeAppend",f="MarkupParse",g="Open",h="Change",i="mfp",j="."+i,k="mfp-ready",l="mfp-removing",m="mfp-prevent-close",n,o=function(){},p=!!window.jQuery,q,r=a(window),s,t,u,v,w,x=function(a,b){n.ev.on(i+a+j,b)},y=function(b,c,d,e){var f=document.createElement("div");return f.className="mfp-"+b,d&&(f.innerHTML=d),e?c&&c.appendChild(f):(f=a(f),c&&f.appendTo(c)),f},z=function(b,c){n.ev.triggerHandler(i+b,c),n.st.callbacks&&(b=b.charAt(0).toLowerCase()+b.slice(1),n.st.callbacks[b]&&n.st.callbacks[b].apply(n,a.isArray(c)?c:[c]))},A=function(b){if(b!==w||!n.currTemplate.closeBtn)n.currTemplate.closeBtn=a(n.st.closeMarkup.replace("%title%",n.st.tClose)),w=b;return n.currTemplate.closeBtn},B=function(){a.magnificPopup.instance||(n=new o,n.init(),a.magnificPopup.instance=n)},C=function(){var a=document.createElement("p").style,b=["ms","O","Moz","Webkit"];if(a.transition!==undefined)return!0;while(b.length)if(b.pop()+"Transition"in a)return!0;return!1};o.prototype={constructor:o,init:function(){var b=navigator.appVersion;n.isIE7=b.indexOf("MSIE 7.")!==-1,n.isIE8=b.indexOf("MSIE 8.")!==-1,n.isLowIE=n.isIE7||n.isIE8,n.isAndroid=/android/gi.test(b),n.isIOS=/iphone|ipad|ipod/gi.test(b),n.supportsTransition=C(),n.probablyMobile=n.isAndroid||n.isIOS||/(Opera Mini)|Kindle|webOS|BlackBerry|(Opera Mobi)|(Windows Phone)|IEMobile/i.test(navigator.userAgent),s=a(document.body),t=a(document),n.popupsCache={}},open:function(b){var c;if(b.isObj===!1){n.items=b.items.toArray(),n.index=0;var d=b.items,e;for(c=0;c(a||r.height())},_setFocus:function(){(n.st.focus?n.content.find(n.st.focus).eq(0):n.wrap).focus()},_onFocusIn:function(b){if(b.target!==n.wrap[0]&&!a.contains(n.wrap[0],b.target))return n._setFocus(),!1},_parseMarkup:function(b,c,d){var e;d.data&&(c=a.extend(d.data,c)),z(f,[b,c,d]),a.each(c,function(a,c){if(c===undefined||c===!1)return!0;e=a.split("_");if(e.length>1){var d=b.find(j+"-"+e[0]);if(d.length>0){var f=e[1];f==="replaceWith"?d[0]!==c[0]&&d.replaceWith(c):f==="img"?d.is("img")?d.attr("src",c):d.replaceWith(''):d.attr(e[1],c)}}else b.find(j+"-"+a).html(c)})},_getScrollbarSize:function(){if(n.scrollbarSize===undefined){var a=document.createElement("div");a.id="mfp-sbm",a.style.cssText="width: 99px; height: 99px; overflow: scroll; position: absolute; top: -9999px;",document.body.appendChild(a),n.scrollbarSize=a.offsetWidth-a.clientWidth,document.body.removeChild(a)}return n.scrollbarSize}},a.magnificPopup={instance:null,proto:o.prototype,modules:[],open:function(b,c){return B(),b?b=a.extend(!0,{},b):b={},b.isObj=!0,b.index=c||0,this.instance.open(b)},close:function(){return a.magnificPopup.instance&&a.magnificPopup.instance.close()},registerModule:function(b,c){c.options&&(a.magnificPopup.defaults[b]=c.options),a.extend(this.proto,c.proto),this.modules.push(b)},defaults:{disableOn:0,key:null,midClick:!1,mainClass:"",preloader:!0,focus:"",closeOnContentClick:!1,closeOnBgClick:!0,closeBtnInside:!0,showCloseBtn:!0,enableEscapeKey:!0,modal:!1,alignTop:!1,removalDelay:0,fixedContentPos:"auto",fixedBgPos:"auto",overflowY:"auto",closeMarkup:'',tClose:"Close (Esc)",tLoading:"Loading..."}},a.fn.magnificPopup=function(b){B();var c=a(this);if(typeof b=="string")if(b==="open"){var d,e=p?c.data("magnificPopup"):c[0].magnificPopup,f=parseInt(arguments[1],10)||0;e.items?d=e.items[f]:(d=c,e.delegate&&(d=d.find(e.delegate)),d=d.eq(f)),n._openClick({mfpEl:d},c,e)}else n.isOpen&&n[b].apply(n,Array.prototype.slice.call(arguments,1));else b=a.extend(!0,{},b),p?c.data("magnificPopup",b):c[0].magnificPopup=b,n.addGroup(c,b);return c};var D,E=function(b){if(b.data&&b.data.title!==undefined)return b.data.title;var c=n.st.image.titleSrc;if(c){if(a.isFunction(c))return c.call(n,b);if(b.el)return b.el.attr(c)||""}return""};a.magnificPopup.registerModule("image",{options:{markup:'
    ',cursor:"mfp-zoom-out-cur",titleSrc:"title",verticalFit:!0,tError:'The image could not be loaded.'},proto:{initImage:function(){var a=n.st.image,c=".image";n.types.push("image"),x(g+c,function(){n.currItem.type==="image"&&a.cursor&&s.addClass(a.cursor)}),x(b+c,function(){a.cursor&&s.removeClass(a.cursor),r.off("resize"+j)}),x("Resize"+c,n.resizeImage),n.isLowIE&&x("AfterChange",n.resizeImage)},resizeImage:function(){var a=n.currItem;if(!a||!a.img)return;if(n.st.image.verticalFit){var b=0;n.isLowIE&&(b=parseInt(a.img.css("padding-top"),10)+parseInt(a.img.css("padding-bottom"),10)),a.img.css("max-height",n.wH-b)}},_onImageHasSize:function(a){a.img&&(a.hasSize=!0,D&&clearInterval(D),a.isCheckingImgSize=!1,z("ImageHasSize",a),a.imgHidden&&(n.content&&n.content.removeClass("mfp-loading"),a.imgHidden=!1))},findImageSize:function(a){var b=0,c=a.img[0],d=function(e){D&&clearInterval(D),D=setInterval(function(){if(c.naturalWidth>0){n._onImageHasSize(a);return}b>200&&clearInterval(D),b++,b===3?d(10):b===40?d(50):b===100&&d(500)},e)};d(1)},getImage:function(b,c){var d=0,e=function(){b&&(b.img[0].complete?(b.img.off(".mfploader"),b===n.currItem&&(n._onImageHasSize(b),n.updateStatus("ready")),b.hasSize=!0,b.loaded=!0,z("ImageLoadComplete")):(d++,d<200?setTimeout(e,100):f()))},f=function(){b&&(b.img.off(".mfploader"),b===n.currItem&&(n._onImageHasSize(b),n.updateStatus("error",g.tError.replace("%url%",b.src))),b.hasSize=!0,b.loaded=!0,b.loadError=!0)},g=n.st.image,h=c.find(".mfp-img");if(h.length){var i=document.createElement("img");i.className="mfp-img",b.img=a(i).on("load.mfploader",e).on("error.mfploader",f),i.src=b.src,h.is("img")&&(b.img=b.img.clone()),i=b.img[0],i.naturalWidth>0?b.hasSize=!0:i.width||(b.hasSize=!1)}return n._parseMarkup(c,{title:E(b),img_replaceWith:b.img},b),n.resizeImage(),b.hasSize?(D&&clearInterval(D),b.loadError?(c.addClass("mfp-loading"),n.updateStatus("error",g.tError.replace("%url%",b.src))):(c.removeClass("mfp-loading"),n.updateStatus("ready")),c):(n.updateStatus("loading"),b.loading=!0,b.hasSize||(b.imgHidden=!0,c.addClass("mfp-loading"),n.findImageSize(b)),c)}}});var F,G=function(){return F===undefined&&(F=document.createElement("p").style.MozTransform!==undefined),F};a.magnificPopup.registerModule("zoom",{options:{enabled:!1,easing:"ease-in-out",duration:300,opener:function(a){return a.is("img")?a:a.find("img")}},proto:{initZoom:function(){var a=n.st.zoom,d=".zoom",e;if(!a.enabled||!n.supportsTransition)return;var f=a.duration,g=function(b){var c=b.clone().removeAttr("style").removeAttr("class").addClass("mfp-animated-image"),d="all "+a.duration/1e3+"s "+a.easing,e={position:"fixed",zIndex:9999,left:0,top:0,"-webkit-backface-visibility":"hidden"},f="transition";return e["-webkit-"+f]=e["-moz-"+f]=e["-o-"+f]=e[f]=d,c.css(e),c},h=function(){n.content.css("visibility","visible")},i,j;x("BuildControls"+d,function(){if(n._allowZoom()){clearTimeout(i),n.content.css("visibility","hidden"),e=n._getItemToZoom();if(!e){h();return}j=g(e),j.css(n._getOffset()),n.wrap.append(j),i=setTimeout(function(){j.css(n._getOffset(!0)),i=setTimeout(function(){h(),setTimeout(function(){j.remove(),e=j=null,z("ZoomAnimationEnded")},16)},f)},16)}}),x(c+d,function(){if(n._allowZoom()){clearTimeout(i),n.st.removalDelay=f;if(!e){e=n._getItemToZoom();if(!e)return;j=g(e)}j.css(n._getOffset(!0)),n.wrap.append(j),n.content.css("visibility","hidden"),setTimeout(function(){j.css(n._getOffset())},16)}}),x(b+d,function(){n._allowZoom()&&(h(),j&&j.remove(),e=null)})},_allowZoom:function(){return n.currItem.type==="image"},_getItemToZoom:function(){return n.currItem.hasSize?n.currItem.img:!1},_getOffset:function(b){var c;b?c=n.currItem.img:c=n.st.zoom.opener(n.currItem.el||n.currItem);var d=c.offset(),e=parseInt(c.css("padding-top"),10),f=parseInt(c.css("padding-bottom"),10);d.top-=a(window).scrollTop()-e;var g={width:c.width(),height:(p?c.innerHeight():c[0].offsetHeight)-f-e};return G()?g["-moz-transform"]=g.transform="translate("+d.left+"px,"+d.top+"px)":(g.left=d.left,g.top=d.top),g}}}),B()})(window.jQuery||window.Zepto) diff --git a/public/javascripts/jquery.magnific-popup.min.js b/public/javascripts/jquery.magnific-popup.min.js new file mode 100644 index 0000000000..6ee3a3bd5b --- /dev/null +++ b/public/javascripts/jquery.magnific-popup.min.js @@ -0,0 +1,4 @@ +/*! Magnific Popup - v1.1.0 - 2016-02-20 +* http://dimsemenov.com/plugins/magnific-popup/ +* Copyright (c) 2016 Dmitry Semenov; */ +!function(a){"function"==typeof define&&define.amd?define(["jquery"],a):a("object"==typeof exports?require("jquery"):window.jQuery||window.Zepto)}(function(a){var b,c,d,e,f,g,h="Close",i="BeforeClose",j="AfterClose",k="BeforeAppend",l="MarkupParse",m="Open",n="Change",o="mfp",p="."+o,q="mfp-ready",r="mfp-removing",s="mfp-prevent-close",t=function(){},u=!!window.jQuery,v=a(window),w=function(a,c){b.ev.on(o+a+p,c)},x=function(b,c,d,e){var f=document.createElement("div");return f.className="mfp-"+b,d&&(f.innerHTML=d),e?c&&c.appendChild(f):(f=a(f),c&&f.appendTo(c)),f},y=function(c,d){b.ev.triggerHandler(o+c,d),b.st.callbacks&&(c=c.charAt(0).toLowerCase()+c.slice(1),b.st.callbacks[c]&&b.st.callbacks[c].apply(b,a.isArray(d)?d:[d]))},z=function(c){return c===g&&b.currTemplate.closeBtn||(b.currTemplate.closeBtn=a(b.st.closeMarkup.replace("%title%",b.st.tClose)),g=c),b.currTemplate.closeBtn},A=function(){a.magnificPopup.instance||(b=new t,b.init(),a.magnificPopup.instance=b)},B=function(){var a=document.createElement("p").style,b=["ms","O","Moz","Webkit"];if(void 0!==a.transition)return!0;for(;b.length;)if(b.pop()+"Transition"in a)return!0;return!1};t.prototype={constructor:t,init:function(){var c=navigator.appVersion;b.isLowIE=b.isIE8=document.all&&!document.addEventListener,b.isAndroid=/android/gi.test(c),b.isIOS=/iphone|ipad|ipod/gi.test(c),b.supportsTransition=B(),b.probablyMobile=b.isAndroid||b.isIOS||/(Opera Mini)|Kindle|webOS|BlackBerry|(Opera Mobi)|(Windows Phone)|IEMobile/i.test(navigator.userAgent),d=a(document),b.popupsCache={}},open:function(c){var e;if(c.isObj===!1){b.items=c.items.toArray(),b.index=0;var g,h=c.items;for(e=0;e(a||v.height())},_setFocus:function(){(b.st.focus?b.content.find(b.st.focus).eq(0):b.wrap).focus()},_onFocusIn:function(c){return c.target===b.wrap[0]||a.contains(b.wrap[0],c.target)?void 0:(b._setFocus(),!1)},_parseMarkup:function(b,c,d){var e;d.data&&(c=a.extend(d.data,c)),y(l,[b,c,d]),a.each(c,function(c,d){if(void 0===d||d===!1)return!0;if(e=c.split("_"),e.length>1){var f=b.find(p+"-"+e[0]);if(f.length>0){var g=e[1];"replaceWith"===g?f[0]!==d[0]&&f.replaceWith(d):"img"===g?f.is("img")?f.attr("src",d):f.replaceWith(a("").attr("src",d).attr("class",f.attr("class"))):f.attr(e[1],d)}}else b.find(p+"-"+c).html(d)})},_getScrollbarSize:function(){if(void 0===b.scrollbarSize){var a=document.createElement("div");a.style.cssText="width: 99px; height: 99px; overflow: scroll; position: absolute; top: -9999px;",document.body.appendChild(a),b.scrollbarSize=a.offsetWidth-a.clientWidth,document.body.removeChild(a)}return b.scrollbarSize}},a.magnificPopup={instance:null,proto:t.prototype,modules:[],open:function(b,c){return A(),b=b?a.extend(!0,{},b):{},b.isObj=!0,b.index=c||0,this.instance.open(b)},close:function(){return a.magnificPopup.instance&&a.magnificPopup.instance.close()},registerModule:function(b,c){c.options&&(a.magnificPopup.defaults[b]=c.options),a.extend(this.proto,c.proto),this.modules.push(b)},defaults:{disableOn:0,key:null,midClick:!1,mainClass:"",preloader:!0,focus:"",closeOnContentClick:!1,closeOnBgClick:!0,closeBtnInside:!0,showCloseBtn:!0,enableEscapeKey:!0,modal:!1,alignTop:!1,removalDelay:0,prependTo:null,fixedContentPos:"auto",fixedBgPos:"auto",overflowY:"auto",closeMarkup:'',tClose:"Close (Esc)",tLoading:"Loading...",autoFocusLast:!0}},a.fn.magnificPopup=function(c){A();var d=a(this);if("string"==typeof c)if("open"===c){var e,f=u?d.data("magnificPopup"):d[0].magnificPopup,g=parseInt(arguments[1],10)||0;f.items?e=f.items[g]:(e=d,f.delegate&&(e=e.find(f.delegate)),e=e.eq(g)),b._openClick({mfpEl:e},d,f)}else b.isOpen&&b[c].apply(b,Array.prototype.slice.call(arguments,1));else c=a.extend(!0,{},c),u?d.data("magnificPopup",c):d[0].magnificPopup=c,b.addGroup(d,c);return d};var C,D,E,F="inline",G=function(){E&&(D.after(E.addClass(C)).detach(),E=null)};a.magnificPopup.registerModule(F,{options:{hiddenClass:"hide",markup:"",tNotFound:"Content not found"},proto:{initInline:function(){b.types.push(F),w(h+"."+F,function(){G()})},getInline:function(c,d){if(G(),c.src){var e=b.st.inline,f=a(c.src);if(f.length){var g=f[0].parentNode;g&&g.tagName&&(D||(C=e.hiddenClass,D=x(C),C="mfp-"+C),E=f.after(D).detach().removeClass(C)),b.updateStatus("ready")}else b.updateStatus("error",e.tNotFound),f=a("
    ");return c.inlineElement=f,f}return b.updateStatus("ready"),b._parseMarkup(d,{},c),d}}});var H,I="ajax",J=function(){H&&a(document.body).removeClass(H)},K=function(){J(),b.req&&b.req.abort()};a.magnificPopup.registerModule(I,{options:{settings:null,cursor:"mfp-ajax-cur",tError:'The content could not be loaded.'},proto:{initAjax:function(){b.types.push(I),H=b.st.ajax.cursor,w(h+"."+I,K),w("BeforeChange."+I,K)},getAjax:function(c){H&&a(document.body).addClass(H),b.updateStatus("loading");var d=a.extend({url:c.src,success:function(d,e,f){var g={data:d,xhr:f};y("ParseAjax",g),b.appendContent(a(g.data),I),c.finished=!0,J(),b._setFocus(),setTimeout(function(){b.wrap.addClass(q)},16),b.updateStatus("ready"),y("AjaxContentAdded")},error:function(){J(),c.finished=c.loadError=!0,b.updateStatus("error",b.st.ajax.tError.replace("%url%",c.src))}},b.st.ajax.settings);return b.req=a.ajax(d),""}}});var L,M=function(c){if(c.data&&void 0!==c.data.title)return c.data.title;var d=b.st.image.titleSrc;if(d){if(a.isFunction(d))return d.call(b,c);if(c.el)return c.el.attr(d)||""}return""};a.magnificPopup.registerModule("image",{options:{markup:'
    ',cursor:"mfp-zoom-out-cur",titleSrc:"title",verticalFit:!0,tError:'The image could not be loaded.'},proto:{initImage:function(){var c=b.st.image,d=".image";b.types.push("image"),w(m+d,function(){"image"===b.currItem.type&&c.cursor&&a(document.body).addClass(c.cursor)}),w(h+d,function(){c.cursor&&a(document.body).removeClass(c.cursor),v.off("resize"+p)}),w("Resize"+d,b.resizeImage),b.isLowIE&&w("AfterChange",b.resizeImage)},resizeImage:function(){var a=b.currItem;if(a&&a.img&&b.st.image.verticalFit){var c=0;b.isLowIE&&(c=parseInt(a.img.css("padding-top"),10)+parseInt(a.img.css("padding-bottom"),10)),a.img.css("max-height",b.wH-c)}},_onImageHasSize:function(a){a.img&&(a.hasSize=!0,L&&clearInterval(L),a.isCheckingImgSize=!1,y("ImageHasSize",a),a.imgHidden&&(b.content&&b.content.removeClass("mfp-loading"),a.imgHidden=!1))},findImageSize:function(a){var c=0,d=a.img[0],e=function(f){L&&clearInterval(L),L=setInterval(function(){return d.naturalWidth>0?void b._onImageHasSize(a):(c>200&&clearInterval(L),c++,void(3===c?e(10):40===c?e(50):100===c&&e(500)))},f)};e(1)},getImage:function(c,d){var e=0,f=function(){c&&(c.img[0].complete?(c.img.off(".mfploader"),c===b.currItem&&(b._onImageHasSize(c),b.updateStatus("ready")),c.hasSize=!0,c.loaded=!0,y("ImageLoadComplete")):(e++,200>e?setTimeout(f,100):g()))},g=function(){c&&(c.img.off(".mfploader"),c===b.currItem&&(b._onImageHasSize(c),b.updateStatus("error",h.tError.replace("%url%",c.src))),c.hasSize=!0,c.loaded=!0,c.loadError=!0)},h=b.st.image,i=d.find(".mfp-img");if(i.length){var j=document.createElement("img");j.className="mfp-img",c.el&&c.el.find("img").length&&(j.alt=c.el.find("img").attr("alt")),c.img=a(j).on("load.mfploader",f).on("error.mfploader",g),j.src=c.src,i.is("img")&&(c.img=c.img.clone()),j=c.img[0],j.naturalWidth>0?c.hasSize=!0:j.width||(c.hasSize=!1)}return b._parseMarkup(d,{title:M(c),img_replaceWith:c.img},c),b.resizeImage(),c.hasSize?(L&&clearInterval(L),c.loadError?(d.addClass("mfp-loading"),b.updateStatus("error",h.tError.replace("%url%",c.src))):(d.removeClass("mfp-loading"),b.updateStatus("ready")),d):(b.updateStatus("loading"),c.loading=!0,c.hasSize||(c.imgHidden=!0,d.addClass("mfp-loading"),b.findImageSize(c)),d)}}});var N,O=function(){return void 0===N&&(N=void 0!==document.createElement("p").style.MozTransform),N};a.magnificPopup.registerModule("zoom",{options:{enabled:!1,easing:"ease-in-out",duration:300,opener:function(a){return a.is("img")?a:a.find("img")}},proto:{initZoom:function(){var a,c=b.st.zoom,d=".zoom";if(c.enabled&&b.supportsTransition){var e,f,g=c.duration,j=function(a){var b=a.clone().removeAttr("style").removeAttr("class").addClass("mfp-animated-image"),d="all "+c.duration/1e3+"s "+c.easing,e={position:"fixed",zIndex:9999,left:0,top:0,"-webkit-backface-visibility":"hidden"},f="transition";return e["-webkit-"+f]=e["-moz-"+f]=e["-o-"+f]=e[f]=d,b.css(e),b},k=function(){b.content.css("visibility","visible")};w("BuildControls"+d,function(){if(b._allowZoom()){if(clearTimeout(e),b.content.css("visibility","hidden"),a=b._getItemToZoom(),!a)return void k();f=j(a),f.css(b._getOffset()),b.wrap.append(f),e=setTimeout(function(){f.css(b._getOffset(!0)),e=setTimeout(function(){k(),setTimeout(function(){f.remove(),a=f=null,y("ZoomAnimationEnded")},16)},g)},16)}}),w(i+d,function(){if(b._allowZoom()){if(clearTimeout(e),b.st.removalDelay=g,!a){if(a=b._getItemToZoom(),!a)return;f=j(a)}f.css(b._getOffset(!0)),b.wrap.append(f),b.content.css("visibility","hidden"),setTimeout(function(){f.css(b._getOffset())},16)}}),w(h+d,function(){b._allowZoom()&&(k(),f&&f.remove(),a=null)})}},_allowZoom:function(){return"image"===b.currItem.type},_getItemToZoom:function(){return b.currItem.hasSize?b.currItem.img:!1},_getOffset:function(c){var d;d=c?b.currItem.img:b.st.zoom.opener(b.currItem.el||b.currItem);var e=d.offset(),f=parseInt(d.css("padding-top"),10),g=parseInt(d.css("padding-bottom"),10);e.top-=a(window).scrollTop()-f;var h={width:d.width(),height:(u?d.innerHeight():d[0].offsetHeight)-g-f};return O()?h["-moz-transform"]=h.transform="translate("+e.left+"px,"+e.top+"px)":(h.left=e.left,h.top=e.top),h}}});var P="iframe",Q="//about:blank",R=function(a){if(b.currTemplate[P]){var c=b.currTemplate[P].find("iframe");c.length&&(a||(c[0].src=Q),b.isIE8&&c.css("display",a?"block":"none"))}};a.magnificPopup.registerModule(P,{options:{markup:'
    ',srcAction:"iframe_src",patterns:{youtube:{index:"youtube.com",id:"v=",src:"//www.youtube.com/embed/%id%?autoplay=1"},vimeo:{index:"vimeo.com/",id:"/",src:"//player.vimeo.com/video/%id%?autoplay=1"},gmaps:{index:"//maps.google.",src:"%id%&output=embed"}}},proto:{initIframe:function(){b.types.push(P),w("BeforeChange",function(a,b,c){b!==c&&(b===P?R():c===P&&R(!0))}),w(h+"."+P,function(){R()})},getIframe:function(c,d){var e=c.src,f=b.st.iframe;a.each(f.patterns,function(){return e.indexOf(this.index)>-1?(this.id&&(e="string"==typeof this.id?e.substr(e.lastIndexOf(this.id)+this.id.length,e.length):this.id.call(this,e)),e=this.src.replace("%id%",e),!1):void 0});var g={};return f.srcAction&&(g[f.srcAction]=e),b._parseMarkup(d,g,c),b.updateStatus("ready"),d}}});var S=function(a){var c=b.items.length;return a>c-1?a-c:0>a?c+a:a},T=function(a,b,c){return a.replace(/%curr%/gi,b+1).replace(/%total%/gi,c)};a.magnificPopup.registerModule("gallery",{options:{enabled:!1,arrowMarkup:'',preload:[0,2],navigateByImgClick:!0,arrows:!0,tPrev:"Previous (Left arrow key)",tNext:"Next (Right arrow key)",tCounter:"%curr% of %total%"},proto:{initGallery:function(){var c=b.st.gallery,e=".mfp-gallery";return b.direction=!0,c&&c.enabled?(f+=" mfp-gallery",w(m+e,function(){c.navigateByImgClick&&b.wrap.on("click"+e,".mfp-img",function(){return b.items.length>1?(b.next(),!1):void 0}),d.on("keydown"+e,function(a){37===a.keyCode?b.prev():39===a.keyCode&&b.next()})}),w("UpdateStatus"+e,function(a,c){c.text&&(c.text=T(c.text,b.currItem.index,b.items.length))}),w(l+e,function(a,d,e,f){var g=b.items.length;e.counter=g>1?T(c.tCounter,f.index,g):""}),w("BuildControls"+e,function(){if(b.items.length>1&&c.arrows&&!b.arrowLeft){var d=c.arrowMarkup,e=b.arrowLeft=a(d.replace(/%title%/gi,c.tPrev).replace(/%dir%/gi,"left")).addClass(s),f=b.arrowRight=a(d.replace(/%title%/gi,c.tNext).replace(/%dir%/gi,"right")).addClass(s);e.click(function(){b.prev()}),f.click(function(){b.next()}),b.container.append(e.add(f))}}),w(n+e,function(){b._preloadTimeout&&clearTimeout(b._preloadTimeout),b._preloadTimeout=setTimeout(function(){b.preloadNearbyImages(),b._preloadTimeout=null},16)}),void w(h+e,function(){d.off(e),b.wrap.off("click"+e),b.arrowRight=b.arrowLeft=null})):!1},next:function(){b.direction=!0,b.index=S(b.index+1),b.updateItemHTML()},prev:function(){b.direction=!1,b.index=S(b.index-1),b.updateItemHTML()},goTo:function(a){b.direction=a>=b.index,b.index=a,b.updateItemHTML()},preloadNearbyImages:function(){var a,c=b.st.gallery.preload,d=Math.min(c[0],b.items.length),e=Math.min(c[1],b.items.length);for(a=1;a<=(b.direction?e:d);a++)b._preloadItem(b.index+a);for(a=1;a<=(b.direction?d:e);a++)b._preloadItem(b.index-a)},_preloadItem:function(c){if(c=S(c),!b.items[c].preloaded){var d=b.items[c];d.parsed||(d=b.parseEl(c)),y("LazyLoad",d),"image"===d.type&&(d.img=a('').on("load.mfploader",function(){d.hasSize=!0}).on("error.mfploader",function(){d.hasSize=!0,d.loadError=!0,y("LazyLoadError",d)}).attr("src",d.src)),d.preloaded=!0}}}});var U="retina";a.magnificPopup.registerModule(U,{options:{replaceSrc:function(a){return a.src.replace(/\.\w+$/,function(a){return"@2x"+a})},ratio:1},proto:{initRetina:function(){if(window.devicePixelRatio>1){var a=b.st.retina,c=a.ratio;c=isNaN(c)?c():c,c>1&&(w("ImageHasSize."+U,function(a,b){b.img.css({"max-width":b.img[0].naturalWidth/c,width:"100%"})}),w("ElementParse."+U,function(b,d){d.src=a.replaceSrc(d,c)}))}}}}),A()}); \ No newline at end of file diff --git a/public/javascripts/spectrum.css b/public/javascripts/spectrum.css new file mode 100644 index 0000000000..a8ad9e4f82 --- /dev/null +++ b/public/javascripts/spectrum.css @@ -0,0 +1,507 @@ +/*** +Spectrum Colorpicker v1.8.0 +https://github.com/bgrins/spectrum +Author: Brian Grinstead +License: MIT +***/ + +.sp-container { + position:absolute; + top:0; + left:0; + display:inline-block; + *display: inline; + *zoom: 1; + /* https://github.com/bgrins/spectrum/issues/40 */ + z-index: 9999994; + overflow: hidden; +} +.sp-container.sp-flat { + position: relative; +} + +/* Fix for * { box-sizing: border-box; } */ +.sp-container, +.sp-container * { + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; +} + +/* http://ansciath.tumblr.com/post/7347495869/css-aspect-ratio */ +.sp-top { + position:relative; + width: 100%; + display:inline-block; +} +.sp-top-inner { + position:absolute; + top:0; + left:0; + bottom:0; + right:0; +} +.sp-color { + position: absolute; + top:0; + left:0; + bottom:0; + right:20%; +} +.sp-hue { + position: absolute; + top:0; + right:0; + bottom:0; + left:84%; + height: 100%; +} + +.sp-clear-enabled .sp-hue { + top:33px; + height: 77.5%; +} + +.sp-fill { + padding-top: 80%; +} +.sp-sat, .sp-val { + position: absolute; + top:0; + left:0; + right:0; + bottom:0; +} + +.sp-alpha-enabled .sp-top { + margin-bottom: 18px; +} +.sp-alpha-enabled .sp-alpha { + display: block; +} +.sp-alpha-handle { + position:absolute; + top:-4px; + bottom: -4px; + width: 6px; + left: 50%; + cursor: pointer; + border: 1px solid black; + background: white; + opacity: .8; +} +.sp-alpha { + display: none; + position: absolute; + bottom: -14px; + right: 0; + left: 0; + height: 8px; +} +.sp-alpha-inner { + border: solid 1px #333; +} + +.sp-clear { + display: none; +} + +.sp-clear.sp-clear-display { + background-position: center; +} + +.sp-clear-enabled .sp-clear { + display: block; + position:absolute; + top:0px; + right:0; + bottom:0; + left:84%; + height: 28px; +} + +/* Don't allow text selection */ +.sp-container, .sp-replacer, .sp-preview, .sp-dragger, .sp-slider, .sp-alpha, .sp-clear, .sp-alpha-handle, .sp-container.sp-dragging .sp-input, .sp-container button { + -webkit-user-select:none; + -moz-user-select: -moz-none; + -o-user-select:none; + user-select: none; +} + +.sp-container.sp-input-disabled .sp-input-container { + display: none; +} +.sp-container.sp-buttons-disabled .sp-button-container { + display: none; +} +.sp-container.sp-palette-buttons-disabled .sp-palette-button-container { + display: none; +} +.sp-palette-only .sp-picker-container { + display: none; +} +.sp-palette-disabled .sp-palette-container { + display: none; +} + +.sp-initial-disabled .sp-initial { + display: none; +} + + +/* Gradients for hue, saturation and value instead of images. Not pretty... but it works */ +.sp-sat { + background-image: -webkit-gradient(linear, 0 0, 100% 0, from(#FFF), to(rgba(204, 154, 129, 0))); + background-image: -webkit-linear-gradient(left, #FFF, rgba(204, 154, 129, 0)); + background-image: -moz-linear-gradient(left, #fff, rgba(204, 154, 129, 0)); + background-image: -o-linear-gradient(left, #fff, rgba(204, 154, 129, 0)); + background-image: -ms-linear-gradient(left, #fff, rgba(204, 154, 129, 0)); + background-image: linear-gradient(to right, #fff, rgba(204, 154, 129, 0)); + -ms-filter: "progid:DXImageTransform.Microsoft.gradient(GradientType = 1, startColorstr=#FFFFFFFF, endColorstr=#00CC9A81)"; + filter : progid:DXImageTransform.Microsoft.gradient(GradientType = 1, startColorstr='#FFFFFFFF', endColorstr='#00CC9A81'); +} +.sp-val { + background-image: -webkit-gradient(linear, 0 100%, 0 0, from(#000000), to(rgba(204, 154, 129, 0))); + background-image: -webkit-linear-gradient(bottom, #000000, rgba(204, 154, 129, 0)); + background-image: -moz-linear-gradient(bottom, #000, rgba(204, 154, 129, 0)); + background-image: -o-linear-gradient(bottom, #000, rgba(204, 154, 129, 0)); + background-image: -ms-linear-gradient(bottom, #000, rgba(204, 154, 129, 0)); + background-image: linear-gradient(to top, #000, rgba(204, 154, 129, 0)); + -ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#00CC9A81, endColorstr=#FF000000)"; + filter : progid:DXImageTransform.Microsoft.gradient(startColorstr='#00CC9A81', endColorstr='#FF000000'); +} + +.sp-hue { + background: -moz-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); + background: -ms-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); + background: -o-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); + background: -webkit-gradient(linear, left top, left bottom, from(#ff0000), color-stop(0.17, #ffff00), color-stop(0.33, #00ff00), color-stop(0.5, #00ffff), color-stop(0.67, #0000ff), color-stop(0.83, #ff00ff), to(#ff0000)); + background: -webkit-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); + background: linear-gradient(to bottom, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); +} + +/* IE filters do not support multiple color stops. + Generate 6 divs, line them up, and do two color gradients for each. + Yes, really. + */ +.sp-1 { + height:17%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0000', endColorstr='#ffff00'); +} +.sp-2 { + height:16%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffff00', endColorstr='#00ff00'); +} +.sp-3 { + height:17%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ff00', endColorstr='#00ffff'); +} +.sp-4 { + height:17%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ffff', endColorstr='#0000ff'); +} +.sp-5 { + height:16%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#0000ff', endColorstr='#ff00ff'); +} +.sp-6 { + height:17%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff00ff', endColorstr='#ff0000'); +} + +.sp-hidden { + display: none !important; +} + +/* Clearfix hack */ +.sp-cf:before, .sp-cf:after { content: ""; display: table; } +.sp-cf:after { clear: both; } +.sp-cf { *zoom: 1; } + +/* Mobile devices, make hue slider bigger so it is easier to slide */ +@media (max-device-width: 480px) { + .sp-color { right: 40%; } + .sp-hue { left: 63%; } + .sp-fill { padding-top: 60%; } +} +.sp-dragger { + border-radius: 5px; + height: 5px; + width: 5px; + border: 1px solid #fff; + background: #000; + cursor: pointer; + position:absolute; + top:0; + left: 0; +} +.sp-slider { + position: absolute; + top:0; + cursor:pointer; + height: 3px; + left: -1px; + right: -1px; + border: 1px solid #000; + background: white; + opacity: .8; +} + +/* +Theme authors: +Here are the basic themeable display options (colors, fonts, global widths). +See http://bgrins.github.io/spectrum/themes/ for instructions. +*/ + +.sp-container { + border-radius: 0; + background-color: #ECECEC; + border: solid 1px #f0c49B; + padding: 0; +} +.sp-container, .sp-container button, .sp-container input, .sp-color, .sp-hue, .sp-clear { + font: normal 12px "Lucida Grande", "Lucida Sans Unicode", "Lucida Sans", Geneva, Verdana, sans-serif; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; +} +.sp-top { + margin-bottom: 3px; +} +.sp-color, .sp-hue, .sp-clear { + border: solid 1px #666; +} + +/* Input */ +.sp-input-container { + float:right; + width: 100px; + margin-bottom: 4px; +} +.sp-initial-disabled .sp-input-container { + width: 100%; +} +.sp-input { + font-size: 12px !important; + border: 1px inset; + padding: 4px 5px; + margin: 0; + width: 100%; + background:transparent; + border-radius: 3px; + color: #222; +} +.sp-input:focus { + border: 1px solid orange; +} +.sp-input.sp-validation-error { + border: 1px solid red; + background: #fdd; +} +.sp-picker-container , .sp-palette-container { + float:left; + position: relative; + padding: 10px; + padding-bottom: 300px; + margin-bottom: -290px; +} +.sp-picker-container { + width: 172px; + border-left: solid 1px #fff; +} + +/* Palettes */ +.sp-palette-container { + border-right: solid 1px #ccc; +} + +.sp-palette-only .sp-palette-container { + border: 0; +} + +.sp-palette .sp-thumb-el { + display: block; + position:relative; + float:left; + width: 24px; + height: 15px; + margin: 3px; + cursor: pointer; + border:solid 2px transparent; +} +.sp-palette .sp-thumb-el:hover, .sp-palette .sp-thumb-el.sp-thumb-active { + border-color: orange; +} +.sp-thumb-el { + position:relative; +} + +/* Initial */ +.sp-initial { + float: left; + border: solid 1px #333; +} +.sp-initial span { + width: 30px; + height: 25px; + border:none; + display:block; + float:left; + margin:0; +} + +.sp-initial .sp-clear-display { + background-position: center; +} + +/* Buttons */ +.sp-palette-button-container, +.sp-button-container { + float: right; +} + +/* Replacer (the little preview div that shows up instead of the ) */ +.sp-replacer { + margin:0; + overflow:hidden; + cursor:pointer; + padding: 4px; + display:inline-block; + *zoom: 1; + *display: inline; + border: solid 1px #91765d; + background: #eee; + color: #333; + vertical-align: middle; +} +.sp-replacer:hover, .sp-replacer.sp-active { + border-color: #F0C49B; + color: #111; +} +.sp-replacer.sp-disabled { + cursor:default; + border-color: silver; + color: silver; +} +.sp-dd { + padding: 2px 0; + height: 16px; + line-height: 16px; + float:left; + font-size:10px; +} +.sp-preview { + position:relative; + width:25px; + height: 20px; + border: solid 1px #222; + margin-right: 5px; + float:left; + z-index: 0; +} + +.sp-palette { + *width: 220px; + max-width: 220px; +} +.sp-palette .sp-thumb-el { + width:16px; + height: 16px; + margin:2px 1px; + border: solid 1px #d0d0d0; +} + +.sp-container { + padding-bottom:0; +} + + +/* Buttons: http://hellohappy.org/css3-buttons/ */ +.sp-container button { + background-color: #eeeeee; + background-image: -webkit-linear-gradient(top, #eeeeee, #cccccc); + background-image: -moz-linear-gradient(top, #eeeeee, #cccccc); + background-image: -ms-linear-gradient(top, #eeeeee, #cccccc); + background-image: -o-linear-gradient(top, #eeeeee, #cccccc); + background-image: linear-gradient(to bottom, #eeeeee, #cccccc); + border: 1px solid #ccc; + border-bottom: 1px solid #bbb; + border-radius: 3px; + color: #333; + font-size: 14px; + line-height: 1; + padding: 5px 4px; + text-align: center; + text-shadow: 0 1px 0 #eee; + vertical-align: middle; +} +.sp-container button:hover { + background-color: #dddddd; + background-image: -webkit-linear-gradient(top, #dddddd, #bbbbbb); + background-image: -moz-linear-gradient(top, #dddddd, #bbbbbb); + background-image: -ms-linear-gradient(top, #dddddd, #bbbbbb); + background-image: -o-linear-gradient(top, #dddddd, #bbbbbb); + background-image: linear-gradient(to bottom, #dddddd, #bbbbbb); + border: 1px solid #bbb; + border-bottom: 1px solid #999; + cursor: pointer; + text-shadow: 0 1px 0 #ddd; +} +.sp-container button:active { + border: 1px solid #aaa; + border-bottom: 1px solid #888; + -webkit-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; + -moz-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; + -ms-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; + -o-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; + box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; +} +.sp-cancel { + font-size: 11px; + color: #d93f3f !important; + margin:0; + padding:2px; + margin-right: 5px; + vertical-align: middle; + text-decoration:none; + +} +.sp-cancel:hover { + color: #d93f3f !important; + text-decoration: underline; +} + + +.sp-palette span:hover, .sp-palette span.sp-thumb-active { + border-color: #000; +} + +.sp-preview, .sp-alpha, .sp-thumb-el { + position:relative; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==); +} +.sp-preview-inner, .sp-alpha-inner, .sp-thumb-inner { + display:block; + position:absolute; + top:0;left:0;bottom:0;right:0; +} + +.sp-palette .sp-thumb-inner { + background-position: 50% 50%; + background-repeat: no-repeat; +} + +.sp-palette .sp-thumb-light.sp-thumb-active .sp-thumb-inner { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAIVJREFUeNpiYBhsgJFMffxAXABlN5JruT4Q3wfi/0DsT64h8UD8HmpIPCWG/KemIfOJCUB+Aoacx6EGBZyHBqI+WsDCwuQ9mhxeg2A210Ntfo8klk9sOMijaURm7yc1UP2RNCMbKE9ODK1HM6iegYLkfx8pligC9lCD7KmRof0ZhjQACDAAceovrtpVBRkAAAAASUVORK5CYII=); +} + +.sp-palette .sp-thumb-dark.sp-thumb-active .sp-thumb-inner { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAadEVYdFNvZnR3YXJlAFBhaW50Lk5FVCB2My41LjEwMPRyoQAAAMdJREFUOE+tkgsNwzAMRMugEAahEAahEAZhEAqlEAZhEAohEAYh81X2dIm8fKpEspLGvudPOsUYpxE2BIJCroJmEW9qJ+MKaBFhEMNabSy9oIcIPwrB+afvAUFoK4H0tMaQ3XtlrggDhOVVMuT4E5MMG0FBbCEYzjYT7OxLEvIHQLY2zWwQ3D+9luyOQTfKDiFD3iUIfPk8VqrKjgAiSfGFPecrg6HN6m/iBcwiDAo7WiBeawa+Kwh7tZoSCGLMqwlSAzVDhoK+6vH4G0P5wdkAAAAASUVORK5CYII=); +} + +.sp-clear-display { + background-repeat:no-repeat; + background-position: center; + background-image: url(data:image/gif;base64,R0lGODlhFAAUAPcAAAAAAJmZmZ2dnZ6enqKioqOjo6SkpKWlpaampqenp6ioqKmpqaqqqqurq/Hx8fLy8vT09PX19ff39/j4+Pn5+fr6+vv7+wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAP8ALAAAAAAUABQAAAihAP9FoPCvoMGDBy08+EdhQAIJCCMybCDAAYUEARBAlFiQQoMABQhKUJBxY0SPICEYHBnggEmDKAuoPMjS5cGYMxHW3IiT478JJA8M/CjTZ0GgLRekNGpwAsYABHIypcAgQMsITDtWJYBR6NSqMico9cqR6tKfY7GeBCuVwlipDNmefAtTrkSzB1RaIAoXodsABiZAEFB06gIBWC1mLVgBa0AAOw==); +} diff --git a/public/javascripts/spectrum.js b/public/javascripts/spectrum.js new file mode 100644 index 0000000000..2b31452841 --- /dev/null +++ b/public/javascripts/spectrum.js @@ -0,0 +1,2 @@ +(function(factory){"use strict";if(typeof define==="function"&&define.amd){define(["jquery"],factory)}else if(typeof exports=="object"&&typeof module=="object"){module.exports=factory(require("jquery"))}else{factory(jQuery)}})(function($,undefined){"use strict";var defaultOpts={beforeShow:noop,move:noop,change:noop,show:noop,hide:noop,color:false,flat:false,showInput:false,allowEmpty:false,showButtons:true,clickoutFiresChange:true,showInitial:false,showPalette:false,showPaletteOnly:false,hideAfterPaletteSelect:false,togglePaletteOnly:false,showSelectionPalette:true,localStorageKey:false,appendTo:"body",maxSelectionSize:7,cancelText:"cancel",chooseText:"choose",togglePaletteMoreText:"more",togglePaletteLessText:"less",clearText:"Clear Color Selection",noColorSelectedText:"No Color Selected",preferredFormat:false,className:"",containerClassName:"",replacerClassName:"",showAlpha:false,theme:"sp-light",palette:[["#ffffff","#000000","#ff0000","#ff8000","#ffff00","#008000","#0000ff","#4b0082","#9400d3"]],selectionPalette:[],disabled:false,offset:null},spectrums=[],IE=!!/msie/i.exec(window.navigator.userAgent),rgbaSupport=function(){function contains(str,substr){return!!~(""+str).indexOf(substr)}var elem=document.createElement("div");var style=elem.style;style.cssText="background-color:rgba(0,0,0,.5)";return contains(style.backgroundColor,"rgba")||contains(style.backgroundColor,"hsla")}(),replaceInput=["
    ","
    ","
    ","
    "].join(""),markup=function(){var gradientFix="";if(IE){for(var i=1;i<=6;i++){gradientFix+="
    "}}return["
    ","
    ","
    ","
    ","","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ","
    ",gradientFix,"
    ","
    ","
    ","
    ","
    ","","
    ","
    ","
    ","","","
    ","
    ","
    "].join("")}();function paletteTemplate(p,color,className,opts){var html=[];for(var i=0;i')}else{var cls="sp-clear-display";html.push($("
    ").append($('').attr("title",opts.noColorSelectedText)).html())}}return"
    "+html.join("")+"
    "}function hideAll(){for(var i=0;iMath.abs(dragY-oldDragY);shiftMovementDirection=furtherFromX?"x":"y"}var setSaturation=!shiftMovementDirection||shiftMovementDirection==="x";var setValue=!shiftMovementDirection||shiftMovementDirection==="y";if(setSaturation){currentSaturation=parseFloat(dragX/dragWidth)}if(setValue){currentValue=parseFloat((dragHeight-dragY)/dragHeight)}isEmpty=false;if(!opts.showAlpha){currentAlpha=1}move()},dragStart,dragStop);if(!!initialColor){set(initialColor);updateUI();currentPreferredFormat=opts.preferredFormat||tinycolor(initialColor).format;addColorToSelectionPalette(initialColor)}else{updateUI()}if(flat){show()}function paletteElementClick(e){if(e.data&&e.data.ignore){set($(e.target).closest(".sp-thumb-el").data("color"));move()}else{set($(e.target).closest(".sp-thumb-el").data("color"));move();updateOriginalInput(true);if(opts.hideAfterPaletteSelect){hide()}}return false}var paletteEvent=IE?"mousedown.spectrum":"click.spectrum touchstart.spectrum";paletteContainer.delegate(".sp-thumb-el",paletteEvent,paletteElementClick);initialColorContainer.delegate(".sp-thumb-el:nth-child(1)",paletteEvent,{ignore:true},paletteElementClick)}function updateSelectionPaletteFromStorage(){if(localStorageKey&&window.localStorage){try{var oldPalette=window.localStorage[localStorageKey].split(",#");if(oldPalette.length>1){delete window.localStorage[localStorageKey];$.each(oldPalette,function(i,c){addColorToSelectionPalette(c)})}}catch(e){}try{selectionPalette=window.localStorage[localStorageKey].split(";")}catch(e){}}}function addColorToSelectionPalette(color){if(showSelectionPalette){var rgb=tinycolor(color).toRgbString();if(!paletteLookup[rgb]&&$.inArray(rgb,selectionPalette)===-1){selectionPalette.push(rgb);while(selectionPalette.length>maxSelectionSize){selectionPalette.shift()}}if(localStorageKey&&window.localStorage){try{window.localStorage[localStorageKey]=selectionPalette.join(";")}catch(e){}}}}function getUniqueSelectionPalette(){var unique=[];if(opts.showPalette){for(var i=0;iviewWidth&&viewWidth>dpWidth?Math.abs(offset.left+dpWidth-viewWidth):0);offset.top-=Math.min(offset.top,offset.top+dpHeight>viewHeight&&viewHeight>dpHeight?Math.abs(dpHeight+inputHeight-extraY):extraY);return offset}function noop(){}function stopPropagation(e){e.stopPropagation()}function bind(func,obj){var slice=Array.prototype.slice;var args=slice.call(arguments,2);return function(){return func.apply(obj,args.concat(slice.call(arguments)))}}function draggable(element,onmove,onstart,onstop){onmove=onmove||function(){};onstart=onstart||function(){};onstop=onstop||function(){};var doc=document;var dragging=false;var offset={};var maxHeight=0;var maxWidth=0;var hasTouch="ontouchstart"in window;var duringDragEvents={};duringDragEvents["selectstart"]=prevent;duringDragEvents["dragstart"]=prevent;duringDragEvents["touchmove mousemove"]=move;duringDragEvents["touchend mouseup"]=stop;function prevent(e){if(e.stopPropagation){e.stopPropagation()}if(e.preventDefault){e.preventDefault()}e.returnValue=false}function move(e){if(dragging){if(IE&&doc.documentMode<9&&!e.button){return stop()}var t0=e.originalEvent&&e.originalEvent.touches&&e.originalEvent.touches[0];var pageX=t0&&t0.pageX||e.pageX;var pageY=t0&&t0.pageY||e.pageY;var dragX=Math.max(0,Math.min(pageX-offset.left,maxWidth));var dragY=Math.max(0,Math.min(pageY-offset.top,maxHeight));if(hasTouch){prevent(e)}onmove.apply(element,[dragX,dragY,e])}}function start(e){var rightclick=e.which?e.which==3:e.button==2;if(!rightclick&&!dragging){if(onstart.apply(element,arguments)!==false){dragging=true;maxHeight=$(element).height();maxWidth=$(element).width();offset=$(element).offset();$(doc).bind(duringDragEvents);$(doc.body).addClass("sp-dragging");move(e);prevent(e)}}}function stop(){if(dragging){$(doc).unbind(duringDragEvents);$(doc.body).removeClass("sp-dragging");setTimeout(function(){onstop.apply(element,arguments)},0)}dragging=false}$(element).bind("touchstart mousedown",start)}function throttle(func,wait,debounce){var timeout;return function(){var context=this,args=arguments;var throttler=function(){timeout=null;func.apply(context,args)};if(debounce)clearTimeout(timeout);if(debounce||!timeout)timeout=setTimeout(throttler,wait)}}function inputTypeColorSupport(){return $.fn.spectrum.inputTypeColorSupport()}var dataID="spectrum.id";$.fn.spectrum=function(opts,extra){if(typeof opts=="string"){var returnValue=this;var args=Array.prototype.slice.call(arguments,1);this.each(function(){var spect=spectrums[$(this).data(dataID)];if(spect){var method=spect[opts];if(!method){throw new Error("Spectrum: no such method: '"+opts+"'")}if(opts=="get"){returnValue=spect.get()}else if(opts=="container"){returnValue=spect.container}else if(opts=="option"){returnValue=spect.option.apply(spect,args)}else if(opts=="destroy"){spect.destroy();$(this).removeData(dataID)}else{method.apply(spect,args)}}});return returnValue}return this.spectrum("destroy").each(function(){var options=$.extend({},opts,$(this).data());var spect=spectrum(this,options);$(this).data(dataID,spect.id)})};$.fn.spectrum.load=true;$.fn.spectrum.loadOpts={};$.fn.spectrum.draggable=draggable;$.fn.spectrum.defaults=defaultOpts;$.fn.spectrum.inputTypeColorSupport=function inputTypeColorSupport(){if(typeof inputTypeColorSupport._cachedResult==="undefined"){var colorInput=$("")[0];inputTypeColorSupport._cachedResult=colorInput.type==="color"&&colorInput.value!==""}return inputTypeColorSupport._cachedResult};$.spectrum={};$.spectrum.localization={};$.spectrum.palettes={};$.fn.spectrum.processNativeColorInputs=function(){var colorInputs=$("input[type=color]");if(colorInputs.length&&!inputTypeColorSupport()){colorInputs.spectrum({preferredFormat:"hex6"})}};(function(){var trimLeft=/^[\s,#]+/,trimRight=/\s+$/,tinyCounter=0,math=Math,mathRound=math.round,mathMin=math.min,mathMax=math.max,mathRandom=math.random;var tinycolor=function(color,opts){color=color?color:"";opts=opts||{};if(color instanceof tinycolor){return color}if(!(this instanceof tinycolor)){return new tinycolor(color,opts)}var rgb=inputToRGB(color);this._originalInput=color,this._r=rgb.r,this._g=rgb.g,this._b=rgb.b,this._a=rgb.a,this._roundA=mathRound(100*this._a)/100,this._format=opts.format||rgb.format;this._gradientType=opts.gradientType;if(this._r<1){this._r=mathRound(this._r)}if(this._g<1){this._g=mathRound(this._g)}if(this._b<1){this._b=mathRound(this._b)}this._ok=rgb.ok;this._tc_id=tinyCounter++};tinycolor.prototype={isDark:function(){return this.getBrightness()<128},isLight:function(){return!this.isDark()},isValid:function(){return this._ok},getOriginalInput:function(){return this._originalInput},getFormat:function(){return this._format},getAlpha:function(){return this._a},getBrightness:function(){var rgb=this.toRgb();return(rgb.r*299+rgb.g*587+rgb.b*114)/1e3},setAlpha:function(value){this._a=boundAlpha(value);this._roundA=mathRound(100*this._a)/100;return this},toHsv:function(){var hsv=rgbToHsv(this._r,this._g,this._b);return{h:hsv.h*360,s:hsv.s,v:hsv.v,a:this._a}},toHsvString:function(){var hsv=rgbToHsv(this._r,this._g,this._b);var h=mathRound(hsv.h*360),s=mathRound(hsv.s*100),v=mathRound(hsv.v*100);return this._a==1?"hsv("+h+", "+s+"%, "+v+"%)":"hsva("+h+", "+s+"%, "+v+"%, "+this._roundA+")"},toHsl:function(){var hsl=rgbToHsl(this._r,this._g,this._b);return{h:hsl.h*360,s:hsl.s,l:hsl.l,a:this._a}},toHslString:function(){var hsl=rgbToHsl(this._r,this._g,this._b);var h=mathRound(hsl.h*360),s=mathRound(hsl.s*100),l=mathRound(hsl.l*100);return this._a==1?"hsl("+h+", "+s+"%, "+l+"%)":"hsla("+h+", "+s+"%, "+l+"%, "+this._roundA+")"},toHex:function(allow3Char){return rgbToHex(this._r,this._g,this._b,allow3Char)},toHexString:function(allow3Char){return"#"+this.toHex(allow3Char)},toHex8:function(){return rgbaToHex(this._r,this._g,this._b,this._a)},toHex8String:function(){return"#"+this.toHex8()},toRgb:function(){return{r:mathRound(this._r),g:mathRound(this._g),b:mathRound(this._b),a:this._a}},toRgbString:function(){return this._a==1?"rgb("+mathRound(this._r)+", "+mathRound(this._g)+", "+mathRound(this._b)+")":"rgba("+mathRound(this._r)+", "+mathRound(this._g)+", "+mathRound(this._b)+", "+this._roundA+")"},toPercentageRgb:function(){return{r:mathRound(bound01(this._r,255)*100)+"%",g:mathRound(bound01(this._g,255)*100)+"%",b:mathRound(bound01(this._b,255)*100)+"%",a:this._a}},toPercentageRgbString:function(){return this._a==1?"rgb("+mathRound(bound01(this._r,255)*100)+"%, "+mathRound(bound01(this._g,255)*100)+"%, "+mathRound(bound01(this._b,255)*100)+"%)":"rgba("+mathRound(bound01(this._r,255)*100)+"%, "+mathRound(bound01(this._g,255)*100)+"%, "+mathRound(bound01(this._b,255)*100)+"%, "+this._roundA+")"},toName:function(){if(this._a===0){return"transparent"}if(this._a<1){return false}return hexNames[rgbToHex(this._r,this._g,this._b,true)]||false},toFilter:function(secondColor){var hex8String="#"+rgbaToHex(this._r,this._g,this._b,this._a);var secondHex8String=hex8String;var gradientType=this._gradientType?"GradientType = 1, ":"";if(secondColor){var s=tinycolor(secondColor);secondHex8String=s.toHex8String()}return"progid:DXImageTransform.Microsoft.gradient("+gradientType+"startColorstr="+hex8String+",endColorstr="+secondHex8String+")"},toString:function(format){var formatSet=!!format;format=format||this._format;var formattedString=false;var hasAlpha=this._a<1&&this._a>=0;var needsAlphaFormat=!formatSet&&hasAlpha&&(format==="hex"||format==="hex6"||format==="hex3"||format==="name");if(needsAlphaFormat){if(format==="name"&&this._a===0){return this.toName()}return this.toRgbString()}if(format==="rgb"){formattedString=this.toRgbString()}if(format==="prgb"){formattedString=this.toPercentageRgbString()}if(format==="hex"||format==="hex6"){formattedString=this.toHexString()}if(format==="hex3"){formattedString=this.toHexString(true)}if(format==="hex8"){formattedString=this.toHex8String()}if(format==="name"){formattedString=this.toName()}if(format==="hsl"){formattedString=this.toHslString()}if(format==="hsv"){formattedString=this.toHsvString()}return formattedString||this.toHexString()},_applyModification:function(fn,args){var color=fn.apply(null,[this].concat([].slice.call(args)));this._r=color._r;this._g=color._g;this._b=color._b;this.setAlpha(color._a);return this},lighten:function(){return this._applyModification(lighten,arguments)},brighten:function(){return this._applyModification(brighten,arguments)},darken:function(){return this._applyModification(darken,arguments)},desaturate:function(){return this._applyModification(desaturate,arguments)},saturate:function(){return this._applyModification(saturate,arguments)},greyscale:function(){return this._applyModification(greyscale,arguments)},spin:function(){return this._applyModification(spin,arguments)},_applyCombination:function(fn,args){return fn.apply(null,[this].concat([].slice.call(args)))},analogous:function(){return this._applyCombination(analogous,arguments)},complement:function(){return this._applyCombination(complement,arguments)},monochromatic:function(){return this._applyCombination(monochromatic,arguments)},splitcomplement:function(){return this._applyCombination(splitcomplement,arguments)},triad:function(){return this._applyCombination(triad,arguments)},tetrad:function(){return this._applyCombination(tetrad,arguments)}};tinycolor.fromRatio=function(color,opts){if(typeof color=="object"){var newColor={};for(var i in color){if(color.hasOwnProperty(i)){if(i==="a"){newColor[i]=color[i]}else{newColor[i]=convertToPercentage(color[i])}}}color=newColor}return tinycolor(color,opts)};function inputToRGB(color){var rgb={r:0,g:0,b:0};var a=1;var ok=false;var format=false;if(typeof color=="string"){color=stringInputToObject(color)}if(typeof color=="object"){if(color.hasOwnProperty("r")&&color.hasOwnProperty("g")&&color.hasOwnProperty("b")){rgb=rgbToRgb(color.r,color.g,color.b);ok=true;format=String(color.r).substr(-1)==="%"?"prgb":"rgb"}else if(color.hasOwnProperty("h")&&color.hasOwnProperty("s")&&color.hasOwnProperty("v")){color.s=convertToPercentage(color.s);color.v=convertToPercentage(color.v);rgb=hsvToRgb(color.h,color.s,color.v);ok=true;format="hsv"}else if(color.hasOwnProperty("h")&&color.hasOwnProperty("s")&&color.hasOwnProperty("l")){color.s=convertToPercentage(color.s);color.l=convertToPercentage(color.l);rgb=hslToRgb(color.h,color.s,color.l);ok=true;format="hsl"}if(color.hasOwnProperty("a")){a=color.a}}a=boundAlpha(a);return{ok:ok,format:color.format||format,r:mathMin(255,mathMax(rgb.r,0)),g:mathMin(255,mathMax(rgb.g,0)),b:mathMin(255,mathMax(rgb.b,0)),a:a}}function rgbToRgb(r,g,b){return{r:bound01(r,255)*255,g:bound01(g,255)*255,b:bound01(b,255)*255}}function rgbToHsl(r,g,b){r=bound01(r,255);g=bound01(g,255);b=bound01(b,255);var max=mathMax(r,g,b),min=mathMin(r,g,b);var h,s,l=(max+min)/2;if(max==min){h=s=0}else{var d=max-min;s=l>.5?d/(2-max-min):d/(max+min);switch(max){case r:h=(g-b)/d+(g1)t-=1;if(t<1/6)return p+(q-p)*6*t;if(t<1/2)return q;if(t<2/3)return p+(q-p)*(2/3-t)*6;return p}if(s===0){r=g=b=l}else{var q=l<.5?l*(1+s):l+s-l*s;var p=2*l-q;r=hue2rgb(p,q,h+1/3);g=hue2rgb(p,q,h);b=hue2rgb(p,q,h-1/3)}return{r:r*255,g:g*255,b:b*255}}function rgbToHsv(r,g,b){r=bound01(r,255);g=bound01(g,255);b=bound01(b,255);var max=mathMax(r,g,b),min=mathMin(r,g,b);var h,s,v=max;var d=max-min;s=max===0?0:d/max;if(max==min){h=0}else{switch(max){case r:h=(g-b)/d+(g>1)+720)%360;--results;){hsl.h=(hsl.h+part)%360;ret.push(tinycolor(hsl))}return ret}function monochromatic(color,results){results=results||6;var hsv=tinycolor(color).toHsv();var h=hsv.h,s=hsv.s,v=hsv.v;var ret=[];var modification=1/results;while(results--){ret.push(tinycolor({h:h,s:s,v:v}));v=(v+modification)%1}return ret}tinycolor.mix=function(color1,color2,amount){amount=amount===0?0:amount||50;var rgb1=tinycolor(color1).toRgb();var rgb2=tinycolor(color2).toRgb();var p=amount/100;var w=p*2-1;var a=rgb2.a-rgb1.a;var w1;if(w*a==-1){w1=w}else{w1=(w+a)/(1+w*a)}w1=(w1+1)/2;var w2=1-w1;var rgba={r:rgb2.r*w1+rgb1.r*w2,g:rgb2.g*w1+rgb1.g*w2,b:rgb2.b*w1+rgb1.b*w2,a:rgb2.a*p+rgb1.a*(1-p)};return tinycolor(rgba)};tinycolor.readability=function(color1,color2){var c1=tinycolor(color1);var c2=tinycolor(color2);var rgb1=c1.toRgb();var rgb2=c2.toRgb();var brightnessA=c1.getBrightness();var brightnessB=c2.getBrightness();var colorDiff=Math.max(rgb1.r,rgb2.r)-Math.min(rgb1.r,rgb2.r)+Math.max(rgb1.g,rgb2.g)-Math.min(rgb1.g,rgb2.g)+Math.max(rgb1.b,rgb2.b)-Math.min(rgb1.b,rgb2.b);return{brightness:Math.abs(brightnessA-brightnessB),color:colorDiff}};tinycolor.isReadable=function(color1,color2){var readability=tinycolor.readability(color1,color2);return readability.brightness>125&&readability.color>500};tinycolor.mostReadable=function(baseColor,colorList){var bestColor=null;var bestScore=0;var bestIsReadable=false;for(var i=0;i125&&readability.color>500;var score=3*(readability.brightness/125)+readability.color/500;if(readable&&!bestIsReadable||readable&&bestIsReadable&&score>bestScore||!readable&&!bestIsReadable&&score>bestScore){bestIsReadable=readable;bestScore=score;bestColor=tinycolor(colorList[i])}}return bestColor};var names=tinycolor.names={aliceblue:"f0f8ff",antiquewhite:"faebd7",aqua:"0ff",aquamarine:"7fffd4",azure:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"000",blanchedalmond:"ffebcd",blue:"00f",blueviolet:"8a2be2",brown:"a52a2a",burlywood:"deb887",burntsienna:"ea7e5d",cadetblue:"5f9ea0",chartreuse:"7fff00",chocolate:"d2691e",coral:"ff7f50",cornflowerblue:"6495ed",cornsilk:"fff8dc",crimson:"dc143c",cyan:"0ff",darkblue:"00008b",darkcyan:"008b8b",darkgoldenrod:"b8860b",darkgray:"a9a9a9",darkgreen:"006400",darkgrey:"a9a9a9",darkkhaki:"bdb76b",darkmagenta:"8b008b",darkolivegreen:"556b2f",darkorange:"ff8c00",darkorchid:"9932cc",darkred:"8b0000",darksalmon:"e9967a",darkseagreen:"8fbc8f",darkslateblue:"483d8b",darkslategray:"2f4f4f",darkslategrey:"2f4f4f",darkturquoise:"00ced1",darkviolet:"9400d3",deeppink:"ff1493",deepskyblue:"00bfff",dimgray:"696969",dimgrey:"696969",dodgerblue:"1e90ff",firebrick:"b22222",floralwhite:"fffaf0",forestgreen:"228b22",fuchsia:"f0f",gainsboro:"dcdcdc",ghostwhite:"f8f8ff",gold:"ffd700",goldenrod:"daa520",gray:"808080",green:"008000",greenyellow:"adff2f",grey:"808080",honeydew:"f0fff0",hotpink:"ff69b4",indianred:"cd5c5c",indigo:"4b0082",ivory:"fffff0",khaki:"f0e68c",lavender:"e6e6fa",lavenderblush:"fff0f5",lawngreen:"7cfc00",lemonchiffon:"fffacd",lightblue:"add8e6",lightcoral:"f08080",lightcyan:"e0ffff",lightgoldenrodyellow:"fafad2",lightgray:"d3d3d3",lightgreen:"90ee90",lightgrey:"d3d3d3",lightpink:"ffb6c1",lightsalmon:"ffa07a",lightseagreen:"20b2aa",lightskyblue:"87cefa",lightslategray:"789",lightslategrey:"789",lightsteelblue:"b0c4de",lightyellow:"ffffe0",lime:"0f0",limegreen:"32cd32",linen:"faf0e6",magenta:"f0f",maroon:"800000",mediumaquamarine:"66cdaa",mediumblue:"0000cd",mediumorchid:"ba55d3",mediumpurple:"9370db",mediumseagreen:"3cb371",mediumslateblue:"7b68ee",mediumspringgreen:"00fa9a",mediumturquoise:"48d1cc",mediumvioletred:"c71585",midnightblue:"191970",mintcream:"f5fffa",mistyrose:"ffe4e1",moccasin:"ffe4b5",navajowhite:"ffdead",navy:"000080",oldlace:"fdf5e6",olive:"808000",olivedrab:"6b8e23",orange:"ffa500",orangered:"ff4500",orchid:"da70d6",palegoldenrod:"eee8aa",palegreen:"98fb98",paleturquoise:"afeeee",palevioletred:"db7093",papayawhip:"ffefd5",peachpuff:"ffdab9",peru:"cd853f",pink:"ffc0cb",plum:"dda0dd",powderblue:"b0e0e6",purple:"800080",rebeccapurple:"663399",red:"f00",rosybrown:"bc8f8f",royalblue:"4169e1",saddlebrown:"8b4513",salmon:"fa8072",sandybrown:"f4a460",seagreen:"2e8b57",seashell:"fff5ee",sienna:"a0522d",silver:"c0c0c0",skyblue:"87ceeb",slateblue:"6a5acd",slategray:"708090",slategrey:"708090",snow:"fffafa",springgreen:"00ff7f",steelblue:"4682b4",tan:"d2b48c",teal:"008080",thistle:"d8bfd8",tomato:"ff6347",turquoise:"40e0d0",violet:"ee82ee",wheat:"f5deb3",white:"fff",whitesmoke:"f5f5f5",yellow:"ff0",yellowgreen:"9acd32"};var hexNames=tinycolor.hexNames=flip(names);function flip(o){var flipped={};for(var i in o){if(o.hasOwnProperty(i)){flipped[o[i]]=i}}return flipped}function boundAlpha(a){a=parseFloat(a);if(isNaN(a)||a<0||a>1){a=1}return a}function bound01(n,max){if(isOnePointZero(n)){n="100%"}var processPercent=isPercentage(n);n=mathMin(max,mathMax(0,parseFloat(n)));if(processPercent){n=parseInt(n*max,10)/100}if(math.abs(n-max)<1e-6){return 1}return n%max/parseFloat(max)}function clamp01(val){return mathMin(1,mathMax(0,val))}function parseIntFromHex(val){return parseInt(val,16)}function isOnePointZero(n){return typeof n=="string"&&n.indexOf(".")!=-1&&parseFloat(n)===1}function isPercentage(n){return typeof n==="string"&&n.indexOf("%")!=-1}function pad2(c){return c.length==1?"0"+c:""+c}function convertToPercentage(n){if(n<=1){n=n*100+"%"}return n}function convertDecimalToHex(d){return Math.round(parseFloat(d)*255).toString(16)}function convertHexToDecimal(h){return parseIntFromHex(h)/255}var matchers=function(){var CSS_INTEGER="[-\\+]?\\d+%?";var CSS_NUMBER="[-\\+]?\\d*\\.\\d+%?";var CSS_UNIT="(?:"+CSS_NUMBER+")|(?:"+CSS_INTEGER+")";var PERMISSIVE_MATCH3="[\\s|\\(]+("+CSS_UNIT+")[,|\\s]+("+CSS_UNIT+")[,|\\s]+("+CSS_UNIT+")\\s*\\)?";var PERMISSIVE_MATCH4="[\\s|\\(]+("+CSS_UNIT+")[,|\\s]+("+CSS_UNIT+")[,|\\s]+("+CSS_UNIT+")[,|\\s]+("+CSS_UNIT+")\\s*\\)?";return{rgb:new RegExp("rgb"+PERMISSIVE_MATCH3),rgba:new RegExp("rgba"+PERMISSIVE_MATCH4),hsl:new RegExp("hsl"+PERMISSIVE_MATCH3),hsla:new RegExp("hsla"+PERMISSIVE_MATCH4),hsv:new RegExp("hsv"+PERMISSIVE_MATCH3),hsva:new RegExp("hsva"+PERMISSIVE_MATCH4),hex3:/^([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,hex6:/^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/,hex8:/^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/}}();function stringInputToObject(color){color=color.replace(trimLeft,"").replace(trimRight,"").toLowerCase();var named=false;if(names[color]){color=names[color];named=true}else if(color=="transparent"){return{r:0,g:0,b:0,a:0,format:"name"}}var match;if(match=matchers.rgb.exec(color)){return{r:match[1],g:match[2],b:match[3]}}if(match=matchers.rgba.exec(color)){return{r:match[1],g:match[2],b:match[3],a:match[4]}}if(match=matchers.hsl.exec(color)){return{h:match[1],s:match[2],l:match[3]}}if(match=matchers.hsla.exec(color)){return{h:match[1],s:match[2],l:match[3],a:match[4]}}if(match=matchers.hsv.exec(color)){return{h:match[1],s:match[2],v:match[3]}}if(match=matchers.hsva.exec(color)){return{h:match[1],s:match[2],v:match[3],a:match[4]}}if(match=matchers.hex8.exec(color)){return{a:convertHexToDecimal(match[1]),r:parseIntFromHex(match[2]),g:parseIntFromHex(match[3]),b:parseIntFromHex(match[4]),format:named?"name":"hex8"}}if(match=matchers.hex6.exec(color)){return{r:parseIntFromHex(match[1]),g:parseIntFromHex(match[2]),b:parseIntFromHex(match[3]),format:named?"name":"hex"}}if(match=matchers.hex3.exec(color)){return{r:parseIntFromHex(match[1]+""+match[1]),g:parseIntFromHex(match[2]+""+match[2]),b:parseIntFromHex(match[3]+""+match[3]),format:named?"name":"hex"}}return false}window.tinycolor=tinycolor})();$(function(){if($.fn.spectrum.load){$.fn.spectrum.processNativeColorInputs()}})}); \ No newline at end of file diff --git a/script/bench.rb b/script/bench.rb index b4b7514a39..9de35492a8 100644 --- a/script/bench.rb +++ b/script/bench.rb @@ -49,13 +49,15 @@ def run(command, opt = nil) system(command, out: $stdout, err: :out) end - exit unless exit_status + abort("Command '#{command}' failed with exit status #{$?}") unless exit_status end begin require 'facter' rescue LoadError run "gem install facter" + # Facter requires CFPropertyList, but doesn't install it. + run "gem install CFPropertyList" puts "please rerun script" exit end @@ -151,12 +153,12 @@ run("bundle exec ruby script/profile_db_generator.rb") puts "Getting api key" api_key = `bundle exec rake api_key:get`.split("\n")[-1] -def bench(path) +def bench(path, name) puts "Running apache bench warmup" add = "" add = "-c 3 " if @unicorn `ab #{add} -n 10 "http://127.0.0.1:#{@port}#{path}"` - puts "Benchmarking #{path}" + puts "Benchmarking #{name} @ #{path}" `ab -n #{@iterations} -e tmp/ab.csv "http://127.0.0.1:#{@port}#{path}"` percentiles = Hash[*[50, 75, 90, 99].zip([]).flatten] @@ -194,7 +196,7 @@ begin ["categories", "/categories"], ["home", "/"], ["topic", "/t/oh-how-i-wish-i-could-shut-up-like-a-tunnel-for-so/69"] - # ["user", "/users/admin1/activity"], + # ["user", "/u/admin1/activity"], ] tests = tests.map{|k,url| ["#{k}_admin", "#{url}#{append}"]} + tests @@ -212,7 +214,7 @@ begin results = {} @best_of.times do tests.each do |name, url| - results[name] = best_of(bench(url),results[name]) + results[name] = best_of(bench(url, name),results[name]) end end diff --git a/script/bulk_import/base.rb b/script/bulk_import/base.rb new file mode 100644 index 0000000000..7b25467838 --- /dev/null +++ b/script/bulk_import/base.rb @@ -0,0 +1,525 @@ +require "pg" +require "set" +require "redcarpet" + +puts "Loading application..." +require_relative "../../config/environment" + +module BulkImport; end + +class BulkImport::Base + + NOW ||= "now()".freeze + PRIVATE_OFFSET ||= 2 ** 30 + + def initialize + db = ActiveRecord::Base.connection_config + @encoder = PG::TextEncoder::CopyRow.new + @raw_connection = PG.connect(dbname: db[:database], host: db[:host_names]&.first, port: db[:port]) + + @markdown = Redcarpet::Markdown.new( + Redcarpet::Render::HTML, + fenced_code_blocks: true, + autolink: true + ) + end + + def run + puts "Starting..." + preload_i18n + fix_highest_post_numbers + load_imported_ids + load_indexes + execute + fix_primary_keys + puts "Done!" + end + + def preload_i18n + puts "Preloading I18n..." + I18n.locale = ENV.fetch("LOCALE") { "en" }.to_sym + I18n.t("test") + ActiveSupport::Inflector.transliterate("test") + end + + def fix_highest_post_numbers + puts "Fixing highest post numbers..." + @raw_connection.exec <<-SQL + WITH X AS ( + SELECT topic_id + , COALESCE(MAX(post_number), 0) max_post_number + FROM posts + WHERE deleted_at IS NULL + GROUP BY topic_id + ) + UPDATE topics + SET highest_post_number = X.max_post_number + FROM X + WHERE id = X.topic_id + AND highest_post_number <> X.max_post_number + SQL + end + + def load_imported_ids + puts "Loading imported group ids..." + @groups = GroupCustomField.where(name: "import_id").pluck(:value, :group_id).to_h + @last_imported_group_id = @groups.keys.map(&:to_i).max || -1 + + puts "Loading imported user ids..." + @users = UserCustomField.where(name: "import_id").pluck(:value, :user_id).to_h + @last_imported_user_id = @users.keys.map(&:to_i).max || -1 + + puts "Loading imported category ids..." + @categories = CategoryCustomField.where(name: "import_id").pluck(:value, :category_id).to_h + @last_imported_category_id = @categories.keys.map(&:to_i).max || -1 + + puts "Loading imported topic ids..." + @topics = TopicCustomField.where(name: "import_id").pluck(:value, :topic_id).to_h + imported_topic_ids = @topics.keys.map(&:to_i) + @last_imported_topic_id = imported_topic_ids.select { |id| id < PRIVATE_OFFSET }.max || -1 + @last_imported_private_topic_id = imported_topic_ids.select { |id| id > PRIVATE_OFFSET }.max || (PRIVATE_OFFSET - 1) + + puts "Loading imported post ids..." + @posts = PostCustomField.where(name: "import_id").pluck(:value, :post_id).to_h + imported_post_ids = @posts.keys.map(&:to_i) + @last_imported_post_id = imported_post_ids.select { |id| id < PRIVATE_OFFSET }.max || -1 + @last_imported_private_post_id = imported_post_ids.select { |id| id > PRIVATE_OFFSET }.max || (PRIVATE_OFFSET - 1) + end + + def load_indexes + puts "Loading groups indexes..." + @last_group_id = Group.unscoped.maximum(:id) + @group_names = Group.unscoped.pluck(:name).map(&:downcase).to_set + + puts "Loading users indexes..." + @last_user_id = User.unscoped.maximum(:id) + @emails = User.unscoped.pluck(:email).to_set + @usernames_lower = User.unscoped.pluck(:username_lower).to_set + @mapped_usernames = UserCustomField.joins(:user).where(name: "import_username").pluck("user_custom_fields.value", "users.username").to_h + + puts "Loading categories indexes..." + @last_category_id = Category.unscoped.maximum(:id) + @category_names = Category.unscoped.pluck(:parent_category_id, :name).map { |pci, name| "#{pci}-#{name}" }.to_set + + puts "Loading topics indexes..." + @last_topic_id = Topic.unscoped.maximum(:id) + @highest_post_number_by_topic_id = Topic.unscoped.pluck(:id, :highest_post_number).to_h + + puts "Loading posts indexes..." + @last_post_id = Post.unscoped.maximum(:id) + @post_number_by_post_id = Post.unscoped.pluck(:id, :post_number).to_h + @topic_id_by_post_id = Post.unscoped.pluck(:id, :topic_id).to_h + end + + def execute + raise NotImplementedError + end + + def fix_primary_keys + puts "Updating primary key sequences..." + @raw_connection.exec("SELECT setval('#{Group.sequence_name}', #{@last_group_id})") + @raw_connection.exec("SELECT setval('#{User.sequence_name}', #{@last_user_id})") + @raw_connection.exec("SELECT setval('#{Category.sequence_name}', #{@last_category_id})") + @raw_connection.exec("SELECT setval('#{Topic.sequence_name}', #{@last_topic_id})") + @raw_connection.exec("SELECT setval('#{Post.sequence_name}', #{@last_post_id})") + end + + def group_id_from_imported_id(id); @groups[id.to_s]; end + def user_id_from_imported_id(id); @users[id.to_s]; end + def category_id_from_imported_id(id); @categories[id.to_s]; end + def topic_id_from_imported_id(id); @topics[id.to_s]; end + def post_id_from_imported_id(id); @posts[id.to_s]; end + + def post_number_from_imported_id(id); @post_number_by_post_id[post_id_from_imported_id(id)]; end + def topic_id_from_imported_post_id(id); @topic_id_by_post_id[post_id_from_imported_id(id)]; end + + GROUP_COLUMNS ||= %i{ + id name title bio_raw bio_cooked created_at updated_at + } + + USER_COLUMNS ||= %i{ + id username username_lower name email active trust_level admin moderator + date_of_birth ip_address registration_ip_address primary_group_id + suspended_at suspended_till last_emailed_at created_at updated_at + } + + USER_PROFILE_COLUMNS ||= %i{ + user_id location website bio_raw bio_cooked views + } + + GROUP_USER_COLUMNS ||= %i{ + group_id user_id created_at updated_at + } + + CATEGORY_COLUMNS ||= %i{ + id name name_lower slug user_id description position parent_category_id + created_at updated_at + } + + TOPIC_COLUMNS ||= %i{ + id archetype title fancy_title slug user_id last_post_user_id category_id + visible closed pinned_at views created_at bumped_at updated_at + } + + POST_COLUMNS ||= %i{ + id user_id last_editor_id topic_id post_number sort_order reply_to_post_number + raw cooked hidden word_count created_at last_version_at updated_at + } + + TOPIC_ALLOWED_USER_COLUMNS ||= %i{ + topic_id user_id created_at updated_at + } + + def create_groups(rows, &block); create_records(rows, "group", GROUP_COLUMNS, &block); end + + def create_users(rows, &block) + @imported_usernames = {} + + create_records(rows, "user", USER_COLUMNS, &block) + + create_custom_fields("user", "username", @imported_usernames.keys) do |username| + { + record_id: @imported_usernames[username], + value: username, + } + end + end + + def create_user_profiles(rows, &block); create_records(rows, "user_profile", USER_PROFILE_COLUMNS, &block); end + def create_group_users(rows, &block); create_records(rows, "group_user", GROUP_USER_COLUMNS, &block); end + def create_categories(rows, &block); create_records(rows, "category", CATEGORY_COLUMNS, &block); end + def create_topics(rows, &block); create_records(rows, "topic", TOPIC_COLUMNS, &block); end + def create_posts(rows, &block); create_records(rows, "post", POST_COLUMNS, &block); end + def create_topic_allowed_users(rows, &block); create_records(rows, "topic_allowed_user", TOPIC_ALLOWED_USER_COLUMNS, &block); end + + def process_group(group) + @groups[group[:imported_id].to_s] = group[:id] = @last_group_id += 1 + + group[:name] = fix_name(group[:name]) + + unless @group_names.add?(group[:name].downcase) + group_name = group[:name] + "_1" + group_name.next! until @group_names.add?(group_name.downcase) + group[:name] = group_name + end + + group[:title] = group[:title].scrub.strip.presence + group[:bio_raw] = group[:bio_raw].scrub.strip.presence + group[:bio_cooked] = pre_cook(group[:bio_raw]) if group[:bio_raw].present? + group[:created_at] ||= NOW + group[:updated_at] ||= group[:created_at] + group + end + + def process_user(user) + @users[user[:imported_id].to_s] = user[:id] = @last_user_id += 1 + + imported_username = user[:username].dup + + user[:username] = fix_name(user[:username]).presence || random_username + + if user[:username] != imported_username + @imported_usernames[imported_username] = user[:id] + @mapped_usernames[imported_username] = user[:username] + end + + # unique username_lower + unless @usernames_lower.add?(user[:username].downcase) + username = user[:username] + "_1" + username.next! until @usernames_lower.add?(username.downcase) + user[:username] = username + end + + user[:username_lower] = user[:username].downcase + user[:email] ||= random_email + user[:email].downcase! + + # unique email + user[:email] = random_email until user[:email] =~ EmailValidator.email_regex && @emails.add?(user[:email]) + user[:trust_level] ||= TrustLevel[1] + user[:active] = true unless user.has_key?(:active) + user[:admin] ||= false + user[:moderator] ||= false + user[:last_emailed_at] ||= NOW + user[:created_at] ||= NOW + user[:updated_at] ||= user[:created_at] + user + end + + def process_user_profile(user_profile) + user_profile[:bio_raw] = (user_profile[:bio_raw].presence || "").scrub.strip.presence + user_profile[:bio_cooked] = pre_cook(user_profile[:bio_raw]) if user_profile[:bio_raw].present? + user_profile + end + + def process_group_user(group_user) + group_user[:created_at] = NOW + group_user[:updated_at] = NOW + group_user + end + + def process_category(category) + @categories[category[:imported_id].to_s] = category[:id] = @last_category_id += 1 + category[:name] = category[:name][0...50].scrub.strip + # TODO: unique name + category[:name_lower] = category[:name].downcase + category[:slug] ||= Slug.ascii_generator(category[:name_lower]) + category[:description] = (category[:description] || "").scrub.strip.presence + category[:user_id] ||= Discourse::SYSTEM_USER_ID + category[:created_at] ||= NOW + category[:updated_at] ||= category[:created_at] + category + end + + def process_topic(topic) + @topics[topic[:imported_id].to_s] = topic[:id] = @last_topic_id += 1 + topic[:archetype] ||= Archetype.default + topic[:title] = topic[:title][0...255].scrub.strip + topic[:fancy_title] ||= pre_fancy(topic[:title]) + topic[:slug] ||= Slug.ascii_generator(topic[:title]) + topic[:user_id] ||= Discourse::SYSTEM_USER_ID + topic[:last_post_user_id] ||= topic[:user_id] + topic[:category_id] ||= -1 if topic[:archetype] != Archetype.private_message + topic[:visible] = true unless topic.has_key?(:visible) + topic[:closed] ||= false + topic[:views] ||= 0 + topic[:created_at] ||= NOW + topic[:bumped_at] ||= topic[:created_at] + topic[:updated_at] ||= topic[:created_at] + topic + end + + def process_post(post) + @posts[post[:imported_id].to_s] = post[:id] = @last_post_id += 1 + post[:user_id] ||= Discourse::SYSTEM_USER_ID + post[:last_editor_id] = post[:user_id] + @highest_post_number_by_topic_id[post[:topic_id]] ||= 0 + post[:post_number] = @highest_post_number_by_topic_id[post[:topic_id]] += 1 + post[:sort_order] = post[:post_number] + @post_number_by_post_id[post[:id]] = post[:post_number] + @topic_id_by_post_id[post[:id]] = post[:topic_id] + post[:raw] = (post[:raw] || "").scrub.strip.presence || "" + post[:raw] = process_raw post[:raw] + post[:cooked] = pre_cook post[:raw] + post[:hidden] ||= false + post[:word_count] = post[:raw].scan(/[[:word:]]+/).size + post[:created_at] ||= NOW + post[:last_version_at] = post[:created_at] + post[:updated_at] ||= post[:created_at] + post + end + + def process_topic_allowed_user(topic_allowed_user) + topic_allowed_user[:created_at] = NOW + topic_allowed_user[:updated_at] = NOW + topic_allowed_user + end + + def process_raw(raw) + # fix whitespaces + raw.gsub!(/(\\r)?\\n/, "\n") + raw.gsub!("\\t", "\t") + + # [HTML]...[/HTML] + raw.gsub!(/\[HTML\]/i, "\n\n```html\n") + raw.gsub!(/\[\/HTML\]/i, "\n```\n\n") + + # [PHP]...[/PHP] + raw.gsub!(/\[PHP\]/i, "\n\n```php\n") + raw.gsub!(/\[\/PHP\]/i, "\n```\n\n") + + # [HIGHLIGHT="..."] + raw.gsub!(/\[HIGHLIGHT="?(\w+)"?\]/i) { "\n\n```#{$1.downcase}\n" } + + # [CODE]...[/CODE] + # [HIGHLIGHT]...[/HIGHLIGHT] + raw.gsub!(/\[\/?CODE\]/i, "\n\n```\n\n") + raw.gsub!(/\[\/?HIGHLIGHT\]/i, "\n\n```\n\n") + + # [SAMP]...[/SAMP] + raw.gsub!(/\[\/?SAMP\]/i, "`") + + # replace all chevrons with HTML entities + # /!\ must be done /!\ + # - AFTER the "code" processing + # - BEFORE the "quote" processing + raw.gsub!(/`([^`]+?)`/im) { "`" + $1.gsub("<", "\u2603") + "`" } + raw.gsub!("<", "<") + raw.gsub!("\u2603", "<") + + raw.gsub!(/`([^`]+?)`/im) { "`" + $1.gsub(">", "\u2603") + "`" } + raw.gsub!(">", ">") + raw.gsub!("\u2603", ">") + + raw.gsub!(/\[\/?I\]/i, "*") + raw.gsub!(/\[\/?B\]/i, "**") + raw.gsub!(/\[\/?U\]/i, "") + + raw.gsub!(/\[\/?RED\]/i, "") + raw.gsub!(/\[\/?BLUE\]/i, "") + + raw.gsub!(/\[AUTEUR\].+?\[\/AUTEUR\]/im, "") + raw.gsub!(/\[VOIRMSG\].+?\[\/VOIRMSG\]/im, "") + raw.gsub!(/\[PSEUDOID\].+?\[\/PSEUDOID\]/im, "") + + # [IMG]...[/IMG] + raw.gsub!(/(?:\s*\[IMG\]\s*)+(.+?)(?:\s*\[\/IMG\]\s*)+/im) { "\n\n#{$1}\n\n" } + + # [URL=...]...[/URL] + raw.gsub!(/\[URL="?(.+?)"?\](.+?)\[\/URL\]/im) { "[#{$2.strip}](#{$1})" } + + # [URL]...[/URL] + # [MP3]...[/MP3] + raw.gsub!(/\[\/?URL\]/i, "") + raw.gsub!(/\[\/?MP3\]/i, "") + + # [FONT=blah] and [COLOR=blah] + raw.gsub!(/\[FONT=.*?\](.*?)\[\/FONT\]/im, "\\1") + raw.gsub!(/\[COLOR=.*?\](.*?)\[\/COLOR\]/im, "\\1") + + raw.gsub!(/\[SIZE=.*?\](.*?)\[\/SIZE\]/im, "\\1") + raw.gsub!(/\[H=.*?\](.*?)\[\/H\]/im, "\\1") + + # [CENTER]...[/CENTER] + raw.gsub!(/\[CENTER\](.*?)\[\/CENTER\]/im, "\\1") + + # [INDENT]...[/INDENT] + raw.gsub!(/\[INDENT\](.*?)\[\/INDENT\]/im, "\\1") + raw.gsub!(/\[TABLE\](.*?)\[\/TABLE\]/im, "\\1") + raw.gsub!(/\[TR\](.*?)\[\/TR\]/im, "\\1") + raw.gsub!(/\[TD\](.*?)\[\/TD\]/im, "\\1") + raw.gsub!(/\[TD="?.*?"?\](.*?)\[\/TD\]/im, "\\1") + + # [QUOTE]...[/QUOTE] + raw.gsub!(/\[QUOTE\](.+?)\[\/QUOTE\]/im) { |quote| + quote.gsub!(/\[QUOTE\](.+?)\[\/QUOTE\]/im) { "\n#{$1}\n" } + quote.gsub!(/\n(.+?)/) { "\n> #{$1}" } + } + + # [QUOTE=;]...[/QUOTE] + raw.gsub!(/\[QUOTE=([^;]+);(\d+)\](.+?)\[\/QUOTE\]/im) do + imported_username, imported_postid, quote = $1, $2, $3 + + username = @mapped_usernames[imported_username] || imported_username + post_id = post_id_from_imported_id(imported_postid) + post_number = @post_number_by_post_id[post_id] + topic_id = @topic_id_by_post_id[post_id] + + if post_number && topic_id + "\n[quote=\"#{username}, post:#{post_number}, topic:#{topic_id}\"]\n#{quote}\n[/quote]" + else + "\n[quote=\"#{username}\"]\n#{quote}\n[/quote]\n" + end + end + + # [YOUTUBE][/YOUTUBE] + raw.gsub!(/\[YOUTUBE\](.+?)\[\/YOUTUBE\]/i) { "\nhttps://www.youtube.com/watch?v=#{$1}\n" } + raw.gsub!(/\[DAILYMOTION\](.+?)\[\/DAILYMOTION\]/i) { "\nhttps://www.dailymotion.com/video/#{$1}\n" } + + # [VIDEO=youtube;]...[/VIDEO] + raw.gsub!(/\[VIDEO=YOUTUBE;([^\]]+)\].*?\[\/VIDEO\]/i) { "\nhttps://www.youtube.com/watch?v=#{$1}\n" } + raw.gsub!(/\[VIDEO=DAILYMOTION;([^\]]+)\].*?\[\/VIDEO\]/i) { "\nhttps://www.dailymotion.com/video/#{$1}\n" } + + # [SPOILER=Some hidden stuff]SPOILER HERE!![/SPOILER] + raw.gsub!(/\[SPOILER="?(.+?)"?\](.+?)\[\/SPOILER\]/im) { "\n#{$1}\n[spoiler]#{$2}[/spoiler]\n" } + + raw + end + + def create_records(rows, name, columns) + start = Time.now + + imported_ids = [] + process_method_name = "process_#{name}" + sql = "COPY #{name.pluralize} (#{columns.join(",")}) FROM STDIN" + + @raw_connection.copy_data(sql, @encoder) do + rows.each do |row| + mapped = yield(row) + next unless mapped + processed = send(process_method_name, mapped) + imported_ids << mapped[:imported_id] + @raw_connection.put_copy_data columns.map { |c| processed[c] } + print "\r%7d - %6d/sec".freeze % [imported_ids.size, imported_ids.size.to_f / (Time.now - start)] if imported_ids.size % 5000 == 0 + end + end + + if imported_ids.size > 0 + print "\r%7d - %6d/sec".freeze % [imported_ids.size, imported_ids.size.to_f / (Time.now - start)] + puts + end + + id_mapping_method_name = "#{name}_id_from_imported_id".freeze + return unless respond_to?(id_mapping_method_name) + create_custom_fields(name, "id", imported_ids) do |imported_id| + { + record_id: send(id_mapping_method_name, imported_id), + value: imported_id, + } + end + end + + def create_custom_fields(table, name, rows) + name = "import_#{name}" + sql = "COPY #{table}_custom_fields (#{table}_id, name, value, created_at, updated_at) FROM STDIN" + @raw_connection.copy_data(sql, @encoder) do + rows.each do |row| + cf = yield row + next unless cf + @raw_connection.put_copy_data [cf[:record_id], name, cf[:value], NOW, NOW] + end + end + end + + def fix_name(name) + return if name.blank? + name.scrub! + name = ActiveSupport::Inflector.transliterate(name) + name.gsub!(/[^\w.-]+/, "_") + name.gsub!(/^\W+/, "") + name.gsub!(/[^A-Za-z0-9]+$/, "") + name.gsub!(/([-_.]{2,})/) { $1.first } + name.strip! + name + end + + def random_username + "Anonymous_#{SecureRandom.hex}" + end + + def random_email + "#{SecureRandom.hex}@ema.il" + end + + def pre_cook(raw) + cooked = @markdown.render(raw).scrub.strip + + cooked.gsub!(/\[QUOTE="?([^,"]+)(?:, post:(\d+), topic:(\d+))?"?\](.+?)\[\/QUOTE\]/im) do + username, post_id, topic_id = $1, $2, $3 + quote = @markdown.render($4.presence || "").scrub.strip + + if post_id.present? && topic_id.present? + <<-HTML + + HTML + else + <<-HTML + + HTML + end + end + + cooked.scrub.strip + end + + def pre_fancy(title) + Redcarpet::Render::SmartyPants.render(ERB::Util.html_escape(title)).scrub.strip + end + +end diff --git a/script/bulk_import/vbulletin.rb b/script/bulk_import/vbulletin.rb new file mode 100644 index 0000000000..2121a47b2d --- /dev/null +++ b/script/bulk_import/vbulletin.rb @@ -0,0 +1,377 @@ +require_relative "base" +require "mysql2" +require "htmlentities" + +class BulkImport::VBulletin < BulkImport::Base + + SUSPENDED_TILL ||= Date.new(3000, 1, 1) + + def initialize + super + + host = ENV["DB_HOST"] + username = ENV["DB_USERNAME"] || "root" + password = ENV["DB_PASSWORD"] + database = ENV["DB_NAME"] || "vbulletin" + + @html_entities = HTMLEntities.new + + @client = Mysql2::Client.new(host: host, username: username, password: password, database: database) + @client.query_options.merge!(as: :array, cache_rows: false) + end + + def execute + import_groups + import_users + import_group_users + + import_user_passwords + import_user_salts + import_user_profiles + + import_categories + import_topics + import_posts + + import_private_topics + import_topic_allowed_users + import_private_posts + end + + def import_groups + puts "Importing groups..." + + groups = mysql_stream <<-SQL + SELECT usergroupid, title, description, usertitle + FROM usergroup + WHERE usergroupid > #{@last_imported_group_id} + ORDER BY usergroupid + SQL + + create_groups(groups) do |row| + { + imported_id: row[0], + name: html_decode(row[1]), + bio_raw: html_decode(row[2]), + title: html_decode(row[3]), + } + end + end + + def import_users + puts "Importing users..." + + users = mysql_stream <<-SQL + SELECT user.userid, username, email, joindate, birthday, ipaddress, user.usergroupid, bandate, liftdate + FROM user + LEFT JOIN userban ON userban.userid = user.userid + WHERE user.userid > #{@last_imported_user_id} + ORDER BY user.userid + SQL + + create_users(users) do |row| + u = { + imported_id: row[0], + username: row[1], + email: row[2], + created_at: Time.zone.at(row[3]), + date_of_birth: parse_birthday(row[4]), + primary_group_id: group_id_from_imported_id(row[6]), + } + u[:ip_address] = row[5][/\b(?:\d{1,3}\.){3}\d{1,3}\b/] if row[5].present? + if row[7] + u[:suspended_at] = Time.zone.at(row[7]) + u[:suspended_till] = row[8] > 0 ? Time.zone.at(row[8]) : SUSPENDED_TILL + end + u + end + end + + def import_group_users + puts "Importing group users..." + + group_users = mysql_stream <<-SQL + SELECT usergroupid, userid + FROM user + WHERE userid > #{@last_imported_user_id} + SQL + + create_group_users(group_users) do |row| + { + group_id: group_id_from_imported_id(row[0]), + user_id: user_id_from_imported_id(row[1]), + } + end + end + + def import_user_passwords + puts "Importing user passwords..." + + user_passwords = mysql_stream <<-SQL + SELECT userid, password + FROM user + WHERE userid > #{@last_imported_user_id} + ORDER BY userid + SQL + + create_custom_fields("user", "password", user_passwords) do |row| + { + record_id: user_id_from_imported_id(row[0]), + value: row[1], + } + end + end + + def import_user_salts + puts "Importing user salts..." + + user_salts = mysql_stream <<-SQL + SELECT userid, salt + FROM user + WHERE userid > #{@last_imported_user_id} + AND LENGTH(COALESCE(salt, '')) > 0 + ORDER BY userid + SQL + + create_custom_fields("user", "salt", user_salts) do |row| + { + record_id: user_id_from_imported_id(row[0]), + value: row[1], + } + end + end + + def import_user_profiles + puts "Importing user profiles..." + + user_profiles = mysql_stream <<-SQL + SELECT userid, homepage, profilevisits + FROM user + WHERE userid > #{@last_imported_user_id} + ORDER BY userid + SQL + + create_user_profiles(user_profiles) do |row| + { + user_id: user_id_from_imported_id(row[0]), + website: (URI.parse(row[1]).to_s rescue nil), + views: row[2], + } + end + end + + def import_categories + puts "Importing categories..." + + categories = mysql_query(<<-SQL + SELECT forumid, parentid, title, description, displayorder + FROM forum + WHERE forumid > #{@last_imported_category_id} + ORDER BY forumid + SQL + ).to_a + + return if categories.empty? + + parent_categories = categories.select { |c| c[1] == -1 } + children_categories = categories.select { |c| c[1] != -1 } + + parent_category_ids = Set.new parent_categories.map { |c| c[0] } + + # cut down the tree to only 2 levels of categories + children_categories.each do |cc| + until parent_category_ids.include?(cc[1]) + cc[1] = categories.find { |c| c[0] == cc[1] }[1] + end + end + + puts "Importing parent categories..." + create_categories(parent_categories) do |row| + { + imported_id: row[0], + name: html_decode(row[2]), + description: html_decode(row[3]), + position: row[4], + } + end + + puts "Importing children categories..." + create_categories(children_categories) do |row| + { + imported_id: row[0], + name: html_decode(row[2]), + description: html_decode(row[3]), + position: row[4], + parent_category_id: category_id_from_imported_id(row[1]), + } + end + end + + def import_topics + puts "Importing topics..." + + topics = mysql_stream <<-SQL + SELECT threadid, title, forumid, postuserid, open, dateline, views, visible, sticky + FROM thread + WHERE threadid > #{@last_imported_topic_id} + AND EXISTS (SELECT 1 FROM post WHERE post.threadid = thread.threadid) + ORDER BY threadid + SQL + + create_topics(topics) do |row| + created_at = Time.zone.at(row[5]) + + t = { + imported_id: row[0], + title: html_decode(row[1]), + category_id: category_id_from_imported_id(row[2]), + user_id: user_id_from_imported_id(row[3]), + closed: row[4] == 0, + created_at: created_at, + views: row[6], + visible: row[7] == 1, + } + + t[:pinned_at] = created_at if row[8] == 1 + + t + end + end + + def import_posts + puts "Importing posts..." + + posts = mysql_stream <<-SQL + SELECT postid, post.threadid, parentid, userid, post.dateline, post.visible, pagetext + FROM post + JOIN thread ON thread.threadid = post.threadid + WHERE postid > #{@last_imported_post_id} + ORDER BY postid + SQL + + create_posts(posts) do |row| + topic_id = topic_id_from_imported_id(row[1]) + replied_post_topic_id = topic_id_from_imported_post_id(row[2]) + reply_to_post_number = topic_id == replied_post_topic_id ? post_number_from_imported_id(row[2]) : nil + + { + imported_id: row[0], + topic_id: topic_id, + reply_to_post_number: reply_to_post_number, + user_id: user_id_from_imported_id(row[3]), + created_at: Time.zone.at(row[4]), + hidden: row[5] == 0, + raw: html_decode(row[6]), + } + end + end + + def import_private_topics + puts "Importing private topics..." + + @imported_topics = {} + + topics = mysql_stream <<-SQL + SELECT pmtextid, title, fromuserid, touserarray, dateline + FROM pmtext + WHERE pmtextid > (#{@last_imported_private_topic_id - PRIVATE_OFFSET}) + ORDER BY pmtextid + SQL + + create_topics(topics) do |row| + title = extract_pm_title(row[1]) + user_ids = [row[2], row[3].scan(/i:(\d+)/)].flatten.map(&:to_i).sort + key = [title, user_ids] + + next if @imported_topics.has_key?(key) + @imported_topics[key] = row[0] + PRIVATE_OFFSET + + { + archetype: Archetype.private_message, + imported_id: row[0] + PRIVATE_OFFSET, + title: title, + user_id: user_id_from_imported_id(row[2]), + created_at: Time.zone.at(row[4]), + } + end + end + + def import_topic_allowed_users + puts "Importing topic allowed users..." + + allowed_users = [] + + mysql_stream(<<-SQL + SELECT pmtextid, touserarray + FROM pmtext + WHERE pmtextid > (#{@last_imported_private_topic_id - PRIVATE_OFFSET}) + ORDER BY pmtextid + SQL + ).each do |row| + next unless topic_id = topic_id_from_imported_id(row[0] + PRIVATE_OFFSET) + row[1].scan(/i:(\d+)/).flatten.each do |id| + next unless user_id = user_id_from_imported_id(id) + allowed_users << [topic_id, user_id] + end + end + + create_topic_allowed_users(allowed_users) do |row| + { + topic_id: row[0], + user_id: row[1], + } + end + end + + def import_private_posts + puts "Importing private posts..." + + posts = mysql_stream <<-SQL + SELECT pmtextid, title, fromuserid, touserarray, dateline, message + FROM pmtext + WHERE pmtextid > #{@last_imported_private_post_id - PRIVATE_OFFSET} + ORDER BY pmtextid + SQL + + create_posts(posts) do |row| + title = extract_pm_title(row[1]) + user_ids = [row[2], row[3].scan(/i:(\d+)/)].flatten.map(&:to_i).sort + key = [title, user_ids] + + next unless topic_id = topic_id_from_imported_id(@imported_topics[key]) + + { + imported_id: row[0] + PRIVATE_OFFSET, + topic_id: topic_id, + user_id: user_id_from_imported_id(row[2]), + created_at: Time.zone.at(row[4]), + raw: html_decode(row[5]), + } + end + end + + def extract_pm_title(title) + html_decode(title).scrub.gsub(/^Re\s*:\s*/i, "") + end + + def html_decode(text) + @html_entities.decode((text.presence || "").scrub) + end + + def parse_birthday(birthday) + return if birthday.blank? + date_of_birth = Date.strptime(birthday, "%m-%d-%Y") + date_of_birth.year < 1904 ? Date.new(1904, date_of_birth.month, date_of_birth.day) : date_of_birth + end + + def mysql_stream(sql) + @client.query(sql, stream: true) + end + + def mysql_query(sql) + @client.query(sql) + end + +end + +BulkImport::VBulletin.new.run diff --git a/script/discourse b/script/discourse index d5d800a331..c07d6685ef 100755 --- a/script/discourse +++ b/script/discourse @@ -5,28 +5,54 @@ require "thor" class DiscourseCLI < Thor class_option :verbose, default: false, aliases: :v - desc "remap", "Remap a string sequence accross all tables" - def remap(from, to, global=nil) + desc "remap [--global,--regex] FROM TO", "Remap a string sequence accross all tables" + long_desc <<-LONGDESC + Replace a string sequence FROM with TO across all tables. + + With --global option, the remapping is run on ***ALL*** + databases. Instead of just running on the current database, run on + every database on this machine. This option is useful for + multi-site setups. + + With --regex option, use PostgreSQL function regexp_replace to do + the remapping. Enabling this interprets FROM as a PostgreSQL + regular expression. TO can contain references to captures in the + FROM match. See the "Regular Expression Details" section and + "regexp_replace" documentation in the PostgreSQL manual for more + details. + + + Examples: + + discourse remap talk.foo.com talk.bar.com # renaming a Discourse domain name + + discourse remap --regex "\[\/?color(=[^\]]*)*]" "" # removing "color" bbcodes + LONGDESC + option :global, :type => :boolean + option :regex, :type => :boolean + def remap(from, to) load_rails - global = global == "--global" - - puts "Rewriting all occurences of #{from} to #{to}" + if options[:regex] + puts "Rewriting all occurences of #{from} to #{to} using regexp_replace" + else + puts "Rewriting all occurences of #{from} to #{to}" + end puts "THIS TASK WILL REWRITE DATA, ARE YOU SURE (type YES)" - puts "WILL RUN ON ALL #{RailsMultisite::ConnectionManagement.all_dbs.length} DBS" if global + puts "WILL RUN ON ALL #{RailsMultisite::ConnectionManagement.all_dbs.length} DBS" if options[:global] text = STDIN.gets if text.strip != "YES" puts "aborting." exit end - if global + if options[:global] RailsMultisite::ConnectionManagement.each_connection do |db| puts "","Remapping tables on #{db}...","" - do_remap(from, to) + do_remap(from, to, options[:regex]) end else - do_remap(from, to) + do_remap(from, to, options[:regex]) end end @@ -199,7 +225,7 @@ class DiscourseCLI < Thor require File.expand_path(File.dirname(__FILE__) + "/../lib/import_export/import_export") end - def do_remap(from, to) + def do_remap(from, to, regex=false) sql = "SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public' and (data_type like 'char%' or data_type like 'text%') and is_updatable = 'YES'" @@ -213,10 +239,17 @@ WHERE table_schema='public' and (data_type like 'char%' or data_type like 'text% column_name = result["column_name"] puts "Remapping #{table_name} #{column_name}" begin - result = cnn.async_exec("UPDATE #{table_name} - SET #{column_name} = replace(#{column_name}, $1, $2) - WHERE NOT #{column_name} IS NULL - AND #{column_name} <> replace(#{column_name}, $1, $2)", [from, to]) + result = if regex + cnn.async_exec("UPDATE #{table_name} + SET #{column_name} = regexp_replace(#{column_name}, $1, $2, 'g') + WHERE NOT #{column_name} IS NULL + AND #{column_name} <> regexp_replace(#{column_name}, $1, $2, 'g')", [from, to]) + else + cnn.async_exec("UPDATE #{table_name} + SET #{column_name} = replace(#{column_name}, $1, $2) + WHERE NOT #{column_name} IS NULL + AND #{column_name} <> replace(#{column_name}, $1, $2)", [from, to]) + end puts "#{result.cmd_tuples} rows affected!" rescue => ex puts "Error: #{ex}" diff --git a/script/import_scripts/base.rb b/script/import_scripts/base.rb index ede4a7654d..616a534a5d 100644 --- a/script/import_scripts/base.rb +++ b/script/import_scripts/base.rb @@ -106,32 +106,16 @@ class ImportScripts::Base raise NotImplementedError end - def post_id_from_imported_post_id(import_id) - @lookup.post_id_from_imported_post_id(import_id) - end - - def topic_lookup_from_imported_post_id(import_id) - @lookup.topic_lookup_from_imported_post_id(import_id) - end - - def group_id_from_imported_group_id(import_id) - @lookup.group_id_from_imported_group_id(import_id) - end - - def find_group_by_import_id(import_id) - @lookup.find_group_by_import_id(import_id) - end - - def user_id_from_imported_user_id(import_id) - @lookup.user_id_from_imported_user_id(import_id) - end - - def find_user_by_import_id(import_id) - @lookup.find_user_by_import_id(import_id) - end - - def category_id_from_imported_category_id(import_id) - @lookup.category_id_from_imported_category_id(import_id) + %i{ post_id_from_imported_post_id + topic_lookup_from_imported_post_id + group_id_from_imported_group_id + find_group_by_import_id + user_id_from_imported_user_id + find_user_by_import_id + category_id_from_imported_category_id + add_group add_user add_category add_topic add_post + }.each do |method_name| + delegate method_name, to: :@lookup end def create_admin(opts={}) @@ -165,14 +149,14 @@ class ImportScripts::Base results.each do |result| g = yield(result) - if @lookup.group_id_from_imported_group_id(g[:id]) + if group_id_from_imported_group_id(g[:id]) skipped += 1 else new_group = create_group(g, g[:id]) created_group(new_group) if new_group.valid? - @lookup.add_group(g[:id].to_s, new_group) + add_group(g[:id].to_s, new_group) created += 1 else failed += 1 @@ -203,24 +187,23 @@ class ImportScripts::Base def all_records_exist?(type, import_ids) return false if import_ids.empty? - orig_conn = ActiveRecord::Base.connection - conn = orig_conn.raw_connection - - conn.exec('CREATE TEMP TABLE import_ids(val varchar(200) PRIMARY KEY)') + connection = ActiveRecord::Base.connection.raw_connection + connection.exec('CREATE TEMP TABLE import_ids(val text PRIMARY KEY)') import_id_clause = import_ids.map { |id| "('#{PG::Connection.escape_string(id.to_s)}')" }.join(",") - conn.exec("INSERT INTO import_ids VALUES #{import_id_clause}") + connection.exec("INSERT INTO import_ids VALUES #{import_id_clause}") - existing = "#{type.to_s.classify}CustomField".constantize.where(name: 'import_id') - existing = existing.joins('JOIN import_ids ON val = value') - - if existing.count == import_ids.length + existing = "#{type.to_s.classify}CustomField".constantize + existing = existing.where(name: 'import_id') + .joins('JOIN import_ids ON val = value') + .count + if existing == import_ids.length puts "Skipping #{import_ids.length} already imported #{type}" return true end ensure - conn.exec('DROP TABLE import_ids') + connection.exec('DROP TABLE import_ids') end def created_user(user) @@ -248,14 +231,14 @@ class ImportScripts::Base else import_id = u[:id] - if @lookup.user_id_from_imported_user_id(import_id) + if user_id_from_imported_user_id(import_id) skipped += 1 elsif u[:email].present? new_user = create_user(u, import_id) created_user(new_user) if new_user && new_user.valid? && new_user.user_profile && new_user.user_profile.valid? - @lookup.add_user(import_id.to_s, new_user) + add_user(import_id.to_s, new_user) created += 1 else failed += 1 @@ -376,7 +359,7 @@ class ImportScripts::Base params = yield(c) # block returns nil to skip - if params.nil? || @lookup.category_id_from_imported_category_id(params[:id]) + if params.nil? || category_id_from_imported_category_id(params[:id]) skipped += 1 else # Basic massaging on the category name @@ -423,7 +406,7 @@ class ImportScripts::Base new_category.custom_fields["import_id"] = import_id if import_id new_category.save! - @lookup.add_category(import_id, new_category) + add_category(import_id, new_category) post_create_action.try(:call, new_category) @@ -454,14 +437,14 @@ class ImportScripts::Base else import_id = params.delete(:id).to_s - if @lookup.post_id_from_imported_post_id(import_id) + if post_id_from_imported_post_id(import_id) skipped += 1 # already imported this post else begin new_post = create_post(params, import_id) if new_post.is_a?(Post) - @lookup.add_post(import_id, new_post) - @lookup.add_topic(new_post) + add_post(import_id, new_post) + add_topic(new_post) created_post(new_post) @@ -534,8 +517,8 @@ class ImportScripts::Base if params.nil? skipped += 1 else - user.id = @lookup.user_id_from_imported_user_id(params[:user_id]) - post.id = @lookup.post_id_from_imported_post_id(params[:post_id]) + user.id = user_id_from_imported_user_id(params[:user_id]) + post.id = post_id_from_imported_post_id(params[:post_id]) if user.id.nil? || post.id.nil? skipped += 1 diff --git a/script/import_scripts/base/lookup_container.rb b/script/import_scripts/base/lookup_container.rb index 0d8070932a..86513775a2 100644 --- a/script/import_scripts/base/lookup_container.rb +++ b/script/import_scripts/base/lookup_container.rb @@ -95,5 +95,14 @@ module ImportScripts url: post.url, } end + + def user_already_imported?(import_id) + @users.has_key?(import_id) || @users.has_key?(import_id.to_s) + end + + def post_already_imported?(import_id) + @posts.has_key?(import_id) || @posts.has_key?(import_id.to_s) + end + end end diff --git a/script/import_scripts/base/uploader.rb b/script/import_scripts/base/uploader.rb index 62ddac451d..ca7b65bb1d 100644 --- a/script/import_scripts/base/uploader.rb +++ b/script/import_scripts/base/uploader.rb @@ -15,7 +15,7 @@ module ImportScripts src.close tmp.rewind - Upload.create_for(user_id, tmp, source_filename, tmp.size) + UploadCreator.new(tmp, source_filename).create_for(user_id) rescue => e Rails.logger.error("Failed to create upload: #{e}") nil diff --git a/script/import_scripts/bbpress.rb b/script/import_scripts/bbpress.rb index 27c3be1dea..e91b8ef8ae 100644 --- a/script/import_scripts/bbpress.rb +++ b/script/import_scripts/bbpress.rb @@ -5,6 +5,7 @@ require File.expand_path(File.dirname(__FILE__) + "/base.rb") # Before running this script, paste these lines into your shell, # then use arrow keys to edit the values =begin +export BBPRESS_HOST="localhost" export BBPRESS_USER="root" export BBPRESS_DB="bbpress" export BBPRESS_PW="" @@ -12,6 +13,7 @@ export BBPRESS_PW="" class ImportScripts::Bbpress < ImportScripts::Base + BB_PRESS_HOST ||= ENV['BBPRESS_HOST'] || "localhost" BB_PRESS_DB ||= ENV['BBPRESS_DB'] || "bbpress" BATCH_SIZE ||= 1000 BB_PRESS_PW ||= ENV['BBPRESS_PW'] || "" @@ -22,7 +24,7 @@ class ImportScripts::Bbpress < ImportScripts::Base super @client = Mysql2::Client.new( - host: "localhost", + host: BB_PRESS_HOST, username: BB_PRESS_USER, database: BB_PRESS_DB, password: BB_PRESS_PW, @@ -31,6 +33,7 @@ class ImportScripts::Bbpress < ImportScripts::Base def execute import_users + import_anonymous_users import_categories import_topics_and_posts end @@ -94,6 +97,61 @@ class ImportScripts::Bbpress < ImportScripts::Base end end + def import_anonymous_users + puts "", "importing anonymous users..." + + anon_posts = Hash.new + anon_names = Hash.new + emails = Array.new + + # gather anonymous users via postmeta table + bbpress_query(<<-SQL + SELECT post_id, meta_key, meta_value + FROM #{BB_PRESS_PREFIX}postmeta + WHERE meta_key LIKE '_bbp_anonymous%' + SQL + ).each do |pm| + anon_posts[pm['post_id']] = Hash.new if not anon_posts[pm['post_id']] + + if pm['meta_key'] == '_bbp_anonymous_email' + anon_posts[pm['post_id']]['email'] = pm['meta_value'] + end + if pm['meta_key'] == '_bbp_anonymous_name' + anon_posts[pm['post_id']]['name'] = pm['meta_value'] + end + if pm['meta_key'] == '_bbp_anonymous_website' + anon_posts[pm['post_id']]['website'] = pm['meta_value'] + end + end + + # gather every existent username + anon_posts.each do |id,post| + anon_names[post['name']] = Hash.new if not anon_names[post['name']] + # overwriting email address, one user can only use one email address + anon_names[post['name']]['email'] = post['email'] + anon_names[post['name']]['website'] = post['website'] if post['website'] != '' + end + + # make sure every user name has a unique email address + anon_names.each do |k,name| + if not emails.include? name['email'] + emails.push ( name['email']) + else + name['email'] = "anonymous_#{SecureRandom.hex}@no-email.invalid" + end + end + + + create_users(anon_names) do |k, n| + { + id: k, + email: n["email"].downcase, + name: k, + website: n["website"] + } + end + end + def import_categories puts "", "importing categories..." @@ -163,12 +221,22 @@ class ImportScripts::Bbpress < ImportScripts::Base SQL ).each { |pm| posts_likes[pm["post_id"]] = pm["likes"].to_i } + anon_names = {} + bbpress_query(<<-SQL + SELECT post_id, meta_value + FROM #{BB_PRESS_PREFIX}postmeta + WHERE post_id IN (#{post_ids_sql}) + AND meta_key = '_bbp_anonymous_name' + SQL + ).each { |pm| anon_names[pm["post_id"]] = pm["meta_value"] } + create_posts(posts, total: total_posts, offset: offset) do |p| skip = false post = { id: p["id"], - user_id: user_id_from_imported_user_id(p["post_author"]) || find_user_by_import_id(p["post_author"]).try(:id) || -1, + user_id: user_id_from_imported_user_id(p["post_author"]) || find_user_by_import_id(p["post_author"]).try(:id) || + user_id_from_imported_user_id(anon_names[p['id']]) || find_user_by_import_id(anon_names[p['id']]).try(:id) || -1, raw: p["post_content"], created_at: p["post_date"], like_count: posts_likes[p["id"]], diff --git a/script/import_scripts/jive_api.rb b/script/import_scripts/jive_api.rb new file mode 100644 index 0000000000..624e360c78 --- /dev/null +++ b/script/import_scripts/jive_api.rb @@ -0,0 +1,319 @@ +require "nokogiri" +require "htmlentities" +require File.expand_path(File.dirname(__FILE__) + "/base.rb") + +# https://developers.jivesoftware.com/api/v3/cloud/rest/index.html + +class ImportScripts::JiveApi < ImportScripts::Base + + USER_COUNT ||= 1000 + POST_COUNT ||= 100 + STAFF_GUARDIAN ||= Guardian.new(Discourse.system_user) + + TO_IMPORT ||= [ + # Announcement & News + { jive_object: { type: 37, id: 1004 }, filters: { created_after: 1.year.ago, type: "post" }, category_id: 7 }, + # Questions & Answers / General Discussions + { jive_object: { type: 14, id: 2006 }, filters: { created_after: 6.months.ago, type: "discussion" }, category: Proc.new { |c| c["question"] ? 5 : 21 } }, + # Anywhere beta + { jive_object: { type: 14, id: 2052 }, filters: { created_after: 6.months.ago, type: "discussion" }, category_id: 22 }, + # Tips & Tricks + { jive_object: { type: 37, id: 1284 }, filters: { type: "post" }, category_id: 6 }, + { jive_object: { type: 37, id: 1319 }, filters: { type: "post" }, category_id: 6 }, + { jive_object: { type: 37, id: 1177 }, filters: { type: "post" }, category_id: 6 }, + { jive_object: { type: 37, id: 1165 }, filters: { type: "post" }, category_id: 6 }, + # Ambassadors + { jive_object: { type: 700, id: 1001 }, filters: { type: "discussion" }, authenticated: true, category_id: 8 }, + # Experts + { jive_object: { type: 700, id: 1034 }, filters: { type: "discussion" }, authenticated: true, category_id: 15 }, + # Feature Requests + { jive_object: { type: 14, id: 2015 }, filters: { type: "idea" }, category_id: 31 }, + ] + + def initialize + super + @base_uri = ENV["BASE_URI"] + @username = ENV["USERNAME"] + @password = ENV["PASSWORD"] + @htmlentities = HTMLEntities.new + end + + def execute + update_existing_users + import_users + import_contents + import_bookmarks + mark_topics_as_solved + end + + def update_existing_users + puts "", "updating existing users..." + + # we just need to do this once + return if User.human_users.limit(101).count > 100 + + User.human_users.find_each do |user| + people = get("people/email/#{user.email}?fields=initialLogin,-resources", true) + if people && people["initialLogin"].present? + created_at = DateTime.parse(people["initialLogin"]) + if user.created_at > created_at + user.update_columns(created_at: created_at) + end + end + end + end + + def import_users + puts "", "importing users..." + + imported_users = 0 + start_index = [0, UserCustomField.where(name: "import_id").count - USER_COUNT].max + + loop do + users = get("people/@all?fields=initialLogin,emails,displayName,mentionName,thumbnailUrl,-resources&count=#{USER_COUNT}&startIndex=#{start_index}", true) + create_users(users["list"], offset: imported_users) do |user| + { + id: user["id"], + created_at: user["initialLogin"], + email: user["emails"].find { |email| email["primary"] }["value"], + username: user["mentionName"], + name: user["displayName"], + avatar_url: user["thumbnailUrl"], + } + end + + break if users["list"].size < USER_COUNT || users["links"].blank? || users["links"]["next"].blank? + imported_users += users["list"].size + break unless start_index = users["links"]["next"][/startIndex=(\d+)/, 1] + end + end + + def import_contents + puts "", "importing contents..." + + TO_IMPORT.each do |to_import| + puts Time.now + entity = to_import[:jive_object] + places = get("places?fields=placeID,name,-resources&filter=entityDescriptor(#{entity[:type]},#{entity[:id]})", to_import[:authenticated]) + import_place_contents(places["list"][0], to_import) if places && places["list"].present? + end + end + + def import_place_contents(place, to_import) + puts "", "importing contents for '#{place["name"]}'..." + + start_index = 0 + + filters = "filter=status(published)" + if to_import[:filters] + filters << "&filter=type(#{to_import[:filters][:type]})" if to_import[:filters][:type].present? + filters << "&filter=creationDate(null,#{to_import[:filters][:created_after].strftime("%Y-%m-%dT%TZ")})" if to_import[:filters][:created_after].present? + end + + loop do + contents = get("places/#{place["placeID"]}/contents?#{filters}&sort=dateCreatedAsc&count=#{POST_COUNT}&startIndex=#{start_index}", to_import[:authenticated]) + contents["list"].each do |content| + custom_fields = { import_id: content["contentID"] } + custom_fields[:import_permalink] = content["permalink"] if content["permalink"].present? + + topic = { + id: content["contentID"], + created_at: content["published"], + title: @htmlentities.decode(content["subject"]), + raw: process_raw(content["content"]["text"]), + user_id: user_id_from_imported_user_id(content["author"]["id"]) || Discourse::SYSTEM_USER_ID, + views: content["viewCount"], + custom_fields: custom_fields, + } + + if to_import[:category] + topic[:category] = to_import[:category].call(content) + else + topic[:category] = to_import[:category_id] + end + + post_id = post_id_from_imported_post_id(topic[:id]) + parent_post = post_id ? Post.unscoped.find_by(id: post_id) : create_post(topic, topic[:id]) + + if parent_post&.id && parent_post&.topic_id + import_likes(content["contentID"], parent_post.id) if content["likeCount"].to_i > 0 + if content["replyCount"].to_i > 0 + import_comments(content["contentID"], parent_post.topic_id, to_import) if content["resources"]["comments"].present? + import_messages(content["contentID"], parent_post.topic_id, to_import) if content["resources"]["messages"].present? + end + end + end + + break if contents["list"].size < POST_COUNT || contents["links"].blank? || contents["links"]["next"].blank? + break unless start_index = contents["links"]["next"][/startIndex=(\d+)/, 1] + end + end + + def import_likes(content_id, post_id) + start_index = 0 + + loop do + likes = get("contents/#{content_id}/likes?&count=#{USER_COUNT}&startIndex=#{start_index}", true) + likes["list"].each do |like| + next unless user_id = user_id_from_imported_user_id(like["id"]) + next if PostAction.exists?(user_id: user_id, post_id: post_id, post_action_type_id: PostActionType.types[:like]) + PostAction.act(User.find(user_id), Post.find(post_id), PostActionType.types[:like]) + end + + break if likes["list"].size < USER_COUNT || likes["links"].blank? || likes["links"]["next"].blank? + break unless start_index = likes["links"]["next"][/startIndex=(\d+)/, 1] + end + end + + def import_comments(content_id, topic_id, to_import) + start_index = 0 + + loop do + comments = get("contents/#{content_id}/comments?hierarchical=false&count=#{POST_COUNT}&startIndex=#{start_index}", to_import[:authenticated]) + comments["list"].each do |comment| + next if post_id_from_imported_post_id(comment["id"]) + + post = { + id: comment["id"], + created_at: comment["published"], + topic_id: topic_id, + user_id: user_id_from_imported_user_id(comment["author"]["id"]) || Discourse::SYSTEM_USER_ID, + raw: process_raw(comment["content"]["text"]), + custom_fields: { import_id: comment["id"] }, + } + + if (parent_post_id = comment["parentID"]).to_i > 0 + if parent = topic_lookup_from_imported_post_id(parent_post_id) + post[:reply_to_post_number] = parent[:post_number] + end + end + + if created_post = create_post(post, post[:id]) + import_likes(content_id, created_post.id) if comment["likeCount"].to_i > 0 + end + end + + break if comments["list"].size < POST_COUNT || comments["links"].blank? || comments["links"]["next"].blank? + break unless start_index = comments["links"]["next"][/startIndex=(\d+)/, 1] + end + end + + def import_messages(content_id, topic_id, to_import) + start_index = 0 + + loop do + messages = get("messages/contents/#{content_id}?hierarchical=false&count=#{POST_COUNT}&startIndex=#{start_index}", to_import[:authenticated]) + messages["list"].each do |message| + next if post_id_from_imported_post_id(message["id"]) + + post = { + id: message["id"], + created_at: message["published"], + topic_id: topic_id, + user_id: user_id_from_imported_user_id(message["author"]["id"]) || Discourse::SYSTEM_USER_ID, + raw: process_raw(message["content"]["text"]), + custom_fields: { import_id: message["id"] }, + } + post[:custom_fields][:is_accepted_answer] = true if message["answer"] + + if (parent_post_id = message["parentID"].to_i) > 0 + if parent = topic_lookup_from_imported_post_id(parent_post_id) + post[:reply_to_post_number] = parent[:post_number] + end + end + + if created_post = create_post(post, post[:id]) + import_likes(content_id, created_post.id) if message["likeCount"].to_i > 0 + end + end + + break if messages["list"].size < POST_COUNT || messages["links"].blank? || messages["links"]["next"].blank? + break unless start_index = messages["links"]["next"][/startIndex=(\d+)/, 1] + end + end + + def create_post(options, import_id) + post = super(options, import_id) + if Post === post + add_post(import_id, post) + add_topic(post) + end + post + end + + def import_bookmarks + puts "", "importing bookmarks..." + + start_index = 0 + fields = "fields=author.id,favoriteObject.id,-resources,-author.resources,-favoriteObject.resources" + filter = "&filter=creationDate(null,2016-01-01T00:00:00Z)" + + loop do + favorites = get("contents?#{fields}&filter=type(favorite)#{filter}&sort=dateCreatedAsc&count=#{POST_COUNT}&startIndex=#{start_index}") + favorites["list"].each do |favorite| + next unless user_id = user_id_from_imported_user_id(favorite["author"]["id"]) + next unless post_id = post_id_from_imported_post_id(favorite["favoriteObject"]["id"]) + next if PostAction.exists?(user_id: user_id, post_id: post_id, post_action_type_id: PostActionType.types[:bookmark]) + PostAction.act(User.find(user_id), Post.find(post_id), PostActionType.types[:bookmark]) + end + + break if favorites["list"].size < POST_COUNT || favorites["links"].blank? || favorites["links"]["next"].blank? + break unless start_index = favorites["links"]["next"][/startIndex=(\d+)/, 1] + end + end + + def process_raw(raw) + doc = Nokogiri::HTML.fragment(raw) + + # convert emoticon + doc.css("span.emoticon-inline").each do |span| + name = span["class"][/emoticon_(\w+)/, 1]&.downcase + name && Emoji.exists?(name) ? span.replace(":#{name}:") : span.remove + end + + # convert mentions + doc.css("a.jive-link-profile-small").each { |a| a.replace("@#{a.content}") } + + # fix links + doc.css("a[href]").each do |a| + if a["href"]["#{@base_uri}/docs/DOC-"] + a["href"] = a["href"][/#{Regexp.escape(@base_uri)}\/docs\/DOC-\d+/] + elsif a["href"][@base_uri] + a.replace(a.inner_html) + end + end + + html = doc.at(".jive-rendered-content").to_html + + HtmlToMarkdown.new(html, keep_img_tags: true).to_markdown + end + + def mark_topics_as_solved + puts "", "Marking topics as solved..." + + PostAction.exec_sql <<-SQL + INSERT INTO topic_custom_fields (name, value, topic_id, created_at, updated_at) + SELECT 'accepted_answer_post_id', pcf.post_id, p.topic_id, p.created_at, p.created_at + FROM post_custom_fields pcf + JOIN posts p ON p.id = pcf.post_id + WHERE pcf.name = 'is_accepted_answer' + SQL + end + + def get(query, authenticated=false) + tries ||= 3 + + command = ["curl", "--silent"] + command << "--user \"#{@username}:#{@password}\"" if !!authenticated + command << "\"#{@base_uri}/api/core/v3/#{query}\"" + + puts command.join(" ") if ENV["VERBOSE"] == "1" + + JSON.parse `#{command.join(" ")}` + rescue + retry if (tries -= 1) >= 0 + end + +end + +ImportScripts::JiveApi.new.perform diff --git a/script/import_scripts/json_generic.rb b/script/import_scripts/json_generic.rb new file mode 100755 index 0000000000..c6474b2667 --- /dev/null +++ b/script/import_scripts/json_generic.rb @@ -0,0 +1,108 @@ +require "csv" +require File.expand_path(File.dirname(__FILE__) + "/base.rb") + +# Edit the constants and initialize method for your import data. + +class ImportScripts::JsonGeneric < ImportScripts::Base + + JSON_FILE_PATH = ENV['JSON_FILE'] + BATCH_SIZE ||= 1000 + + def initialize + super + + @imported_json = load_json + end + + def execute + puts "", "Importing from JSON file..." + + import_users + import_discussions + + puts "", "Done" + end + + def load_json + JSON.parse(File.read(JSON_FILE_PATH)) + end + + def username_for(name) + result = name.downcase.gsub(/[^a-z0-9\-\_]/, '') + + if result.blank? + result = Digest::SHA1.hexdigest(name)[0...10] + end + + result + end + + def import_users + puts '', "Importing users" + + users = [] + @imported_json['topics'].each do |t| + t['posts'].each do |p| + users << p['author'].scrub + end + end + users.uniq! + + create_users(users) do |u| + { + id: username_for(u), + username: username_for(u), + name: u, + email: "#{username_for(u)}@example.com", + created_at: Time.now + } + end + end + + + def import_discussions + puts "", "Importing discussions" + + topics = 0 + posts = 0 + + @imported_json['topics'].each do |t| + first_post = t['posts'][0] + next unless first_post + + topic = { + id: t["id"], + user_id: user_id_from_imported_user_id(username_for(first_post["author"])) || -1, + raw: first_post["body"], + created_at: Time.zone.parse(first_post["date"]), + cook_method: Post.cook_methods[:raw_html], + title: t['title'], + category: ENV['CATEGORY_ID'], + custom_fields: { import_id: "pid:#{first_post['id']}" } + } + + topic[:pinned_at] = Time.zone.parse(first_post["date"]) if t['pinned'] + topics += 1 + parent_post = create_post(topic, topic[:id]) + + t['posts'][1..-1].each do |p| + create_post({ + id: p["id"], + topic_id: parent_post.topic_id, + user_id: user_id_from_imported_user_id(username_for(p["author"])) || -1, + raw: p["body"], + created_at: Time.zone.parse(p["date"]), + cook_method: Post.cook_methods[:raw_html], + custom_fields: { import_id: "pid:#{p['id']}" } + }, p['id']) + posts += 1 + end + end + + puts "", "Imported #{topics} topics with #{topics + posts} posts." + end +end + +if __FILE__==$0 + ImportScripts::JsonGeneric.new.perform +end diff --git a/script/import_scripts/lithium.rb b/script/import_scripts/lithium.rb index 1591cb8f2b..bd0a6f0055 100644 --- a/script/import_scripts/lithium.rb +++ b/script/import_scripts/lithium.rb @@ -144,7 +144,7 @@ class ImportScripts::Lithium < ImportScripts::Base file.write(picture["filedata"].encode("ASCII-8BIT").force_encoding("UTF-8")) file.rewind - upload = Upload.create_for(imported_user.id, file, picture["filename"], file.size) + upload = UploadCreator.new(file, picture["filename"]).create_for(imported_user.id) return if !upload.persisted? @@ -173,7 +173,7 @@ class ImportScripts::Lithium < ImportScripts::Base file.write(background["filedata"].encode("ASCII-8BIT").force_encoding("UTF-8")) file.rewind - upload = Upload.create_for(imported_user.id, file, background["filename"], file.size) + upload = UploadCreator.new(file, background["filename"]).create_for(imported_user.id) return if !upload.persisted? @@ -807,7 +807,7 @@ SQL if image File.open(image) do |file| - upload = Upload.create_for(user_id, file, "image." + (image =~ /.png$/ ? "png": "jpg"), File.size(image)) + upload = UploadCreator.new(file, "image." + (image.ends_with?(".png") ? "png" : "jpg")).create_for(user_id) l["src"] = upload.url end else diff --git a/script/import_scripts/mbox-experimental.rb b/script/import_scripts/mbox-experimental.rb new file mode 100644 index 0000000000..065b0111bd --- /dev/null +++ b/script/import_scripts/mbox-experimental.rb @@ -0,0 +1,17 @@ +if ARGV.length != 1 || !File.exists?(ARGV[0]) + STDERR.puts '', 'Usage of mbox importer:', 'bundle exec ruby mbox-experimental.rb ' + STDERR.puts '', "Use the settings file from #{File.expand_path('mbox/settings.yml', File.dirname(__FILE__))} as an example." + exit 1 +end + +module ImportScripts + module Mbox + require_relative 'mbox/support/settings' + + @settings = Settings.load(ARGV[0]) + + require_relative 'mbox/importer' + Importer.new(@settings).perform + end +end + diff --git a/script/import_scripts/mbox.rb b/script/import_scripts/mbox.rb index dce24c5402..e7c25ed18c 100755 --- a/script/import_scripts/mbox.rb +++ b/script/import_scripts/mbox.rb @@ -439,7 +439,7 @@ p end # read attachment File.open(tmp.path, "w+b") { |f| f.write attachment.body.decoded } # create the upload for the user - upload = Upload.create_for(user_id_from_imported_user_id(from_email) || Discourse::SYSTEM_USER_ID, tmp, attachment.filename, tmp.size ) + upload = UploadCreator.new(tmp, attachment.filename).create_for(user_id_from_imported_user_id(from_email) || Discourse::SYSTEM_USER_ID) if upload && upload.errors.empty? raw << "\n\n#{receiver.attachment_markdown(upload)}\n\n" end @@ -530,7 +530,7 @@ p end # read attachment File.open(tmp.path, "w+b") { |f| f.write attachment.body.decoded } # create the upload for the user - upload = Upload.create_for(user_id_from_imported_user_id(from_email) || Discourse::SYSTEM_USER_ID, tmp, attachment.filename, tmp.size ) + upload = UploadCreator.new(tmp, attachment.filename).create_for(user_id_from_imported_user_id(from_email) || Discourse::SYSTEM_USER_ID) if upload && upload.errors.empty? raw << "\n\n#{receiver.attachment_markdown(upload)}\n\n" end diff --git a/script/import_scripts/mbox/importer.rb b/script/import_scripts/mbox/importer.rb new file mode 100644 index 0000000000..762c162e82 --- /dev/null +++ b/script/import_scripts/mbox/importer.rb @@ -0,0 +1,161 @@ +require_relative '../base' +require_relative 'support/database' +require_relative 'support/indexer' +require_relative 'support/settings' + +module ImportScripts::Mbox + class Importer < ImportScripts::Base + # @param settings [ImportScripts::Mbox::Settings] + def initialize(settings) + @settings = settings + super() + + @database = Database.new(@settings.data_dir, @settings.batch_size) + end + + def change_site_settings + super + + SiteSetting.enable_staged_users = true + end + + protected + + def execute + index_messages + import_categories + import_users + import_posts + end + + def index_messages + puts '', 'creating index' + indexer = Indexer.new(@database, @settings) + indexer.execute + end + + def import_categories + puts '', 'creating categories' + rows = @database.fetch_categories + + create_categories(rows) do |row| + { + id: row['name'], + name: row['name'] + } + end + end + + def import_users + puts '', 'creating users' + total_count = @database.count_users + last_email = '' + + batches do |offset| + rows, last_email = @database.fetch_users(last_email) + break if rows.empty? + + next if all_records_exist?(:users, rows.map { |row| row['email'] }) + + create_users(rows, total: total_count, offset: offset) do |row| + { + id: row['email'], + email: row['email'], + name: row['name'], + trust_level: @settings.trust_level, + staged: true, + created_at: to_time(row['date_of_first_message']) + } + end + end + end + + def batches + super(@settings.batch_size) + end + + def import_posts + puts '', 'creating topics and posts' + total_count = @database.count_messages + last_row_id = 0 + + batches do |offset| + rows, last_row_id = @database.fetch_messages(last_row_id) + break if rows.empty? + + next if all_records_exist?(:posts, rows.map { |row| row['msg_id'] }) + + create_posts(rows, total: total_count, offset: offset) do |row| + if row['in_reply_to'].blank? + map_first_post(row) + else + map_reply(row) + end + end + end + end + + def map_post(row) + user_id = user_id_from_imported_user_id(row['from_email']) || Discourse::SYSTEM_USER_ID + body = row['body'] || '' + body << map_attachments(row['raw_message'], user_id) if row['attachment_count'].positive? + body << Email::Receiver.elided_html(row['elided']) if row['elided'].present? + + { + id: row['msg_id'], + user_id: user_id, + created_at: to_time(row['email_date']), + raw: body, + raw_email: row['raw_message'], + via_email: true, + # cook_method: Post.cook_methods[:email] # this is slowing down the import by factor 4 + } + end + + def map_first_post(row) + mapped = map_post(row) + mapped[:category] = category_id_from_imported_category_id(row['category']) + mapped[:title] = row['subject'].strip[0...255] + mapped + end + + def map_reply(row) + parent = @lookup.topic_lookup_from_imported_post_id(row['in_reply_to']) + + if parent.blank? + puts "Parent message #{row['in_reply_to']} doesn't exist. Skipping #{row['msg_id']}: #{row['subject'][0..40]}" + return nil + end + + mapped = map_post(row) + mapped[:topic_id] = parent[:topic_id] + mapped + end + + def map_attachments(raw_message, user_id) + receiver = Email::Receiver.new(raw_message) + attachment_markdown = '' + + receiver.attachments.each do |attachment| + tmp = Tempfile.new(['discourse-email-attachment', File.extname(attachment.filename)]) + + begin + File.open(tmp.path, 'w+b') { |f| f.write attachment.body.decoded } + upload = UploadCreator.new(tmp, attachment.filename).create_for(user_id) + + if upload && upload.errors.empty? + attachment_markdown << "\n\n#{receiver.attachment_markdown(upload)}\n\n" + end + ensure + tmp.try(:close!) + end + end + + attachment_markdown + end + + def to_time(datetime) + Time.zone.at(DateTime.iso8601(datetime)) if datetime + end + end +end diff --git a/script/import_scripts/mbox/settings.yml b/script/import_scripts/mbox/settings.yml new file mode 100644 index 0000000000..0a193aa1ed --- /dev/null +++ b/script/import_scripts/mbox/settings.yml @@ -0,0 +1,9 @@ +# PostgreSQL mailing lists +#data_dir: /data/import/postgres +#split_regex: "^From .*@postgresql.org.*" + +# ruby-talk mailing list +data_dir: /data/import/ruby-talk/news/gmane/comp/lang/ruby +split_regex: "" + +default_trust_level: 1 diff --git a/script/import_scripts/mbox/support/database.rb b/script/import_scripts/mbox/support/database.rb new file mode 100644 index 0000000000..396d23e5b2 --- /dev/null +++ b/script/import_scripts/mbox/support/database.rb @@ -0,0 +1,261 @@ +require 'sqlite3' + +module ImportScripts::Mbox + class Database + SCHEMA_VERSION = 1 + + def initialize(directory, batch_size) + @db = SQLite3::Database.new("#{directory}/index.db", results_as_hash: true) + @batch_size = batch_size + + configure_database + upgrade_schema_version + create_table_for_categories + create_table_for_imported_files + create_table_for_emails + create_table_for_replies + create_table_for_users + end + + def insert_category(category) + @db.execute(<<-SQL, category) + INSERT OR REPLACE INTO category (name, description) + VALUES (:name, :description) + SQL + end + + def insert_imported_file(imported_file) + @db.execute(<<-SQL, imported_file) + INSERT OR REPLACE INTO imported_file (category, filename, checksum) + VALUES (:category, :filename, :checksum) + SQL + end + + def insert_email(email) + @db.execute(<<-SQL, email) + INSERT OR REPLACE INTO email (msg_id, from_email, from_name, subject, + email_date, raw_message, body, elided, attachment_count, charset, + category, filename, first_line_number, last_line_number) + VALUES (:msg_id, :from_email, :from_name, :subject, + :email_date, :raw_message, :body, :elided, :attachment_count, :charset, + :category, :filename, :first_line_number, :last_line_number) + SQL + end + + def insert_replies(msg_id, reply_message_ids) + sql = <<-SQL + INSERT OR REPLACE INTO reply (msg_id, in_reply_to) + VALUES (:msg_id, :in_reply_to) + SQL + + @db.prepare(sql) do |stmt| + reply_message_ids.each do |in_reply_to| + stmt.execute(msg_id, in_reply_to) + end + end + end + + def update_in_reply_to_of_emails + @db.execute <<-SQL + UPDATE email + SET in_reply_to = ( + SELECT e.msg_id + FROM reply r + JOIN email e ON (r.in_reply_to = e.msg_id) + WHERE r.msg_id = email.msg_id + ORDER BY e.email_date DESC + LIMIT 1 + ) + SQL + end + + def sort_emails + @db.execute 'DELETE FROM email_order' + + @db.execute <<-SQL + WITH RECURSIVE + messages(msg_id, level, email_date) AS ( + SELECT msg_id, 0 AS level, email_date + FROM email + WHERE in_reply_to IS NULL + UNION ALL + SELECT e.msg_id, m.level + 1, e.email_date + FROM email e + JOIN messages m ON e.in_reply_to = m.msg_id + ORDER BY level, email_date, msg_id + ) + INSERT INTO email_order (msg_id) + SELECT msg_id + FROM messages + SQL + end + + def fill_users_from_emails + @db.execute 'DELETE FROM user' + + @db.execute <<-SQL + INSERT INTO user (email, name, date_of_first_message) + SELECT from_email, MIN(from_name) AS from_name, MIN(email_date) + FROM email + WHERE from_email IS NOT NULL + GROUP BY from_email + ORDER BY from_email + SQL + end + + def fetch_imported_files(category) + @db.execute(<<-SQL, category) + SELECT filename, checksum + FROM imported_file + WHERE category = :category + SQL + end + + def fetch_categories + @db.execute <<-SQL + SELECT name, description + FROM category + ORDER BY name + SQL + end + + def count_users + @db.get_first_value <<-SQL + SELECT COUNT(*) + FROM user + SQL + end + + def fetch_users(last_email) + rows = @db.execute(<<-SQL, last_email) + SELECT email, name, date_of_first_message + FROM user + WHERE email > :last_email + LIMIT #{@batch_size} + SQL + + add_last_column_value(rows, 'email') + end + + def count_messages + @db.get_first_value <<-SQL + SELECT COUNT(*) + FROM email + WHERE email_date IS NOT NULL + SQL + end + + def fetch_messages(last_row_id) + rows = @db.execute(<<-SQL, last_row_id) + SELECT o.ROWID, e.msg_id, from_email, subject, email_date, in_reply_to, + raw_message, body, elided, attachment_count, category + FROM email e + JOIN email_order o USING (msg_id) + WHERE email_date IS NOT NULL AND + o.ROWID > :last_row_id + ORDER BY o.ROWID + LIMIT #{@batch_size} + SQL + + add_last_column_value(rows, 'rowid') + end + + private + + def configure_database + @db.execute 'PRAGMA journal_mode = TRUNCATE' + end + + def upgrade_schema_version + # current_version = query("PRAGMA user_version").last[0] + @db.execute "PRAGMA user_version = #{SCHEMA_VERSION}" + end + + def create_table_for_categories + @db.execute <<-SQL + CREATE TABLE IF NOT EXISTS category ( + name TEXT NOT NULL PRIMARY KEY, + description TEXT + ) + SQL + end + + def create_table_for_imported_files + @db.execute <<-SQL + CREATE TABLE IF NOT EXISTS imported_file ( + category TEXT NOT NULL, + filename TEXT NOT NULL, + checksum TEXT NOT NULL, + PRIMARY KEY (category, filename), + FOREIGN KEY(category) REFERENCES category(name) + ) + SQL + end + + def create_table_for_emails + @db.execute <<-SQL + CREATE TABLE IF NOT EXISTS email ( + msg_id TEXT NOT NULL PRIMARY KEY, + from_email TEXT, + from_name TEXT, + subject TEXT, + in_reply_to TEXT, + email_date DATETIME, + raw_message TEXT, + body TEXT, + elided TEXT, + attachment_count INTEGER NOT NULL DEFAULT 0, + charset TEXT, + category TEXT NOT NULL, + filename TEXT NOT NULL, + first_line_number INTEGER, + last_line_number INTEGER, + FOREIGN KEY(category) REFERENCES category(name) + ) + SQL + + @db.execute 'CREATE INDEX IF NOT EXISTS email_by_from ON email (from_email)' + @db.execute 'CREATE INDEX IF NOT EXISTS email_by_in_reply_to ON email (in_reply_to)' + @db.execute 'CREATE INDEX IF NOT EXISTS email_by_date ON email (email_date)' + + @db.execute <<-SQL + CREATE TABLE IF NOT EXISTS email_order ( + msg_id TEXT NOT NULL PRIMARY KEY + ) + SQL + end + + def create_table_for_replies + @db.execute <<-SQL + CREATE TABLE IF NOT EXISTS reply ( + msg_id TEXT NOT NULL, + in_reply_to TEXT NOT NULL, + PRIMARY KEY (msg_id, in_reply_to), + FOREIGN KEY(msg_id) REFERENCES email(msg_id) + ) + SQL + + @db.execute 'CREATE INDEX IF NOT EXISTS reply_by_in_reply_to ON reply (in_reply_to)' + end + + def create_table_for_users + @db.execute <<-SQL + CREATE TABLE IF NOT EXISTS user ( + email TEXT NOT NULL PRIMARY KEY, + name TEXT, + date_of_first_message DATETIME NOT NULL + ) + SQL + end + + def add_last_column_value(rows, *last_columns) + return rows if last_columns.empty? + + result = [rows] + last_row = rows.last + + last_columns.each { |column| result.push(last_row ? last_row[column] : nil) } + result + end + end +end diff --git a/script/import_scripts/mbox/support/indexer.rb b/script/import_scripts/mbox/support/indexer.rb new file mode 100644 index 0000000000..4884365a06 --- /dev/null +++ b/script/import_scripts/mbox/support/indexer.rb @@ -0,0 +1,226 @@ +require_relative 'database' +require 'json' +require 'yaml' + +module ImportScripts::Mbox + class Indexer + # @param database [ImportScripts::Mbox::Database] + # @param settings [ImportScripts::Mbox::Settings] + def initialize(database, settings) + @database = database + @root_directory = settings.data_dir + @split_regex = settings.split_regex + end + + def execute + directories = Dir.glob(File.join(@root_directory, '*')) + directories.select! { |f| File.directory?(f) } + directories.sort! + + directories.each do |directory| + puts "indexing files in #{directory}" + category = index_category(directory) + index_emails(directory, category[:name]) + end + + puts '', 'indexing replies and users' + @database.update_in_reply_to_of_emails + @database.sort_emails + @database.fill_users_from_emails + end + + private + + METADATA_FILENAME = 'metadata.yml'.freeze + + def index_category(directory) + metadata_file = File.join(directory, METADATA_FILENAME) + + if File.exist?(metadata_file) + # workaround for YML files that contain classname in file header + yaml = File.read(metadata_file).sub(/^--- !.*$/, '---') + metadata = YAML.load(yaml) + else + metadata = {} + end + + category = { + name: metadata['name'].presence || File.basename(directory), + description: metadata['description'] + } + + @database.insert_category(category) + category + end + + def index_emails(directory, category_name) + all_messages(directory, category_name) do |receiver, filename, first_line_number, last_line_number| + msg_id = receiver.message_id + parsed_email = receiver.mail + from_email, from_display_name = receiver.parse_from_field(parsed_email) + body, elided = receiver.select_body + reply_message_ids = extract_reply_message_ids(parsed_email) + + email = { + msg_id: msg_id, + from_email: from_email, + from_name: from_display_name, + subject: extract_subject(receiver, category_name), + email_date: parsed_email.date&.to_s, + raw_message: receiver.raw_email, + body: body, + elided: elided, + attachment_count: receiver.attachments.count, + charset: parsed_email.charset&.downcase, + category: category_name, + filename: File.basename(filename), + first_line_number: first_line_number, + last_line_number: last_line_number + } + + @database.insert_email(email) + @database.insert_replies(msg_id, reply_message_ids) unless reply_message_ids.empty? + end + end + + def imported_file_checksums(category_name) + rows = @database.fetch_imported_files(category_name) + rows.each_with_object({}) do |row, hash| + hash[row['filename']] = row['checksum'] + end + end + + def all_messages(directory, category_name) + checksums = imported_file_checksums(category_name) + + Dir.foreach(directory) do |filename| + filename = File.join(directory, filename) + next if ignored_file?(filename, checksums) + + puts "indexing #{filename}" + + if @split_regex.present? + each_mail(filename) do |raw_message, first_line_number, last_line_number| + yield read_mail_from_string(raw_message), filename, first_line_number, last_line_number + end + else + yield read_mail_from_file(filename), filename + end + + mark_as_fully_indexed(category_name, filename) + end + end + + def mark_as_fully_indexed(category_name, filename) + imported_file = { + category: category_name, + filename: filename, + checksum: calc_checksum(filename) + } + + @database.insert_imported_file(imported_file) + end + + def each_mail(filename) + raw_message = '' + first_line_number = 1 + last_line_number = 0 + + each_line(filename) do |line| + line = line.scrub + + if line =~ @split_regex && last_line_number.positive? + yield raw_message, first_line_number, last_line_number + raw_message = '' + first_line_number = last_line_number + 1 + else + raw_message << line + end + + last_line_number += 1 + end + + yield raw_message, first_line_number, last_line_number if raw_message.present? + end + + def each_line(filename) + raw_file = File.open(filename, 'r') + text_file = filename.end_with?('.gz') ? Zlib::GzipReader.new(raw_file) : raw_file + + text_file.each_line do |line| + yield line + end + ensure + raw_file.close if raw_file + end + + def read_mail_from_file(filename) + raw_message = File.read(filename) + read_mail_from_string(raw_message) + end + + def read_mail_from_string(raw_message) + Email::Receiver.new(raw_message) + end + + def extract_reply_message_ids(mail) + message_ids = [mail.in_reply_to, Email::Receiver.extract_references(mail.references)] + message_ids.flatten! + message_ids.select!(&:present?) + message_ids.uniq! + message_ids.first(20) + end + + def extract_subject(receiver, list_name) + subject = receiver.subject + return nil if subject.blank? + + # TODO: make the list name (or maybe multiple names) configurable + # Strip mailing list name from subject + subject = subject.gsub(/\[#{Regexp.escape(list_name)}\]/, '').strip + + clean_subject(subject) + end + + # TODO: refactor and move prefixes to settings + def clean_subject(subject) + original_length = subject.length + + # Strip Reply prefix from title (Standard and localized) + subject = subject.gsub(/^Re: */i, '') + subject = subject.gsub(/^R: */i, '') #Italian + subject = subject.gsub(/^RIF: */i, '') #Italian + + # Strip Forward prefix from title (Standard and localized) + subject = subject.gsub(/^Fwd: */i, '') + subject = subject.gsub(/^I: */i, '') #Italian + + subject.strip + + # In case of mixed localized prefixes there could be many of them + # if the mail client didn't strip the localized ones + if original_length > subject.length + clean_subject(subject) + else + subject + end + end + + def ignored_file?(filename, checksums) + File.directory?(filename) || metadata_file?(filename) || fully_indexed?(filename, checksums) + end + + def metadata_file?(filename) + File.basename(filename) == METADATA_FILENAME + end + + def fully_indexed?(filename, checksums) + checksum = checksums[filename] + checksum.present? && calc_checksum(filename) == checksum + end + + def calc_checksum(filename) + Digest::SHA256.file(filename).hexdigest + end + end +end diff --git a/script/import_scripts/mbox/support/settings.rb b/script/import_scripts/mbox/support/settings.rb new file mode 100644 index 0000000000..79fb5ab56b --- /dev/null +++ b/script/import_scripts/mbox/support/settings.rb @@ -0,0 +1,22 @@ +require 'yaml' + +module ImportScripts::Mbox + class Settings + def self.load(filename) + yaml = YAML.load_file(filename) + Settings.new(yaml) + end + + attr_reader :data_dir + attr_reader :split_regex + attr_reader :batch_size + attr_reader :trust_level + + def initialize(yaml) + @data_dir = yaml['data_dir'] + @split_regex = Regexp.new(yaml['split_regex']) unless yaml['split_regex'].empty? + @batch_size = 1000 # no need to make this actually configurable at the moment + @trust_level = yaml['default_trust_level'] + end + end +end diff --git a/script/import_scripts/mylittleforum.rb b/script/import_scripts/mylittleforum.rb index 795148c7d4..fbec5dad46 100644 --- a/script/import_scripts/mylittleforum.rb +++ b/script/import_scripts/mylittleforum.rb @@ -98,8 +98,6 @@ EOM def import_users puts '', "creating users" - username = nil - total_count = mysql_query("SELECT count(*) count FROM #{TABLE_PREFIX}userdata WHERE last_login > '#{IMPORT_AFTER}';").first['count'] batches(BATCH_SIZE) do |offset| @@ -404,7 +402,7 @@ EOM User.find_each do |u| ucf = u.custom_fields if ucf && ucf["import_id"] && ucf["import_username"] - Permalink.create( url: "#{BASE}/user-id-#{ucf['import_id']}.html", external_url: "/users/#{u.username}" ) rescue nil + Permalink.create( url: "#{BASE}/user-id-#{ucf['import_id']}.html", external_url: "/u/#{u.username}" ) rescue nil print '.' end end diff --git a/script/import_scripts/phpbb3/database/database_3_0.rb b/script/import_scripts/phpbb3/database/database_3_0.rb index c596b37338..a54e0540a0 100644 --- a/script/import_scripts/phpbb3/database/database_3_0.rb +++ b/script/import_scripts/phpbb3/database/database_3_0.rb @@ -7,7 +7,6 @@ module ImportScripts::PhpBB3 count(<<-SQL) SELECT COUNT(*) AS count FROM #{@table_prefix}users u - JOIN #{@table_prefix}groups g ON g.group_id = u.group_id WHERE u.user_type != #{Constants::USER_TYPE_IGNORE} SQL end @@ -18,7 +17,7 @@ module ImportScripts::PhpBB3 u.user_type, u.user_inactive_reason, g.group_name, b.ban_start, b.ban_end, b.ban_reason, u.user_posts, u.user_website, u.user_from, u.user_birthday, u.user_avatar_type, u.user_avatar FROM #{@table_prefix}users u - JOIN #{@table_prefix}groups g ON (g.group_id = u.group_id) + LEFT OUTER JOIN #{@table_prefix}groups g ON (g.group_id = u.group_id) LEFT OUTER JOIN #{@table_prefix}banlist b ON ( u.user_id = b.ban_userid AND b.ban_exclude = 0 AND (b.ban_end = 0 OR b.ban_end >= UNIX_TIMESTAMP()) diff --git a/script/import_scripts/phpbb3/importer.rb b/script/import_scripts/phpbb3/importer.rb index b9daaa1568..dfaa21951b 100644 --- a/script/import_scripts/phpbb3/importer.rb +++ b/script/import_scripts/phpbb3/importer.rb @@ -34,9 +34,32 @@ module ImportScripts::PhpBB3 end def change_site_settings + # let's make sure that we import all attachments no matter how big they are + setting_keys = [:max_image_size_kb, :max_attachment_size_kb] + original_validators = disable_setting_validators(setting_keys) + super @importers.permalink_importer.change_site_settings + + enable_setting_validators(original_validators) + end + + def disable_setting_validators(setting_keys) + original_validators = {} + + setting_keys.each do |key| + original_validators[key] = SiteSetting.validators[key] + SiteSetting.validators[key] = nil + end + + original_validators + end + + def enable_setting_validators(original_validators) + original_validators.each do |key, validator| + SiteSetting.validators[key] = validator + end end def get_site_settings_for_import diff --git a/script/import_scripts/phpbb3/importers/avatar_importer.rb b/script/import_scripts/phpbb3/importers/avatar_importer.rb index 825bc445fa..8a5e3a0c6f 100644 --- a/script/import_scripts/phpbb3/importers/avatar_importer.rb +++ b/script/import_scripts/phpbb3/importers/avatar_importer.rb @@ -65,7 +65,11 @@ module ImportScripts::PhpBB3 max_image_size_kb = SiteSetting.max_image_size_kb.kilobytes begin - avatar_file = FileHelper.download(url, max_image_size_kb, 'discourse-avatar') + avatar_file = FileHelper.download( + url, + max_file_size: max_image_size_kb, + tmp_file_name: 'discourse-avatar' + ) rescue StandardError => err warn "Error downloading avatar: #{err.message}. Skipping..." return nil @@ -75,7 +79,6 @@ module ImportScripts::PhpBB3 if avatar_file.size <= max_image_size_kb return avatar_file else - Rails.logger.error("Failed to download remote avatar: #{url} - Image is larger than #{max_image_size_kb} KB") return nil end end diff --git a/script/import_scripts/phpbb3/importers/user_importer.rb b/script/import_scripts/phpbb3/importers/user_importer.rb index 2d98854df1..f7414bb1f0 100644 --- a/script/import_scripts/phpbb3/importers/user_importer.rb +++ b/script/import_scripts/phpbb3/importers/user_importer.rb @@ -51,7 +51,7 @@ module ImportScripts::PhpBB3 { id: username, - email: "anonymous_no_email_#{SecureRandom.hex}", + email: "anonymous_#{SecureRandom.hex}@no-email.invalid", username: username, name: @settings.username_as_name ? username : '', created_at: Time.zone.at(row[:first_post_time]), diff --git a/script/import_scripts/phpbb3/settings.yml b/script/import_scripts/phpbb3/settings.yml index e7160a4d8e..4fd15fc9d0 100644 --- a/script/import_scripts/phpbb3/settings.yml +++ b/script/import_scripts/phpbb3/settings.yml @@ -7,7 +7,7 @@ database: username: root password: schema: phpbb - table_prefix: phpbb_ # Usually all table names start with phpbb_. Change this, if your forum is using a different prefix. + table_prefix: phpbb_ # Change this, if your forum is using a different prefix. Usually all table names start with phpbb_ batch_size: 1000 # Don't change this unless you know what you're doing. The default (1000) should work just fine. import: diff --git a/script/import_scripts/phpbb3/support/text_processor.rb b/script/import_scripts/phpbb3/support/text_processor.rb index bcdb770fb2..3eceff3f75 100644 --- a/script/import_scripts/phpbb3/support/text_processor.rb +++ b/script/import_scripts/phpbb3/support/text_processor.rb @@ -137,7 +137,7 @@ module ImportScripts::PhpBB3 def create_internal_link_regexps(original_site_prefix) host = original_site_prefix.gsub('.', '\.') - link_regex = "http(?:s)?://#{host}/viewtopic\\.php\\?(?:\\S*)(?:t=(\\d+)|p=(\\d+)(?:#p\\d+)?)(?:\\S*)" + link_regex = "http(?:s)?://#{host}/viewtopic\\.php\\?(?:\\S*)(?:t=(\\d+)|p=(\\d+)(?:#p\\d+)?)(?:[^\\s\\)\\]]*)" @long_internal_link_regexp = Regexp.new(%Q||, Regexp::IGNORECASE) @short_internal_link_regexp = Regexp.new(link_regex, Regexp::IGNORECASE) diff --git a/script/import_scripts/sfn.rb b/script/import_scripts/sfn.rb index 14e54196cd..64a1dc54a9 100644 --- a/script/import_scripts/sfn.rb +++ b/script/import_scripts/sfn.rb @@ -101,7 +101,7 @@ class ImportScripts::Sfn < ImportScripts::Base avatar.write(user["avatar"].encode("ASCII-8BIT").force_encoding("UTF-8")) avatar.rewind - upload = Upload.create_for(newuser.id, avatar, "avatar.jpg", avatar.size) + upload = UploadCreator.new(avatar, "avatar.jpg").create_for(newuser.id) if upload.persisted? newuser.create_user_avatar newuser.user_avatar.update(custom_upload_id: upload.id) diff --git a/script/import_scripts/smf2.rb b/script/import_scripts/smf2.rb index 2ee607a228..c65d13bedb 100644 --- a/script/import_scripts/smf2.rb +++ b/script/import_scripts/smf2.rb @@ -65,6 +65,7 @@ class ImportScripts::Smf2 < ImportScripts::Base import_categories import_posts postprocess_posts + make_prettyurl_permalinks('/forum') end def import_groups @@ -93,6 +94,7 @@ class ImportScripts::Smf2 < ImportScripts::Base create_users(query(<<-SQL), total: total) do |member| SELECT a.id_member, a.member_name, a.date_registered, a.real_name, a.email_address, + CONCAT(LCASE(a.member_name),':', a.passwd) AS password, a.is_activated, a.last_login, a.birthdate, a.member_ip, a.id_group, a.additional_groups, b.id_attach, b.file_hash, b.filename FROM {prefix}members AS a @@ -105,6 +107,7 @@ class ImportScripts::Smf2 < ImportScripts::Base { id: member[:id_member], username: member[:member_name], + password: member[:password], created_at: create_time, name: member[:real_name], email: member[:email_address], @@ -118,6 +121,7 @@ class ImportScripts::Smf2 < ImportScripts::Base post_create_action: proc do |user| user.update(created_at: create_time) if create_time < user.created_at + user.save GroupUser.transaction do group_ids.each do |gid| group_id = group_id_from_imported_group_id(gid) and @@ -180,73 +184,20 @@ class ImportScripts::Smf2 < ImportScripts::Base end end - topics = Enumerator.new do |y| - last_topic_id = nil - topic_messages = nil - query("SELECT id_msg, id_topic, body FROM {prefix}messages ORDER BY id_topic ASC, id_msg ASC") do |message| - if last_topic_id != message[:id_topic] - y << topic_messages - last_topic_id = message[:id_topic] - topic_messages = [ message ] - else - topic_messages << message - end - end - y << topic_messages - end - - graph = MessageDependencyGraph.new - topics.each do |messages| - next unless messages.present? - (messages.reverse << nil).each_cons(2) do |message, prev| - graph.add_message(message[:id_msg], prev ? prev[:id_msg] : nil, - extract_quoted_message_ids(message[:body]).to_a) - end - print "\r#{spinner.next}" - end - - begin - cycles = graph.cycles - print "\r#{spinner.next}" - cycles.each do |cycle| - candidate = cycle.detect {|n| ((cycle - [n]) & n.quoted).present? } - candidate.ignore_quotes = true - end - end while cycles.present? - message_order = graph.tsort - print "\r#{spinner.next}" - - query(<<-SQL, as: :array) - CREATE TEMPORARY TABLE {prefix}import_message_order ( - message_id int(11) NOT NULL, - message_order int(11) NOT NULL AUTO_INCREMENT, - ignore_quotes tinyint(1) NOT NULL, - PRIMARY KEY (message_id), - UNIQUE KEY message_order (message_order) - ) ENGINE=MEMORY - SQL - message_order.each_slice(100) do |nodes| - query(<<-SQL, as: :array) - INSERT INTO {prefix}import_message_order (message_id, ignore_quotes) - VALUES #{ nodes.map {|n| "(#{n.id}, #{n.ignore_quotes? ? 1 : 0})" }.join(',') } - SQL - print "\r#{spinner.next}" - end db2 = create_db_connection create_posts(query(<<-SQL), total: total) do |message| - SELECT m.id_msg, m.id_topic, m.id_member, m.poster_time, m.body, o.ignore_quotes, + SELECT m.id_msg, m.id_topic, m.id_member, m.poster_time, m.body, m.subject, t.id_board, t.id_first_msg, COUNT(a.id_attach) AS attachment_count FROM {prefix}messages AS m - LEFT JOIN {prefix}import_message_order AS o ON o.message_id = m.id_msg LEFT JOIN {prefix}topics AS t ON t.id_topic = m.id_topic LEFT JOIN {prefix}attachments AS a ON a.id_msg = m.id_msg AND a.attachment_type = 0 GROUP BY m.id_msg - ORDER BY o.message_order ASC + ORDER BY m.id_topic ASC, m.id_msg ASC SQL skip = false - ignore_quotes = (message[:ignore_quotes] == 1) + ignore_quotes = false post = { id: message[:id_msg], user_id: user_id_from_imported_user_id(message[:id_member]) || -1, @@ -390,30 +341,8 @@ class ImportScripts::Smf2 < ImportScripts::Base return opts[:ignore_quotes] ? body : convert_quotes(body) end - def v8 - @ctx ||= begin - ctx = PrettyText.create_new_context - PrettyText.decorate_context(ctx) - # provides toHumanSize but restores I18n.t which we need to fix again - ctx.load(Rails.root + "app/assets/javascripts/locales/i18n.js") - helper = PrettyText::Helpers.new - ctx['I18n']['t'] = proc {|_,key,opts| helper.t(key, opts) } - # from i18n_helpers.js -- can't load it directly because Ember is missing - ctx.eval(<<-'end') - var oldI18ntoHumanSize = I18n.toHumanSize; - I18n.toHumanSize = function(number, options) { - options = options || {}; - options.format = I18n.t("number.human.storage_units.format"); - return oldI18ntoHumanSize.apply(this, [number, options]); - }; - end - ctx - end - end - def get_upload_markdown(upload) - @func ||= v8.eval("Discourse.Utilities.getUploadMarkdown") - return @func.call(upload).to_s + html_for_upload(upload, upload.original_filename) end def convert_quotes(body) @@ -426,9 +355,9 @@ class ImportScripts::Smf2 < ImportScripts::Base tl = topic_lookup_from_imported_post_id($~[:msg].to_i) quote << ", post:#{tl[:post_number]}, topic:#{tl[:topic_id]}" if tl end - quote << "\"]#{inner}[/quote]" + quote << "\"]#{convert_quotes(inner)}[/quote]" else - "
    #{inner}
    " + "
    #{convert_quotes(inner)}
    " end end end @@ -536,7 +465,7 @@ class ImportScripts::Smf2 < ImportScripts::Base private def get_php_timezone - phpinfo, status = Open3.capture2('phpnope', '-i') + phpinfo, status = Open3.capture2('php', '-i') phpinfo.lines.each do |line| key, *vals = line.split(' => ').map(&:strip) break vals[0] if key == 'Default timezone' @@ -662,6 +591,31 @@ class ImportScripts::Smf2 < ImportScripts::Base end #MessageDependencyGraph + def make_prettyurl_permalinks(prefix) + puts 'creating permalinks for prettyurl plugin' + begin + serialized = query(<<-SQL, as: :single) + SELECT value FROM {prefix}settings + WHERE variable='pretty_board_urls'; + SQL + board_slugs = Array.new + ser = /\{(.*)\}/.match(serialized)[1] + ser.scan(/i:(\d+);s:\d+:\"(.*?)\";/).each do |nv| + board_slugs[nv[0].to_i] = nv[1] + end + topic_urls = query(<<-SQL, as: :array) + SELECT t.id_first_msg, t.id_board,u.pretty_url + FROM smf_topics t + LEFT JOIN smf_pretty_topic_urls u ON u.id_topic = t.id_topic ; + SQL + topic_urls.each do |url| + t = topic_lookup_from_imported_post_id(url[:id_first_msg]) + Permalink.create(url: "#{prefix}/#{board_slugs[url[:id_board]]}/#{url[:pretty_url]}", topic_id: t[:topic_id]) + end + rescue + end + end + end ImportScripts::Smf2.run diff --git a/script/import_scripts/socialcast/README.md b/script/import_scripts/socialcast/README.md index 84e5d66c89..403acc7147 100644 --- a/script/import_scripts/socialcast/README.md +++ b/script/import_scripts/socialcast/README.md @@ -10,12 +10,22 @@ password: 'my-socialcast-password' ``` Create the directory for the json files to export: `mkdir output` -Then run `ruby export.rb /path/to/config.yml` +Then run `bundle exec ruby export.rb /path/to/config.yml` -Create a category named "Socialcast Import" or all topics will be imported into -the "Uncategorized" category. +If desired, edit the `socialcast_message.rb` file to set the category +and tags for each topic based on the name of the Socialcast group it was +originally posted to. -Topics will be tagged with the names of the groups they were originally posted -in on Socialcast. +You must create categories with the same names first in your site. -To run the import, run `ruby import.rb` +All topics will get the `DEFAULT_TAG` at minimum. + +Topics posted to a group that matches any group name in the `TAGS_AND_CATEGORIES` +map will get the associated category and tags. + +Other topics will be tagged with the original groupname and placed in the +`DEFAULT_CATEGORY`. + +To run the import, run `bundle exec ruby import.rb` + +To run the import in a production, run `RAILS_ENV=production bundle exec ruby import.rb` diff --git a/script/import_scripts/socialcast/import.rb b/script/import_scripts/socialcast/import.rb index b28b1e5058..7aaee20e18 100644 --- a/script/import_scripts/socialcast/import.rb +++ b/script/import_scripts/socialcast/import.rb @@ -65,7 +65,6 @@ class ImportScripts::Socialcast < ImportScripts::Base post = Post.find(post_id) # already imported this topic else topic[:user_id] = user_id_from_imported_user_id(topic[:author_id]) || -1 - topic[:category] = 'Socialcast Import' post = create_post(topic, topic[:id]) diff --git a/script/import_scripts/socialcast/socialcast_message.rb b/script/import_scripts/socialcast/socialcast_message.rb index 115ab77b12..602252be94 100644 --- a/script/import_scripts/socialcast/socialcast_message.rb +++ b/script/import_scripts/socialcast/socialcast_message.rb @@ -5,6 +5,19 @@ require_relative 'create_title.rb' class SocialcastMessage + DEFAULT_CATEGORY = "Socialcast Import" + DEFAULT_TAG = "socialcast-import" + TAGS_AND_CATEGORIES = { + "somegroupname" => { + category: "Apple Stems", + tags: ["waxy", "tough"] + }, + "someothergroupname" => { + category: "Orange Peels", + tags: ["oily"] + } + } + def initialize message_json @parsed_json = JSON.parse message_json end @@ -16,7 +29,8 @@ class SocialcastMessage topic[:title] = title topic[:raw] = @parsed_json['body'] topic[:created_at] = Time.parse @parsed_json['created_at'] - topic[:tags] = [group] if group + topic[:tags] = tags + topic[:category] = category topic end @@ -24,8 +38,30 @@ class SocialcastMessage CreateTitle.from_body @parsed_json['body'] end + def tags + tags = [] + if group + if TAGS_AND_CATEGORIES[group] + tags = TAGS_AND_CATEGORIES[group][:tags] + else + tags << group + end + end + tags << DEFAULT_TAG + tags + end + + + def category + category = DEFAULT_CATEGORY + if group && TAGS_AND_CATEGORIES[group] + category = TAGS_AND_CATEGORIES[group][:category] + end + category + end + def group - @parsed_json['group']['groupname'] if @parsed_json['group'] + @parsed_json['group']['groupname'].downcase if @parsed_json['group'] && @parsed_json['group']['groupname'] end def url diff --git a/script/import_scripts/vbulletin.rb b/script/import_scripts/vbulletin.rb index 967a88bf71..0ed2bd6a91 100644 --- a/script/import_scripts/vbulletin.rb +++ b/script/import_scripts/vbulletin.rb @@ -1,16 +1,35 @@ require 'mysql2' require File.expand_path(File.dirname(__FILE__) + "/base.rb") require 'htmlentities' -require 'php_serialize' # https://github.com/jqr/php-serialize +begin + require 'php_serialize' # https://github.com/jqr/php-serialize +rescue LoadError + puts + puts 'php_serialize not found.' + puts 'Add to Gemfile, like this: ' + puts + puts "echo gem \\'php-serialize\\' >> Gemfile" + puts "bundle install" + exit +end + +# See https://meta.discourse.org/t/importing-from-vbulletin-4/54881 +# Please update there if substantive changes are made! class ImportScripts::VBulletin < ImportScripts::Base BATCH_SIZE = 1000 # CHANGE THESE BEFORE RUNNING THE IMPORTER - DATABASE = "q23" - TABLE_PREFIX = "vb_" - TIMEZONE = "America/Los_Angeles" - ATTACHMENT_DIR = '/path/to/your/attachment/folder' + + DB_HOST ||= ENV['DB_HOST'] || "localhost" + DB_NAME ||= ENV['DB_NAME'] || "vbulletin" + DB_PW ||= ENV['DB_PW'] || "" + DB_USER ||= ENV['DB_USER'] || "root" + TIMEZONE ||= ENV['TIMEZONE'] || "America/Los_Angeles" + TABLE_PREFIX ||= ENV['TABLE_PREFIX'] || "vb_" + ATTACHMENT_DIR ||= ENV['ATTACHMENT_DIR'] || '/path/to/your/attachment/folder' + + puts "#{DB_USER}:#{DB_PW}@#{DB_HOST} wants #{DB_NAME}" def initialize super @@ -22,13 +41,40 @@ class ImportScripts::VBulletin < ImportScripts::Base @htmlentities = HTMLEntities.new @client = Mysql2::Client.new( - host: "localhost", - username: "root", - database: DATABASE + host: DB_HOST, + username: DB_USER, + password: DB_PW, + database: DB_NAME ) + rescue Exception => e + puts '='*50 + puts e.message + puts < #{last_user_id} ORDER BY userid LIMIT #{BATCH_SIZE} - OFFSET #{offset} SQL + ).to_a - break if users.size < 1 + break if users.empty? - next if all_records_exist? :users, users.map {|u| u["userid"].to_i} + last_user_id = users[-1]["userid"] + before = users.size + users.reject! { |u| @lookup.user_already_imported?(u["userid"].to_i) } create_users(users, total: user_count, offset: offset) do |user| + email = user["email"].presence || fake_email + email = fake_email unless email[EmailValidator.email_regex] + username = @htmlentities.decode(user["username"]).strip { id: user["userid"], name: username, username: username, - email: user["email"].presence || fake_email, + email: email, website: user["homepage"].strip, title: @htmlentities.decode(user["usertitle"]).strip, primary_group_id: group_id_from_imported_group_id(user["usergroupid"].to_i), @@ -141,12 +195,13 @@ class ImportScripts::VBulletin < ImportScripts::Base picture = query.first return if picture.nil? + return if picture["filedata"].nil? file = Tempfile.new("profile-picture") file.write(picture["filedata"].encode("ASCII-8BIT").force_encoding("UTF-8")) file.rewind - upload = Upload.create_for(imported_user.id, file, picture["filename"], file.size) + upload = UploadCreator.new(file, picture["filename"]).create_for(imported_user.id) return if !upload.persisted? @@ -170,12 +225,13 @@ class ImportScripts::VBulletin < ImportScripts::Base background = query.first return if background.nil? + return if background["filedata"].nil? file = Tempfile.new("profile-background") file.write(background["filedata"].encode("ASCII-8BIT").force_encoding("UTF-8")) file.rewind - upload = Upload.create_for(imported_user.id, file, background["filename"], file.size) + upload = UploadCreator.new(file, background["filename"]).create_for(imported_user.id) return if !upload.persisted? @@ -229,19 +285,24 @@ class ImportScripts::VBulletin < ImportScripts::Base topic_count = mysql_query("SELECT COUNT(threadid) count FROM #{TABLE_PREFIX}thread").first["count"] + last_topic_id = -1 + batches(BATCH_SIZE) do |offset| - topics = mysql_query <<-SQL + topics = mysql_query(<<-SQL SELECT t.threadid threadid, t.title title, forumid, open, postuserid, t.dateline dateline, views, t.visible visible, sticky, p.pagetext raw FROM #{TABLE_PREFIX}thread t JOIN #{TABLE_PREFIX}post p ON p.postid = t.firstpostid + WHERE t.threadid > #{last_topic_id} ORDER BY t.threadid LIMIT #{BATCH_SIZE} - OFFSET #{offset} SQL + ).to_a - break if topics.size < 1 - next if all_records_exist? :posts, topics.map {|t| "thread-#{t["threadid"]}" } + break if topics.empty? + + last_topic_id = topics[-1]["threadid"] + topics.reject! { |t| @lookup.post_already_imported?("thread-#{t["threadid"]}") } create_posts(topics, total: topic_count, offset: offset) do |topic| raw = preprocess_post_raw(topic["raw"]) rescue nil @@ -278,27 +339,32 @@ class ImportScripts::VBulletin < ImportScripts::Base def import_posts puts "", "importing posts..." - # make sure `firstpostid` is indexed - begin - mysql_query("CREATE INDEX firstpostid_index ON #{TABLE_PREFIX}thread (firstpostid)") - rescue Mysql2::Error - puts 'Index already exists' - end + post_count = mysql_query(<<-SQL + SELECT COUNT(postid) count + FROM #{TABLE_PREFIX}post p + JOIN #{TABLE_PREFIX}thread t ON t.threadid = p.threadid + WHERE t.firstpostid <> p.postid + SQL + ).first["count"] - post_count = mysql_query("SELECT COUNT(postid) count FROM #{TABLE_PREFIX}post WHERE postid NOT IN (SELECT firstpostid FROM #{TABLE_PREFIX}thread)").first["count"] + last_post_id = -1 batches(BATCH_SIZE) do |offset| - posts = mysql_query <<-SQL - SELECT postid, userid, threadid, pagetext raw, dateline, visible, parentid - FROM #{TABLE_PREFIX}post - WHERE postid NOT IN (SELECT firstpostid FROM #{TABLE_PREFIX}thread) - ORDER BY postid + posts = mysql_query(<<-SQL + SELECT p.postid, p.userid, p.threadid, p.pagetext raw, p.dateline, p.visible, p.parentid + FROM #{TABLE_PREFIX}post p + JOIN #{TABLE_PREFIX}thread t ON t.threadid = p.threadid + WHERE t.firstpostid <> p.postid + AND p.postid > #{last_post_id} + ORDER BY p.postid LIMIT #{BATCH_SIZE} - OFFSET #{offset} SQL + ).to_a - break if posts.size < 1 - next if all_records_exist? :posts, posts.map {|p| p["postid"] } + break if posts.empty? + + last_post_id = posts[-1]["postid"] + posts.reject! { |p| @lookup.post_already_imported?(p["postid"].to_i) } create_posts(posts, total: post_count, offset: offset) do |post| raw = preprocess_post_raw(post["raw"]) rescue nil @@ -328,16 +394,17 @@ class ImportScripts::VBulletin < ImportScripts::Base WHERE a.attachmentid = #{attachment_id}" results = mysql_query(sql) - unless (row = results.first) + unless row = results.first puts "Couldn't find attachment record for post.id = #{post.id}, import_id = #{post.custom_fields['import_id']}" - return nil + return end filename = File.join(ATTACHMENT_DIR, row['user_id'].to_s.split('').join('/'), "#{row['file_id']}.attach") unless File.exists?(filename) puts "Attachment file doesn't exist: #{filename}" - return nil + return end + real_filename = row['filename'] real_filename.prepend SecureRandom.hex if real_filename[0] == '.' upload = create_upload(post.user.id, filename, real_filename) @@ -345,15 +412,14 @@ class ImportScripts::VBulletin < ImportScripts::Base if upload.nil? || !upload.valid? puts "Upload not valid :(" puts upload.errors.inspect if upload - return nil + return end - return upload, real_filename + [upload, real_filename] rescue Mysql2::Error => e puts "SQL Error" puts e.message puts sql - return nil end @@ -362,17 +428,22 @@ class ImportScripts::VBulletin < ImportScripts::Base topic_count = mysql_query("SELECT COUNT(pmtextid) count FROM #{TABLE_PREFIX}pmtext").first["count"] - batches(BATCH_SIZE) do |offset| - private_messages = mysql_query <<-SQL - SELECT pmtextid, fromuserid, title, message, touserarray, dateline - FROM #{TABLE_PREFIX}pmtext - ORDER BY pmtextid - LIMIT #{BATCH_SIZE} - OFFSET #{offset} - SQL + last_private_message_id = -1 - break if private_messages.size < 1 - next if all_records_exist? :posts, private_messages.map {|pm| "pm-#{pm['pmtextid']}" } + batches(BATCH_SIZE) do |offset| + private_messages = mysql_query(<<-SQL + SELECT pmtextid, fromuserid, title, message, touserarray, dateline + FROM #{TABLE_PREFIX}pmtext + WHERE pmtextid > #{last_private_message_id} + ORDER BY pmtextid + LIMIT #{BATCH_SIZE} + SQL + ).to_a + + break if private_messages.empty? + + last_private_message_id = private_messages[-1]["pmtextid"] + private_messages.reject! { |pm| @lookup.post_already_imported?("pm-#{pm['pmtextid']}") } title_username_of_pm_first_post = {} @@ -430,12 +501,13 @@ class ImportScripts::VBulletin < ImportScripts::Base if title =~ /^Re:/ - parent_id = title_username_of_pm_first_post[[title[3..-1], participants]] - parent_id = title_username_of_pm_first_post[[title[4..-1], participants]] unless parent_id - parent_id = title_username_of_pm_first_post[[title[5..-1], participants]] unless parent_id - parent_id = title_username_of_pm_first_post[[title[6..-1], participants]] unless parent_id - parent_id = title_username_of_pm_first_post[[title[7..-1], participants]] unless parent_id - parent_id = title_username_of_pm_first_post[[title[8..-1], participants]] unless parent_id + parent_id = title_username_of_pm_first_post[[title[3..-1], participants]] || + title_username_of_pm_first_post[[title[4..-1], participants]] || + title_username_of_pm_first_post[[title[5..-1], participants]] || + title_username_of_pm_first_post[[title[6..-1], participants]] || + title_username_of_pm_first_post[[title[7..-1], participants]] || + title_username_of_pm_first_post[[title[8..-1], participants]] + if parent_id if t = topic_lookup_from_imported_post_id("pm-#{parent_id}") topic_id = t[:topic_id] @@ -450,7 +522,7 @@ class ImportScripts::VBulletin < ImportScripts::Base mapped[:archetype] = Archetype.private_message mapped[:target_usernames] = target_usernames.join(',') - if mapped[:target_usernames].empty? # pm with yourself? + if mapped[:target_usernames].size < 1 # pm with yourself? # skip = true mapped[:target_usernames] = "system" puts "pm-#{m['pmtextid']} has no target (#{m['touserarray']})" @@ -469,7 +541,14 @@ class ImportScripts::VBulletin < ImportScripts::Base puts '', 'importing attachments...' current_count = 0 - total_count = mysql_query("SELECT COUNT(postid) count FROM #{TABLE_PREFIX}post WHERE postid NOT IN (SELECT firstpostid FROM #{TABLE_PREFIX}thread)").first["count"] + + total_count = mysql_query(<<-SQL + SELECT COUNT(postid) count + FROM #{TABLE_PREFIX}post p + JOIN #{TABLE_PREFIX}thread t ON t.threadid = p.threadid + WHERE t.firstpostid <> p.postid + SQL + ).first["count"] success_count = 0 fail_count = 0 diff --git a/script/import_scripts/vbulletin5.rb b/script/import_scripts/vbulletin5.rb index 21a9f851f2..263cfb4f48 100644 --- a/script/import_scripts/vbulletin5.rb +++ b/script/import_scripts/vbulletin5.rb @@ -9,7 +9,7 @@ class ImportScripts::VBulletin < ImportScripts::Base # CHANGE THESE BEFORE RUNNING THE IMPORTER DATABASE = "yourforum" - TIMEZONE = "America/Los_Angeles" + TIMEZONE = "America/Los_Angeles" ATTACHMENT_DIR = '/home/discourse/yourforum/customattachments/' AVATAR_DIR = '/home/discourse/yourforum/avatars/' @@ -25,7 +25,7 @@ class ImportScripts::VBulletin < ImportScripts::Base @client = Mysql2::Client.new( host: "localhost", username: "root", - database: DATABASE, + database: DATABASE, password: "password" ) @@ -123,7 +123,7 @@ class ImportScripts::VBulletin < ImportScripts::Base file = Tempfile.new("profile-picture") file.write(picture["filedata"].encode("ASCII-8BIT").force_encoding("UTF-8")) file.rewind - upload = Upload.create_for(imported_user.id, file, picture["filename"], file.size) + upload = UploadCreator.new(file, picture["filename"]).create_for(imported_user.id) else filename = File.join(AVATAR_DIR, picture['filename']) unless File.exists?(filename) @@ -160,7 +160,7 @@ class ImportScripts::VBulletin < ImportScripts::Base file.write(background["filedata"].encode("ASCII-8BIT").force_encoding("UTF-8")) file.rewind - upload = Upload.create_for(imported_user.id, file, background["filename"], file.size) + upload = UploadCreator.new(file, background["filename"]).create_for(imported_user.id) return if !upload.persisted? @@ -173,13 +173,13 @@ class ImportScripts::VBulletin < ImportScripts::Base def import_categories puts "", "importing top level categories..." - categories = mysql_query("SELECT nodeid AS forumid, title, description, displayorder, parentid - FROM #{DBPREFIX}node - WHERE parentid=#{ROOT_NODE} - UNION - SELECT nodeid, title, description, displayorder, parentid - FROM #{DBPREFIX}node - WHERE contenttypeid = 23 + categories = mysql_query("SELECT nodeid AS forumid, title, description, displayorder, parentid + FROM #{DBPREFIX}node + WHERE parentid=#{ROOT_NODE} + UNION + SELECT nodeid, title, description, displayorder, parentid + FROM #{DBPREFIX}node + WHERE contenttypeid = 23 AND parentid IN (SELECT nodeid FROM #{DBPREFIX}node WHERE parentid=#{ROOT_NODE})").to_a top_level_categories = categories.select { |c| c["parentid"] == ROOT_NODE } @@ -222,17 +222,17 @@ class ImportScripts::VBulletin < ImportScripts::Base # keep track of closed topics @closed_topic_ids = [] - topic_count = mysql_query("select count(nodeid) cnt from #{DBPREFIX}node where parentid in ( + topic_count = mysql_query("select count(nodeid) cnt from #{DBPREFIX}node where parentid in ( select nodeid from #{DBPREFIX}node where contenttypeid=23 ) and contenttypeid=22;").first["cnt"] batches(BATCH_SIZE) do |offset| topics = mysql_query <<-SQL SELECT t.nodeid AS threadid, t.title, t.parentid AS forumid,t.open,t.userid AS postuserid,t.publishdate AS dateline, - nv.count views, 1 AS visible, t.sticky, + nv.count views, 1 AS visible, t.sticky, CONVERT(CAST(rawtext AS BINARY)USING utf8) AS raw - FROM #{DBPREFIX}node t - LEFT JOIN #{DBPREFIX}nodeview nv ON nv.nodeid=t.nodeid - LEFT JOIN #{DBPREFIX}text txt ON txt.nodeid=t.nodeid + FROM #{DBPREFIX}node t + LEFT JOIN #{DBPREFIX}nodeview nv ON nv.nodeid=t.nodeid + LEFT JOIN #{DBPREFIX}text txt ON txt.nodeid=t.nodeid WHERE t.parentid in ( select nodeid from #{DBPREFIX}node where contenttypeid=23 ) AND t.contenttypeid = 22 ORDER BY t.nodeid @@ -275,17 +275,17 @@ class ImportScripts::VBulletin < ImportScripts::Base rescue end - post_count = mysql_query("SELECT COUNT(nodeid) cnt FROM #{DBPREFIX}node WHERE parentid NOT IN ( + post_count = mysql_query("SELECT COUNT(nodeid) cnt FROM #{DBPREFIX}node WHERE parentid NOT IN ( SELECT nodeid FROM #{DBPREFIX}node WHERE contenttypeid=23 ) AND contenttypeid=22;").first["cnt"] batches(BATCH_SIZE) do |offset| posts = mysql_query <<-SQL - SELECT p.nodeid AS postid, p.userid AS userid, p.parentid AS threadid, + SELECT p.nodeid AS postid, p.userid AS userid, p.parentid AS threadid, CONVERT(CAST(rawtext AS BINARY)USING utf8) AS raw, p.publishdate AS dateline, 1 AS visible, p.parentid AS parentid - FROM #{DBPREFIX}node p - LEFT JOIN #{DBPREFIX}nodeview nv ON nv.nodeid=p.nodeid - LEFT JOIN #{DBPREFIX}text txt ON txt.nodeid=p.nodeid + FROM #{DBPREFIX}node p + LEFT JOIN #{DBPREFIX}nodeview nv ON nv.nodeid=p.nodeid + LEFT JOIN #{DBPREFIX}text txt ON txt.nodeid=p.nodeid WHERE p.parentid NOT IN ( select nodeid from #{DBPREFIX}node where contenttypeid=23 ) AND p.contenttypeid = 22 ORDER BY postid @@ -299,7 +299,7 @@ class ImportScripts::VBulletin < ImportScripts::Base # next if all_records_exist? :posts, posts.map {|p| p["postid"] } create_posts(posts, total: post_count, offset: offset) do |post| - raw = preprocess_post_raw(post["raw"]) + raw = preprocess_post_raw(post["raw"]) next if raw.blank? next unless topic = topic_lookup_from_imported_post_id("thread-#{post["threadid"]}") p = { @@ -336,7 +336,7 @@ class ImportScripts::VBulletin < ImportScripts::Base real_filename.prepend SecureRandom.hex if real_filename[0] == '.' unless File.exists?(filename) - if row['dbsize'].to_i == 0 + if row['dbsize'].to_i == 0 puts "Attachment file #{row['filedataid']} doesn't exist" return nil end diff --git a/script/profile_db_generator.rb b/script/profile_db_generator.rb index b9de3618ff..79b62064a7 100644 --- a/script/profile_db_generator.rb +++ b/script/profile_db_generator.rb @@ -44,7 +44,7 @@ end def create_admin(seq) User.new.tap { |admin| - admin.email = "admin@localhost#{seq}" + admin.email = "admin@localhost#{seq}.fake" admin.username = "admin#{seq}" admin.password = "password" admin.save diff --git a/script/pull_translations.rb b/script/pull_translations.rb index 106e6e0a72..fc03507556 100644 --- a/script/pull_translations.rb +++ b/script/pull_translations.rb @@ -6,7 +6,9 @@ # team will pull them in. require 'open3' -require_relative '../lib/locale_file_walker' +require 'psych' +require 'set' +require_relative '../lib/i18n/locale_file_walker' if `which tx`.strip.empty? puts '', 'The Transifex client needs to be installed to use this script.' @@ -60,6 +62,7 @@ END YML_DIRS = ['config/locales', 'plugins/poll/config/locales', + 'plugins/discourse-narrative-bot/config/locales', 'vendor/gems/discourse_imgur/lib/discourse_imgur/locale'] YML_FILE_PREFIXES = ['server', 'client'] diff --git a/spec/components/admin_confirmation_spec.rb b/spec/components/admin_confirmation_spec.rb new file mode 100644 index 0000000000..20d74fec96 --- /dev/null +++ b/spec/components/admin_confirmation_spec.rb @@ -0,0 +1,54 @@ +require 'admin_confirmation' +require 'rails_helper' + +describe AdminConfirmation do + + let(:admin) { Fabricate(:admin) } + let(:user) { Fabricate(:user) } + + describe "create_confirmation" do + it "raises an error for non-admins" do + ac = AdminConfirmation.new(user, Fabricate(:moderator)) + expect { ac.create_confirmation }.to raise_error(Discourse::InvalidAccess) + end + end + + describe "email_confirmed!" do + before do + ac = AdminConfirmation.new(user, admin) + ac.create_confirmation + @token = ac.token + end + + it "cannot confirm if the user loses admin access" do + ac = AdminConfirmation.find_by_code(@token) + ac.performed_by.update_column(:admin, false) + expect { ac.email_confirmed! }.to raise_error(Discourse::InvalidAccess) + end + + it "can confirm admin accounts" do + ac = AdminConfirmation.find_by_code(@token) + expect(ac.performed_by).to eq(admin) + expect(ac.target_user).to eq(user) + expect(ac.token).to eq(@token) + ac.email_confirmed! + + user.reload + expect(user.admin?).to eq(true) + + # It creates a staff log + logs = UserHistory.where( + action: UserHistory.actions[:grant_admin], + target_user_id: user.id + ) + expect(logs).to be_present + + # It removes the redis keys for another user + expect(AdminConfirmation.find_by_code(ac.token)).to eq(nil) + expect(AdminConfirmation.exists_for?(user.id)).to eq(false) + end + + end + +end + diff --git a/spec/components/admin_user_index_query_spec.rb b/spec/components/admin_user_index_query_spec.rb index a4a7e48abf..cb0bf16130 100644 --- a/spec/components/admin_user_index_query_spec.rb +++ b/spec/components/admin_user_index_query_spec.rb @@ -23,9 +23,46 @@ describe AdminUserIndexQuery do end it "allows custom ordering" do - query = ::AdminUserIndexQuery.new({ order: "trust_level DESC" }) + query = ::AdminUserIndexQuery.new({ order: "trust_level" }) expect(query.find_users_query.to_sql).to match("trust_level DESC") end + + it "allows custom ordering asc" do + query = ::AdminUserIndexQuery.new({ order: "trust_level", ascending: true }) + expect(query.find_users_query.to_sql).to match("trust_level ASC" ) + end + + it "allows custom ordering for stats wtih default direction" do + query = ::AdminUserIndexQuery.new({ order: "topics_viewed" }) + expect(query.find_users_query.to_sql).to match("topics_entered DESC") + end + + it "allows custom ordering and direction for stats" do + query = ::AdminUserIndexQuery.new({ order: "topics_viewed", ascending: true }) + expect(query.find_users_query.to_sql).to match("topics_entered ASC") + end + end + + describe "pagination" do + it "defaults to the first page" do + query = ::AdminUserIndexQuery.new({}) + expect(query.find_users.to_sql).to match("OFFSET 0") + end + + it "offsets by 100 by default for page 2" do + query = ::AdminUserIndexQuery.new({ page: "2"}) + expect(query.find_users.to_sql).to match("OFFSET 100") + end + + it "offsets by limit for page 2" do + query = ::AdminUserIndexQuery.new({ page: "2"}) + expect(query.find_users(10).to_sql).to match("OFFSET 10") + end + + it "ignores negative pages" do + query = ::AdminUserIndexQuery.new({ page: "-2" }) + expect(query.find_users.to_sql).to match("OFFSET 0") + end end describe "no users with trust level" do @@ -70,6 +107,28 @@ describe AdminUserIndexQuery do end + describe "correct order with nil values" do + before(:each) do + Fabricate(:user, email: "test2@example.com", last_emailed_at: 1.hour.ago) + end + + it "shows nil values first with asc" do + users = ::AdminUserIndexQuery.new({ order: "last_emailed", ascending: true }).find_users + + expect(users.count).to eq(2) + expect(users.first.username).to eq("system") + expect(users.first.last_emailed_at).to eq(nil) + end + + it "shows nil values last with desc" do + users = ::AdminUserIndexQuery.new({ order: "last_emailed"}).find_users + + expect(users.count).to eq(2) + expect(users.first.last_emailed_at).to_not eq(nil) + end + + end + describe "with an admin user" do let!(:user) { Fabricate(:user, admin: true) } diff --git a/spec/components/auth/default_current_user_provider_spec.rb b/spec/components/auth/default_current_user_provider_spec.rb index 038915bd23..a80a1b4848 100644 --- a/spec/components/auth/default_current_user_provider_spec.rb +++ b/spec/components/auth/default_current_user_provider_spec.rb @@ -3,10 +3,17 @@ require_dependency 'auth/default_current_user_provider' describe Auth::DefaultCurrentUserProvider do + class TestProvider < Auth::DefaultCurrentUserProvider + attr_reader :env + def initialize(env) + super(env) + end + end + def provider(url, opts=nil) opts ||= {method: "GET"} env = Rack::MockRequest.env_for(url, opts) - Auth::DefaultCurrentUserProvider.new(env) + TestProvider.new(env) end it "raises errors for incorrect api_key" do @@ -75,31 +82,101 @@ describe Auth::DefaultCurrentUserProvider do expect(provider("/?api_key=hello&api_username=#{user.username.downcase}").current_user.id).to eq(user.id) end - it "should not update last seen for message bus" do - expect(provider("/message-bus/anything/goes", method: "POST").should_update_last_seen?).to eq(false) - expect(provider("/message-bus/anything/goes", method: "GET").should_update_last_seen?).to eq(false) + it "should not update last seen for ajax calls without Discourse-Visible header" do + expect(provider("/topic/anything/goes", + :method => "POST", + "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest" + ).should_update_last_seen?).to eq(false) end - it "should update last seen for others" do + it "should update ajax reqs with discourse visible" do + expect(provider("/topic/anything/goes", + :method => "POST", + "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest", + "HTTP_DISCOURSE_VISIBLE" => "true" + ).should_update_last_seen?).to eq(true) + end + + it "should update last seen for non ajax" do expect(provider("/topic/anything/goes", method: "POST").should_update_last_seen?).to eq(true) expect(provider("/topic/anything/goes", method: "GET").should_update_last_seen?).to eq(true) end - it "correctly renews session once an hour" do + it "correctly supports legacy tokens" do + user = Fabricate(:user) + token = SecureRandom.hex(16) + user_token = UserAuthToken.create!(user_id: user.id, auth_token: token, + prev_auth_token: token, legacy: true, + rotated_at: Time.zone.now + ) + + prov = provider("/", "HTTP_COOKIE" => "_t=#{user_token.auth_token}") + expect(prov.current_user.id).to eq(user.id) + + # sets a new token up cause it got a global token + cookies = {} + prov.refresh_session(user, {}, cookies) + user.reload + + expect(user.user_auth_tokens.count).to eq(2) + expect(cookies["_t"][:value]).not_to eq(token) + end + + it "correctly rotates tokens" do SiteSetting.maximum_session_age = 3 user = Fabricate(:user) - provider('/').log_on_user(user, {}, {}) - - freeze_time 2.hours.from_now + @provider = provider('/') cookies = {} - provider("/", "HTTP_COOKIE" => "_t=#{user.auth_token}").refresh_session(user, {}, cookies) + @provider.log_on_user(user, {}, cookies) + + unhashed_token = cookies["_t"][:value] + + token = UserAuthToken.find_by(user_id: user.id) + + expect(token.auth_token_seen).to eq(false) + expect(token.auth_token).not_to eq(unhashed_token) + expect(token.auth_token).to eq(UserAuthToken.hash_token(unhashed_token)) + + # at this point we are going to try to rotate token + freeze_time 20.minutes.from_now + + provider2 = provider("/", "HTTP_COOKIE" => "_t=#{unhashed_token}") + provider2.current_user + + token.reload + expect(token.auth_token_seen).to eq(true) + + cookies = {} + provider2.refresh_session(user, {}, cookies) + expect(cookies["_t"][:value]).not_to eq(unhashed_token) + + token.reload + expect(token.auth_token_seen).to eq(false) + + freeze_time 21.minutes.from_now + + old_token = token.prev_auth_token + unverified_token = token.auth_token + + # old token should still work + provider2 = provider("/", "HTTP_COOKIE" => "_t=#{unhashed_token}") + expect(provider2.current_user.id).to eq(user.id) + + provider2.refresh_session(user, {}, cookies) + + token.reload + + # because this should cause a rotation since we can safely + # assume it never reached the client + expect(token.prev_auth_token).to eq(old_token) + expect(token.auth_token).not_to eq(unverified_token) - expect(user.auth_token_updated_at - Time.now).to eq(0) end it "can only try 10 bad cookies a minute" do - user = Fabricate(:user) + token = UserAuthToken.generate!(user_id: user.id) + provider('/').log_on_user(user, {}, {}) RateLimiter.stubs(:disabled?).returns(false) @@ -119,51 +196,66 @@ describe Auth::DefaultCurrentUserProvider do }.to raise_error(Discourse::InvalidAccess) expect { - env["HTTP_COOKIE"] = "_t=#{user.auth_token}" + env["HTTP_COOKIE"] = "_t=#{token.unhashed_auth_token}" provider("/", env).current_user }.to raise_error(Discourse::InvalidAccess) env["REMOTE_ADDR"] = "10.0.0.2" - provider('/', env).current_user + + expect { + provider('/', env).current_user + }.not_to raise_error end it "correctly removes invalid cookies" do - - cookies = {"_t" => "BAAAD"} + cookies = {"_t" => SecureRandom.hex} provider('/').refresh_session(nil, {}, cookies) - expect(cookies.key?("_t")).to eq(false) end - it "recycles existing auth_token correctly" do - SiteSetting.maximum_session_age = 3 + it "logging on user always creates a new token" do user = Fabricate(:user) - provider('/').log_on_user(user, {}, {}) - - original_auth_token = user.auth_token - - freeze_time 2.hours.from_now - provider('/').log_on_user(user, {}, {}) - - user.reload - expect(user.auth_token).to eq(original_auth_token) - - freeze_time 10.hours.from_now provider('/').log_on_user(user, {}, {}) - user.reload - expect(user.auth_token).not_to eq(original_auth_token) + provider('/').log_on_user(user, {}, {}) + + expect(UserAuthToken.where(user_id: user.id).count).to eq(2) + end + + it "sets secure, same site lax cookies" do + SiteSetting.force_https = false + SiteSetting.same_site_cookies = "Lax" + + user = Fabricate(:user) + cookies = {} + provider('/').log_on_user(user, {}, cookies) + + + expect(cookies["_t"][:same_site]).to eq("Lax") + expect(cookies["_t"][:httponly]).to eq(true) + expect(cookies["_t"][:secure]).to eq(false) + + SiteSetting.force_https = true + SiteSetting.same_site_cookies = "Disabled" + + cookies = {} + provider('/').log_on_user(user, {}, cookies) + + expect(cookies["_t"][:secure]).to eq(true) + expect(cookies["_t"].key?(:same_site)).to eq(false) end it "correctly expires session" do SiteSetting.maximum_session_age = 2 user = Fabricate(:user) + token = UserAuthToken.generate!(user_id: user.id) + provider('/').log_on_user(user, {}, {}) - expect(provider("/", "HTTP_COOKIE" => "_t=#{user.auth_token}").current_user.id).to eq(user.id) + expect(provider("/", "HTTP_COOKIE" => "_t=#{token.unhashed_auth_token}").current_user.id).to eq(user.id) freeze_time 3.hours.from_now - expect(provider("/", "HTTP_COOKIE" => "_t=#{user.auth_token}").current_user).to eq(nil) + expect(provider("/", "HTTP_COOKIE" => "_t=#{token.unhashed_auth_token}").current_user).to eq(nil) end context "user api" do diff --git a/spec/components/column_dropper_spec.rb b/spec/components/column_dropper_spec.rb new file mode 100644 index 0000000000..c1274ae606 --- /dev/null +++ b/spec/components/column_dropper_spec.rb @@ -0,0 +1,52 @@ +require 'rails_helper' +require 'column_dropper' + +describe ColumnDropper do + + def has_column?(table, column) + Topic.exec_sql("SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE + table_schema = 'public' AND + table_name = :table AND + column_name = :column + ", + table: table, column: column + ).to_a.length == 1 + end + + it "can correctly drop columns after correct delay" do + Topic.exec_sql "ALTER TABLE topics ADD COLUMN junk int" + name = Topic + .exec_sql("SELECT name FROM schema_migration_details LIMIT 1") + .getvalue(0,0) + + Topic.exec_sql("UPDATE schema_migration_details SET created_at = :created_at WHERE name = :name", + name: name, created_at: 15.minutes.ago) + + dropped_proc_called = false + + ColumnDropper.drop( + table: 'topics', + after_migration: name, + columns: ['junk'], + delay: 20.minutes, + on_drop: ->(){dropped_proc_called = true} + ) + + expect(has_column?('topics', 'junk')).to eq(true) + expect(dropped_proc_called).to eq(false) + + ColumnDropper.drop( + table: 'topics', + after_migration: name, + columns: ['junk'], + delay: 10.minutes, + on_drop: ->(){dropped_proc_called = true} + ) + + expect(has_column?('topics', 'junk')).to eq(false) + expect(dropped_proc_called).to eq(true) + + end +end + diff --git a/spec/components/composer_messages_finder_spec.rb b/spec/components/composer_messages_finder_spec.rb index 8bc709e58a..adf6f81db2 100644 --- a/spec/components/composer_messages_finder_spec.rb +++ b/spec/components/composer_messages_finder_spec.rb @@ -15,6 +15,7 @@ describe ComposerMessagesFinder do finder.expects(:check_sequential_replies).once finder.expects(:check_dominating_topic).once finder.expects(:check_reviving_old_topic).once + finder.expects(:check_get_a_room).once finder.find end @@ -197,7 +198,7 @@ describe ComposerMessagesFinder do expect(finder.check_sequential_replies).to be_blank end - it "doesn't notify in message" do + it "doesn't notify in a message" do Topic.any_instance.expects(:private_message?).returns(true) expect(finder.check_sequential_replies).to be_blank end @@ -303,6 +304,123 @@ describe ComposerMessagesFinder do end + context '.check_get_a_room' do + let(:user) { Fabricate(:user) } + let(:other_user) { Fabricate(:user) } + let(:third_user) { Fabricate(:user) } + let(:topic) { Fabricate(:topic, user: other_user) } + let(:op) { Fabricate(:post, topic_id: topic.id, user: other_user) } + + let!(:other_user_reply) do + Fabricate(:post, topic: topic, user: third_user, reply_to_user_id: op.user_id) + end + + let!(:first_reply) do + Fabricate(:post, topic: topic, user: user, reply_to_user_id: op.user_id) + end + + let!(:second_reply) do + Fabricate(:post, topic: topic, user: user, reply_to_user_id: op.user_id) + end + + before do + SiteSetting.educate_until_posts = 10 + user.stubs(:post_count).returns(11) + SiteSetting.get_a_room_threshold = 2 + end + + it "does not show the message for new topics" do + finder = ComposerMessagesFinder.new(user, composer_action: 'createTopic') + expect(finder.check_get_a_room(min_users_posted: 2)).to be_blank + end + + it "does not give a message without a topic id" do + expect(ComposerMessagesFinder.new(user, composer_action: 'reply').check_get_a_room(min_users_posted: 2)).to be_blank + end + + context "reply" do + let(:finder) { ComposerMessagesFinder.new(user, composer_action: 'reply', topic_id: topic.id, post_id: op.id) } + + it "does not give a message to users who are still in the 'education' phase" do + user.stubs(:post_count).returns(9) + expect(finder.check_get_a_room(min_users_posted: 2)).to be_blank + end + + it "doesn't notify a user it has already notified about sequential replies" do + UserHistory.create!( + action: UserHistory.actions[:notified_about_get_a_room], + target_user_id: user.id, + topic_id: topic.id + ) + expect(finder.check_get_a_room(min_users_posted: 2)).to be_blank + end + + it "will notify you if it hasn't in the current topic" do + UserHistory.create!( + action: UserHistory.actions[:notified_about_get_a_room], + target_user_id: user.id, + topic_id: topic.id+1 + ) + expect(finder.check_get_a_room(min_users_posted: 2)).to be_present + end + + it "won't notify you if you haven't had enough posts" do + SiteSetting.get_a_room_threshold = 10 + expect(finder.check_get_a_room(min_users_posted: 2)).to be_blank + end + + it "doesn't notify you if the posts aren't all to the same person" do + first_reply.update_column(:reply_to_user_id, user.id) + expect(finder.check_get_a_room(min_users_posted: 2)).to be_blank + end + + it "doesn't notify you of posts to yourself" do + first_reply.update_column(:reply_to_user_id, user.id) + second_reply.update_column(:reply_to_user_id, user.id) + expect(finder.check_get_a_room(min_users_posted: 2)).to be_blank + end + + it "doesn't notify in a message" do + topic.update_columns(category_id: nil, archetype: 'private_message') + expect(finder.check_get_a_room(min_users_posted: 2)).to be_blank + end + + it "doesn't notify when replying to a different user" do + other_finder = ComposerMessagesFinder.new( + user, + composer_action: 'reply', + topic_id: topic.id, + post_id: other_user_reply.id + ) + + expect(other_finder.check_get_a_room(min_users_posted: 2)).to be_blank + end + + context "with a default min_users_posted value" do + let!(:message) { finder.check_get_a_room } + + it "works as expected" do + expect(message).to be_blank + end + end + + context "success" do + let!(:message) { finder.check_get_a_room(min_users_posted: 2) } + + it "works as expected" do + expect(message).to be_present + expect(message[:id]).to eq('get_a_room') + expect(message[:wait_for_typing]).to eq(true) + expect(message[:templateName]).to eq('education') + + expect(UserHistory.exists_for_user?(user, :notified_about_get_a_room)).to eq(true) + end + end + end + + end + + context '.check_reviving_old_topic' do let(:user) { Fabricate(:user) } let(:topic) { Fabricate(:topic) } diff --git a/spec/components/cooked_post_processor_spec.rb b/spec/components/cooked_post_processor_spec.rb index 46db958932..cc0f5f4467 100644 --- a/spec/components/cooked_post_processor_spec.rb +++ b/spec/components/cooked_post_processor_spec.rb @@ -310,6 +310,12 @@ describe CookedPostProcessor do expect(cpp.get_size_from_attributes(img)).to eq([33, 100]) end + it "doesn't raise an error with a weird url" do + img = { 'src' => nil, 'height' => 100} + SiteSetting.stubs(:crawl_images?).returns(true) + expect(cpp.get_size_from_attributes(img)).to be_nil + end + end context ".get_size_from_image_sizes" do @@ -446,28 +452,65 @@ describe CookedPostProcessor do it "uses schemaless url for uploads" do cpp.optimize_urls - expect(cpp.html).to match_html '

    Link

    Google

    text.txt (20 Bytes)
    :smile:

    ' + expect(cpp.html).to match_html '

    Link
    +
    + Google
    +
    + text.txt (20 Bytes)
    + :smile: +

    ' end context "when CDN is enabled" do - it "does use schemaless CDN url for http uploads" do + it "uses schemaless CDN url for http uploads" do Rails.configuration.action_controller.stubs(:asset_host).returns("http://my.cdn.com") cpp.optimize_urls - expect(cpp.html).to match_html '

    Link

    Google

    text.txt (20 Bytes)
    :smile:

    ' + expect(cpp.html).to match_html '

    Link
    +
    + Google
    +
    + text.txt (20 Bytes)
    + :smile: +

    ' end - it "does not use schemaless CDN url for https uploads" do + it "doesn't use schemaless CDN url for https uploads" do Rails.configuration.action_controller.stubs(:asset_host).returns("https://my.cdn.com") cpp.optimize_urls - expect(cpp.html).to match_html '

    Link

    Google

    text.txt (20 Bytes)
    :smile:

    ' + expect(cpp.html).to match_html '

    Link
    +
    + Google
    +
    + text.txt (20 Bytes)
    + :smile: +

    ' end - it "does not use CDN when login is required" do + it "doesn't use CDN when login is required" do SiteSetting.login_required = true Rails.configuration.action_controller.stubs(:asset_host).returns("http://my.cdn.com") cpp.optimize_urls - expect(cpp.html).to match_html '

    Link

    Google

    text.txt (20 Bytes)
    :smile:

    ' + expect(cpp.html).to match_html '

    Link
    +
    + Google
    +
    + text.txt (20 Bytes)
    + :smile: +

    ' + end + + it "doesn't use CDN when preventing anons from downloading files" do + SiteSetting.prevent_anons_from_downloading_files = true + Rails.configuration.action_controller.stubs(:asset_host).returns("http://my.cdn.com") + cpp.optimize_urls + expect(cpp.html).to match_html '

    Link
    +
    + Google
    +
    + text.txt (20 Bytes)
    + :smile: +

    ' end end @@ -602,16 +645,6 @@ describe CookedPostProcessor do end - context "extracts links" do - let(:post) { Fabricate(:post, raw: "sam has a blog at https://samsaffron.com") } - - it "always re-extracts links on post process" do - TopicLink.destroy_all - CookedPostProcessor.new(post).post_process - expect(TopicLink.count).to eq(1) - end - end - context "grant badges" do let(:cpp) { CookedPostProcessor.new(post) } diff --git a/spec/components/current_user_spec.rb b/spec/components/current_user_spec.rb index 2cb1061e08..e7631e5996 100644 --- a/spec/components/current_user_spec.rb +++ b/spec/components/current_user_spec.rb @@ -3,10 +3,10 @@ require_dependency 'current_user' describe CurrentUser do it "allows us to lookup a user from our environment" do - user = Fabricate(:user, auth_token: EmailToken.generate_token, active: true) - EmailToken.confirm(user.auth_token) + user = Fabricate(:user, active: true) + token = UserAuthToken.generate!(user_id: user.id) - env = Rack::MockRequest.env_for("/test", "HTTP_COOKIE" => "_t=#{user.auth_token};") + env = Rack::MockRequest.env_for("/test", "HTTP_COOKIE" => "_t=#{token.unhashed_auth_token};") expect(CurrentUser.lookup_from_env(env)).to eq(user) end diff --git a/spec/components/discourse_hub_spec.rb b/spec/components/discourse_hub_spec.rb index b9ebdee282..29699ebeec 100644 --- a/spec/components/discourse_hub_spec.rb +++ b/spec/components/discourse_hub_spec.rb @@ -2,11 +2,73 @@ require 'rails_helper' require_dependency 'discourse_hub' describe DiscourseHub do - describe '#discourse_version_check' do + describe '.discourse_version_check' do it 'should return just return the json that the hub returns' do hub_response = {'success' => 'OK', 'latest_version' => '0.8.1', 'critical_updates' => false} RestClient.stubs(:get).returns( hub_response.to_json ) expect(DiscourseHub.discourse_version_check).to eq(hub_response) end end + + describe '.version_check_payload' do + + describe 'when Discourse Hub has not fetched stats since past 7 days' do + it 'should include stats' do + DiscourseHub.stats_fetched_at = 8.days.ago + json = JSON.parse(DiscourseHub.version_check_payload.to_json) + + expect(json["topic_count"]).to be_present + expect(json["post_count"]).to be_present + expect(json["user_count"]).to be_present + expect(json["topics_7_days"]).to be_present + expect(json["topics_30_days"]).to be_present + expect(json["posts_7_days"]).to be_present + expect(json["posts_30_days"]).to be_present + expect(json["users_7_days"]).to be_present + expect(json["users_30_days"]).to be_present + expect(json["active_users_7_days"]).to be_present + expect(json["active_users_30_days"]).to be_present + expect(json["like_count"]).to be_present + expect(json["likes_7_days"]).to be_present + expect(json["likes_30_days"]).to be_present + expect(json["installed_version"]).to be_present + expect(json["branch"]).to be_present + end + end + + describe 'when Discourse Hub has fetched stats in past 7 days' do + it 'should not include stats' do + DiscourseHub.stats_fetched_at = 2.days.ago + json = JSON.parse(DiscourseHub.version_check_payload.to_json) + + expect(json["topic_count"]).not_to be_present + expect(json["post_count"]).not_to be_present + expect(json["user_count"]).not_to be_present + expect(json["like_count"]).not_to be_present + expect(json["likes_7_days"]).not_to be_present + expect(json["likes_30_days"]).not_to be_present + expect(json["installed_version"]).to be_present + expect(json["branch"]).to be_present + end + end + + describe 'when send_anonymize_stats is disabled' do + describe 'when Discourse Hub has not fetched stats for the past year' do + it 'should not include stats' do + DiscourseHub.stats_fetched_at = 1.year.ago + SiteSetting.share_anonymized_statistics = false + json = JSON.parse(DiscourseHub.version_check_payload.to_json) + + expect(json["topic_count"]).not_to be_present + expect(json["post_count"]).not_to be_present + expect(json["user_count"]).not_to be_present + expect(json["like_count"]).not_to be_present + expect(json["likes_7_days"]).not_to be_present + expect(json["likes_30_days"]).not_to be_present + expect(json["installed_version"]).to be_present + expect(json["branch"]).to be_present + end + end + end + end end diff --git a/spec/components/discourse_plugin_registry_spec.rb b/spec/components/discourse_plugin_registry_spec.rb index 26cb894d46..b280939012 100644 --- a/spec/components/discourse_plugin_registry_spec.rb +++ b/spec/components/discourse_plugin_registry_spec.rb @@ -44,6 +44,15 @@ describe DiscoursePluginRegistry do end end + context '.register_html_builder' do + it "can register and build html" do + DiscoursePluginRegistry.register_html_builder(:my_html) { "my html" } + expect(DiscoursePluginRegistry.build_html(:my_html)).to eq('my html') + DiscoursePluginRegistry.reset! + expect(DiscoursePluginRegistry.build_html(:my_html)).to be_blank + end + end + context '.register_css' do before do registry_instance.register_css('hello.css') diff --git a/spec/components/discourse_sass_compiler_spec.rb b/spec/components/discourse_sass_compiler_spec.rb deleted file mode 100644 index baa998cf3a..0000000000 --- a/spec/components/discourse_sass_compiler_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -require 'rails_helper' -require_dependency 'sass/discourse_sass_compiler' - -describe DiscourseSassCompiler do - - let(:test_scss) { "body { p {color: blue;} }\n@import 'common/foundation/variables';\n@import 'plugins';" } - - describe '#compile' do - it "compiles scss" do - DiscoursePluginRegistry.stubs(:stylesheets).returns(["#{Rails.root}/spec/fixtures/scss/my_plugin.scss"]) - css = described_class.compile(test_scss, "test") - expect(css).to include("color") - expect(css).to include('my-plugin-thing') - end - - it "raises error for invalid scss" do - expect { - described_class.compile("this isn't valid scss", "test") - }.to raise_error(Sass::SyntaxError) - end - - it "doesn't load theme or plugins in safe mode" do - ColorScheme.expects(:enabled).never - DiscoursePluginRegistry.stubs(:stylesheets).returns(["#{Rails.root}/spec/fixtures/scss/my_plugin.scss"]) - css = described_class.compile(test_scss, "test", safe: true) - expect(css).not_to include('my-plugin-thing') - end - end - -end diff --git a/spec/components/discourse_stylesheets_spec.rb b/spec/components/discourse_stylesheets_spec.rb deleted file mode 100644 index 30562a5844..0000000000 --- a/spec/components/discourse_stylesheets_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -require 'rails_helper' -require_dependency 'sass/discourse_stylesheets' - -describe DiscourseStylesheets do - - describe "compile" do - it "can compile desktop bundle" do - DiscoursePluginRegistry.stubs(:stylesheets).returns(["#{Rails.root}/spec/fixtures/scss/my_plugin.scss"]) - builder = described_class.new(:desktop) - expect(builder.compile(force: true)).to include('my-plugin-thing') - FileUtils.rm builder.stylesheet_fullpath - end - - it "can compile mobile bundle" do - DiscoursePluginRegistry.stubs(:mobile_stylesheets).returns(["#{Rails.root}/spec/fixtures/scss/my_plugin.scss"]) - builder = described_class.new(:mobile) - expect(builder.compile(force: true)).to include('my-plugin-thing') - FileUtils.rm builder.stylesheet_fullpath - end - - it "can fallback when css is bad" do - DiscoursePluginRegistry.stubs(:stylesheets).returns([ - "#{Rails.root}/spec/fixtures/scss/my_plugin.scss", - "#{Rails.root}/spec/fixtures/scss/broken.scss" - ]) - builder = described_class.new(:desktop) - expect(builder.compile(force: true)).not_to include('my-plugin-thing') - FileUtils.rm builder.stylesheet_fullpath - end - end - - describe "#digest" do - before do - described_class.expects(:max_file_mtime).returns(Time.new(2016, 06, 05, 12, 30, 0, 0)) - end - - it "should return a digest" do - expect(described_class.new.digest).to eq('0e6c2e957cfc92ed60661c90ec3345198ccef887') - end - - it "should include the cdn url when generating the digest" do - GlobalSetting.expects(:cdn_url).returns('https://fastly.maxcdn.org') - expect(described_class.new.digest).to eq('4995163b1232c54c8ed3b44200d803a90bc47613') - end - end -end diff --git a/spec/components/email/message_builder_spec.rb b/spec/components/email/message_builder_spec.rb index eec704c605..5cf6d4877a 100644 --- a/spec/components/email/message_builder_spec.rb +++ b/spec/components/email/message_builder_spec.rb @@ -58,12 +58,12 @@ describe Email::MessageBuilder do end it "returns a Reply-To header with the reply key" do - expect(reply_by_email_builder.header_args['Reply-To']).to eq(SiteSetting.title + " ") + expect(reply_by_email_builder.header_args['Reply-To']).to eq("\"#{SiteSetting.title}\" ") end it "cleans up the site title" do - SiteSetting.stubs(:title).returns(">>>Obnoxious Title: Deal, With It<<<") - expect(reply_by_email_builder.header_args['Reply-To']).to eq("Obnoxious Title Deal With It ") + SiteSetting.stubs(:title).returns(">>>Obnoxious Title: Deal, \"With\" It<<<") + expect(reply_by_email_builder.header_args['Reply-To']).to eq("\"Obnoxious Title Deal With It\" ") end end @@ -98,7 +98,7 @@ describe Email::MessageBuilder do end it "returns a Reply-To header with the reply key" do - expect(reply_by_email_builder.header_args['Reply-To']).to eq("Username ") + expect(reply_by_email_builder.header_args['Reply-To']).to eq("\"Username\" ") end end @@ -199,13 +199,13 @@ describe Email::MessageBuilder do context "template_args" do let(:template_args) { builder.template_args } - it "has the site name as the site title when `SiteSetting.email_prefix` is not set" do - expect(template_args[:site_name]).to eq(SiteSetting.title) + it "has the email prefix as the site title when `SiteSetting.email_prefix` is not set" do + expect(template_args[:email_prefix]).to eq(SiteSetting.title) end - it "has the site name as SiteSetting.email_prefix when it is set" do + it "has the email prefix as SiteSetting.email_prefix when it is set" do SiteSetting.email_prefix = 'some email prefix' - expect(template_args[:site_name]).to eq(SiteSetting.email_prefix) + expect(template_args[:email_prefix]).to eq(SiteSetting.email_prefix) end it "has the base url" do @@ -237,9 +237,15 @@ describe Email::MessageBuilder do context "from field" do it "has the default from" do + SiteSetting.title = "" expect(build_args[:from]).to eq(SiteSetting.notification_email) end + it "title setting will be added if present" do + SiteSetting.title = "Dog Talk" + expect(build_args[:from]).to eq("\"Dog Talk\" <#{SiteSetting.notification_email}>") + end + let(:finn_email) { 'finn@adventuretime.ooo' } let(:custom_from) { Email::MessageBuilder.new(to_address, from: finn_email).build_args } @@ -250,7 +256,7 @@ describe Email::MessageBuilder do let(:aliased_from) { Email::MessageBuilder.new(to_address, from_alias: "Finn the Dog") } it "allows us to alias the from address" do - expect(aliased_from.build_args[:from]).to eq("Finn the Dog <#{SiteSetting.notification_email}>") + expect(aliased_from.build_args[:from]).to eq("\"Finn the Dog\" <#{SiteSetting.notification_email}>") end let(:custom_aliased_from) { Email::MessageBuilder.new(to_address, @@ -258,22 +264,28 @@ describe Email::MessageBuilder do from: finn_email) } it "allows us to alias a custom from address" do - expect(custom_aliased_from.build_args[:from]).to eq("Finn the Dog <#{finn_email}>") + expect(custom_aliased_from.build_args[:from]).to eq("\"Finn the Dog\" <#{finn_email}>") end it "email_site_title will be added if it's set" do - SiteSetting.stubs(:email_site_title).returns("The Forum") - expect(build_args[:from]).to eq("The Forum <#{SiteSetting.notification_email}>") + SiteSetting.email_site_title = "The Forum" + expect(build_args[:from]).to eq("\"The Forum\" <#{SiteSetting.notification_email}>") + end + + it "email_site_title overrides title" do + SiteSetting.title = "Dog Talk" + SiteSetting.email_site_title = "The Forum" + expect(build_args[:from]).to eq("\"The Forum\" <#{SiteSetting.notification_email}>") end it "cleans up aliases in the from_alias arg" do builder = Email::MessageBuilder.new(to_address, from_alias: "Finn: the Dog, <3", from: finn_email) - expect(builder.build_args[:from]).to eq("Finn the Dog 3 <#{finn_email}>") + expect(builder.build_args[:from]).to eq("\"Finn the Dog 3\" <#{finn_email}>") end it "cleans up the email_site_title" do - SiteSetting.stubs(:email_site_title).returns("::>>>Best Forum, EU: Award Winning<<<") - expect(build_args[:from]).to eq("Best Forum EU Award Winning <#{SiteSetting.notification_email}>") + SiteSetting.stubs(:email_site_title).returns("::>>>Best \"Forum\", EU: Award Winning<<<") + expect(build_args[:from]).to eq("\"Best Forum EU Award Winning\" <#{SiteSetting.notification_email}>") end end diff --git a/spec/components/email/receiver_spec.rb b/spec/components/email/receiver_spec.rb index 47c7c4a1da..3242e55a41 100644 --- a/spec/components/email/receiver_spec.rb +++ b/spec/components/email/receiver_spec.rb @@ -157,8 +157,17 @@ describe Email::Receiver do expect(topic.posts.last.cooked).not_to match(/
    HTML reply ;)") + expect(topic.posts.last.raw).to eq("This is a **HTML** reply ;)") + end + it "doesn't process email with same message-id more than once" do + expect do + process(:text_reply) + process(:text_reply) + end.to change { topic.posts.count }.by(1) + end + + it "handles different encodings correctly" do expect { process(:hebrew_reply) }.to change { topic.posts.count } expect(topic.posts.last.raw).to eq("שלום! מה שלומך היום?") @@ -167,6 +176,10 @@ describe Email::Receiver do expect { process(:reply_with_weird_encoding) }.to change { topic.posts.count } expect(topic.posts.last.raw).to eq("This is a reply with a weird encoding.") + + expect { process(:reply_with_8bit_encoding) }.to change { topic.posts.count } + expect(topic.posts.last.raw).to eq("hab vergessen kritische zeichen einzufügen:\näöüÄÖÜß") + end it "prefers text over html" do @@ -177,7 +190,7 @@ describe Email::Receiver do it "prefers html over text when site setting is enabled" do SiteSetting.incoming_email_prefer_html = true expect { process(:text_and_html_reply) }.to change { topic.posts.count } - expect(topic.posts.last.raw).to eq('This is the html part.') + expect(topic.posts.last.raw).to eq('This is the **html** part.') end it "uses text when prefer_html site setting is enabled but no html is available" do @@ -296,12 +309,22 @@ describe Email::Receiver do expect(topic.posts.last.raw).to eq("This is a reply :)\n\n
    \n···\n---Original Message---\nThis part should not be included\n
    ") end - it "supports attached images" do + it "supports attached images in TEXT part" do + SiteSetting.queue_jobs = true + expect { process(:no_body_with_image) }.to change { topic.posts.count } expect(topic.posts.last.raw).to match(/\s+After/) + end + + it "supports attached images in HTML part" do + SiteSetting.queue_jobs = true + SiteSetting.incoming_email_prefer_html = true + + expect { process(:inline_image) }.to change { topic.posts.count } + expect(topic.posts.last.raw).to match(/\*\*Before\*\*\s+\s+\*After\*/) end it "supports attachments" do @@ -341,7 +364,7 @@ describe Email::Receiver do it "handles email with no subject" do expect { process(:no_subject) }.to change(Topic, :count) - expect(Topic.last.title).to eq("Incoming email from some@one.com") + expect(Topic.last.title).to eq("This topic needs a title") end it "invites everyone in the chain but emails configured as 'incoming' (via reply, group or category)" do @@ -503,4 +526,33 @@ describe Email::Receiver do end + context "#reply_by_email_address_regex" do + + before do + SiteSetting.reply_by_email_address = nil + SiteSetting.alternative_reply_by_email_addresses = nil + end + + it "is empty by default" do + expect(Email::Receiver.reply_by_email_address_regex).to eq(//) + end + + it "uses 'reply_by_email_address' site setting" do + SiteSetting.reply_by_email_address = "foo+%{reply_key}@bar.com" + expect(Email::Receiver.reply_by_email_address_regex).to eq(/foo\+(\h{32})@bar\.com/) + end + + it "uses 'alternative_reply_by_email_addresses' site setting" do + SiteSetting.alternative_reply_by_email_addresses = "alt.foo+%{reply_key}@bar.com" + expect(Email::Receiver.reply_by_email_address_regex).to eq(/alt\.foo\+(\h{32})@bar\.com/) + end + + it "combines both 'reply_by_email' settings" do + SiteSetting.reply_by_email_address = "foo+%{reply_key}@bar.com" + SiteSetting.alternative_reply_by_email_addresses = "alt.foo+%{reply_key}@bar.com" + expect(Email::Receiver.reply_by_email_address_regex).to eq(/foo\+(\h{32})@bar\.com|alt\.foo\+(\h{32})@bar\.com/) + end + + end + end diff --git a/spec/components/email/sender_spec.rb b/spec/components/email/sender_spec.rb index 09076a5627..bde8d4cf41 100644 --- a/spec/components/email/sender_spec.rb +++ b/spec/components/email/sender_spec.rb @@ -96,9 +96,12 @@ describe Email::Sender do end context "adds a List-ID header to identify the forum" do + let(:category) { Fabricate(:category, name: 'Name With Space') } + let(:topic) { Fabricate(:topic, category: category) } + let(:post) { Fabricate(:post, topic: topic) } + before do - category = Fabricate(:category, name: 'Name With Space') - topic = Fabricate(:topic, category_id: category.id) + message.header['X-Discourse-Post-Id'] = post.id message.header['X-Discourse-Topic-Id'] = topic.id end @@ -120,8 +123,12 @@ describe Email::Sender do end context "adds Precedence header" do - before do - message.header['X-Discourse-Topic-Id'] = 5577 + let(:topic) { Fabricate(:topic) } + let(:post) { Fabricate(:post, topic: topic) } + + before do + message.header['X-Discourse-Post-Id'] = post.id + message.header['X-Discourse-Topic-Id'] = topic.id end it 'should add the right header' do @@ -131,8 +138,12 @@ describe Email::Sender do end context "removes custom Discourse headers from topic notification mails" do + let(:topic) { Fabricate(:topic) } + let(:post) { Fabricate(:post, topic: topic) } + before do - message.header['X-Discourse-Topic-Id'] = 5577 + message.header['X-Discourse-Post-Id'] = post.id + message.header['X-Discourse-Topic-Id'] = topic.id end it 'should remove the right headers' do @@ -160,21 +171,18 @@ describe Email::Sender do let(:post_3) { Fabricate(:post, topic: topic, post_number: 3) } let(:post_4) { Fabricate(:post, topic: topic, post_number: 4) } - let!(:incoming_email) { IncomingEmail.create(topic: topic, post: post_4, message_id: "foobar") } + let!(:post_reply_1_4) { PostReply.create(post: post_1, reply: post_4) } + let!(:post_reply_2_4) { PostReply.create(post: post_2, reply: post_4) } + let!(:post_reply_3_4) { PostReply.create(post: post_3, reply: post_4) } - let!(:post_reply_1_3) { PostReply.create(post: post_1, reply: post_3) } - let!(:post_reply_2_3) { PostReply.create(post: post_2, reply: post_3) } - - before do - message.header['X-Discourse-Topic-Id'] = topic.id - end + before { message.header['X-Discourse-Topic-Id'] = topic.id } it "doesn't set the 'In-Reply-To' and 'References' headers on the first post" do message.header['X-Discourse-Post-Id'] = post_1.id email_sender.send - expect(message.header['Message-Id'].to_s).to eq("") + expect(message.header['Message-Id'].to_s).to eq("") expect(message.header['In-Reply-To'].to_s).to be_blank expect(message.header['References'].to_s).to be_blank end @@ -189,34 +197,46 @@ describe Email::Sender do end it "sets the 'In-Reply-To' header to the newest replied post" do - message.header['X-Discourse-Post-Id'] = post_3.id + message.header['X-Discourse-Post-Id'] = post_4.id email_sender.send - expect(message.header['Message-Id'].to_s).to eq("") - expect(message.header['In-Reply-To'].to_s).to eq("") + expect(message.header['Message-Id'].to_s).to eq("") + expect(message.header['In-Reply-To'].to_s).to eq("") end it "sets the 'References' header to the topic and all replied posts" do - message.header['X-Discourse-Post-Id'] = post_3.id + message.header['X-Discourse-Post-Id'] = post_4.id email_sender.send references = [ - "", + "", "", - "", + "", ] expect(message.header['References'].to_s).to eq(references.join(" ")) end it "uses the incoming_email message_id when available" do + topic_incoming_email = IncomingEmail.create(topic: topic, post: post_1, message_id: "foo@bar") + post_2_incoming_email = IncomingEmail.create(topic: topic, post: post_2, message_id: "bar@foo") + post_4_incoming_email = IncomingEmail.create(topic: topic, post: post_4, message_id: "wat@wat") + message.header['X-Discourse-Post-Id'] = post_4.id email_sender.send - expect(message.header['Message-Id'].to_s).to eq("<#{incoming_email.message_id}>") + expect(message.header['Message-Id'].to_s).to eq("<#{post_4_incoming_email.message_id}>") + + references = [ + "", + "<#{post_2_incoming_email.message_id}>", + "<#{topic_incoming_email.message_id}>", + ] + + expect(message.header['References'].to_s).to eq(references.join(" ")) end end @@ -260,17 +280,20 @@ describe Email::Sender do end context "email log with a post id and topic id" do + let(:topic) { Fabricate(:topic) } + let(:post) { Fabricate(:post, topic: topic) } + before do - message.header['X-Discourse-Post-Id'] = 3344 - message.header['X-Discourse-Topic-Id'] = 5577 + message.header['X-Discourse-Post-Id'] = post.id + message.header['X-Discourse-Topic-Id'] = topic.id end let(:email_log) { EmailLog.last } it 'should create the right log' do email_sender.send - expect(email_log.post_id).to eq(3344) - expect(email_log.topic_id).to eq(5577) + expect(email_log.post_id).to eq(post.id) + expect(email_log.topic_id).to eq(topic.id) end end diff --git a/spec/components/email/styles_spec.rb b/spec/components/email/styles_spec.rb index 07061dbc3a..38ccdde962 100644 --- a/spec/components/email/styles_spec.rb +++ b/spec/components/email/styles_spec.rb @@ -30,12 +30,18 @@ describe Email::Styles do expect(frag.at("img")["style"]).to match("max-width") end - it "adds a width and height to images with an emoji path" do + it "adds a width and height to emojis" do frag = basic_fragment("") expect(frag.at("img")["width"]).to eq("20") expect(frag.at("img")["height"]).to eq("20") end + it "adds a width and height to custom emojis" do + frag = basic_fragment("") + expect(frag.at("img")["width"]).to eq("20") + expect(frag.at("img")["height"]).to eq("20") + end + it "converts relative paths to absolute paths" do frag = basic_fragment("") expect(frag.at("img")["src"]).to eq("#{Discourse.base_url}/some-image.png") diff --git a/spec/components/email_cook_spec.rb b/spec/components/email_cook_spec.rb index fff81d40b8..48e38169e9 100644 --- a/spec/components/email_cook_spec.rb +++ b/spec/components/email_cook_spec.rb @@ -28,6 +28,8 @@ LONG_COOKED end it 'autolinks' do + stub_request(:get, "https://www.eviltrout.com").to_return(body: "") + stub_request(:head, "https://www.eviltrout.com").to_return(body: "") expect(EmailCook.new("https://www.eviltrout.com").cook).to eq("https://www.eviltrout.com
    ") end diff --git a/spec/components/file_helper_spec.rb b/spec/components/file_helper_spec.rb new file mode 100644 index 0000000000..4f085de7b2 --- /dev/null +++ b/spec/components/file_helper_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' +require 'file_helper' + +describe FileHelper do + + let(:url) { "https://eviltrout.com/trout.png" } + let(:png) { Base64.decode64("R0lGODlhAQABALMAAAAAAIAAAACAAICAAAAAgIAAgACAgMDAwICAgP8AAAD/AP//AAAA//8A/wD//wBiZCH5BAEAAA8ALAAAAAABAAEAAAQC8EUAOw==") } + + before do + stub_request(:any, /https:\/\/eviltrout.com/) + stub_request(:get, url).to_return(body: png) + end + + describe "download" do + it "returns a file with the image" do + tmpfile = FileHelper.download( + url, + max_file_size: 10000, + tmp_file_name: 'trouttmp' + ) + expect(tmpfile.read[0..5]).to eq("GIF89a") + end + + it "works with a protocol relative url" do + tmpfile = FileHelper.download( + "//eviltrout.com/trout.png", + max_file_size: 10000, + tmp_file_name: 'trouttmp' + ) + expect(tmpfile.read[0..5]).to eq("GIF89a") + end + end + +end diff --git a/spec/components/final_destination_spec.rb b/spec/components/final_destination_spec.rb new file mode 100644 index 0000000000..5956ceb1e1 --- /dev/null +++ b/spec/components/final_destination_spec.rb @@ -0,0 +1,206 @@ +require 'rails_helper' +require 'final_destination' + +describe FinalDestination do + + let(:opts) do + { # avoid IP lookups in test + lookup_ip: lambda do |host| + case host + when 'eviltrout.com' then '52.84.143.152' + when 'codinghorror.com' then '91.146.108.148' + when 'discourse.org' then '104.25.152.10' + when 'some_thing.example.com' then '104.25.152.10' + when 'private-host.com' then '192.168.10.1' + when 'internal-ipv6.com' then '2001:abc:de:01:3:3d0:6a65:c2bf' + else + as_ip = IPAddr.new(host) rescue nil + raise "couldn't lookup #{host}" if as_ip.nil? + host + end + end + } + end + + let(:doc_response) do + { + status: 200, + headers: { "Content-Type" => "text/html" } + } + end + + def redirect_response(from, dest) + stub_request(:head, from).to_return( + status: 302, + headers: { "Location" => dest } + ) + end + + def fd(url) + FinalDestination.new(url, opts) + end + + describe '.resolve' do + + it "has a ready status code before anything happens" do + expect(fd('https://eviltrout.com').status).to eq(:ready) + end + + it "returns nil an invalid url" do + expect(fd(nil).resolve).to be_nil + expect(fd('asdf').resolve).to be_nil + end + + context "without redirects" do + before do + stub_request(:head, "https://eviltrout.com").to_return(doc_response) + end + + it "returns the final url" do + final = FinalDestination.new('https://eviltrout.com', opts) + expect(final.resolve.to_s).to eq('https://eviltrout.com') + expect(final.redirected?).to eq(false) + expect(final.status).to eq(:resolved) + end + end + + context "underscores in URLs" do + before do + stub_request(:head, 'https://some_thing.example.com').to_return(doc_response) + end + + it "doesn't raise errors with underscores in urls" do + final = FinalDestination.new('https://some_thing.example.com', opts) + expect(final.resolve.to_s).to eq('https://some_thing.example.com') + expect(final.redirected?).to eq(false) + expect(final.status).to eq(:resolved) + end + end + + context "with a couple of redirects" do + before do + redirect_response("https://eviltrout.com", "https://codinghorror.com/blog") + redirect_response("https://codinghorror.com/blog", "https://discourse.org") + stub_request(:head, "https://discourse.org").to_return(doc_response) + end + + it "returns the final url" do + final = FinalDestination.new('https://eviltrout.com', opts) + expect(final.resolve.to_s).to eq('https://discourse.org') + expect(final.redirected?).to eq(true) + expect(final.status).to eq(:resolved) + end + end + + context "with too many redirects" do + before do + redirect_response("https://eviltrout.com", "https://codinghorror.com/blog") + redirect_response("https://codinghorror.com/blog", "https://discourse.org") + stub_request(:head, "https://discourse.org").to_return(doc_response) + end + + it "returns the final url" do + final = FinalDestination.new('https://eviltrout.com', opts.merge(max_redirects: 1)) + expect(final.resolve).to be_nil + expect(final.redirected?).to eq(true) + expect(final.status).to eq(:too_many_redirects) + end + end + + context "with a redirect to an internal IP" do + before do + redirect_response("https://eviltrout.com", "https://private-host.com") + stub_request(:head, "https://private-host.com").to_return(doc_response) + end + + it "returns the final url" do + final = FinalDestination.new('https://eviltrout.com', opts) + expect(final.resolve).to be_nil + expect(final.redirected?).to eq(true) + expect(final.status).to eq(:invalid_address) + end + end + end + + describe '.validate_uri' do + context "host lookups" do + it "works for various hosts" do + expect(fd('https://private-host.com').validate_uri).to eq(false) + expect(fd('https://eviltrout.com:443').validate_uri).to eq(true) + end + end + end + + describe ".validate_url_format" do + it "supports http urls" do + expect(fd('http://eviltrout.com').validate_uri_format).to eq(true) + end + + it "supports https urls" do + expect(fd('https://eviltrout.com').validate_uri_format).to eq(true) + end + + it "doesn't support ftp urls" do + expect(fd('ftp://eviltrout.com').validate_uri_format).to eq(false) + end + + it "doesn't support IP urls" do + expect(fd('http://104.25.152.10').validate_uri_format).to eq(false) + expect(fd('https://[2001:abc:de:01:0:3f0:6a65:c2bf]').validate_uri_format).to eq(false) + end + + it "returns false for schemeless URL" do + expect(fd('eviltrout.com').validate_uri_format).to eq(false) + end + + it "returns false for nil URL" do + expect(fd(nil).validate_uri_format).to eq(false) + end + + it "returns false for invalid ports" do + expect(fd('http://eviltrout.com:21').validate_uri_format).to eq(false) + expect(fd('https://eviltrout.com:8000').validate_uri_format).to eq(false) + end + + it "returns true for valid ports" do + expect(fd('http://eviltrout.com:80').validate_uri_format).to eq(true) + expect(fd('https://eviltrout.com:443').validate_uri_format).to eq(true) + end + end + + describe ".is_dest_valid" do + it "returns false for a valid ipv4" do + expect(fd("https://52.84.143.67").is_dest_valid?).to eq(true) + expect(fd("https://104.25.153.10").is_dest_valid?).to eq(true) + end + + it "returns false for private ipv4" do + expect(fd("https://127.0.0.1").is_dest_valid?).to eq(false) + expect(fd("https://192.168.1.3").is_dest_valid?).to eq(false) + expect(fd("https://10.0.0.5").is_dest_valid?).to eq(false) + expect(fd("https://172.16.0.1").is_dest_valid?).to eq(false) + end + + it "returns false for IPV6 via site settings" do + SiteSetting.blacklist_ip_blocks = '2001:abc:de::/48|2002:abc:de::/48' + expect(fd('https://[2001:abc:de:01:0:3f0:6a65:c2bf]').is_dest_valid?).to eq(false) + expect(fd('https://[2002:abc:de:01:0:3f0:6a65:c2bf]').is_dest_valid?).to eq(false) + expect(fd('https://internal-ipv6.com').is_dest_valid?).to eq(false) + expect(fd('https://[2003:abc:de:01:0:3f0:6a65:c2bf]').is_dest_valid?).to eq(true) + end + + it "ignores invalid ranges" do + SiteSetting.blacklist_ip_blocks = '2001:abc:de::/48|eviltrout' + expect(fd('https://[2001:abc:de:01:0:3f0:6a65:c2bf]').is_dest_valid?).to eq(false) + end + + it "returns true for public ipv6" do + expect(fd("https://[2001:470:1:3a8::251]").is_dest_valid?).to eq(true) + end + + it "returns true for private ipv6" do + expect(fd("https://[fdd7:b450:d4d1:6b44::1]").is_dest_valid?).to eq(false) + end + end + +end diff --git a/spec/components/flag_query_spec.rb b/spec/components/flag_query_spec.rb index 9609ab6acc..cb2a3b5615 100644 --- a/spec/components/flag_query_spec.rb +++ b/spec/components/flag_query_spec.rb @@ -6,6 +6,17 @@ describe FlagQuery do let(:codinghorror) { Fabricate(:coding_horror) } describe "flagged_posts_report" do + it "does not return flags on system posts" do + admin = Fabricate(:admin) + post = create_post(user: Discourse.system_user) + PostAction.act(codinghorror, post, PostActionType.types[:spam]) + posts, topics, users = FlagQuery.flagged_posts_report(admin, "") + + expect(posts).to be_blank + expect(topics).to be_blank + expect(users).to be_blank + end + it "operates correctly" do admin = Fabricate(:admin) moderator = Fabricate(:moderator) diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb index 346265a718..f8704c2fb3 100644 --- a/spec/components/guardian_spec.rb +++ b/spec/components/guardian_spec.rb @@ -168,14 +168,13 @@ describe Guardian do context "enable_private_messages is false" do before { SiteSetting.enable_private_messages = false } - it "returns false if user is not the contact user" do - expect(Guardian.new(user).can_send_private_message?(another_user)).to be_falsey + it "returns false if user is not staff member" do + expect(Guardian.new(trust_level_4).can_send_private_message?(another_user)).to be_falsey end - it "returns true for the contact user and system user" do - SiteSetting.site_contact_username = user.username - expect(Guardian.new(user).can_send_private_message?(another_user)).to be_truthy - expect(Guardian.new(Discourse.system_user).can_send_private_message?(another_user)).to be_truthy + it "returns true for staff member" do + expect(Guardian.new(moderator).can_send_private_message?(another_user)).to be_truthy + expect(Guardian.new(admin).can_send_private_message?(another_user)).to be_truthy end end @@ -218,6 +217,7 @@ describe Guardian do describe 'can_reply_as_new_topic' do let(:user) { Fabricate(:user) } let(:topic) { Fabricate(:topic) } + let(:private_message) { Fabricate(:private_message_topic) } it "returns false for a non logged in user" do expect(Guardian.new(nil).can_reply_as_new_topic?(topic)).to be_falsey @@ -235,6 +235,10 @@ describe Guardian do it "returns true for a trusted user" do expect(Guardian.new(user).can_reply_as_new_topic?(topic)).to be_truthy end + + it "returns true for a private message" do + expect(Guardian.new(user).can_reply_as_new_topic?(private_message)).to be_truthy + end end describe 'can_see_post_actors?' do @@ -276,6 +280,20 @@ describe Guardian do end end + describe "can_view_action_logs?" do + it 'is false for non-staff acting user' do + expect(Guardian.new(user).can_view_action_logs?(moderator)).to be_falsey + end + + it 'is false without a target user' do + expect(Guardian.new(moderator).can_view_action_logs?(nil)).to be_falsey + end + + it 'is true when target user is present' do + expect(Guardian.new(moderator).can_view_action_logs?(user)).to be_truthy + end + end + describe 'can_invite_to_forum?' do let(:user) { Fabricate.build(:user) } let(:moderator) { Fabricate.build(:moderator) } @@ -312,50 +330,101 @@ describe Guardian do end describe 'can_invite_to?' do - let(:group) { Fabricate(:group) } - let(:category) { Fabricate(:category, read_restricted: true) } - let(:topic) { Fabricate(:topic) } - let(:private_topic) { Fabricate(:topic, category: category) } - let(:user) { topic.user } - let(:moderator) { Fabricate(:moderator) } - let(:admin) { Fabricate(:admin) } - let(:private_category) { Fabricate(:private_category, group: group) } - let(:group_private_topic) { Fabricate(:topic, category: private_category) } - let(:group_owner) { group_private_topic.user.tap { |u| group.add_owner(u) } } - it 'handles invitation correctly' do - expect(Guardian.new(nil).can_invite_to?(topic)).to be_falsey - expect(Guardian.new(moderator).can_invite_to?(nil)).to be_falsey - expect(Guardian.new(moderator).can_invite_to?(topic)).to be_truthy - expect(Guardian.new(user).can_invite_to?(topic)).to be_falsey + describe "regular topics" do + let(:group) { Fabricate(:group) } + let(:category) { Fabricate(:category, read_restricted: true) } + let(:topic) { Fabricate(:topic) } + let(:private_topic) { Fabricate(:topic, category: category) } + let(:user) { topic.user } + let(:moderator) { Fabricate(:moderator) } + let(:admin) { Fabricate(:admin) } + let(:private_category) { Fabricate(:private_category, group: group) } + let(:group_private_topic) { Fabricate(:topic, category: private_category) } + let(:group_owner) { group_private_topic.user.tap { |u| group.add_owner(u) } } + let(:pm) { Fabricate(:topic) } - SiteSetting.max_invites_per_day = 0 + it 'handles invitation correctly' do + expect(Guardian.new(nil).can_invite_to?(topic)).to be_falsey + expect(Guardian.new(moderator).can_invite_to?(nil)).to be_falsey + expect(Guardian.new(moderator).can_invite_to?(topic)).to be_truthy + expect(Guardian.new(user).can_invite_to?(topic)).to be_falsey - expect(Guardian.new(user).can_invite_to?(topic)).to be_falsey - # staff should be immune to max_invites_per_day setting - expect(Guardian.new(moderator).can_invite_to?(topic)).to be_truthy + SiteSetting.max_invites_per_day = 0 + + expect(Guardian.new(user).can_invite_to?(topic)).to be_falsey + # staff should be immune to max_invites_per_day setting + expect(Guardian.new(moderator).can_invite_to?(topic)).to be_truthy + end + + it 'returns false for normal user on private topic' do + expect(Guardian.new(user).can_invite_to?(private_topic)).to be_falsey + end + + it 'returns true for admin on private topic' do + expect(Guardian.new(admin).can_invite_to?(private_topic)).to be_truthy + end + + it 'returns true for a group owner' do + expect(Guardian.new(group_owner).can_invite_to?(group_private_topic)).to be_truthy + end end - it 'returns true when the site requires approving users and is mod' do - SiteSetting.expects(:must_approve_users?).returns(true) - expect(Guardian.new(moderator).can_invite_to?(topic)).to be_truthy + describe "private messages" do + let(:user) { Fabricate(:user, trust_level: TrustLevel[2]) } + let!(:pm) { Fabricate(:private_message_topic, user: user) } + let(:admin) { Fabricate(:admin) } + + context "when private messages are disabled" do + it "allows an admin to invite to the pm" do + expect(Guardian.new(admin).can_invite_to?(pm)).to be_truthy + expect(Guardian.new(user).can_invite_to?(pm)).to be_truthy + end + end + + context "when private messages are disabled" do + before do + SiteSetting.enable_private_messages = false + end + + it "doesn't allow a regular user to invite" do + expect(Guardian.new(admin).can_invite_to?(pm)).to be_truthy + expect(Guardian.new(user).can_invite_to?(pm)).to be_falsey + end + end + end + end + + + describe 'can_invite_via_email?' do + it 'returns true for all (tl2 and above) users when sso is disabled, local logins are enabled, user approval is not required' do + expect(Guardian.new(trust_level_2).can_invite_via_email?(topic)).to be_truthy + expect(Guardian.new(moderator).can_invite_via_email?(topic)).to be_truthy + expect(Guardian.new(admin).can_invite_via_email?(topic)).to be_truthy end - it 'returns false when the site requires approving users and is regular' do - SiteSetting.expects(:must_approve_users?).returns(true) - expect(Guardian.new(coding_horror).can_invite_to?(topic)).to be_falsey + it 'returns false for all users when sso is enabled' do + SiteSetting.enable_sso = true + + expect(Guardian.new(trust_level_2).can_invite_via_email?(topic)).to be_falsey + expect(Guardian.new(moderator).can_invite_via_email?(topic)).to be_falsey + expect(Guardian.new(admin).can_invite_via_email?(topic)).to be_falsey end - it 'returns false for normal user on private topic' do - expect(Guardian.new(user).can_invite_to?(private_topic)).to be_falsey + it 'returns false for all users when local logins are disabled' do + SiteSetting.enable_local_logins = false + + expect(Guardian.new(trust_level_2).can_invite_via_email?(topic)).to be_falsey + expect(Guardian.new(moderator).can_invite_via_email?(topic)).to be_falsey + expect(Guardian.new(admin).can_invite_via_email?(topic)).to be_falsey end - it 'returns true for admin on private topic' do - expect(Guardian.new(admin).can_invite_to?(private_topic)).to be_truthy - end + it 'returns correct valuse when user approval is required' do + SiteSetting.must_approve_users = true - it 'returns true for a group owner' do - expect(Guardian.new(group_owner).can_invite_to?(group_private_topic)).to be_truthy + expect(Guardian.new(trust_level_2).can_invite_via_email?(topic)).to be_falsey + expect(Guardian.new(moderator).can_invite_via_email?(topic)).to be_truthy + expect(Guardian.new(admin).can_invite_via_email?(topic)).to be_truthy end end @@ -840,8 +909,30 @@ describe Guardian do expect(Guardian.new(user).can_recover_topic?(topic)).to be_falsey end - it "returns true for a moderator" do - expect(Guardian.new(moderator).can_recover_topic?(topic)).to be_truthy + context 'as a moderator' do + before do + topic.save! + post.save! + end + + describe 'when post has been deleted' do + it "should return the right value" do + expect(Guardian.new(moderator).can_recover_topic?(topic)).to be_falsey + + PostDestroyer.new(moderator, topic.first_post).destroy + + expect(Guardian.new(moderator).can_recover_topic?(topic.reload)).to be_truthy + end + end + + describe "when post's user has been deleted" do + it 'should return the right value' do + PostDestroyer.new(moderator, topic.first_post).destroy + topic.first_post.user.destroy! + + expect(Guardian.new(moderator).can_recover_topic?(topic.reload)).to be_falsey + end + end end end @@ -859,8 +950,32 @@ describe Guardian do expect(Guardian.new(user).can_recover_post?(post)).to be_falsey end - it "returns true for a moderator" do - expect(Guardian.new(moderator).can_recover_post?(post)).to be_truthy + context 'as a moderator' do + let(:other_post) { Fabricate(:post, topic: topic, user: topic.user) } + + before do + topic.save! + post.save! + end + + describe 'when post has been deleted' do + it "should return the right value" do + expect(Guardian.new(moderator).can_recover_post?(post)).to be_falsey + + PostDestroyer.new(moderator, post).destroy + + expect(Guardian.new(moderator).can_recover_post?(post.reload)).to be_truthy + end + + describe "when post's user has been deleted" do + it 'should return the right value' do + PostDestroyer.new(moderator, post).destroy + post.user.destroy! + + expect(Guardian.new(moderator).can_recover_post?(post.reload)).to be_falsey + end + end + end end end @@ -964,6 +1079,19 @@ describe Guardian do expect(Guardian.new(coding_horror).can_edit?(post)).to be_truthy end + it "returns false if a wiki but the user can't create a post" do + c = Fabricate(:category) + c.set_permissions(:everyone => :readonly) + c.save + + topic = Fabricate(:topic, category: c) + post = Fabricate(:post, topic: topic) + post.wiki = true + + user = Fabricate(:user) + expect(Guardian.new(user).can_edit?(post)).to eq(false) + end + it 'returns true as a moderator' do expect(Guardian.new(moderator).can_edit?(post)).to be_truthy end diff --git a/spec/components/html_prettify_spec.rb b/spec/components/html_prettify_spec.rb index 1f1b6fad2c..189bfec8b3 100644 --- a/spec/components/html_prettify_spec.rb +++ b/spec/components/html_prettify_spec.rb @@ -24,6 +24,8 @@ describe HtmlPrettify do t 'src="test.png"> yay', "src=“test.png”> yay" + t '\\\\mnt\\c', "\\\\mnt\\c" + t ERB::Util.html_escape(' yay'), "<img src=“test.png”> yay" end diff --git a/spec/components/html_to_markdown_spec.rb b/spec/components/html_to_markdown_spec.rb new file mode 100644 index 0000000000..c6daa6286b --- /dev/null +++ b/spec/components/html_to_markdown_spec.rb @@ -0,0 +1,231 @@ +require 'rails_helper' +require 'html_to_markdown' + +describe HtmlToMarkdown do + + def html_to_markdown(html, opts={}) + HtmlToMarkdown.new(html, opts).to_markdown + end + + it "remove whitespaces" do + expect(html_to_markdown(<<-HTML +
    Hello, +

    +
        This is the 1st paragraph.   
    +

    +
    +         This is another paragraph +
    +
    + HTML + )).to eq("Hello,\n\nThis is the 1st paragraph.\n\nThis is another paragraph") + end + + it "skips hidden tags" do + expect(html_to_markdown(%Q{

    Hello cruel World!

    })).to eq("Hello World!") + end + + it "converts " do + expect(html_to_markdown("Strong")).to eq("**Strong**") + expect(html_to_markdown("Str*ng")).to eq("__Str*ng__") + end + + it "converts " do + expect(html_to_markdown("Bold")).to eq("**Bold**") + expect(html_to_markdown("B*ld")).to eq("__B*ld__") + end + + it "converts " do + expect(html_to_markdown("Emphasis")).to eq("*Emphasis*") + expect(html_to_markdown("Emph*sis")).to eq("_Emph*sis_") + end + + it "converts " do + expect(html_to_markdown("Italic")).to eq("*Italic*") + expect(html_to_markdown("It*lic")).to eq("_It*lic_") + end + + it "converts " do + expect(html_to_markdown(%Q{Discourse})).to eq("[Discourse](https://www.discourse.org)") + end + + it "removes empty & invalid " do + expect(html_to_markdown(%Q{Discourse})).to eq("Discourse") + expect(html_to_markdown(%Q{Discourse})).to eq("Discourse") + expect(html_to_markdown(%Q{Discourse})).to eq("Discourse") + end + + HTML_WITH_IMG ||= %Q{Discourse Logo} + HTML_WITH_CID_IMG ||= %Q{Discourse Logo} + + it "converts " do + expect(html_to_markdown(HTML_WITH_IMG)).to eq("![Discourse Logo](https://www.discourse.org/logo.svg)") + end + + it "keeps with 'keep_img_tags'" do + expect(html_to_markdown(HTML_WITH_IMG, keep_img_tags: true)).to eq(HTML_WITH_IMG) + end + + it "removes empty & invalid " do + expect(html_to_markdown(%Q{})).to eq("") + expect(html_to_markdown(%Q{})).to eq("") + expect(html_to_markdown(%Q{})).to eq("") + end + + it "keeps with src='cid:' whith 'keep_cid_imgs'" do + expect(html_to_markdown(HTML_WITH_CID_IMG, keep_cid_imgs: true)).to eq("![Discourse Logo](cid:ii_1525434659ddb4cb)") + expect(html_to_markdown(HTML_WITH_CID_IMG, keep_img_tags: true, keep_cid_imgs: true)).to eq("\"Discourse") + end + + it "skips hidden " do + expect(html_to_markdown(%Q{})).to eq("") + expect(html_to_markdown(%Q{})).to eq("") + expect(html_to_markdown(%Q{})).to eq("") + expect(html_to_markdown(%Q{})).to eq("") + end + + (1..6).each do |n| + it "converts " do + expect(html_to_markdown("Header #{n}")).to eq("#" * n + " Header #{n}") + end + end + + it "converts
    " do + expect(html_to_markdown("Before
    Inside
    After")).to eq("Before\nInside\nAfter") + end + + it "converts
    " do + expect(html_to_markdown("Before
    Inside
    After")).to eq("Before\n\n---\n\nInside\n\n---\n\nAfter") + end + + it "converts " do + expect(html_to_markdown("Teletype")).to eq("`Teletype`") + end + + it "converts " do + expect(html_to_markdown("Code")).to eq("`Code`") + end + + it "supports " do + expect(html_to_markdown("This is an insertion")).to eq("This is an insertion") + end + + it "supports " do + expect(html_to_markdown("This is a deletion")).to eq("This is a deletion") + end + + it "supports " do + expect(html_to_markdown("H2O")).to eq("H2O") + end + + it "supports " do + expect(html_to_markdown("Super Script!")).to eq("Super Script!") + end + + it "supports " do + expect(html_to_markdown("Small")).to eq("Small") + end + + it "supports " do + expect(html_to_markdown("CTRL+C")).to eq("CTRL+C") + end + + it "supports " do + expect(html_to_markdown(%Q{CDCK})).to eq(%Q{CDCK}) + end + + it "supports " do + expect(html_to_markdown("Strike Through")).to eq("Strike Through") + end + + it "supports " do + expect(html_to_markdown("Strike Through")).to eq("Strike Through") + end + + it "supports
    " do + expect(html_to_markdown("
    Quote
    ")).to eq("> Quote") + end + + it "supports
      " do + expect(html_to_markdown("
      • 🍏
      • 🍐
      • 🍌
      ")).to eq("- 🍏\n- 🍐\n- 🍌") + expect(html_to_markdown("
        \n
      • 🍏
      • \n
      • 🍐
      • \n
      • 🍌
      • \n
      ")).to eq("- 🍏\n- 🍐\n- 🍌") + end + + it "supports
        " do + expect(html_to_markdown("
        1. 🍆
        2. 🍅
        3. 🍄
        ")).to eq("1. 🍆\n1. 🍅\n1. 🍄") + end + + it "supports

        inside

      1. " do + expect(html_to_markdown("
        • 🍏

        • 🍐

        • 🍌

        ")).to eq("- 🍏\n\n- 🍐\n\n- 🍌") + end + + it "supports
          inside
            " do + expect(html_to_markdown(<<-HTML +
              +
            • Fruits +
                +
              • 🍏
              • +
              • 🍐
              • +
              • 🍌
              • +
              +
            • +
            • Vegetables +
                +
              • 🍆
              • +
              • 🍅
              • +
              • 🍄
              • +
              +
            • +
            + HTML + )).to eq("- Fruits\n - 🍏\n - 🍐\n - 🍌\n- Vegetables\n - 🍆\n - 🍅\n - 🍄") + end + + it "supports bare
          • " do + expect(html_to_markdown("
          • I'm alone
          • ")).to eq("- I'm alone") + end + + it "supports
            " do
            +    expect(html_to_markdown("
            var foo = 'bar';
            ")).to eq("```\nvar foo = 'bar';\n```") + expect(html_to_markdown("
            var foo = 'bar';
            ")).to eq("```\nvar foo = 'bar';\n```") + expect(html_to_markdown(%Q{
            var foo = 'bar';
            })).to eq("```javascript\nvar foo = 'bar';\n```") + end + + it "works" do + expect(html_to_markdown("
            • A list item with a blockquote:

              This is a blockquote
              inside a list item.

            ")).to eq("- A list item with a blockquote:\n\n > This is a **blockquote**\n > inside a list item.") + end + + it "supports html document" do + expect(html_to_markdown("Hello
            World
            ")).to eq("Hello\nWorld") + end + + it "handles

            " do + expect(html_to_markdown("

            1st paragraph

            2nd paragraph

            ")).to eq("1st paragraph\n\n2nd paragraph") + end + + it "handles
            " do + expect(html_to_markdown("
            1st div
            2nd div
            ")).to eq("1st div\n\n2nd div") + end + + it "swallows " do + expect(html_to_markdown("Span")).to eq("Span") + end + + it "swallows " do + expect(html_to_markdown("Underline")).to eq("Underline") + end + + it "removes ")).to eq("") + end + + it "removes ")).to eq("") + end + + it "handles divs within spans" do + html = "
            1st paragraph
            2nd paragraph
            " + expect(html_to_markdown(html)).to eq("1st paragraph\n2nd paragraph") + end + +end diff --git a/spec/components/js_locale_helper_spec.rb b/spec/components/js_locale_helper_spec.rb index a5ce82ed92..b5a658f52b 100644 --- a/spec/components/js_locale_helper_spec.rb +++ b/spec/components/js_locale_helper_spec.rb @@ -15,6 +15,7 @@ describe JsLocaleHelper do @loaded_merges = nil end end + JsLocaleHelper.extend StubLoadTranslations after do @@ -22,91 +23,102 @@ describe JsLocaleHelper do JsLocaleHelper.clear_cache! end - it 'should be able to generate translations' do - expect(JsLocaleHelper.output_locale('en').length).to be > 0 + describe "#output_locale" do + + it "doesn't change the cached translations hash" do + I18n.locale = :fr + expect(JsLocaleHelper.output_locale('fr').length).to be > 0 + expect(JsLocaleHelper.translations_for('fr')['fr'].keys).to contain_exactly("js", "admin_js", "wizard_js") + end + end - def setup_message_format(format) - @ctx = MiniRacer::Context.new - @ctx.eval('MessageFormat = {locale: {}};') - @ctx.load(Rails.root + 'lib/javascripts/locale/en.js') - compiled = JsLocaleHelper.compile_message_format('en', format) - @ctx.eval("var test = #{compiled}") - end + context "message format" do - def localize(opts) - @ctx.eval("test(#{opts.to_json})") - end + def setup_message_format(format) + @ctx = MiniRacer::Context.new + @ctx.eval('MessageFormat = {locale: {}};') + @ctx.load(Rails.root + 'lib/javascripts/locale/en.js') + compiled = JsLocaleHelper.compile_message_format('en', format) + @ctx.eval("var test = #{compiled}") + end - it 'handles plurals' do - setup_message_format('{NUM_RESULTS, plural, - one {1 result} - other {# results} - }') - expect(localize(NUM_RESULTS: 1)).to eq('1 result') - expect(localize(NUM_RESULTS: 2)).to eq('2 results') - end + def localize(opts) + @ctx.eval("test(#{opts.to_json})") + end - it 'handles double plurals' do - setup_message_format('{NUM_RESULTS, plural, - one {1 result} - other {# results} - } and {NUM_APPLES, plural, - one {1 apple} - other {# apples} - }') + it 'handles plurals' do + setup_message_format('{NUM_RESULTS, plural, + one {1 result} + other {# results} + }') + expect(localize(NUM_RESULTS: 1)).to eq('1 result') + expect(localize(NUM_RESULTS: 2)).to eq('2 results') + end - expect(localize(NUM_RESULTS: 1, NUM_APPLES: 2)).to eq('1 result and 2 apples') - expect(localize(NUM_RESULTS: 2, NUM_APPLES: 1)).to eq('2 results and 1 apple') - end + it 'handles double plurals' do + setup_message_format('{NUM_RESULTS, plural, + one {1 result} + other {# results} + } and {NUM_APPLES, plural, + one {1 apple} + other {# apples} + }') - it 'handles select' do - setup_message_format('{GENDER, select, male {He} female {She} other {They}} read a book') - expect(localize(GENDER: 'male')).to eq('He read a book') - expect(localize(GENDER: 'female')).to eq('She read a book') - expect(localize(GENDER: 'none')).to eq('They read a book') - end + expect(localize(NUM_RESULTS: 1, NUM_APPLES: 2)).to eq('1 result and 2 apples') + expect(localize(NUM_RESULTS: 2, NUM_APPLES: 1)).to eq('2 results and 1 apple') + end - it 'can strip out message formats' do - hash = {"a" => "b", "c" => { "d" => {"f_MF" => "bob"} }} - expect(JsLocaleHelper.strip_out_message_formats!(hash)).to eq({"c.d.f_MF" => "bob"}) - expect(hash["c"]["d"]).to eq({}) - end + it 'handles select' do + setup_message_format('{GENDER, select, male {He} female {She} other {They}} read a book') + expect(localize(GENDER: 'male')).to eq('He read a book') + expect(localize(GENDER: 'female')).to eq('She read a book') + expect(localize(GENDER: 'none')).to eq('They read a book') + end - it 'handles message format special keys' do - ctx = MiniRacer::Context.new - ctx.eval("I18n = {};") + it 'can strip out message formats' do + hash = {"a" => "b", "c" => { "d" => {"f_MF" => "bob"} }} + expect(JsLocaleHelper.strip_out_message_formats!(hash)).to eq({"c.d.f_MF" => "bob"}) + expect(hash["c"]["d"]).to eq({}) + end - JsLocaleHelper.set_translations 'en', { - "en" => - { + it 'handles message format special keys' do + JsLocaleHelper.set_translations('en', { + "en" => { "js" => { "hello" => "world", "test_MF" => "{HELLO} {COUNT, plural, one {1 duck} other {# ducks}}", "error_MF" => "{{BLA}", "simple_MF" => "{COUNT, plural, one {1} other {#}}" + }, + "admin_js" => { + "foo_MF" => "{HELLO} {COUNT, plural, one {1 duck} other {# ducks}}" } } - } + }) - ctx.eval(JsLocaleHelper.output_locale('en')) + ctx = MiniRacer::Context.new + ctx.eval("I18n = { pluralizationRules: {} };") + ctx.eval(JsLocaleHelper.output_locale('en')) - expect(ctx.eval('I18n.translations["en"]["js"]["hello"]')).to eq("world") - expect(ctx.eval('I18n.translations["en"]["js"]["test_MF"]')).to eq(nil) + expect(ctx.eval('I18n.translations["en"]["js"]["hello"]')).to eq("world") + expect(ctx.eval('I18n.translations["en"]["js"]["test_MF"]')).to eq(nil) - expect(ctx.eval('I18n.messageFormat("test_MF", { HELLO: "hi", COUNT: 3 })')).to eq("hi 3 ducks") - expect(ctx.eval('I18n.messageFormat("error_MF", { HELLO: "hi", COUNT: 3 })')).to match(/Invalid Format/) - expect(ctx.eval('I18n.messageFormat("missing", {})')).to match(/missing/) - expect(ctx.eval('I18n.messageFormat("simple_MF", {})')).to match(/COUNT/) # error - end + expect(ctx.eval('I18n.messageFormat("test_MF", { HELLO: "hi", COUNT: 3 })')).to eq("hi 3 ducks") + expect(ctx.eval('I18n.messageFormat("error_MF", { HELLO: "hi", COUNT: 3 })')).to match(/Invalid Format/) + expect(ctx.eval('I18n.messageFormat("missing", {})')).to match(/missing/) + expect(ctx.eval('I18n.messageFormat("simple_MF", {})')).to match(/COUNT/) # error + expect(ctx.eval('I18n.messageFormat("foo_MF", { HELLO: "hi", COUNT: 4 })')).to eq("hi 4 ducks") + end - it 'load pluralizations rules before precompile' do - message = JsLocaleHelper.compile_message_format('ru', 'format') - expect(message).not_to match 'Plural Function not found' + it 'load pluralizations rules before precompile' do + message = JsLocaleHelper.compile_message_format('ru', 'format') + expect(message).not_to match 'Plural Function not found' + end end it 'performs fallbacks to english if a translation is not available' do - JsLocaleHelper.set_translations 'en', { + JsLocaleHelper.set_translations('en', { "en" => { "js" => { "only_english" => "1-en", @@ -115,8 +127,9 @@ describe JsLocaleHelper do "all_three" => "7-en", } } - } - JsLocaleHelper.set_translations 'ru', { + }) + + JsLocaleHelper.set_translations('ru', { "ru" => { "js" => { "only_site" => "2-ru", @@ -125,8 +138,9 @@ describe JsLocaleHelper do "all_three" => "7-ru", } } - } - JsLocaleHelper.set_translations 'uk', { + }) + + JsLocaleHelper.set_translations('uk', { "uk" => { "js" => { "only_user" => "4-uk", @@ -135,7 +149,7 @@ describe JsLocaleHelper do "all_three" => "7-uk", } } - } + }) expected = { "none" => "[uk.js.none]", @@ -157,9 +171,9 @@ describe JsLocaleHelper do ctx.eval(JsLocaleHelper.output_locale(I18n.locale)) ctx.eval('I18n.defaultLocale = "ru";') - # Test - unneeded translations are not emitted - expect(ctx.eval('I18n.translations.en.js').keys).to eq(["only_english"]) - expect(ctx.eval('I18n.translations.ru.js').keys).to eq(["only_site", "english_and_site"]) + expect(ctx.eval('I18n.translations.en.js').keys).to contain_exactly("only_english") + expect(ctx.eval('I18n.translations.ru.js').keys).to contain_exactly("only_site", "english_and_site") + expect(ctx.eval('I18n.translations.uk.js').keys).to contain_exactly("all_three", "english_and_user", "only_user", "site_and_user") expected.each do |key, expect| expect(ctx.eval("I18n.t(#{"js.#{key}".inspect})")).to eq(expect) diff --git a/spec/components/onebox/engine/discourse_local_onebox_spec.rb b/spec/components/onebox/engine/discourse_local_onebox_spec.rb index 3a6d0862ce..c072928594 100644 --- a/spec/components/onebox/engine/discourse_local_onebox_spec.rb +++ b/spec/components/onebox/engine/discourse_local_onebox_spec.rb @@ -4,25 +4,29 @@ describe Onebox::Engine::DiscourseLocalOnebox do before { SiteSetting.external_system_avatars_enabled = false } + def build_link(url) + %|#{url}| + end + context "for a link to a post" do let(:post) { Fabricate(:post) } let(:post2) { Fabricate(:post, topic: post.topic, post_number: 2) } it "returns a link if post isn't found" do url = "#{Discourse.base_url}/t/not-exist/3/2" - expect(Onebox.preview(url).to_s).to eq("#{url}") + expect(Onebox.preview(url).to_s).to eq(build_link(url)) end it "returns a link if not allowed to see the post" do url = "#{Discourse.base_url}#{post2.url}" Guardian.any_instance.expects(:can_see_post?).returns(false) - expect(Onebox.preview(url).to_s).to eq("#{url}") + expect(Onebox.preview(url).to_s).to eq(build_link(url)) end it "returns a link if post is hidden" do hidden_post = Fabricate(:post, topic: post.topic, post_number: 2, hidden: true, hidden_reason_id: Post.hidden_reasons[:flag_threshold_reached]) url = "#{Discourse.base_url}#{hidden_post.url}" - expect(Onebox.preview(url).to_s).to eq("#{url}") + expect(Onebox.preview(url).to_s).to eq(build_link(url)) end it "returns some onebox goodness if post exists and can be seen" do @@ -43,13 +47,13 @@ describe Onebox::Engine::DiscourseLocalOnebox do it "returns a link if topic isn't found" do url = "#{Discourse.base_url}/t/not-found/123" - expect(Onebox.preview(url).to_s).to eq("#{url}") + expect(Onebox.preview(url).to_s).to eq(build_link(url)) end it "returns a link if not allowed to see the topic" do url = topic.url Guardian.any_instance.expects(:can_see_topic?).returns(false) - expect(Onebox.preview(url).to_s).to eq("#{url}") + expect(Onebox.preview(url).to_s).to eq(build_link(url)) end it "replaces emoji in the title" do @@ -71,20 +75,22 @@ describe Onebox::Engine::DiscourseLocalOnebox do it "returns nil if file type is not audio or video" do url = "#{Discourse.base_url}#{path}.pdf" - FakeWeb.register_uri(:get, url, body: "") + stub_request(:get, url).to_return(body: '') expect(Onebox.preview(url).to_s).to eq("") end it "returns some onebox goodness for audio file" do url = "#{Discourse.base_url}#{path}.MP3" html = Onebox.preview(url).to_s - expect(html).to eq("") + # will be removed by the browser + # need to fix https://github.com/rubys/nokogumbo/issues/14 + expect(html).to eq(%||) end it "returns some onebox goodness for video file" do url = "#{Discourse.base_url}#{path}.mov" html = Onebox.preview(url).to_s - expect(html).to eq("") + expect(html).to eq(%||) end end diff --git a/spec/components/oneboxer_spec.rb b/spec/components/oneboxer_spec.rb index 1df8b30027..6579e1f6e2 100644 --- a/spec/components/oneboxer_spec.rb +++ b/spec/components/oneboxer_spec.rb @@ -4,9 +4,11 @@ require_dependency 'oneboxer' describe Oneboxer do it "returns blank string for an invalid onebox" do + stub_request(:get, "http://boom.com").to_return(body: "") + stub_request(:head, "http://boom.com").to_return(body: "") + expect(Oneboxer.preview("http://boom.com")).to eq("") expect(Oneboxer.onebox("http://boom.com")).to eq("") end end - diff --git a/spec/components/plugin/instance_spec.rb b/spec/components/plugin/instance_spec.rb index d5c7150850..0e9c0fd592 100644 --- a/spec/components/plugin/instance_spec.rb +++ b/spec/components/plugin/instance_spec.rb @@ -160,6 +160,27 @@ describe Plugin::Instance do end end + context "themes" do + it "can register a theme" do + plugin = Plugin::Instance.new nil, "/tmp/test.rb" + plugin.register_theme('plugin') do |theme| + theme.set_color_scheme( + primary: 'ffff00', + secondary: '222222', + tertiary: '0f82af', + quaternary: 'c14924', + header_background: '111111', + header_primary: '333333', + highlight: 'a87137', + danger: 'e45735', + success: '1ca551', + love: 'fa6c8d' + ) + end + expect(plugin.themes).to be_present + end + end + context "register_color_scheme" do it "can add a color scheme for the first time" do plugin = Plugin::Instance.new nil, "/tmp/test.rb" @@ -207,7 +228,7 @@ describe Plugin::Instance do it 'should add the right callback' do called = 0 - method_name = plugin_instance.add_model_callback(User, :after_create) do + plugin_instance.add_model_callback(User, :after_create) do called += 1 end @@ -223,7 +244,7 @@ describe Plugin::Instance do it 'should add the right callback with options' do called = 0 - method_name = plugin_instance.add_model_callback(User, :after_commit, on: :create) do + plugin_instance.add_model_callback(User, :after_commit, on: :create) do called += 1 end diff --git a/spec/components/post_creator_spec.rb b/spec/components/post_creator_spec.rb index b59758adb4..dcfecfe83f 100644 --- a/spec/components/post_creator_spec.rb +++ b/spec/components/post_creator_spec.rb @@ -4,9 +4,6 @@ require 'topic_subtype' describe PostCreator do - before do - end - let(:user) { Fabricate(:user) } let(:topic) { Fabricate(:topic, user: user) } @@ -80,7 +77,6 @@ describe PostCreator do end it "triggers extensibility events" do - creator # bypass a user_created event, can be removed when there is a UserCreator DiscourseEvent.expects(:trigger).with(:before_create_post, anything).once DiscourseEvent.expects(:trigger).with(:validate_post, anything).once DiscourseEvent.expects(:trigger).with(:topic_created, anything, anything, user).once @@ -89,6 +85,7 @@ describe PostCreator do DiscourseEvent.expects(:trigger).with(:before_create_topic, anything, anything).once DiscourseEvent.expects(:trigger).with(:after_trigger_post_process, anything).once DiscourseEvent.expects(:trigger).with(:markdown_context, anything).at_least_once + DiscourseEvent.expects(:trigger).with(:topic_notification_level_changed, anything, anything, anything).at_least_once creator.create end @@ -125,8 +122,8 @@ describe PostCreator do # 2 for topic, one to notify of new topic another for tracking state expect(messages.map{|m| m.channel}.sort).to eq([ "/new", - "/users/#{admin.username}", - "/users/#{admin.username}", + "/u/#{admin.username}", + "/u/#{admin.username}", "/unread/#{admin.id}", "/unread/#{admin.id}", "/latest", @@ -157,7 +154,7 @@ describe PostCreator do read = messages.find{|m| m.channel == "/unread/#{p.user_id}"} expect(read).not_to eq(nil) - user_action = messages.find{|m| m.channel == "/users/#{p.user.username}"} + user_action = messages.find{|m| m.channel == "/u/#{p.user.username}"} expect(user_action).not_to eq(nil) expect(messages.length).to eq(5) @@ -258,31 +255,49 @@ describe PostCreator do it 'creates a post with featured link' do SiteSetting.topic_featured_link_enabled = true SiteSetting.min_first_post_length = 100 + SiteSetting.queue_jobs = true + post = creator_with_featured_link.create expect(post.topic.featured_link).to eq('http://www.discourse.org') expect(post.valid?).to eq(true) end describe "topic's auto close" do + before do + SiteSetting.queue_jobs = true + end it "doesn't update topic's auto close when it's not based on last post" do - auto_close_time = 1.day.from_now - topic = Fabricate(:topic, auto_close_at: auto_close_time, auto_close_hours: 12) + Timecop.freeze do + topic = Fabricate(:topic).set_or_create_timer(TopicTimer.types[:close], 12) - PostCreator.new(topic.user, topic_id: topic.id, raw: "this is a second post").create - topic.reload + PostCreator.new(topic.user, topic_id: topic.id, raw: "this is a second post").create + topic.reload - expect(topic.auto_close_at).to be_within(1.second).of(auto_close_time) + topic_status_update = TopicTimer.last + expect(topic_status_update.execute_at).to be_within(1.second).of(Time.zone.now + 12.hours) + expect(topic_status_update.created_at).to be_within(1.second).of(Time.zone.now) + end end it "updates topic's auto close date when it's based on last post" do - auto_close_time = 1.day.from_now - topic = Fabricate(:topic, auto_close_at: auto_close_time, auto_close_hours: 12, auto_close_based_on_last_post: true) + Timecop.freeze do + topic = Fabricate(:topic, + topic_timers: [Fabricate(:topic_timer, + based_on_last_post: true, + execute_at: Time.zone.now - 12.hours, + created_at: Time.zone.now - 24.hours + )] + ) - PostCreator.new(topic.user, topic_id: topic.id, raw: "this is a second post").create - topic.reload + Fabricate(:post, topic: topic) - expect(topic.auto_close_at).not_to be_within(1.second).of(auto_close_time) + PostCreator.new(topic.user, topic_id: topic.id, raw: "this is a second post").create + + topic_status_update = TopicTimer.last + expect(topic_status_update.execute_at).to be_within(1.second).of(Time.zone.now + 12.hours) + expect(topic_status_update.created_at).to be_within(1.second).of(Time.zone.now) + end end end @@ -345,8 +360,9 @@ describe PostCreator do context 'when auto-close param is given' do it 'ensures the user can auto-close the topic, but ignores auto-close param silently' do Guardian.any_instance.stubs(:can_moderate?).returns(false) - post = PostCreator.new(user, basic_topic_params.merge(auto_close_time: 2)).create - expect(post.topic.auto_close_at).to eq(nil) + expect { + PostCreator.new(user, basic_topic_params.merge(auto_close_time: 2)).create! + }.to_not change { TopicTimer.count } end end end @@ -379,6 +395,9 @@ describe PostCreator do expect(whisper_reply).to be_present expect(whisper_reply.post_type).to eq(Post.types[:whisper]) + # date is not precise enough in db + whisper_reply.reload + first.reload # does not leak into the OP @@ -392,7 +411,10 @@ describe PostCreator do expect(topic.posts_count).to eq(1) expect(topic.highest_staff_post_number).to eq(3) - topic.update_columns(highest_staff_post_number:0, highest_post_number:0, posts_count: 0, last_posted_at: 1.year.ago) + topic.update_columns(highest_staff_post_number:0, + highest_post_number:0, + posts_count: 0, + last_posted_at: 1.year.ago) Topic.reset_highest(topic.id) @@ -571,7 +593,7 @@ describe PostCreator do it 'acts correctly' do # It's not a warning - expect(post.topic.warning).to be_blank + expect(post.topic.user_warning).to be_blank expect(post.topic.archetype).to eq(Archetype.private_message) expect(post.topic.subtype).to eq(TopicSubtype.user_to_user) @@ -643,11 +665,11 @@ describe PostCreator do topic = post.topic expect(topic).to be_present - expect(topic.warning).to be_present + expect(topic.user_warning).to be_present expect(topic.subtype).to eq(TopicSubtype.moderator_warning) - expect(topic.warning.user).to eq(target_user1) - expect(topic.warning.created_by).to eq(user) - expect(target_user1.warnings.count).to eq(1) + expect(topic.user_warning.user).to eq(target_user1) + expect(topic.user_warning.created_by).to eq(user) + expect(target_user1.user_warnings.count).to eq(1) end end @@ -883,6 +905,23 @@ describe PostCreator do topic_user = TopicUser.find_by(user_id: user.id, topic_id: post.topic_id) expect(topic_user.notification_level).to eq(TopicUser.notification_levels[:tracking]) end + + it "topic notification level is normal based on preference" do + user.user_option.notification_level_when_replying = 1 + + admin = Fabricate(:admin) + topic = PostCreator.create(admin, + title: "this is the title of a topic created by an admin for tracking notification", + raw: "this is the content of a topic created by an admin for keeping a tracking notification state on a topic ;)" + ) + + post = PostCreator.create(user, + topic_id: topic.topic_id, + raw: "this is a reply to set the tracking state to normal ;)" + ) + topic_user = TopicUser.find_by(user_id: user.id, topic_id: post.topic_id) + expect(topic_user.notification_level).to eq(TopicUser.notification_levels[:regular]) + end end describe '#create!' do diff --git a/spec/components/post_destroyer_spec.rb b/spec/components/post_destroyer_spec.rb index 58781bd2f1..37b35ab285 100644 --- a/spec/components/post_destroyer_spec.rb +++ b/spec/components/post_destroyer_spec.rb @@ -467,4 +467,3 @@ describe PostDestroyer do end end - diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb index b63d77584b..8b0dfae4e9 100644 --- a/spec/components/pretty_text_spec.rb +++ b/spec/components/pretty_text_spec.rb @@ -124,12 +124,28 @@ HTML expect(PrettyText.excerpt("",100)).to eq("[image]") end - it "should keep alt tags" do - expect(PrettyText.excerpt("car",100)).to eq("[car]") + context 'alt tags' do + it "should keep alt tags" do + expect(PrettyText.excerpt("car", 100)).to eq("[car]") + end + + describe 'when alt tag is empty' do + it "should not keep alt tags" do + expect(PrettyText.excerpt("", 100)).to eq("[#{I18n.t('excerpt_image')}]") + end + end end - it "should keep title tags" do - expect(PrettyText.excerpt("",100)).to eq("[car]") + context 'title tags' do + it "should keep title tags" do + expect(PrettyText.excerpt("", 100)).to eq("[car]") + end + + describe 'when title tag is empty' do + it "should not keep title tags" do + expect(PrettyText.excerpt("", 100)).to eq("[#{I18n.t('excerpt_image')}]") + end + end end it "should convert images to markdown if the option is set" do @@ -225,12 +241,11 @@ HTML http://useless2.com ") - expect(links.map{|l| [l.url, l.is_quote]}.to_a.sort).to eq( - [["http://body_only.com",false], - ["http://body_and_quote.com", false], - ["/t/topic/1234",true] - ].sort - ) + expect(links.map { |l| [l.url, l.is_quote] }.sort).to eq([ + ["http://body_only.com", false], + ["http://body_and_quote.com", false], + ["/t/topic/1234", true], + ].sort) end it "should not preserve tags in code blocks" do @@ -274,6 +289,25 @@ HTML expect(PrettyText.excerpt(emoji_code, 100)).to eq(":heart:") end + context 'option ot preserve onebox source' do + it "should return the right excerpt" do + onebox = "\n\n\n" + expected = "meta.discourse.org" + + expect(PrettyText.excerpt(onebox, 100, keep_onebox_source: true)) + .to eq(expected) + + expect(PrettyText.excerpt("#{onebox}\n \n \n \n\n\n #{onebox}", 100, keep_onebox_source: true)) + .to eq("#{expected}\n\n#{expected}") + end + + it 'should continue to strip quotes' do + expect(PrettyText.excerpt( + "boom", 100, keep_onebox_source: true + )).to eq("boom") + end + end + end describe "strip links" do @@ -324,9 +358,9 @@ HTML end it "adds base url to relative links" do - html = "

            @wiseguy, @trollol what do you guys think?

            " + html = "

            @wiseguy, @trollol what do you guys think?

            " output = described_class.format_for_email(html, post) - expect(output).to eq("

            @wiseguy, @trollol what do you guys think?

            ") + expect(output).to eq("

            @wiseguy, @trollol what do you guys think?

            ") end it "doesn't change external absolute links" do @@ -432,7 +466,9 @@ HTML describe "custom emoji" do it "replaces the custom emoji" do - Emoji.stubs(:custom).returns([ Emoji.create_from_path('trout') ]) + CustomEmoji.create!(name: 'trout', upload: Fabricate(:upload)) + Emoji.clear_cache + expect(PrettyText.cook("hello :trout:")).to match(/]+trout[^>]+>/) end end diff --git a/spec/components/search_spec.rb b/spec/components/search_spec.rb index a9184ef090..d215c8835c 100644 --- a/spec/components/search_spec.rb +++ b/spec/components/search_spec.rb @@ -159,44 +159,64 @@ describe Search do it 'searches correctly' do - expect do - Search.execute('mars', type_filter: 'private_messages') - end.to raise_error(Discourse::InvalidAccess) + expect do + Search.execute('mars', type_filter: 'private_messages') + end.to raise_error(Discourse::InvalidAccess) - TopicAllowedUser.create!(user_id: reply.user_id, topic_id: topic.id) - TopicAllowedUser.create!(user_id: post.user_id, topic_id: topic.id) + TopicAllowedUser.create!(user_id: reply.user_id, topic_id: topic.id) + TopicAllowedUser.create!(user_id: post.user_id, topic_id: topic.id) - results = Search.execute('mars', - type_filter: 'private_messages', - guardian: Guardian.new(reply.user)) + results = Search.execute('mars', + type_filter: 'private_messages', + guardian: Guardian.new(reply.user)) - expect(results.posts.length).to eq(1) + expect(results.posts.length).to eq(1) - results = Search.execute('mars', - search_context: topic, - guardian: Guardian.new(reply.user)) + results = Search.execute('mars', + search_context: topic, + guardian: Guardian.new(reply.user)) - expect(results.posts.length).to eq(1) + expect(results.posts.length).to eq(1) - # does not leak out - results = Search.execute('mars', - type_filter: 'private_messages', - guardian: Guardian.new(Fabricate(:user))) + # does not leak out + results = Search.execute('mars', + type_filter: 'private_messages', + guardian: Guardian.new(Fabricate(:user))) - expect(results.posts.length).to eq(0) + expect(results.posts.length).to eq(0) - Fabricate(:topic, category_id: nil, archetype: 'private_message') - Fabricate(:post, topic: topic, raw: 'another secret pm from mars, testing') + Fabricate(:topic, category_id: nil, archetype: 'private_message') + Fabricate(:post, topic: topic, raw: 'another secret pm from mars, testing') - # admin can search everything with correct context - results = Search.execute('mars', - type_filter: 'private_messages', - search_context: post.user, - guardian: Guardian.new(Fabricate(:admin))) + # admin can search everything with correct context + results = Search.execute('mars', + type_filter: 'private_messages', + search_context: post.user, + guardian: Guardian.new(Fabricate(:admin))) - expect(results.posts.length).to eq(1) + expect(results.posts.length).to eq(1) + + results = Search.execute('mars in:private', + search_context: post.user, + guardian: Guardian.new(post.user)) + + expect(results.posts.length).to eq(1) + + # can search group PMs as well as non admin + # + user = Fabricate(:user) + group = Fabricate.build(:group) + group.add(user) + group.save! + + TopicAllowedGroup.create!(group_id: group.id, topic_id: topic.id) + + results = Search.execute('mars in:private', + guardian: Guardian.new(user)) + + expect(results.posts.length).to eq(1) end @@ -233,6 +253,9 @@ describe Search do results = Search.execute('posting', search_context: post1.topic) expect(results.posts.map(&:id)).to eq([post1.id, post2.id, post3.id, post4.id]) + results = Search.execute('posting l', search_context: post1.topic) + expect(results.posts.map(&:id)).to eq([post4.id, post3.id, post2.id, post1.id]) + # stop words should work results = Search.execute('this', search_context: post1.topic) expect(results.posts.length).to eq(4) @@ -397,12 +420,17 @@ describe Search do topic = Fabricate(:topic, category: category) topic_no_cat = Fabricate(:topic) + # includes subcategory in search + subcategory = Fabricate(:category, parent_category_id: category.id) + sub_topic = Fabricate(:topic, category: subcategory) + post = Fabricate(:post, topic: topic, user: topic.user ) _another_post = Fabricate(:post, topic: topic_no_cat, user: topic.user ) + sub_post = Fabricate(:post, raw: 'I am saying hello from a subcategory', topic: sub_topic, user: topic.user ) search = Search.execute('hello', search_context: category) - expect(search.posts.length).to eq(1) - expect(search.posts.first.id).to eq(post.id) + expect(search.posts.map(&:id).sort).to eq([post.id,sub_post.id].sort) + expect(search.posts.length).to eq(2) end end @@ -462,9 +490,49 @@ describe Search do it 'supports wiki' do topic = Fabricate(:topic) - Fabricate(:post, raw: 'this is a test 248', wiki: true, topic: topic) + topic_2 = Fabricate(:topic) + post = Fabricate(:post, raw: 'this is a test 248', wiki: true, topic: topic) + Fabricate(:post, raw: 'this is a test 248', wiki: false, topic: topic_2) - expect(Search.execute('test 248 in:wiki').posts.length).to eq(1) + expect(Search.execute('test 248').posts.length).to eq(2) + expect(Search.execute('test 248 in:wiki').posts.first).to eq(post) + end + + it 'supports searching for posts that the user has seen/unseen' do + topic = Fabricate(:topic) + topic_2 = Fabricate(:topic) + post = Fabricate(:post, raw: 'logan is longan', topic: topic) + post_2 = Fabricate(:post, raw: 'longan is logan', topic: topic_2) + + [post.user, topic.user].each do |user| + PostTiming.create!( + post_number: post.post_number, + topic: topic, + user: user, + msecs: 1 + ) + end + + expect(post.seen?(post.user)).to eq(true) + + expect(Search.execute('longan').posts.sort).to eq([post, post_2]) + + expect(Search.execute('longan in:seen', guardian: Guardian.new(post.user)).posts) + .to eq([post]) + + expect(Search.execute('longan in:seen').posts.sort).to eq([post, post_2]) + + expect(Search.execute('longan in:seen', guardian: Guardian.new(post_2.user)).posts) + .to eq([]) + + expect(Search.execute('longan', guardian: Guardian.new(post_2.user)).posts.sort) + .to eq([post, post_2]) + + expect(Search.execute('longan in:unseen', guardian: Guardian.new(post_2.user)).posts.sort) + .to eq([post, post_2]) + + expect(Search.execute('longan in:unseen', guardian: Guardian.new(post.user)).posts) + .to eq([post_2]) end it 'supports before and after, in:first, user:, @username' do @@ -568,7 +636,30 @@ describe Search do expect(Search.execute('sam').posts.map(&:id)).to eq([post1.id, post2.id]) expect(Search.execute('sam order:latest').posts.map(&:id)).to eq([post2.id, post1.id]) + expect(Search.execute('sam l').posts.map(&:id)).to eq([post2.id, post1.id]) + expect(Search.execute('l sam').posts.map(&:id)).to eq([post2.id, post1.id]) + end + it 'can order by topic creation' do + today = Date.today + yesterday = 1.day.ago + two_days_ago = 2.days.ago + + old_topic = Fabricate(:topic, + title: 'First Topic, testing the created_at sort', + created_at: two_days_ago) + latest_topic = Fabricate(:topic, + title: 'Second Topic, testing the created_at sort', + created_at: yesterday) + + old_relevant_topic_post = Fabricate(:post, topic: old_topic, created_at: yesterday, raw: 'Relevant Topic') + latest_irelevant_topic_post = Fabricate(:post, topic: latest_topic, created_at: today, raw: 'Not Relevant') + + # Expecting the default results + expect(Search.execute('Topic').posts.map(&:id)).to eq([old_relevant_topic_post.id, latest_irelevant_topic_post.id]) + + # Expecting the ordered by topic creation results + expect(Search.execute('Topic order:latest_topic').posts.map(&:id)).to eq([latest_irelevant_topic_post.id, old_relevant_topic_post.id]) end it 'can tokenize dots' do @@ -580,21 +671,22 @@ describe Search do # main category category = Fabricate(:category, name: 'category 24', slug: 'category-24') topic = Fabricate(:topic, created_at: 3.months.ago, category: category) - post = Fabricate(:post, raw: 'hi this is a test 123', topic: topic) + post = Fabricate(:post, raw: 'Sams first post', topic: topic) - expect(Search.execute('this is a test #category-24').posts.length).to eq(1) - expect(Search.execute("this is a test category:#{category.id}").posts.length).to eq(1) - expect(Search.execute('this is a test #category-25').posts.length).to eq(0) + expect(Search.execute('sams post #category-24').posts.length).to eq(1) + expect(Search.execute("sams post category:#{category.id}").posts.length).to eq(1) + expect(Search.execute('sams post #category-25').posts.length).to eq(0) - # sub category sub_category = Fabricate(:category, name: 'sub category', slug: 'sub-category', parent_category_id: category.id) second_topic = Fabricate(:topic, created_at: 3.months.ago, category: sub_category) - Fabricate(:post, raw: 'hi testing again 123', topic: second_topic) + Fabricate(:post, raw: 'sams second post', topic: second_topic) - expect(Search.execute('testing again #category-24:sub-category').posts.length).to eq(1) - expect(Search.execute("testing again category:#{category.id}").posts.length).to eq(2) - expect(Search.execute("testing again category:#{sub_category.id}").posts.length).to eq(1) - expect(Search.execute('testing again #sub-category').posts.length).to eq(0) + expect(Search.execute("sams post category:category-24").posts.length).to eq(2) + expect(Search.execute("sams post category:=category-24").posts.length).to eq(1) + + expect(Search.execute("sams post #category-24").posts.length).to eq(2) + expect(Search.execute("sams post #=category-24").posts.length).to eq(1) + expect(Search.execute("sams post #sub-category").posts.length).to eq(1) # tags topic.tags = [Fabricate(:tag, name: 'alpha')] diff --git a/spec/components/site_setting_extension_spec.rb b/spec/components/site_setting_extension_spec.rb index e981f4fbf7..353c04bd4c 100644 --- a/spec/components/site_setting_extension_spec.rb +++ b/spec/components/site_setting_extension_spec.rb @@ -58,7 +58,7 @@ describe SiteSettingExtension do settings.hello = 100 expect(settings.hello).to eq(100) - settings.provider.save(:hello, 99, SiteSetting.types[:fixnum] ) + settings.provider.save(:hello, 99, SiteSetting.types[:integer] ) settings.refresh! expect(settings.hello).to eq(99) @@ -386,16 +386,6 @@ describe SiteSettingExtension do end end - describe "set for an invalid fixnum value" do - it "raises an error" do - settings.setting(:test_setting, 80) - settings.refresh! - expect { - settings.set("test_setting", 9999999999999999999) - }.to raise_error(ArgumentError) - end - end - describe "filter domain name" do before do settings.setting(:white_listed_spam_host_domains, "www.example.com") diff --git a/spec/components/stats_socket_spec.rb b/spec/components/stats_socket_spec.rb new file mode 100644 index 0000000000..cac144439e --- /dev/null +++ b/spec/components/stats_socket_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' +require_dependency 'stats_socket' + +describe StatsSocket do + let :socket_path do + "#{Dir.tmpdir}/#{SecureRandom.hex}" + end + + let :stats_socket do + StatsSocket.new(socket_path) + end + + before do + stats_socket.start + end + + after do + stats_socket.stop + end + + it "can respond to various stats commands" do + line = nil + + # ensure this works more than once :) + 2.times do + socket = UNIXSocket.new(socket_path) + socket.send "gc_stat\n", 0 + line = socket.readline + socket.close + end + + socket = UNIXSocket.new(socket_path) + socket.send "gc_st", 0 + socket.flush + sleep 0.001 + socket.send "at\n", 0 + line = socket.readline + socket.close + + parsed = JSON.parse(line) + + expect(parsed.keys.sort).to eq(GC.stat.keys.map(&:to_s).sort) + end + +end diff --git a/spec/components/stylesheet/compiler_spec.rb b/spec/components/stylesheet/compiler_spec.rb new file mode 100644 index 0000000000..c19a4169bd --- /dev/null +++ b/spec/components/stylesheet/compiler_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' +require 'stylesheet/compiler' + +describe Stylesheet::Compiler do + it "can compile desktop mobile and desktop css" do + css,_map = Stylesheet::Compiler.compile_asset("desktop") + expect(css.length).to be > 1000 + + css,_map = Stylesheet::Compiler.compile_asset("mobile") + expect(css.length).to be > 1000 + end + + it "supports asset-url" do + css,_map = Stylesheet::Compiler.compile(".body{background-image: asset-url('foo.png');}","test.scss") + + expect(css).to include("url('/foo.png')") + expect(css).not_to include('asset-url') + end + + it "supports image-url" do + css,_map = Stylesheet::Compiler.compile(".body{background-image: image-url('foo.png');}","test.scss") + + expect(css).to include("url('/images/foo.png')") + expect(css).not_to include('image-url') + end +end + + diff --git a/spec/components/stylesheet/importer_spec.rb b/spec/components/stylesheet/importer_spec.rb new file mode 100644 index 0000000000..8aafb5c927 --- /dev/null +++ b/spec/components/stylesheet/importer_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' +require 'stylesheet/importer' + +describe Stylesheet::Importer do + + def compile_css(name) + Stylesheet::Compiler.compile_asset(name)[0] + end + + it "applies CDN to background category images" do + expect(compile_css("category_backgrounds")).to_not include("background-image") + + background = Fabricate(:upload) + category = Fabricate(:category, uploaded_background: background) + + expect(compile_css("category_backgrounds")).to include("body.category-#{category.full_slug}{background-image:url(#{background.url})}") + + GlobalSetting.expects(:cdn_url).returns("//awesome.cdn") + expect(compile_css("category_backgrounds")).to include("body.category-#{category.full_slug}{background-image:url(//awesome.cdn#{background.url})}") + end + + it "applies S3 CDN to background category images" do + SiteSetting.s3_cdn_url = "https://s3.cdn" + + background = Fabricate(:upload_s3) + category = Fabricate(:category, uploaded_background: background) + + expect(compile_css("category_backgrounds")).to include("body.category-#{category.full_slug}{background-image:url(https://s3.cdn/uploads") + end + +end diff --git a/spec/components/stylesheet/manager_spec.rb b/spec/components/stylesheet/manager_spec.rb new file mode 100644 index 0000000000..5582eeee61 --- /dev/null +++ b/spec/components/stylesheet/manager_spec.rb @@ -0,0 +1,70 @@ +require 'rails_helper' +require 'stylesheet/compiler' + +describe Stylesheet::Manager do + + it 'does not crash for missing theme' do + Theme.clear_default! + link = Stylesheet::Manager.stylesheet_link_tag(:embedded_theme) + expect(link).to eq("") + + theme = Theme.create(name: "embedded", user_id: -1) + SiteSetting.default_theme_key = theme.key + + link = Stylesheet::Manager.stylesheet_link_tag(:embedded_theme) + expect(link).not_to eq("") + end + + it 'can correctly compile theme css' do + theme = Theme.new( + name: 'parent', + user_id: -1 + ) + + theme.set_field(target: :common, name: "scss", value: ".common{.scss{color: red;}}") + theme.set_field(target: :desktop, name: "scss", value: ".desktop{.scss{color: red;}}") + theme.set_field(target: :mobile, name: "scss", value: ".mobile{.scss{color: red;}}") + theme.set_field(target: :common, name: "embedded_scss", value: ".embedded{.scss{color: red;}}") + + theme.save! + + + child_theme = Theme.new( + name: 'parent', + user_id: -1, + ) + + child_theme.set_field(target: :common, name: "scss", value: ".child_common{.scss{color: red;}}") + child_theme.set_field(target: :desktop, name: "scss", value: ".child_desktop{.scss{color: red;}}") + child_theme.set_field(target: :mobile, name: "scss", value: ".child_mobile{.scss{color: red;}}") + child_theme.set_field(target: :common, name: "embedded_scss", value: ".child_embedded{.scss{color: red;}}") + child_theme.save! + + theme.add_child_theme!(child_theme) + + old_link = Stylesheet::Manager.stylesheet_link_tag(:desktop_theme, 'all', theme.key) + + manager = Stylesheet::Manager.new(:desktop_theme, theme.key) + manager.compile(force: true) + + css = File.read(manager.stylesheet_fullpath) + _source_map = File.read(manager.source_map_fullpath) + + expect(css).to match(/child_common/) + expect(css).to match(/child_desktop/) + expect(css).to match(/\.common/) + expect(css).to match(/\.desktop/) + + + child_theme.set_field(target: :desktop, name: :scss, value: ".nothing{color: green;}") + child_theme.save! + + new_link = Stylesheet::Manager.stylesheet_link_tag(:desktop_theme, 'all', theme.key) + + expect(new_link).not_to eq(old_link) + + # our theme better have a name with the theme_id as part of it + expect(new_link).to include("/stylesheets/desktop_theme_#{theme.id}_") + end +end + diff --git a/spec/components/table_migration_helper_spec.rb b/spec/components/table_migration_helper_spec.rb new file mode 100644 index 0000000000..12aa17614b --- /dev/null +++ b/spec/components/table_migration_helper_spec.rb @@ -0,0 +1,66 @@ +require 'rails_helper' +require 'table_migration_helper' + +describe TableMigrationHelper do + + def table_exists?(table_name) + sql = <<-SQL + SELECT 1 + FROM INFORMATION_SCHEMA.TABLES + WHERE table_schema = 'public' AND + table_name = '#{table_name}' + SQL + + ActiveRecord::Base.exec_sql(sql).to_a.length > 0 + end + + describe '#delayed_drop' do + it "can drop a table after correct delay and when new table exists" do + ActiveRecord::Base.exec_sql "CREATE TABLE table_with_old_name (topic_id INTEGER)" + + name = ActiveRecord::Base + .exec_sql("SELECT name FROM schema_migration_details LIMIT 1") + .getvalue(0,0) + + Topic.exec_sql("UPDATE schema_migration_details SET created_at = :created_at WHERE name = :name", + name: name, created_at: 15.minutes.ago) + + dropped_proc_called = false + + described_class.delayed_drop( + old_name: 'table_with_old_name', + new_name: 'table_with_new_name', + after_migration: name, + delay: 20.minutes, + on_drop: ->(){dropped_proc_called = true} + ) + + expect(table_exists?('table_with_old_name')).to eq(true) + expect(dropped_proc_called).to eq(false) + + described_class.delayed_drop( + old_name: 'table_with_old_name', + new_name: 'table_with_new_name', + after_migration: name, + delay: 10.minutes, + on_drop: ->(){dropped_proc_called = true} + ) + + expect(table_exists?('table_with_old_name')).to eq(true) + expect(dropped_proc_called).to eq(false) + + ActiveRecord::Base.exec_sql "CREATE TABLE table_with_new_name (topic_id INTEGER)" + + described_class.delayed_drop( + old_name: 'table_with_old_name', + new_name: 'table_with_new_name', + after_migration: name, + delay: 10.minutes, + on_drop: ->(){dropped_proc_called = true} + ) + + expect(table_exists?('table_with_old_name')).to eq(false) + expect(dropped_proc_called).to eq(true) + end + end +end diff --git a/spec/components/topic_creator_spec.rb b/spec/components/topic_creator_spec.rb index dcca1dd101..1e97a81b79 100644 --- a/spec/components/topic_creator_spec.rb +++ b/spec/components/topic_creator_spec.rb @@ -39,7 +39,7 @@ describe TopicCreator do it "ignores auto_close_time without raising an error" do topic = TopicCreator.create(user, Guardian.new(user), valid_attrs.merge(auto_close_time: '24')) expect(topic).to be_valid - expect(topic.auto_close_at).to eq(nil) + expect(topic.public_topic_timer).to eq(nil) end it "category name is case insensitive" do diff --git a/spec/components/topic_query_spec.rb b/spec/components/topic_query_spec.rb index 619a080aae..4d542032e4 100644 --- a/spec/components/topic_query_spec.rb +++ b/spec/components/topic_query_spec.rb @@ -40,6 +40,21 @@ describe TopicQuery do end + context "custom filters" do + it "allows custom filters to be applied" do + topic1 = Fabricate(:topic) + _topic2 = Fabricate(:topic) + + TopicQuery.add_custom_filter(:only_topic_id) do |results, topic_query| + results = results.where('topics.id = ?', topic_query.options[:only_topic_id]) + end + + expect(TopicQuery.new(nil, {only_topic_id: topic1.id}).list_latest.topics.map(&:id)).to eq([topic1.id]) + + TopicQuery.remove_custom_filter(:only_topic_id) + end + end + context "list_topics_by" do it "allows users to view their own invisible topics" do @@ -443,6 +458,8 @@ describe TopicQuery do context 'list_unread' do it 'lists topics correctly' do + new_topic = Fabricate(:post, user: creator).topic + expect(topic_query.list_unread.topics).to eq([]) expect(topic_query.list_read.topics).to match_array([fully_read, partially_read]) end diff --git a/spec/components/topic_view_spec.rb b/spec/components/topic_view_spec.rb index b31dd1264d..c041d4e4fe 100644 --- a/spec/components/topic_view_spec.rb +++ b/spec/components/topic_view_spec.rb @@ -428,5 +428,73 @@ describe TopicView do end end end + + context "page_title" do + let(:tag1) { Fabricate(:tag) } + let(:tag2) { Fabricate(:tag, topic_count: 2) } + + subject { TopicView.new(topic.id, coding_horror).page_title } + + context "uncategorized topic" do + context "topic_page_title_includes_category is false" do + before { SiteSetting.topic_page_title_includes_category = false } + it { should eq(topic.title) } + end + + context "topic_page_title_includes_category is true" do + before { SiteSetting.topic_page_title_includes_category = true } + it { should eq(topic.title) } + + context "tagged topic" do + before { topic.tags << [tag1, tag2] } + + context "tagging enabled" do + before { SiteSetting.tagging_enabled = true } + + it { should start_with(topic.title) } + it { should_not include(tag1.name) } + it { should end_with(tag2.name) } # tag2 has higher topic count + end + + context "tagging disabled" do + before { SiteSetting.tagging_enabled = false } + + it { should start_with(topic.title) } + it { should_not include(tag1.name) } + it { should_not include(tag2.name) } + end + end + end + end + + context "categorized topic" do + let(:category) { Fabricate(:category) } + + before { topic.update_attributes(category_id: category.id) } + + context "topic_page_title_includes_category is false" do + before { SiteSetting.topic_page_title_includes_category = false } + it { should eq(topic.title) } + end + + context "topic_page_title_includes_category is true" do + before { SiteSetting.topic_page_title_includes_category = true } + it { should start_with(topic.title) } + it { should end_with(category.name) } + + context "tagged topic" do + before do + SiteSetting.tagging_enabled = true + topic.tags << [tag1, tag2] + end + + it { should start_with(topic.title) } + it { should end_with(category.name) } + it { should_not include(tag1.name) } + it { should_not include(tag2.name) } + end + end + end + end end diff --git a/spec/components/topics_bulk_action_spec.rb b/spec/components/topics_bulk_action_spec.rb index 6aa99fcd10..91168ccc40 100644 --- a/spec/components/topics_bulk_action_spec.rb +++ b/spec/components/topics_bulk_action_spec.rb @@ -198,7 +198,7 @@ describe TopicsBulkAction do topic_ids = tba.perform! expect(topic_ids).to eq([topic.id]) topic.reload - expect(topic.tags.map(&:name).sort).to eq(['newtag', tag1.name].sort) + expect(topic.tags.map(&:name)).to contain_exactly('newtag', tag1.name) end it "can change the tags but not create new ones" do @@ -207,7 +207,7 @@ describe TopicsBulkAction do topic_ids = tba.perform! expect(topic_ids).to eq([topic.id]) topic.reload - expect(topic.tags.map(&:name)).to eq([tag1.name]) + expect(topic.tags.map(&:name)).to contain_exactly(tag1.name) end it "can remove all tags" do @@ -228,7 +228,65 @@ describe TopicsBulkAction do topic_ids = tba.perform! expect(topic_ids).to eq([]) topic.reload - expect(topic.tags.map(&:name)).to eq([tag1.name, tag2.name]) + expect(topic.tags.map(&:name)).to contain_exactly(tag1.name, tag2.name) + end + end + end + + describe "append tags" do + let(:topic) { Fabricate(:topic) } + let(:tag1) { Fabricate(:tag) } + let(:tag2) { Fabricate(:tag) } + let(:tag3) { Fabricate(:tag) } + + before do + SiteSetting.tagging_enabled = true + SiteSetting.min_trust_level_to_tag_topics = 0 + topic.tags = [tag1, tag2] + end + + it "can append new or existing tags" do + SiteSetting.min_trust_to_create_tag = 0 + tba = TopicsBulkAction.new(topic.user, [topic.id], type: 'append_tags', tags: [tag1.name, tag3.name, 'newtag']) + topic_ids = tba.perform! + expect(topic_ids).to eq([topic.id]) + topic.reload + expect(topic.tags.map(&:name)).to contain_exactly(tag1.name, tag2.name, tag3.name, 'newtag') + end + + it "can append empty tags" do + tba = TopicsBulkAction.new(topic.user, [topic.id], type: 'append_tags', tags: []) + topic_ids = tba.perform! + expect(topic_ids).to eq([topic.id]) + topic.reload + expect(topic.tags.map(&:name)).to contain_exactly(tag1.name, tag2.name) + end + + context "when the user can't create new topics" do + before do + SiteSetting.min_trust_to_create_tag = 4 + end + + it "can append existing tags but doesn't append new tags" do + tba = TopicsBulkAction.new(topic.user, [topic.id], type: 'append_tags', tags: [tag3.name, 'newtag']) + topic_ids = tba.perform! + expect(topic_ids).to eq([topic.id]) + topic.reload + expect(topic.tags.map(&:name)).to contain_exactly(tag1.name, tag2.name, tag3.name) + end + end + + context "when user can't edit topic" do + before do + Guardian.any_instance.expects(:can_edit?).returns(false) + end + + it "doesn't change the tags" do + tba = TopicsBulkAction.new(topic.user, [topic.id], type: 'append_tags', tags: ['newtag', tag3.name]) + topic_ids = tba.perform! + expect(topic_ids).to eq([]) + topic.reload + expect(topic.tags.map(&:name)).to contain_exactly(tag1.name, tag2.name) end end end diff --git a/spec/components/validators/integer_setting_validator_spec.rb b/spec/components/validators/integer_setting_validator_spec.rb index d681f2bc63..db0b1cd281 100644 --- a/spec/components/validators/integer_setting_validator_spec.rb +++ b/spec/components/validators/integer_setting_validator_spec.rb @@ -21,9 +21,21 @@ describe IntegerSettingValidator do it "returns true if value is a valid integer" do expect(validator.valid_value?(1)).to eq(true) - expect(validator.valid_value?(-1)).to eq(true) expect(validator.valid_value?('1')).to eq(true) - expect(validator.valid_value?('-1')).to eq(true) + end + + it "defaults min to 0" do + expect(validator.valid_value?(-1)).to eq(false) + expect(validator.valid_value?('-1')).to eq(false) + expect(validator.valid_value?(0)).to eq(true) + expect(validator.valid_value?('0')).to eq(true) + end + + it "defaults max to 20000" do + expect(validator.valid_value?(20001)).to eq(false) + expect(validator.valid_value?('20001')).to eq(false) + expect(validator.valid_value?(20000)).to eq(true) + expect(validator.valid_value?('20000')).to eq(true) end end @@ -85,5 +97,14 @@ describe IntegerSettingValidator do expect(validator.valid_value?(-2)).to eq(false) end end + + context "when setting is hidden" do + subject(:validator) { described_class.new(hidden: true) } + + it "does not impose default validations" do + expect(validator.valid_value?(-1)).to eq(true) + expect(validator.valid_value?(20001)).to eq(true) + end + end end end diff --git a/spec/components/validators/password_validator_spec.rb b/spec/components/validators/password_validator_spec.rb index 664fc7b114..92f6095c3f 100644 --- a/spec/components/validators/password_validator_spec.rb +++ b/spec/components/validators/password_validator_spec.rb @@ -3,6 +3,10 @@ require_dependency "common_passwords/common_passwords" describe PasswordValidator do + def password_error_message(key) + I18n.t("activerecord.errors.models.user.attributes.password.#{key.to_s}") + end + let(:validator) { described_class.new({attributes: :password}) } subject(:validate) { validator.validate_each(record,:password,@password) } @@ -72,7 +76,7 @@ describe PasswordValidator do SiteSetting.stubs(:block_common_passwords).returns(true) @password = "password" validate - expect(record.errors[:password]).to be_present + expect(record.errors[:password]).to include(password_error_message(:common)) end it "doesn't add an error when block_common_passwords is disabled" do @@ -83,18 +87,43 @@ describe PasswordValidator do end end + context "password_unique_characters is 5" do + before do + SiteSetting.password_unique_characters = 5 + end + + it "adds an error when there are too few unique characters" do + SiteSetting.password_unique_characters = 6 + @password = "aaaaaa5432" + validate + expect(record.errors[:password]).to include(password_error_message(:unique_characters)) + end + + it "doesn't add an error when there are enough unique characters" do + @password = "aaaaa12345" + validate + expect(record.errors[:password]).not_to be_present + end + + it "counts capital letters as different" do + @password = "aaaAaa1234" + validate + expect(record.errors[:password]).not_to be_present + end + end + it "adds an error when password is the same as the username" do @password = "porkchops1234" record.username = @password validate - expect(record.errors[:password]).to be_present + expect(record.errors[:password]).to include(password_error_message(:same_as_username)) end it "adds an error when password is the same as the email" do @password = "pork@chops.com" record.email = @password validate - expect(record.errors[:password]).to be_present + expect(record.errors[:password]).to include(password_error_message(:same_as_email)) end it "adds an error when new password is same as current password" do @@ -103,7 +132,7 @@ describe PasswordValidator do record.reload record.password = @password validate - expect(record.errors[:password]).to be_present + expect(record.errors[:password]).to include(password_error_message(:same_as_current)) end end diff --git a/spec/components/validators/post_validator_spec.rb b/spec/components/validators/post_validator_spec.rb index 093cff9ff5..25893f34e5 100644 --- a/spec/components/validators/post_validator_spec.rb +++ b/spec/components/validators/post_validator_spec.rb @@ -5,13 +5,41 @@ describe Validators::PostValidator do let(:post) { build(:post) } let(:validator) { Validators::PostValidator.new({}) } - context "when empty raw can bypass post body validation" do - let(:validator) { Validators::PostValidator.new(skip_post_body: true) } - - it "should be allowed for empty raw based on site setting" do + context "#post_body_validator" do + it 'should not allow a post with an empty raw' do post.raw = "" validator.post_body_validator(post) - expect(post.errors).to be_empty + expect(post.errors).to_not be_empty + end + + context "when empty raw can bypass validation" do + let(:validator) { Validators::PostValidator.new(skip_post_body: true) } + + it "should be allowed for empty raw based on site setting" do + post.raw = "" + validator.post_body_validator(post) + expect(post.errors).to be_empty + end + end + + describe "when post's topic is a PM between a human and a non human user" do + let(:robot) { Fabricate(:user, id: -3) } + let(:user) { Fabricate(:user) } + + let(:topic) do + Fabricate(:private_message_topic, topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: robot), + Fabricate.build(:topic_allowed_user, user: user) + ]) + end + + it 'should allow a post with an empty raw' do + post = Fabricate.build(:post, topic: topic) + post.raw = "" + validator.post_body_validator(post) + + expect(post.errors).to be_empty + end end end @@ -63,7 +91,7 @@ describe Validators::PostValidator do expect(post.errors.count).to be > 0 end - it "should be invalid when elder user exceeds max mentions limit" do + it "should be invalid when leader user exceeds max mentions limit" do post.acting_user = build(:trust_level_4) post.expects(:raw_mentions).returns(['jake', 'finn', 'jake_old', 'jake_new']) validator.max_mention_validator(post) @@ -85,7 +113,7 @@ describe Validators::PostValidator do expect(post.errors.count).to be(0) end - it "should be valid when elder user does not exceed max mentions limit" do + it "should be valid when leader user does not exceed max mentions limit" do post.acting_user = build(:trust_level_4) post.expects(:raw_mentions).returns(['jake', 'finn', 'jake_old']) validator.max_mention_validator(post) diff --git a/spec/components/step_updater_spec.rb b/spec/components/wizard/step_updater_spec.rb similarity index 86% rename from spec/components/step_updater_spec.rb rename to spec/components/wizard/step_updater_spec.rb index eed8e7f7f5..5307b3779d 100644 --- a/spec/components/step_updater_spec.rb +++ b/spec/components/wizard/step_updater_spec.rb @@ -151,27 +151,62 @@ describe Wizard::StepUpdater do let!(:color_scheme) { Fabricate(:color_scheme, name: 'existing', via_wizard: true) } it "updates the scheme" do - updater = wizard.create_updater('colors', theme_id: 'dark') + updater = wizard.create_updater('colors', base_scheme_id: 'dark') updater.update expect(updater.success?).to eq(true) expect(wizard.completed_steps?('colors')).to eq(true) + theme = Theme.find_by(key: SiteSetting.default_theme_key) + expect(theme.color_scheme.base_scheme_id).to eq('dark') + end + end - color_scheme.reload - expect(color_scheme).to be_enabled + context "without an existing theme" do + before do + Theme.delete_all + end + + context 'dark theme' do + it "creates the theme" do + updater = wizard.create_updater('colors', base_scheme_id: 'dark', allow_dark_light_selection: true) + + expect { updater.update }.to change { Theme.count }.by(1) + + theme = Theme.last + + expect(theme.user_id).to eq(wizard.user.id) + expect(theme.color_scheme.base_scheme_id).to eq('dark') + end + end + + context 'light theme' do + it "creates the theme" do + updater = wizard.create_updater('colors', allow_dark_light_selection: true) + + expect { updater.update }.to change { Theme.count }.by(1) + + theme = Theme.last + + expect(theme.user_id).to eq(wizard.user.id) + expect(theme.color_scheme).to eq(nil) + end end end context "without an existing scheme" do it "creates the scheme" do - updater = wizard.create_updater('colors', theme_id: 'dark') + updater = wizard.create_updater('colors', base_scheme_id: 'dark', allow_dark_light_selection: true) updater.update expect(updater.success?).to eq(true) expect(wizard.completed_steps?('colors')).to eq(true) color_scheme = ColorScheme.where(via_wizard: true).first expect(color_scheme).to be_present - expect(color_scheme).to be_enabled expect(color_scheme.colors).to be_present + + theme = Theme.find_by(key: SiteSetting.default_theme_key) + expect(theme.color_scheme_id).to eq(color_scheme.id) + + expect(Theme.where(user_selectable: true).count).to eq(2) end end end diff --git a/spec/components/wizard_builder_spec.rb b/spec/components/wizard/wizard_builder_spec.rb similarity index 100% rename from spec/components/wizard_builder_spec.rb rename to spec/components/wizard/wizard_builder_spec.rb diff --git a/spec/components/wizard_spec.rb b/spec/components/wizard/wizard_spec.rb similarity index 86% rename from spec/components/wizard_spec.rb rename to spec/components/wizard/wizard_spec.rb index 951b72e884..f9266f83e5 100644 --- a/spec/components/wizard_spec.rb +++ b/spec/components/wizard/wizard_spec.rb @@ -120,9 +120,27 @@ describe Wizard do expect(build_simple(admin).requires_completion?).to eq(false) end + it "its false when the wizard is bypassed" do + SiteSetting.bypass_wizard_check = true + admin = Fabricate(:admin) + expect(build_simple(admin).requires_completion?).to eq(false) + end + + it "its automatically bypasses after you reach topic limit" do + Fabricate(:topic) + admin = Fabricate(:admin) + wizard = build_simple(admin) + + wizard.max_topics_to_require_completion = Topic.count-1 + + expect(wizard.requires_completion?).to eq(false) + expect(SiteSetting.bypass_wizard_check).to eq(true) + end + it "it's true for the first admin who logs in" do admin = Fabricate(:admin) - second_admin = Fabricate(:admin, auth_token_updated_at: Time.now) + second_admin = Fabricate(:admin) + UserAuthToken.generate!(user_id: second_admin.id) expect(build_simple(admin).requires_completion?).to eq(false) expect(build_simple(second_admin).requires_completion?).to eq(true) diff --git a/spec/components/wizard_step_spec.rb b/spec/components/wizard/wizard_step_spec.rb similarity index 100% rename from spec/components/wizard_step_spec.rb rename to spec/components/wizard/wizard_step_spec.rb diff --git a/spec/controllers/about_controller_spec.rb b/spec/controllers/about_controller_spec.rb new file mode 100644 index 0000000000..4589fda351 --- /dev/null +++ b/spec/controllers/about_controller_spec.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +describe AboutController do + + context '.index' do + + it "should display the about page for anonymous user when login_required is false" do + SiteSetting.login_required = false + xhr :get, :index + expect(response).to be_success + end + + it 'should redirect to login page for anonymous user when login_required is true' do + SiteSetting.login_required = true + xhr :get, :index + expect(response).to redirect_to '/login' + end + + it "should display the about page for logged in user when login_required is true" do + SiteSetting.login_required = true + log_in + xhr :get, :index + expect(response).to be_success + end + end +end diff --git a/spec/controllers/admin/backups_controller_spec.rb b/spec/controllers/admin/backups_controller_spec.rb index 06fe4a4be6..5da9c9023a 100644 --- a/spec/controllers/admin/backups_controller_spec.rb +++ b/spec/controllers/admin/backups_controller_spec.rb @@ -78,25 +78,69 @@ describe Admin::BackupsController do describe ".show" do it "uses send_file to transmit the backup" do - path = File.join(Backup.base_directory, backup_filename) - File.open(path, "w") { |f| f.write("hello") } + begin + token = EmailBackupToken.set(@admin.id) + path = File.join(Backup.base_directory, backup_filename) + File.open(path, "w") { |f| f.write("hello") } - Backup.create_from_filename(backup_filename) + Backup.create_from_filename(backup_filename) - StaffActionLogger.any_instance.expects(:log_backup_download).once + StaffActionLogger.any_instance.expects(:log_backup_download).once - get :show, id: backup_filename + get :show, id: backup_filename, token: token - File.delete(path) rescue nil - expect(response.headers['Content-Length']).to eq("5") - expect(response.headers['Content-Disposition']).to match(/attachment; filename/) + expect(response.headers['Content-Length']).to eq("5") + expect(response.headers['Content-Disposition']).to match(/attachment; filename/) + ensure + File.delete(path) + EmailBackupToken.del(@admin.id) + end + end + + it "returns 422 when token is bad" do + begin + path = File.join(Backup.base_directory, backup_filename) + File.open(path, "w") { |f| f.write("hello") } + + Backup.create_from_filename(backup_filename) + + get :show, id: backup_filename, token: "bad_value" + + expect(response.status).to eq(422) + ensure + File.delete(path) + end end it "returns 404 when the backup does not exist" do + token = EmailBackupToken.set(@admin.id) Backup.expects(:[]).returns(nil) - get :show, id: backup_filename + get :show, id: backup_filename, token: token + + EmailBackupToken.del(@admin.id) + + expect(response).to be_not_found + end + + end + + describe ".email" do + + let(:b) { Backup.new(backup_filename) } + + it "enqueues email job" do + Backup.expects(:[]).with(backup_filename).returns(b) + Jobs.expects(:enqueue).with(:download_backup_email, has_entries(to_address: @admin.email)) + + xhr :put, :email, id: backup_filename + + expect(response).to be_success + end + + it "returns 404 when the backup does not exist" do + xhr :put, :email, id: backup_filename expect(response).to be_not_found end @@ -203,23 +247,25 @@ describe Admin::BackupsController do describe "when filename is valid" do it "should upload the file successfully" do - described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true) + begin + described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true) - filename = 'test_Site-0123456789.tar.gz' + filename = 'test_Site-0123456789.tar.gz' - xhr :post, :upload_backup_chunk, - resumableFilename: filename, - resumableTotalSize: 1, - resumableIdentifier: 'test', - resumableChunkNumber: '1', - resumableChunkSize: '1', - resumableCurrentChunkSize: '1', - file: fixture_file_upload(Tempfile.new) + xhr :post, :upload_backup_chunk, + resumableFilename: filename, + resumableTotalSize: 1, + resumableIdentifier: 'test', + resumableChunkNumber: '1', + resumableChunkSize: '1', + resumableCurrentChunkSize: '1', + file: fixture_file_upload(Tempfile.new) - expect(response.status).to eq(200) - expect(response.body).to eq("") - - File.delete(File.join(Backup.base_directory, filename)) rescue nil + expect(response.status).to eq(200) + expect(response.body).to eq("") + ensure + File.delete(File.join(Backup.base_directory, filename)) + end end end end diff --git a/spec/controllers/admin/badges_controller_spec.rb b/spec/controllers/admin/badges_controller_spec.rb index f9ed0956b4..95bb08377a 100644 --- a/spec/controllers/admin/badges_controller_spec.rb +++ b/spec/controllers/admin/badges_controller_spec.rb @@ -26,6 +26,19 @@ describe Admin::BadgesController do end end + describe '.create' do + render_views + + it 'can create badges correctly' do + SiteSetting.enable_badge_sql = true + result = xhr :post, :create, name: 'test', query: 'select 1 as user_id, null as granted_at', badge_type_id: 1 + json = JSON.parse(result.body) + expect(result.status).to eq(200) + expect(json["badge"]["name"]).to eq('test') + expect(json["badge"]["query"]).to eq('select 1 as user_id, null as granted_at') + end + end + context '.save_badge_groupings' do it 'can save badge groupings' do @@ -76,6 +89,19 @@ describe Admin::BadgesController do context '.update' do + it 'does not update the name of system badges' do + editor_badge = Badge.find(Badge::Editor) + editor_badge_name = editor_badge.name + + xhr :put, :update, + id: editor_badge.id, + name: "123456" + + expect(response).to be_success + editor_badge.reload + expect(editor_badge.name).to eq(editor_badge_name) + end + it 'does not allow query updates if badge_sql is disabled' do badge.query = "select 123" badge.save diff --git a/spec/controllers/admin/color_schemes_controller_spec.rb b/spec/controllers/admin/color_schemes_controller_spec.rb index 9059330b44..c1eb2c9abb 100644 --- a/spec/controllers/admin/color_schemes_controller_spec.rb +++ b/spec/controllers/admin/color_schemes_controller_spec.rb @@ -9,7 +9,6 @@ describe Admin::ColorSchemesController do let!(:user) { log_in(:admin) } let(:valid_params) { { color_scheme: { name: 'Such Design', - enabled: true, colors: [ {name: 'primary', hex: 'FFBB00'}, {name: 'secondary', hex: '888888'} diff --git a/spec/controllers/admin/email_controller_spec.rb b/spec/controllers/admin/email_controller_spec.rb index 86d2b42e9a..b14b6ae979 100644 --- a/spec/controllers/admin/email_controller_spec.rb +++ b/spec/controllers/admin/email_controller_spec.rb @@ -71,4 +71,16 @@ describe Admin::EmailController do end end + context '#handle_mail' do + before do + log_in_user(Fabricate(:admin)) + SiteSetting.queue_jobs = true + end + + it 'should enqueue the right job' do + expect { xhr :post, :handle_mail, email: email('cc') } + .to change { Jobs::ProcessEmail.jobs.count }.by(1) + end + end + end diff --git a/spec/controllers/admin/emojis_controller_spec.rb b/spec/controllers/admin/emojis_controller_spec.rb index fa776bb892..5b769ab90a 100644 --- a/spec/controllers/admin/emojis_controller_spec.rb +++ b/spec/controllers/admin/emojis_controller_spec.rb @@ -16,10 +16,6 @@ describe Admin::EmojisController do end end - it "is a subclass of AdminController" do - expect(Admin::EmojisController < Admin::AdminController).to eq(true) - end - context "when logged in" do let!(:user) { log_in(:admin) } @@ -33,56 +29,6 @@ describe Admin::EmojisController do expect(json[0]["url"]).to eq(custom_emoji.url) end end - - context ".create" do - - before { Emoji.expects(:custom).returns([custom_emoji]) } - - context "name already exist" do - it "throws an error" do - message = MessageBus.track_publish do - xhr :post, :create, { name: "hello", file: "" } - end.first - - expect(response).to be_success - expect(message.data["errors"]).to be - end - end - - context "error while saving emoji" do - it "throws an error" do - Emoji.expects(:create_for).returns(nil) - message = MessageBus.track_publish do - xhr :post, :create, { name: "garbage", file: "" } - end.first - - expect(response).to be_success - expect(message.data["errors"]).to be - end - end - - it "works" do - Emoji.expects(:create_for).returns(custom_emoji2) - - message = MessageBus.track_publish do - xhr :post, :create, { name: "hello2", file: ""} - end.first - - expect(response).to be_success - - expect(message.data["name"]).to eq(custom_emoji2.name) - expect(message.data["url"]).to eq(custom_emoji2.url) - end - end - - context ".destroy" do - it "deletes the custom emoji" do - custom_emoji.expects(:remove) - Emoji.expects(:custom).returns([custom_emoji]) - xhr :delete, :destroy, id: "hello" - expect(response).to be_success - end - end end end diff --git a/spec/controllers/admin/groups_controller_spec.rb b/spec/controllers/admin/groups_controller_spec.rb index 732d07c3ad..bb7ebbf71a 100644 --- a/spec/controllers/admin/groups_controller_spec.rb +++ b/spec/controllers/admin/groups_controller_spec.rb @@ -42,7 +42,8 @@ describe Admin::GroupsController do "bio_cooked"=>nil, "public"=>false, "allow_membership_requests"=>false, - "full_name"=>group.full_name + "full_name"=>group.full_name, + "default_notification_level"=>3 }]) end @@ -51,9 +52,9 @@ describe Admin::GroupsController do context ".bulk" do it "can assign users to a group by email or username" do - group = Fabricate(:group, name: "test", primary_group: true, title: 'WAT') - user = Fabricate(:user) - user2 = Fabricate(:user) + group = Fabricate(:group, name: "test", primary_group: true, title: 'WAT', grant_trust_level: 3) + user = Fabricate(:user, trust_level: 2) + user2 = Fabricate(:user, trust_level: 4) xhr :put, :bulk_perform, group_id: group.id, users: [user.username.upcase, user2.email, 'doesnt_exist'] @@ -62,10 +63,17 @@ describe Admin::GroupsController do user.reload expect(user.primary_group).to eq(group) expect(user.title).to eq("WAT") + expect(user.trust_level).to eq(3) user2.reload expect(user2.primary_group).to eq(group) + expect(user2.title).to eq("WAT") + expect(user2.trust_level).to eq(4) + # verify JSON response + json = ::JSON.parse(response.body) + expect(json['message']).to eq("2 users have been added to the group.") + expect(json['users_not_added'][0]).to eq("doesnt_exist") end end diff --git a/spec/controllers/admin/reports_controller_spec.rb b/spec/controllers/admin/reports_controller_spec.rb index 92da083f32..9d34ebbe1c 100644 --- a/spec/controllers/admin/reports_controller_spec.rb +++ b/spec/controllers/admin/reports_controller_spec.rb @@ -1,7 +1,6 @@ require 'rails_helper' describe Admin::ReportsController do - it "is a subclass of AdminController" do expect(Admin::ReportsController < Admin::AdminController).to eq(true) end @@ -58,6 +57,46 @@ describe Admin::ReportsController do end + describe 'when report is scoped to a category' do + let(:category) { Fabricate(:category) } + let(:topic) { Fabricate(:topic, category: category) } + let(:other_topic) { Fabricate(:topic) } + + it 'should render the report as JSON' do + topic + other_topic + + xhr :get, :show, type: 'topics', category_id: category.id + + expect(response).to be_success + + report = JSON.parse(response.body)["report"] + + expect(report["type"]).to eq('topics') + expect(report["data"].count).to eq(1) + end + end + + describe 'when report is scoped to a group' do + let(:user) { Fabricate(:user) } + let(:other_user) { Fabricate(:user) } + let(:group) { Fabricate(:group) } + + it 'should render the report as JSON' do + other_user + group.add(user) + + xhr :get, :show, type: 'signups', group_id: group.id + + expect(response).to be_success + + report = JSON.parse(response.body)["report"] + + expect(report["type"]).to eq('signups') + expect(report["data"].count).to eq(1) + end + end + end end diff --git a/spec/controllers/admin/site_customizations_controller_spec.rb b/spec/controllers/admin/site_customizations_controller_spec.rb deleted file mode 100644 index 2695f17c7e..0000000000 --- a/spec/controllers/admin/site_customizations_controller_spec.rb +++ /dev/null @@ -1,48 +0,0 @@ -require 'rails_helper' - -describe Admin::SiteCustomizationsController do - - it "is a subclass of AdminController" do - expect(Admin::UsersController < Admin::AdminController).to eq(true) - end - - context 'while logged in as an admin' do - before do - @user = log_in(:admin) - end - - context ' .index' do - it 'returns success' do - SiteCustomization.create!(name: 'my name', user_id: Fabricate(:user).id, header: "my awesome header", stylesheet: "my awesome css") - xhr :get, :index - expect(response).to be_success - end - - it 'returns JSON' do - xhr :get, :index - expect(::JSON.parse(response.body)).to be_present - end - end - - context ' .create' do - it 'returns success' do - xhr :post, :create, site_customization: {name: 'my test name'} - expect(response).to be_success - end - - it 'returns json' do - xhr :post, :create, site_customization: {name: 'my test name'} - expect(::JSON.parse(response.body)).to be_present - end - - it 'logs the change' do - StaffActionLogger.any_instance.expects(:log_site_customization_change).once - xhr :post, :create, site_customization: {name: 'my test name'} - end - end - - end - - - -end diff --git a/spec/controllers/admin/staff_action_logs_controller_spec.rb b/spec/controllers/admin/staff_action_logs_controller_spec.rb index a566571708..2712d2f813 100644 --- a/spec/controllers/admin/staff_action_logs_controller_spec.rb +++ b/spec/controllers/admin/staff_action_logs_controller_spec.rb @@ -8,15 +8,46 @@ describe Admin::StaffActionLogsController do let!(:user) { log_in(:admin) } context '.index' do - before do - xhr :get, :index + + it 'generates logs' do + + topic = Fabricate(:topic) + _record = StaffActionLogger.new(Discourse.system_user).log_topic_deletion(topic) + + xhr :get, :index, action_id: UserHistory.actions[:delete_topic] + + json = JSON.parse(response.body) + expect(response).to be_success + + expect(json["staff_action_logs"].length).to eq(1) + expect(json["staff_action_logs"][0]["action_name"]).to eq("delete_topic") + + expect(json["user_history_actions"]).to include({"id" => UserHistory.actions[:delete_topic], "name" => 'delete_topic'}) + end + end - subject { response } - it { is_expected.to be_success } + context '.diff' do + it 'can generate diffs for theme changes' do + theme = Theme.new(user_id: -1, name: 'bob') + theme.set_field(target: :mobile, name: :scss, value: 'body {.up}') + theme.set_field(target: :common, name: :scss, value: 'omit-dupe') - it 'returns JSON' do - expect(::JSON.parse(subject.body)).to be_a(Array) + original_json = ThemeSerializer.new(theme, root: false).to_json + + theme.set_field(target: :mobile, name: :scss, value: 'body {.down}') + + record = StaffActionLogger.new(Discourse.system_user) + .log_theme_change(original_json, theme) + + xhr :get, :diff, id: record.id + expect(response).to be_success + + parsed = JSON.parse(response.body) + expect(parsed["side_by_side"]).to include("up") + expect(parsed["side_by_side"]).to include("down") + + expect(parsed["side_by_side"]).not_to include("omit-dupe") end end end diff --git a/spec/controllers/admin/themes_controller_spec.rb b/spec/controllers/admin/themes_controller_spec.rb new file mode 100644 index 0000000000..b2c1bf7e54 --- /dev/null +++ b/spec/controllers/admin/themes_controller_spec.rb @@ -0,0 +1,152 @@ +require 'rails_helper' + +describe Admin::ThemesController do + + it "is a subclass of AdminController" do + expect(Admin::UsersController < Admin::AdminController).to eq(true) + end + + context 'while logged in as an admin' do + before do + @user = log_in(:admin) + end + + context '.upload_asset' do + render_views + + let(:upload) do + ActionDispatch::Http::UploadedFile.new({ + filename: 'test.woff2', + tempfile: file_from_fixtures("fake.woff2", "woff2") + }) + end + + it 'can create a theme upload' do + xhr :post, :upload_asset, file: upload + expect(response.status).to eq(201) + upload = Upload.find_by(original_filename: "test.woff2") + expect(upload.id).not_to be_nil + expect(JSON.parse(response.body)["upload_id"]).to eq(upload.id) + end + end + + context '.import' do + let(:theme_file) do + ActionDispatch::Http::UploadedFile.new({ + filename: 'sam-s-simple-theme.dcstyle.json', + tempfile: file_from_fixtures("sam-s-simple-theme.dcstyle.json", "json") + }) + end + + it 'imports a theme' do + xhr :post, :import, theme: theme_file + expect(response).to be_success + + json = ::JSON.parse(response.body) + + expect(json["theme"]["name"]).to eq("Sam's Simple Theme") + expect(json["theme"]["theme_fields"].length).to eq(2) + expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) + end + end + + context ' .index' do + it 'correctly returns themes' do + + ColorScheme.destroy_all + Theme.destroy_all + + theme = Theme.new(name: 'my name', user_id: -1) + theme.set_field(target: :common, name: :scss, value: '.body{color: black;}') + theme.set_field(target: :desktop, name: :after_header, value: 'test') + + theme.remote_theme = RemoteTheme.new( + remote_url: 'awesome.git', + remote_version: '7', + local_version: '8', + remote_updated_at: Time.zone.now + ) + + theme.save! + + # this will get serialized as well + ColorScheme.create_from_base(name: "test", colors: []) + + xhr :get, :index + + expect(response).to be_success + + json = ::JSON.parse(response.body) + + expect(json["extras"]["color_schemes"].length).to eq(2) + theme_json = json["themes"].find{|t| t["id"] == theme.id} + expect(theme_json["theme_fields"].length).to eq(2) + expect(theme_json["remote_theme"]["remote_version"]).to eq("7") + end + end + + context ' .create' do + it 'creates a theme' do + xhr :post, :create, theme: {name: 'my test name', theme_fields: [name: 'scss', target: 'common', value: 'body{color: red;}']} + expect(response).to be_success + + json = ::JSON.parse(response.body) + + expect(json["theme"]["theme_fields"].length).to eq(1) + expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) + end + end + + context ' .update' do + it 'can change default theme' do + theme = Theme.create(name: 'my name', user_id: -1) + xhr :put, :update, id: theme.id, theme: { default: true } + expect(SiteSetting.default_theme_key).to eq(theme.key) + end + + it 'can unset default theme' do + theme = Theme.create(name: 'my name', user_id: -1) + SiteSetting.default_theme_key = theme.key + xhr :put, :update, id: theme.id, theme: { default: false} + expect(SiteSetting.default_theme_key).to be_blank + end + + it 'updates a theme' do + theme = Theme.new(name: 'my name', user_id: -1) + theme.set_field(target: :common, name: :scss, value: '.body{color: black;}') + theme.save + + child_theme = Theme.create(name: 'my name', user_id: -1) + + upload = Fabricate(:upload) + + xhr :put, :update, id: theme.id, + theme: { + child_theme_ids: [child_theme.id], + name: 'my test name', + theme_fields: [ + { name: 'scss', target: 'common', value: '' }, + { name: 'scss', target: 'desktop', value: 'body{color: blue;}' }, + { name: 'bob', target: 'common', value: '', type_id: 2, upload_id: upload.id }, + ] + } + expect(response).to be_success + + json = ::JSON.parse(response.body) + + fields = json["theme"]["theme_fields"].sort{|a,b| a["value"] <=> b["value"]} + + expect(fields[0]["value"]).to eq('') + expect(fields[0]["upload_id"]).to eq(upload.id) + expect(fields[1]["value"]).to eq('body{color: blue;}') + + expect(fields.length).to eq(2) + + expect(json["theme"]["child_themes"].length).to eq(1) + + expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) + end + end + end + +end diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 912338f262..f061351085 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -166,9 +166,9 @@ describe Admin::UsersController do end it 'updates the admin flag' do + expect(AdminConfirmation.exists_for?(@another_user.id)).to eq(false) xhr :put, :grant_admin, user_id: @another_user.id - @another_user.reload - expect(@another_user).to be_admin + expect(AdminConfirmation.exists_for?(@another_user.id)).to eq(true) end end @@ -195,6 +195,8 @@ describe Admin::UsersController do end context '.primary_group' do + let(:group) { Fabricate(:group) } + before do @another_user = Fabricate(:coding_horror) end @@ -211,9 +213,16 @@ describe Admin::UsersController do end it "changes the user's primary group" do - xhr :put, :primary_group, user_id: @another_user.id, primary_group_id: 2 + group.add(@another_user) + xhr :put, :primary_group, user_id: @another_user.id, primary_group_id: group.id @another_user.reload - expect(@another_user.primary_group_id).to eq(2) + expect(@another_user.primary_group_id).to eq(group.id) + end + + it "doesn't change primary group if they aren't a member of the group" do + xhr :put, :primary_group, user_id: @another_user.id, primary_group_id: group.id + @another_user.reload + expect(@another_user.primary_group_id).to be_nil end end @@ -491,7 +500,14 @@ describe Admin::UsersController do end context ".invite_admin" do + it "doesn't work when not via API" do + controller.stubs(:is_api?).returns(false) + xhr :post, :invite_admin, name: 'Bill', username: 'bill22', email: 'bill@bill.com' + expect(response).not_to be_success + end + it 'should invite admin' do + controller.stubs(:is_api?).returns(true) Jobs.expects(:enqueue).with(:critical_user_email, anything).returns(true) xhr :post, :invite_admin, name: 'Bill', username: 'bill22', email: 'bill@bill.com' expect(response).to be_success @@ -503,6 +519,7 @@ describe Admin::UsersController do end it "doesn't send the email with send_email falsy" do + controller.stubs(:is_api?).returns(true) Jobs.expects(:enqueue).with(:user_email, anything).never xhr :post, :invite_admin, name: 'Bill', username: 'bill22', email: 'bill@bill.com', send_email: '0' expect(response).to be_success @@ -511,6 +528,15 @@ describe Admin::UsersController do end end + context 'remove_group' do + it "also clears the user's primary group" do + g = Fabricate(:group) + u = Fabricate(:user, primary_group: g) + xhr :delete, :remove_group, group_id: g.id, user_id: u.id + expect(u.reload.primary_group).to be_nil + end + end + end diff --git a/spec/controllers/admin/web_hooks_controller_spec.rb b/spec/controllers/admin/web_hooks_controller_spec.rb new file mode 100644 index 0000000000..bf6b894c44 --- /dev/null +++ b/spec/controllers/admin/web_hooks_controller_spec.rb @@ -0,0 +1,62 @@ +require "rails_helper" + +describe Admin::WebHooksController do + + it 'is a subclass of AdminController' do + expect(Admin::WebHooksController < Admin::AdminController).to eq(true) + end + + context 'while logged in as an admin' do + before do + @user = log_in(:admin) + end + let(:web_hook) { Fabricate(:web_hook) } + + describe '#create' do + it 'creates a webhook' do + xhr :post, :create, web_hook: { + payload_url: 'https://meta.discourse.org/', + content_type: 1, + secret: "a_secret_for_webhooks", + wildcard_web_hook: false, + active: true, + verify_certificate: true, + web_hook_event_type_ids: [1], + group_ids: [], + category_ids: [] + } + expect(response).to be_success + + json = ::JSON.parse(response.body) + expect(json["web_hook"]["payload_url"]).to be_present + end + + it 'returns error when field is not filled correctly' do + xhr :post, :create, web_hook: { + content_type: 1, + secret: "a_secret_for_webhooks", + wildcard_web_hook: false, + active: true, + verify_certificate: true, + web_hook_event_type_ids: [1], + group_ids: [], + category_ids: [] + } + expect(response.status).to eq 422 + response_body = JSON.parse(response.body) + + expect(response_body["errors"]).to be_present + end + end + + describe '#ping' do + it 'enqueues the ping event' do + Jobs.expects(:enqueue) + .with(:emit_web_hook_event, web_hook_id: web_hook.id, event_type: 'ping', event_name: 'ping') + xhr :post, :ping, id: web_hook.id + + expect(response).to be_success + end + end + end +end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 62b9bb5afd..dc86db9122 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -17,6 +17,49 @@ describe TopicsController do request.env['HTTP_ACCEPT_LANGUAGE'] = locale end + describe "themes" do + let :theme do + Theme.create!(user_id: -1, name: 'bob', user_selectable: true) + end + + let :theme2 do + Theme.create!(user_id: -1, name: 'bobbob', user_selectable: true) + end + + it "selects the theme the user has selected" do + user = log_in + user.user_option.update_columns(theme_key: theme.key) + + get :show, id: 666 + expect(controller.theme_key).to eq(theme.key) + + theme.update_columns(user_selectable: false) + + get :show, id: 666 + expect(controller.theme_key).not_to eq(theme.key) + end + + it "can be overridden with a cookie" do + user = log_in + user.user_option.update_columns(theme_key: theme.key) + + cookies['theme_key'] = "#{theme2.key},#{user.user_option.theme_key_seq}" + + get :show, id: 666 + expect(controller.theme_key).to eq(theme2.key) + + end + + it "cookie can fail back to user if out of sync" do + user = log_in + user.user_option.update_columns(theme_key: theme.key) + cookies['theme_key'] = "#{theme2.key},#{user.user_option.theme_key_seq-1}" + + get :show, id: 666 + expect(controller.theme_key).to eq(theme.key) + end + end + it "doesn't store an incoming link when there's no referer" do expect { get :show, id: topic.id @@ -32,20 +75,19 @@ describe TopicsController do render_views context "when the SiteSetting is disabled" do - before do - SiteSetting.stubs(:enable_escaped_fragments?).returns(false) - end it "uses the application layout even with an escaped fragment param" do + SiteSetting.enable_escaped_fragments = false get :show, {'topic_id' => topic.id, 'slug' => topic.slug, '_escaped_fragment_' => 'true'} expect(response).to render_template(layout: 'application') assert_select "meta[name=fragment]", false, "it doesn't have the meta tag" end + end context "when the SiteSetting is enabled" do before do - SiteSetting.stubs(:enable_escaped_fragments?).returns(true) + SiteSetting.enable_escaped_fragments = true end it "uses the application layout when there's no param" do diff --git a/spec/controllers/email_controller_spec.rb b/spec/controllers/email_controller_spec.rb index 03c0df3c3e..996d88cf3e 100644 --- a/spec/controllers/email_controller_spec.rb +++ b/spec/controllers/email_controller_spec.rb @@ -13,7 +13,7 @@ describe EmailController do it 'redirects to your user preferences' do get :preferences_redirect - expect(response).to redirect_to("/users/#{user.username}/preferences") + expect(response).to redirect_to("/u/#{user.username}/preferences") end end @@ -232,6 +232,4 @@ describe EmailController do end end - - end diff --git a/spec/controllers/embed_controller_spec.rb b/spec/controllers/embed_controller_spec.rb index a7ca371c1d..e81faa0371 100644 --- a/spec/controllers/embed_controller_spec.rb +++ b/spec/controllers/embed_controller_spec.rb @@ -108,7 +108,7 @@ describe EmbedController do before do Fabricate(:embeddable_host) Fabricate(:embeddable_host, host: 'http://discourse.org') - Fabricate(:embeddable_host, host: 'https://example.com/1234') + Fabricate(:embeddable_host, host: 'https://example.com/1234', class_name: 'example') end context "success" do @@ -130,6 +130,12 @@ describe EmbedController do expect(response).to be_success end + it "contains custom class name" do + controller.request.stubs(:referer).returns("https://example.com/some-other-path") + get :comments, embed_url: embed_url + expect(assigns(:embeddable_css_class)).to eq(' class="example"') + end + it "doesn't work with a made up host" do controller.request.stubs(:referer).returns("http://codinghorror.com/invalid-url") get :comments, embed_url: embed_url diff --git a/spec/controllers/extra_locales_controller_spec.rb b/spec/controllers/extra_locales_controller_spec.rb index 6289845f79..b848f95399 100644 --- a/spec/controllers/extra_locales_controller_spec.rb +++ b/spec/controllers/extra_locales_controller_spec.rb @@ -3,10 +3,6 @@ require 'rails_helper' describe ExtraLocalesController do context 'show' do - before do - I18n.locale = :en - I18n.reload! - end it "needs a valid bundle" do get :show, bundle: 'made-up-bundle' @@ -19,19 +15,23 @@ describe ExtraLocalesController do expect(response).to_not be_success end - it "should include plugin translations" do - skip "FIXME: Randomly failing" - JsLocaleHelper.expects(:plugin_translations).with(I18n.locale.to_s).returns({ - "admin_js" => { - "admin" => { - "site_settings" => { - "categories" => { - "github_badges" => "Github Badges" - } - } - } - } - }).at_least_once + it "includes plugin translations" do + I18n.locale = :en + I18n.reload! + + JsLocaleHelper.expects(:plugin_translations) + .with(I18n.locale.to_s) + .returns({ + "admin_js" => { + "admin" => { + "site_settings" => { + "categories" => { + "github_badges" => "Github Badges" + } + } + } + } + }).at_least_once get :show, bundle: "admin" diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb index be07bb7894..9079592775 100644 --- a/spec/controllers/invites_controller_spec.rb +++ b/spec/controllers/invites_controller_spec.rb @@ -2,6 +2,21 @@ require 'rails_helper' describe InvitesController do + context '.show' do + it "shows error if invite not found" do + get :show, id: 'nopeNOPEnope' + expect(response).to render_template(layout: 'no_ember') + expect(flash[:error]).to be_present + end + + it "renders the accept invite page if invite exists" do + i = Fabricate(:invite) + get :show, id: i.invite_key + expect(response).to render_template(layout: 'application') + expect(flash[:error]).to be_nil + end + end + context '.destroy' do it 'requires you to be logged in' do @@ -123,15 +138,18 @@ describe InvitesController do end end - context '.show' do + context '.perform_accept_invitation' do context 'with an invalid invite id' do before do - get :show, id: "doesn't exist" + xhr :put, :perform_accept_invitation, id: "doesn't exist", format: :json end it "redirects to the root" do - expect(response).to redirect_to("/") + expect(response).to be_success + json = JSON.parse(response.body) + expect(json["success"]).to eq(false) + expect(json["message"]).to eq(I18n.t('invite.not_found')) end it "should not change the session" do @@ -144,11 +162,14 @@ describe InvitesController do let(:invite) { topic.invite_by_email(topic.user, "iceking@adventuretime.ooo") } let(:deleted_invite) { invite.destroy; invite } before do - get :show, id: deleted_invite.invite_key + xhr :put, :perform_accept_invitation, id: deleted_invite.invite_key, format: :json end it "redirects to the root" do - expect(response).to redirect_to("/") + expect(response).to be_success + json = JSON.parse(response.body) + expect(json["success"]).to eq(false) + expect(json["message"]).to eq(I18n.t('invite.not_found')) end it "should not change the session" do @@ -160,51 +181,88 @@ describe InvitesController do let(:topic) { Fabricate(:topic) } let(:invite) { topic.invite_by_email(topic.user, "iceking@adventuretime.ooo") } - it 'redeems the invite' do Invite.any_instance.expects(:redeem) - get :show, id: invite.invite_key + xhr :put, :perform_accept_invitation, id: invite.invite_key, format: :json end context 'when redeem returns a user' do let(:user) { Fabricate(:coding_horror) } context 'success' do + subject { xhr :put, :perform_accept_invitation, id: invite.invite_key, format: :json } + before do Invite.any_instance.expects(:redeem).returns(user) - get :show, id: invite.invite_key end it 'logs in the user' do + subject expect(session[:current_user_id]).to eq(user.id) end it 'redirects to the first topic the user was invited to' do - expect(response).to redirect_to(topic.relative_url) + subject + json = JSON.parse(response.body) + expect(json["success"]).to eq(true) + expect(json["redirect_to"]).to eq(topic.relative_url) end end - context 'welcome message' do + context 'failure' do + subject { xhr :put, :perform_accept_invitation, id: invite.invite_key, format: :json } + + it "doesn't log in the user if there's a validation error" do + user.errors.add(:password, :common) + Invite.any_instance.expects(:redeem).raises(ActiveRecord::RecordInvalid.new(user)) + subject + expect(response).to be_success + json = JSON.parse(response.body) + expect(json["success"]).to eq(false) + expect(json["errors"]["password"]).to be_present + end + end + + context '.post_process_invite' do before do Invite.any_instance.stubs(:redeem).returns(user) Jobs.expects(:enqueue).with(:invite_email, has_key(:invite_id)) + user.password_hash = nil end it 'sends a welcome message if set' do user.send_welcome_message = true user.expects(:enqueue_welcome_message).with('welcome_invite') - get :show, id: invite.invite_key + Jobs.expects(:enqueue).with(:invite_password_instructions_email, has_entries(username: user.username)) + xhr :put, :perform_accept_invitation, id: invite.invite_key, format: :json end - it "doesn't send a welcome message if not set" do + it "sends password reset email if password is not set" do user.expects(:enqueue_welcome_message).with('welcome_invite').never - get :show, id: invite.invite_key + Jobs.expects(:enqueue).with(:invite_password_instructions_email, has_entries(username: user.username)) + xhr :put, :perform_accept_invitation, id: invite.invite_key, format: :json end + it "does not send password reset email if sso is enabled" do + SiteSetting.enable_sso = true + Jobs.expects(:enqueue).with(:invite_password_instructions_email, has_key(:username)).never + xhr :put, :perform_accept_invitation, id: invite.invite_key, format: :json + end + + it "does not send password reset email if local login is disabled" do + SiteSetting.enable_local_logins = false + Jobs.expects(:enqueue).with(:invite_password_instructions_email, has_key(:username)).never + xhr :put, :perform_accept_invitation, id: invite.invite_key, format: :json + end + + it 'sends an activation email if password is set' do + user.password_hash = 'qaw3ni3h2wyr63lakw7pea1nrtr44pls' + Jobs.expects(:enqueue).with(:invite_password_instructions_email, has_key(:username)).never + Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :signup, user_id: user.id)) + xhr :put, :perform_accept_invitation, id: invite.invite_key, format: :json + end end - end - end context 'new registrations are disabled' do @@ -214,7 +272,7 @@ describe InvitesController do it "doesn't redeem the invite" do Invite.any_instance.stubs(:redeem).never - get :show, id: invite.invite_key + put :perform_accept_invitation, id: invite.invite_key end end @@ -225,7 +283,7 @@ describe InvitesController do it "doesn't redeem the invite" do Invite.any_instance.stubs(:redeem).never - get :show, id: invite.invite_key + put :perform_accept_invitation, id: invite.invite_key end end end diff --git a/spec/controllers/list_controller_spec.rb b/spec/controllers/list_controller_spec.rb index 23c3d866ab..0c5b41596b 100644 --- a/spec/controllers/list_controller_spec.rb +++ b/spec/controllers/list_controller_spec.rb @@ -49,13 +49,53 @@ describe ListController do end describe 'RSS feeds' do - - it 'renders RSS' do + it 'renders latest RSS' do get "latest_feed", format: :rss expect(response).to be_success expect(response.content_type).to eq('application/rss+xml') end + it 'renders top RSS' do + get "top_feed", format: :rss + expect(response).to be_success + expect(response.content_type).to eq('application/rss+xml') + end + + it 'renders all time top RSS' do + get "top_all_feed", format: :rss + expect(response).to be_success + expect(response.content_type).to eq('application/rss+xml') + end + + it 'renders yearly top RSS' do + get "top_yearly_feed", format: :rss + expect(response).to be_success + expect(response.content_type).to eq('application/rss+xml') + end + + it 'renders quarterly top RSS' do + get "top_quarterly_feed", format: :rss + expect(response).to be_success + expect(response.content_type).to eq('application/rss+xml') + end + + it 'renders monthly top RSS' do + get "top_monthly_feed", format: :rss + expect(response).to be_success + expect(response.content_type).to eq('application/rss+xml') + end + + it 'renders weekly top RSS' do + get "top_weekly_feed", format: :rss + expect(response).to be_success + expect(response.content_type).to eq('application/rss+xml') + end + + it 'renders daily top RSS' do + get "top_daily_feed", format: :rss + expect(response).to be_success + expect(response.content_type).to eq('application/rss+xml') + end end context 'category' do @@ -141,7 +181,6 @@ describe ListController do it { is_expected.not_to respond_with(:success) } end - end describe 'feed' do @@ -151,6 +190,37 @@ describe ListController do expect(response.content_type).to eq('application/rss+xml') end end + + describe "category default views" do + it "has a top default view" do + category.update_attributes!(default_view: 'top', default_top_period: 'monthly') + described_class.expects(:best_period_with_topics_for).with(anything, category.id, :monthly).returns(:monthly) + xhr :get, :category_default, category: category.slug + expect(response).to be_success + end + + it "has a default view of nil" do + category.update_attributes!(default_view: nil) + described_class.expects(:best_period_for).never + xhr :get, :category_default, category: category.slug + expect(response).to be_success + end + + it "has a default view of ''" do + category.update_attributes!(default_view: '') + described_class.expects(:best_period_for).never + xhr :get, :category_default, category: category.slug + expect(response).to be_success + end + + it "has a default view of latest" do + category.update_attributes!(default_view: 'latest') + described_class.expects(:best_period_for).never + xhr :get, :category_default, category: category.slug + expect(response).to be_success + end + + end end end @@ -229,62 +299,52 @@ describe ListController do describe "best_periods_for" do it "returns yearly for more than 180 days" do - SiteSetting.top_page_default_timeframe = 'all' - expect(ListController.best_periods_for(nil)).to eq([:yearly]) - expect(ListController.best_periods_for(180.days.ago)).to eq([:yearly]) + expect(ListController.best_periods_for(nil, :all)).to eq([:yearly]) + expect(ListController.best_periods_for(180.days.ago, :all)).to eq([:yearly]) end it "includes monthly when less than 180 days and more than 35 days" do - SiteSetting.top_page_default_timeframe = 'all' (35...180).each do |date| - expect(ListController.best_periods_for(date.days.ago)).to eq([:monthly, :yearly]) + expect(ListController.best_periods_for(date.days.ago, :all)).to eq([:monthly, :yearly]) end end it "includes weekly when less than 35 days and more than 8 days" do - SiteSetting.top_page_default_timeframe = 'all' (8...35).each do |date| - expect(ListController.best_periods_for(date.days.ago)).to eq([:weekly, :monthly, :yearly]) + expect(ListController.best_periods_for(date.days.ago, :all)).to eq([:weekly, :monthly, :yearly]) end end it "includes daily when less than 8 days" do - SiteSetting.top_page_default_timeframe = 'all' (0...8).each do |date| - expect(ListController.best_periods_for(date.days.ago)).to eq([:daily, :weekly, :monthly, :yearly]) + expect(ListController.best_periods_for(date.days.ago, :all)).to eq([:daily, :weekly, :monthly, :yearly]) end end it "returns default even for more than 180 days" do - SiteSetting.top_page_default_timeframe = 'monthly' - expect(ListController.best_periods_for(nil)).to eq([:monthly, :yearly]) - expect(ListController.best_periods_for(180.days.ago)).to eq([:monthly, :yearly]) + expect(ListController.best_periods_for(nil, :monthly)).to eq([:monthly, :yearly]) + expect(ListController.best_periods_for(180.days.ago, :monthly)).to eq([:monthly, :yearly]) end it "returns default even when less than 180 days and more than 35 days" do - SiteSetting.top_page_default_timeframe = 'weekly' (35...180).each do |date| - expect(ListController.best_periods_for(date.days.ago)).to eq([:weekly, :monthly, :yearly]) + expect(ListController.best_periods_for(date.days.ago, :weekly)).to eq([:weekly, :monthly, :yearly]) end end it "returns default even when less than 35 days and more than 8 days" do - SiteSetting.top_page_default_timeframe = 'daily' (8...35).each do |date| - expect(ListController.best_periods_for(date.days.ago)).to eq([:daily, :weekly, :monthly, :yearly]) + expect(ListController.best_periods_for(date.days.ago, :daily)).to eq([:daily, :weekly, :monthly, :yearly]) end end it "doesn't return default when set to all" do - SiteSetting.top_page_default_timeframe = 'all' - expect(ListController.best_periods_for(nil)).to eq([:yearly]) + expect(ListController.best_periods_for(nil, :all)).to eq([:yearly]) end it "doesn't return value twice when matches default" do - SiteSetting.top_page_default_timeframe = 'yearly' - expect(ListController.best_periods_for(nil)).to eq([:yearly]) + expect(ListController.best_periods_for(nil, :yearly)).to eq([:yearly]) end - end describe "categories suppression" do diff --git a/spec/controllers/notifications_controller_spec.rb b/spec/controllers/notifications_controller_spec.rb index dbf8066639..f521898bf0 100644 --- a/spec/controllers/notifications_controller_spec.rb +++ b/spec/controllers/notifications_controller_spec.rb @@ -5,14 +5,43 @@ describe NotificationsController do context 'when logged in' do let!(:user) { log_in } - it 'should succeed for recent' do - xhr :get, :index, recent: true - expect(response).to be_success - end + describe '#index' do + it 'should succeed for recent' do + xhr :get, :index, recent: true + expect(response).to be_success + end - it 'should succeed for history' do - xhr :get, :index - expect(response).to be_success + it 'should succeed for history' do + xhr :get, :index + expect(response).to be_success + end + + it 'should mark notifications as viewed' do + notification = Fabricate(:notification, user: user) + expect(user.reload.unread_notifications).to eq(1) + expect(user.reload.total_unread_notifications).to eq(1) + xhr :get, :index, recent: true + expect(user.reload.unread_notifications).to eq(0) + expect(user.reload.total_unread_notifications).to eq(1) + end + + it 'should not mark notifications as viewed if silent param is present' do + notification = Fabricate(:notification, user: user) + expect(user.reload.unread_notifications).to eq(1) + expect(user.reload.total_unread_notifications).to eq(1) + xhr :get, :index, recent: true, silent: true + expect(user.reload.unread_notifications).to eq(1) + expect(user.reload.total_unread_notifications).to eq(1) + end + + context 'when username params is not valid' do + it 'should raise the right error' do + xhr :get, :index, username: 'somedude' + + expect(response).to_not be_success + expect(response.status).to eq(404) + end + end end it 'should succeed' do @@ -20,24 +49,6 @@ describe NotificationsController do expect(response).to be_success end - it 'should mark notifications as viewed' do - notification = Fabricate(:notification, user: user) - expect(user.reload.unread_notifications).to eq(1) - expect(user.reload.total_unread_notifications).to eq(1) - xhr :get, :index, recent: true - expect(user.reload.unread_notifications).to eq(0) - expect(user.reload.total_unread_notifications).to eq(1) - end - - it 'should not mark notifications as viewed if silent param is present' do - notification = Fabricate(:notification, user: user) - expect(user.reload.unread_notifications).to eq(1) - expect(user.reload.total_unread_notifications).to eq(1) - xhr :get, :index, recent: true, silent: true - expect(user.reload.unread_notifications).to eq(1) - expect(user.reload.total_unread_notifications).to eq(1) - end - it "can update a single notification" do notification = Fabricate(:notification, user: user) notification2 = Fabricate(:notification, user: user) diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb index de2a7d8f4d..df05b2c2f1 100644 --- a/spec/controllers/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/omniauth_callbacks_controller_spec.rb @@ -4,20 +4,21 @@ describe Users::OmniauthCallbacksController do context ".find_authenticator" do it "fails if a provider is disabled" do - SiteSetting.stubs("enable_twitter_logins?").returns(false) - expect(lambda { + SiteSetting.enable_twitter_logins = false + + expect { Users::OmniauthCallbacksController.find_authenticator("twitter") - }).to raise_error(Discourse::InvalidAccess) + }.to raise_error(Discourse::InvalidAccess) end it "fails for unknown" do - expect(lambda { + expect { Users::OmniauthCallbacksController.find_authenticator("twitter1") - }).to raise_error(Discourse::InvalidAccess) + }.to raise_error(Discourse::InvalidAccess) end it "finds an authenticator when enabled" do - SiteSetting.stubs("enable_twitter_logins?").returns(true) + SiteSetting.enable_twitter_logins = true expect(Users::OmniauthCallbacksController.find_authenticator("twitter")).not_to eq(nil) end end diff --git a/spec/controllers/posts_controller_spec.rb b/spec/controllers/posts_controller_spec.rb index c2071de4f4..6efb7380a3 100644 --- a/spec/controllers/posts_controller_spec.rb +++ b/spec/controllers/posts_controller_spec.rb @@ -426,7 +426,8 @@ describe PostsController do include_examples 'action requires login', :put, :bookmark, post_id: 2 describe 'when logged in' do - let(:post) { Fabricate(:post, user: log_in) } + let(:user) { log_in } + let(:post) { Fabricate(:post, user: user) } let(:private_message) { Fabricate(:private_message_post) } it "raises an error if the user doesn't have permission to see the post" do @@ -436,13 +437,56 @@ describe PostsController do end it 'creates a bookmark' do - PostAction.expects(:act).with(post.user, post, PostActionType.types[:bookmark]) xhr :put, :bookmark, post_id: post.id, bookmarked: 'true' + + post_action = PostAction.find_by(user:user, post: post) + + expect(post_action.post_action_type_id).to eq(PostActionType.types[:bookmark]) end - it 'removes a bookmark' do - PostAction.expects(:remove_act).with(post.user, post, PostActionType.types[:bookmark]) - xhr :put, :bookmark, post_id: post.id + context "removing a bookmark" do + let(:post_action) { PostAction.act(user, post, PostActionType.types[:bookmark]) } + let(:admin) { Fabricate(:admin) } + + it "returns the right response when post is not bookmarked" do + xhr :put, :bookmark, post_id: Fabricate(:post, user: user).id + + expect(response.status).to eq(404) + end + + it 'should be able to remove a bookmark' do + post_action + xhr :put, :bookmark, post_id: post.id + + expect(PostAction.find_by(id: post_action.id)).to eq(nil) + end + + describe "when user doesn't have permission to see bookmarked post" do + it "should still be able to remove a bookmark" do + post_action + post = post_action.post + topic = post.topic + topic.convert_to_private_message(admin) + topic.remove_allowed_user(admin, user.username) + + expect(Guardian.new(user).can_see_post?(post.reload)).to eq(false) + + xhr :put, :bookmark, post_id: post.id + + expect(PostAction.find_by(id: post_action.id)).to eq(nil) + end + end + + describe "when post has been deleted" do + it "should still be able to remove a bookmark" do + post = post_action.post + post.trash! + + xhr :put, :bookmark, post_id: post.id + + expect(PostAction.find_by(id: post_action.id)).to eq(nil) + end + end end end @@ -465,6 +509,23 @@ describe PostsController do expect(response).to be_forbidden end + it "toggle wiki status should create a new version" do + admin = log_in(:admin) + another_user = Fabricate(:user) + another_post = Fabricate(:post, user: another_user) + + expect { xhr :put, :wiki, post_id: another_post.id, wiki: 'true' } + .to change { another_post.reload.version }.by(1) + + expect { xhr :put, :wiki, post_id: another_post.id, wiki: 'false' } + .to change { another_post.reload.version }.by(-1) + + another_admin = log_in(:admin) + + expect { xhr :put, :wiki, post_id: another_post.id, wiki: 'true' } + .to change { another_post.reload.version }.by(1) + end + it "can wiki a post" do Guardian.any_instance.expects(:can_wiki?).with(post).returns(true) diff --git a/spec/controllers/session_controller_spec.rb b/spec/controllers/session_controller_spec.rb index 50858f7acd..5645eca822 100644 --- a/spec/controllers/session_controller_spec.rb +++ b/spec/controllers/session_controller_spec.rb @@ -505,8 +505,8 @@ describe SessionController do user.reload expect(session[:current_user_id]).to eq(user.id) - expect(user.auth_token).to be_present - expect(cookies[:_t]).to eq(user.auth_token) + expect(user.user_auth_tokens.count).to eq(1) + expect(UserAuthToken.hash_token(cookies[:_t])).to eq(user.user_auth_tokens.first.auth_token) end end @@ -659,6 +659,23 @@ describe SessionController do end end end + + context 'rate limited' do + it 'rate limits login' do + SiteSetting.max_logins_per_ip_per_hour = 2 + RateLimiter.stubs(:disabled?).returns(false) + RateLimiter.clear_all! + + 2.times do + xhr :post, :create, login: user.username, password: 'myawesomepassword' + expect(response).to be_success + end + xhr :post, :create, login: user.username, password: 'myawesomepassword' + expect(response).not_to be_success + json = JSON.parse(response.body) + expect(json["error_type"]).to eq("rate_limit") + end + end end describe '.destroy' do diff --git a/spec/controllers/site_controller_spec.rb b/spec/controllers/site_controller_spec.rb index 5e328f5a3e..1dccba86ad 100644 --- a/spec/controllers/site_controller_spec.rb +++ b/spec/controllers/site_controller_spec.rb @@ -24,4 +24,38 @@ describe SiteController do expect(json["mobile_logo_url"]).to eq("https://a.a/a.png") end end + + describe '.statistics' do + + it 'is visible for sites requiring login' do + SiteSetting.login_required = true + SiteSetting.share_anonymized_statistics = true + + xhr :get, :statistics + json = JSON.parse(response.body) + + expect(response).to be_success + expect(json["topic_count"]).to be_present + expect(json["post_count"]).to be_present + expect(json["user_count"]).to be_present + expect(json["topics_7_days"]).to be_present + expect(json["topics_30_days"]).to be_present + expect(json["posts_7_days"]).to be_present + expect(json["posts_30_days"]).to be_present + expect(json["users_7_days"]).to be_present + expect(json["users_30_days"]).to be_present + expect(json["active_users_7_days"]).to be_present + expect(json["active_users_30_days"]).to be_present + expect(json["like_count"]).to be_present + expect(json["likes_7_days"]).to be_present + expect(json["likes_30_days"]).to be_present + end + + it 'is not visible if site setting share_anonymized_statistics is disabled' do + SiteSetting.share_anonymized_statistics = false + + xhr :get, :statistics + expect(response).to redirect_to '/' + end + end end diff --git a/spec/controllers/site_customizations_controller_spec.rb b/spec/controllers/site_customizations_controller_spec.rb deleted file mode 100644 index d3a0f17451..0000000000 --- a/spec/controllers/site_customizations_controller_spec.rb +++ /dev/null @@ -1,45 +0,0 @@ -require 'rails_helper' - -describe SiteCustomizationsController do - - before do - SiteCustomization.clear_cache! - end - - it 'can deliver enabled css' do - SiteCustomization.create!(name: '1', - user_id: -1, - enabled: true, - mobile_stylesheet: '.a1{margin: 1px;}', - stylesheet: '.b1{margin: 1px;}' - ) - - SiteCustomization.create!(name: '2', - user_id: -1, - enabled: true, - mobile_stylesheet: '.a2{margin: 1px;}', - stylesheet: '.b2{margin: 1px;}' - ) - - get :show, key: SiteCustomization::ENABLED_KEY, format: :css, target: 'mobile' - expect(response.body).to match(/\.a1.*\.a2/m) - - get :show, key: SiteCustomization::ENABLED_KEY, format: :css - expect(response.body).to match(/\.b1.*\.b2/m) - end - - it 'can deliver specific css' do - c = SiteCustomization.create!(name: '1', - user_id: -1, - enabled: true, - mobile_stylesheet: '.a1{margin: 1px;}', - stylesheet: '.b1{margin: 1px;}' - ) - - get :show, key: c.key, format: :css, target: 'mobile' - expect(response.body).to match(/\.a1/) - - get :show, key: c.key, format: :css - expect(response.body).to match(/\.b1/) - end -end diff --git a/spec/controllers/static_controller_spec.rb b/spec/controllers/static_controller_spec.rb index 732f94255a..fba85ae8c1 100644 --- a/spec/controllers/static_controller_spec.rb +++ b/spec/controllers/static_controller_spec.rb @@ -3,13 +3,33 @@ require 'rails_helper' describe StaticController do context 'brotli_asset' do - it 'returns a brotli encoded 404 if asset is missing' do + it 'returns a non brotli encoded 404 if asset is missing' do get :brotli_asset, path: 'missing.js' expect(response.status).to eq(404) expect(response.headers['Content-Encoding']).not_to eq('br') - expect(response.headers["Cache-Control"]).to match(/max-age=5/) + expect(response.headers["Cache-Control"]).to match(/max-age=1/) + end + + it 'can handle fallback brotli assets' do + begin + assets_path = Rails.root.join("tmp/backup_assets") + + GlobalSetting.stubs(:fallback_assets_path).returns(assets_path.to_s) + + FileUtils.mkdir_p(assets_path) + + file_path = assets_path.join("test.js.br") + File.write(file_path, 'fake brotli file') + + get :brotli_asset, path: 'test.js' + + expect(response.status).to eq(200) + expect(response.headers["Cache-Control"]).to match(/public/) + ensure + File.delete(file_path) + end end it 'has correct headers for brotli assets' do @@ -94,6 +114,38 @@ describe StaticController do xhr :get, :show, id: 'login' expect(response).to be_success end + + context "when login_required is enabled" do + before do + SiteSetting.login_required = true + end + + it 'faq page redirects to login page for anon' do + xhr :get, :show, id: 'faq' + expect(response).to redirect_to '/login' + end + + it 'guidelines page redirects to login page for anon' do + xhr :get, :show, id: 'guidelines' + expect(response).to redirect_to '/login' + end + + it 'faq page loads for logged in user' do + log_in + xhr :get, :show, id: 'faq' + expect(response).to be_success + expect(response).to render_template('static/show') + expect(assigns(:page)).to eq('faq') + end + + it 'guidelines page loads for logged in user' do + log_in + xhr :get, :show, id: 'guidelines' + expect(response).to be_success + expect(response).to render_template('static/show') + expect(assigns(:page)).to eq('faq') + end + end end describe '#enter' do diff --git a/spec/controllers/stylesheets_controller_spec.rb b/spec/controllers/stylesheets_controller_spec.rb index 5a5f9cf05f..8f18d2b2d5 100644 --- a/spec/controllers/stylesheets_controller_spec.rb +++ b/spec/controllers/stylesheets_controller_spec.rb @@ -5,19 +5,12 @@ describe StylesheetsController do it 'can survive cache miss' do StylesheetCache.destroy_all - builder = DiscourseStylesheets.new('desktop_rtl') + builder = Stylesheet::Manager.new('desktop_rtl', nil) builder.compile - builder.ensure_digestless_file digest = StylesheetCache.first.digest StylesheetCache.destroy_all - # digestless - get :show, name: 'desktop_rtl' - expect(response).to be_success - - StylesheetCache.destroy_all - get :show, name: "desktop_rtl_#{digest}" expect(response).to be_success @@ -26,10 +19,7 @@ describe StylesheetsController do expect(cached.digest).to eq digest # tmp folder destruction and cached - `rm #{DiscourseStylesheets.cache_fullpath}/*` - - get :show, name: 'desktop_rtl' - expect(response).to be_success + `rm #{Stylesheet::Manager.cache_fullpath}/*` get :show, name: "desktop_rtl_#{digest}" expect(response).to be_success @@ -38,4 +28,31 @@ describe StylesheetsController do end + it 'can lookup theme specific css' do + scheme = ColorScheme.create_from_base({name: "testing", colors: []}) + theme = Theme.create!(name: "test", color_scheme_id: scheme.id, user_id: -1) + + builder = Stylesheet::Manager.new(:desktop, theme.key) + builder.compile + + `rm #{Stylesheet::Manager.cache_fullpath}/*` + + get :show, name: builder.stylesheet_filename.sub(".css", "") + expect(response).to be_success + + get :show, name: builder.stylesheet_filename_no_digest.sub(".css", "") + expect(response).to be_success + + builder = Stylesheet::Manager.new(:desktop_theme, theme.key) + builder.compile + + `rm #{Stylesheet::Manager.cache_fullpath}/*` + + get :show, name: builder.stylesheet_filename.sub(".css", "") + expect(response).to be_success + + get :show, name: builder.stylesheet_filename_no_digest.sub(".css", "") + expect(response).to be_success + end + end diff --git a/spec/controllers/tags_controller_spec.rb b/spec/controllers/tags_controller_spec.rb index 75696f18cc..fcfcdb754d 100644 --- a/spec/controllers/tags_controller_spec.rb +++ b/spec/controllers/tags_controller_spec.rb @@ -121,6 +121,15 @@ describe TagsController do expect(json["results"].map{|j| j["id"]}.sort).to eq([]) expect(json["forbidden"]).to be_present end + + it "can return tags that are in secured categories but are allowed to be used" do + c = Fabricate(:private_category, group: Fabricate(:group)) + Fabricate(:topic, category: c, tags: [Fabricate(:tag, name: "cooltag")]) + xhr :get, :search, q: "cool" + expect(response).to be_success + json = ::JSON.parse(response.body) + expect(json["results"].map{|j| j["id"]}).to eq(['cooltag']) + end end end end diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb index 67342edd04..27a87c43ab 100644 --- a/spec/controllers/topics_controller_spec.rb +++ b/spec/controllers/topics_controller_spec.rb @@ -64,7 +64,7 @@ describe TopicsController do end describe 'moving to a new topic' do - let!(:user) { log_in(:moderator) } + let(:user) { log_in(:moderator) } let(:p1) { Fabricate(:post, user: user) } let(:topic) { p1.topic } @@ -79,18 +79,49 @@ describe TopicsController do end context 'success' do - let(:p2) { Fabricate(:post, user: user) } - - before do - Topic.any_instance.expects(:move_posts).with(user, [p2.id], title: 'blah', category_id: 123).returns(topic) - xhr :post, :move_posts, topic_id: topic.id, title: 'blah', post_ids: [p2.id], category_id: 123 - end + let(:user) { log_in(:admin) } + let(:p2) { Fabricate(:post, user: user, topic: topic) } it "returns success" do + p2 + + expect do + xhr :post, :move_posts, + topic_id: topic.id, + title: 'Logan is a good movie', + post_ids: [p2.id], + category_id: 123 + end.to change { Topic.count }.by(1) + expect(response).to be_success + result = ::JSON.parse(response.body) + expect(result['success']).to eq(true) - expect(result['url']).to be_present + expect(result['url']).to eq(Topic.last.relative_url) + end + + describe 'when topic has been deleted' do + it 'should still be able to move posts' do + PostDestroyer.new(user, topic.first_post).destroy + + expect(topic.reload.deleted_at).to_not be_nil + + expect do + xhr :post, :move_posts, + topic_id: topic.id, + title: 'Logan is a good movie', + post_ids: [p2.id], + category_id: 123 + end.to change { Topic.count }.by(1) + + expect(response).to be_success + + result = JSON.parse(response.body) + + expect(result['success']).to eq(true) + expect(result['url']).to eq(Topic.last.relative_url) + end end end @@ -131,7 +162,6 @@ describe TopicsController do end - describe 'moving to an existing topic' do let!(:user) { log_in(:moderator) } let(:p1) { Fabricate(:post, user: user) } @@ -382,14 +412,19 @@ describe TopicsController do expect { xhr :put, :status, topic_id: @topic.id, status: 'title', enabled: 'true' }.to raise_error(Discourse::InvalidParameters) end - it 'calls update_status on the forum topic with false' do - Topic.any_instance.expects(:update_status).with('closed', false, @user, until: nil) - xhr :put, :status, topic_id: @topic.id, status: 'closed', enabled: 'false' - end + it 'should update the status of the topic correctly' do + @topic = Fabricate(:topic, user: @user, closed: true, topic_timers: [ + Fabricate(:topic_timer, status_type: TopicTimer.types[:open]) + ]) - it 'calls update_status on the forum topic with true' do - Topic.any_instance.expects(:update_status).with('closed', true, @user, until: nil) - xhr :put, :status, topic_id: @topic.id, status: 'closed', enabled: 'true' + xhr :put, :status, topic_id: @topic.id, status: 'closed', enabled: 'false' + + expect(response).to be_success + expect(@topic.reload.closed).to eq(false) + + body = JSON.parse(response.body) + + expect(body['topic_status_update']).to eq(nil) end end @@ -1082,82 +1117,6 @@ describe TopicsController do end - describe 'autoclose' do - - it 'needs you to be logged in' do - expect { - xhr :put, :autoclose, topic_id: 99, auto_close_time: '24', auto_close_based_on_last_post: false - }.to raise_error(Discourse::NotLoggedIn) - end - - it 'needs you to be an admin or mod' do - log_in - xhr :put, :autoclose, topic_id: 99, auto_close_time: '24', auto_close_based_on_last_post: false - expect(response).to be_forbidden - end - - describe 'when logged in' do - before do - @admin = log_in(:admin) - @topic = Fabricate(:topic, user: @admin) - end - - it "can set a topic's auto close time and 'based on last post' property" do - Topic.any_instance.expects(:set_auto_close).with("24", {by_user: @admin, timezone_offset: -240}) - xhr :put, :autoclose, topic_id: @topic.id, auto_close_time: '24', auto_close_based_on_last_post: true, timezone_offset: -240 - json = ::JSON.parse(response.body) - expect(json).to have_key('auto_close_at') - expect(json).to have_key('auto_close_hours') - end - - it "can remove a topic's auto close time" do - Topic.any_instance.expects(:set_auto_close).with(nil, anything) - xhr :put, :autoclose, topic_id: @topic.id, auto_close_time: nil, auto_close_based_on_last_post: false, timezone_offset: -240 - end - - it "will close a topic when the time expires" do - topic = Fabricate(:topic) - Timecop.freeze(20.hours.ago) do - create_post(topic: topic, raw: "This is the body of my cool post in the topic, but it's a bit old now") - end - topic.save - - Jobs.expects(:enqueue_at).at_least_once - xhr :put, :autoclose, topic_id: topic.id, auto_close_time: 24, auto_close_based_on_last_post: true - - topic.reload - expect(topic.closed).to eq(false) - expect(topic.posts.last.raw).to match(/cool post/) - - Timecop.freeze(5.hours.from_now) do - Jobs::CloseTopic.new.execute({topic_id: topic.id, user_id: @admin.id}) - end - - topic.reload - expect(topic.closed).to eq(true) - expect(topic.posts.last.raw).to match(/automatically closed/) - end - - it "will immediately close if the last post is old enough" do - topic = Fabricate(:topic) - Timecop.freeze(20.hours.ago) do - create_post(topic: topic) - end - topic.save - Topic.reset_highest(topic.id) - topic.reload - - xhr :put, :autoclose, topic_id: topic.id, auto_close_time: 10, auto_close_based_on_last_post: true - - topic.reload - expect(topic.closed).to eq(true) - expect(topic.posts.last.raw).to match(/after the last reply/) - expect(topic.posts.last.raw).to match(/10 hours/) - end - end - - end - describe 'make_banner' do it 'needs you to be a staff member' do @@ -1175,9 +1134,21 @@ describe TopicsController do xhr :put, :make_banner, topic_id: topic.id expect(response).to be_success end - end + end + describe 'remove_allowed_user' do + it 'admin can be removed from a pm' do + + admin = log_in :admin + user = Fabricate(:user) + pm = create_post(user: user, archetype: 'private_message', target_usernames: [user.username, admin.username]) + + xhr :put, :remove_allowed_user, topic_id: pm.topic_id, username: admin.username + + expect(response.status).to eq(200) + expect(TopicAllowedUser.where(topic_id: pm.topic_id, user_id: admin.id).first).to eq(nil) + end end describe 'remove_banner' do diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index 3dd88514a0..46f987d16e 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -33,22 +33,17 @@ describe UploadsController do }) end - it 'fails if type is invalid' do - xhr :post, :create, file: logo, type: "invalid type cause has space" - expect(response.status).to eq 403 + it 'expects a type' do + expect { xhr :post, :create, file: logo }.to raise_error(ActionController::ParameterMissing) + end - xhr :post, :create, file: logo, type: "\\invalid" - expect(response.status).to eq 403 - - xhr :post, :create, file: logo, type: "invalid." - expect(response.status).to eq 403 - - xhr :post, :create, file: logo, type: "toolong"*100 - expect(response.status).to eq 403 + it 'parameterize the type' do + subject.expects(:create_upload).with(logo, nil, "super_long_type_with_charssuper_long_type_with_char") + xhr :post, :create, file: logo, type: "super \# long \//\\ type with \\. $%^&*( chars" * 5 end it 'is successful with an image' do - Jobs.expects(:enqueue).with(:create_thumbnails, anything) + Jobs.expects(:enqueue).with(:create_avatar_thumbnails, anything) message = MessageBus.track_publish do xhr :post, :create, file: logo, type: "avatar" @@ -75,12 +70,13 @@ describe UploadsController do end it 'is successful with synchronous api' do - SiteSetting.stubs(:authorized_extensions).returns("*") + SiteSetting.authorized_extensions = "*" controller.stubs(:is_api?).returns(true) - Jobs.expects(:enqueue).with(:create_thumbnails, anything) + Jobs.expects(:enqueue).with(:create_avatar_thumbnails, anything) - FakeWeb.register_uri(:get, "http://example.com/image.png", :body => File.read('spec/fixtures/images/logo.png')) + stub_request(:head, 'http://example.com/image.png') + stub_request(:get, "http://example.com/image.png").to_return(body: File.read('spec/fixtures/images/logo.png')) xhr :post, :create, url: 'http://example.com/image.png', type: "avatar", synchronous: true @@ -92,7 +88,7 @@ describe UploadsController do it 'correctly sets retain_hours for admins' do log_in :admin - Jobs.expects(:enqueue).with(:create_thumbnails, anything) + Jobs.expects(:enqueue).with(:create_avatar_thumbnails, anything).never message = MessageBus.track_publish do xhr :post, :create, file: logo, retain_hours: 100, type: "profile_background" @@ -110,7 +106,7 @@ describe UploadsController do end.first expect(response.status).to eq 200 - expect(message.data["errors"]).to eq(I18n.t("upload.file_missing")) + expect(message.data["errors"]).to contain_exactly(I18n.t("upload.file_missing")) end it 'properly returns errors' do @@ -139,7 +135,7 @@ describe UploadsController do end it 'returns an error when it could not determine the dimensions of an image' do - Jobs.expects(:enqueue).with(:create_thumbnails, anything).never + Jobs.expects(:enqueue).with(:create_avatar_thumbnails, anything).never message = MessageBus.track_publish do xhr :post, :create, file: fake_jpg, type: "composer" @@ -148,8 +144,7 @@ describe UploadsController do expect(response.status).to eq 200 expect(message.channel).to eq("/uploads/composer") - expect(message.data["errors"]).to be - expect(message.data["errors"][0]).to eq(I18n.t("upload.images.size_not_found")) + expect(message.data["errors"]).to contain_exactly(I18n.t("upload.images.size_not_found")) end end @@ -189,7 +184,7 @@ describe UploadsController do it "handles file without extension" do SiteSetting.authorized_extensions = "*" - upload = Fabricate(:upload, original_filename: "image_file", sha1: sha) + Fabricate(:upload, original_filename: "image_file", sha1: sha) controller.stubs(:render) controller.expects(:send_file) diff --git a/spec/controllers/user_avatars_controller_spec.rb b/spec/controllers/user_avatars_controller_spec.rb index 7c40e2ba72..e558c1a8c2 100644 --- a/spec/controllers/user_avatars_controller_spec.rb +++ b/spec/controllers/user_avatars_controller_spec.rb @@ -10,7 +10,7 @@ describe UserAvatarsController do end it 'returns an avatar if we are allowing the proxy' do - response = get :show_proxy_letter, version: 'v2', letter: 'a', color: 'aaaaaa', size: 20 + response = get :show_proxy_letter, version: 'v2', letter: 'a', color: 'aaaaaa', size: 360 expect(response.status).to eq(200) end end @@ -24,7 +24,8 @@ describe UserAvatarsController do SiteSetting.s3_upload_bucket = "test" SiteSetting.s3_cdn_url = "http://cdn.com" - FakeWeb.register_uri(:get, "http://cdn.com/something/else", :body => 'image') + stub_request(:head, "http://cdn.com/something/else") + stub_request(:get, "http://cdn.com/something/else").to_return(body: 'image') GlobalSetting.expects(:cdn_url).returns("http://awesome.com/boom") @@ -46,7 +47,7 @@ describe UserAvatarsController do get :show, size: 98, username: user.username, version: upload.id, hostname: 'default' expect(response.body).to eq("image") - expect(response.headers["Cache-Control"]).to eq('max-age=31557600, public') + expect(response.headers["Cache-Control"]).to eq('max-age=31557600, public, immutable') end it 'serves image even if size missing and its in local mode' do diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 525049bc1f..8c2c33e48d 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -13,10 +13,10 @@ describe UsersController do expect(response).to be_success end - it "raises an error for anon when profiles are hidden" do - SiteSetting.stubs(:hide_user_profiles_from_public).returns(true) + it "should redirect to login page for anonymous user when profiles are hidden" do + SiteSetting.hide_user_profiles_from_public = true xhr :get, :show, username: user.username, format: :json - expect(response).not_to be_success + expect(response).to redirect_to '/login' end end @@ -100,7 +100,7 @@ describe UsersController do it "redirects to their profile when logged in" do user = log_in get :user_preferences_redirect - expect(response).to redirect_to("/users/#{user.username_lower}/preferences") + expect(response).to redirect_to("/u/#{user.username_lower}/preferences") end end @@ -155,21 +155,13 @@ describe UsersController do put :perform_account_activation, token: 'asdfasdf' end - it 'returns success' do + it 'correctly logs on user' do expect(response).to be_success - end - - it "doesn't set an error" do expect(flash[:error]).to be_blank - end - - it 'logs in as the user' do expect(session[:current_user_id]).to be_present - end - - it "doesn't set @needs_approval" do expect(assigns[:needs_approval]).to be_blank end + end context 'user is not approved' do @@ -199,6 +191,27 @@ describe UsersController do end end + describe '#perform_account_activation' do + describe 'when cookies contains a destination URL' do + let(:token) { 'asdadwewq' } + let(:user) { Fabricate(:user) } + + before do + UsersController.any_instance.stubs(:honeypot_or_challenge_fails?).returns(false) + EmailToken.expects(:confirm).with(token).returns(user) + end + + it 'should redirect to the URL' do + destination_url = 'http://thisisasite.com/somepath' + request.cookies[:destination_url] = destination_url + + put :perform_account_activation, token: token + + expect(response).to redirect_to(destination_url) + end + end + end + describe '.password_reset' do let(:user) { Fabricate(:user) } @@ -218,8 +231,8 @@ describe UsersController do it 'disallows login' do expect(assigns[:error]).to be_present expect(session[:current_user_id]).to be_blank - expect(assigns[:invalid_token]).to eq(nil) expect(response).to be_success + expect(response).to render_template(layout: 'no_ember') end end @@ -231,8 +244,8 @@ describe UsersController do it 'disallows login' do expect(assigns[:error]).to be_present expect(session[:current_user_id]).to be_blank - expect(assigns[:invalid_token]).to eq(true) expect(response).to be_success + expect(response).to render_template(layout: 'no_ember') end end @@ -241,7 +254,7 @@ describe UsersController do render_views it 'renders referrer never on get requests' do - user = Fabricate(:user, auth_token: SecureRandom.hex(16)) + user = Fabricate(:user) token = user.email_tokens.create(email: user.email).token get :password_reset, token: token @@ -250,37 +263,39 @@ describe UsersController do end it 'returns success' do - user = Fabricate(:user, auth_token: SecureRandom.hex(16)) + user = Fabricate(:user) + user_auth_token = UserAuthToken.generate!(user_id: user.id) token = user.email_tokens.create(email: user.email).token - old_token = user.auth_token - get :password_reset, token: token put :password_reset, token: token, password: 'hg9ow8yhg98o' + expect(response).to be_success expect(assigns[:error]).to be_blank user.reload - expect(user.auth_token).to_not eq old_token - expect(user.auth_token.length).to eq 32 + expect(session["password-#{token}"]).to be_blank + expect(UserAuthToken.where(id: user_auth_token.id).count).to eq(0) end it 'disallows double password reset' do - - user = Fabricate(:user, auth_token: SecureRandom.hex(16)) + user = Fabricate(:user) token = user.email_tokens.create(email: user.email).token get :password_reset, token: token - put :password_reset, token: token, password: 'hg9ow8yhg98o' - put :password_reset, token: token, password: 'test123123Asdfsdf' + put :password_reset, token: token, password: 'hg9ow8yHG32O' + put :password_reset, token: token, password: 'test123987AsdfXYZ' user.reload - expect(user.confirm_password?('hg9ow8yhg98o')).to eq(true) + expect(user.confirm_password?('hg9ow8yHG32O')).to eq(true) + + # logged in now + expect(user.user_auth_tokens.count).to eq(1) end it "redirects to the wizard if you're the first admin" do - user = Fabricate(:admin, auth_token: SecureRandom.hex(16), auth_token_updated_at: Time.now) + user = Fabricate(:admin) token = user.email_tokens.create(email: user.email).token get :password_reset, token: token put :password_reset, token: token, password: 'hg9ow8yhg98oadminlonger' @@ -288,13 +303,17 @@ describe UsersController do end it "doesn't invalidate the token when loading the page" do - user = Fabricate(:user, auth_token: SecureRandom.hex(16)) + user = Fabricate(:user) + user_token = UserAuthToken.generate!(user_id: user.id) + email_token = user.email_tokens.create(email: user.email) get :password_reset, token: email_token.token email_token.reload + expect(email_token.confirmed).to eq(false) + expect(UserAuthToken.where(id: user_token.id).count).to eq(1) end end @@ -323,7 +342,7 @@ describe UsersController do end it "doesn't log in the user when not approved" do - SiteSetting.expects(:must_approve_users?).returns(true) + SiteSetting.must_approve_users = true put :password_reset, token: token, password: 'ksjafh928r' expect(assigns(:user).errors).to be_blank expect(session[:current_user_id]).to be_blank @@ -411,7 +430,7 @@ describe UsersController do before do UsersController.any_instance.stubs(:honeypot_value).returns(nil) UsersController.any_instance.stubs(:challenge_value).returns(nil) - SiteSetting.stubs(:allow_new_registrations).returns(true) + SiteSetting.allow_new_registrations = true @user = Fabricate.build(:user) @user.password = "strongpassword" end @@ -429,7 +448,7 @@ describe UsersController do context 'when creating a user' do it 'sets the user locale to I18n.locale' do - SiteSetting.stubs(:default_locale).returns('en') + SiteSetting.default_locale = 'en' I18n.stubs(:locale).returns(:fr) post_user expect(User.find_by(username: @user.username).locale).to eq('fr') @@ -439,14 +458,14 @@ describe UsersController do context 'when creating a non active user (unconfirmed email)' do it 'returns a 500 when local logins are disabled' do - SiteSetting.expects(:enable_local_logins).returns(false) + SiteSetting.enable_local_logins = false post_user expect(response.status).to eq(500) end it 'returns an error when new registrations are disabled' do - SiteSetting.stubs(:allow_new_registrations).returns(false) + SiteSetting.allow_new_registrations = false post_user json = JSON.parse(response.body) expect(json['success']).to eq(false) @@ -463,10 +482,11 @@ describe UsersController do # should save user_created_message in session expect(session["user_created_message"]).to be_present + expect(session[SessionController::ACTIVATE_USER_KEY]).to be_present end context "and 'must approve users' site setting is enabled" do - before { SiteSetting.expects(:must_approve_users).returns(true) } + before { SiteSetting.must_approve_users = true } it 'does not enqueue an email' do Jobs.expects(:enqueue).never @@ -572,6 +592,7 @@ describe UsersController do # should save user_created_message in session expect(session["user_created_message"]).to be_present + expect(session[SessionController::ACTIVATE_USER_KEY]).to be_present end it "shows the 'active' message" do @@ -595,8 +616,10 @@ describe UsersController do end it 'returns 500 status when new registrations are disabled' do - SiteSetting.stubs(:allow_new_registrations).returns(false) + SiteSetting.allow_new_registrations = false + post_user + json = JSON.parse(response.body) expect(json['success']).to eq(false) expect(json['message']).to be_present @@ -604,20 +627,17 @@ describe UsersController do context 'authentication records for' do - before do - SiteSetting.expects(:must_approve_users).returns(true) - end - it 'should create twitter user info if required' do - SiteSetting.stubs(:enable_twitter_logins?).returns(true) + SiteSetting.must_approve_users = true + SiteSetting.enable_twitter_logins = true twitter_auth = { twitter_user_id: 42, twitter_screen_name: "bruce" } auth = session[:authentication] = {} auth[:authenticator_name] = 'twitter' auth[:extra_data] = twitter_auth - TwitterUserInfo.expects(:create) - post_user + + expect(TwitterUserInfo.count).to eq(1) end end end @@ -658,6 +678,7 @@ describe UsersController do # should not change the session expect(session["user_created_message"]).to be_blank + expect(session[SessionController::ACTIVATE_USER_KEY]).to be_blank end end @@ -678,7 +699,7 @@ describe UsersController do end context "when 'invite only' setting is enabled" do - before { SiteSetting.expects(:invite_only?).returns(true) } + before { SiteSetting.invite_only = true } let(:create_params) {{ name: @user.name, @@ -702,6 +723,7 @@ describe UsersController do # should not change the session expect(session["user_created_message"]).to be_blank + expect(session[SessionController::ACTIVATE_USER_KEY]).to be_blank end end @@ -728,17 +750,14 @@ describe UsersController do end context 'when an Exception is raised' do - [ ActiveRecord::StatementInvalid, - RestClient::Forbidden ].each do |exception| - before { User.any_instance.stubs(:save).raises(exception) } + before { User.any_instance.stubs(:save).raises(ActiveRecord::StatementInvalid.new('Oh no')) } - let(:create_params) { - { name: @user.name, username: @user.username, - password: "strongpassword", email: @user.email} - } + let(:create_params) { + { name: @user.name, username: @user.username, + password: "strongpassword", email: @user.email} + } - include_examples 'failed signup' - end + include_examples 'failed signup' end context "with custom fields" do @@ -754,7 +773,7 @@ describe UsersController do context "with values for the fields" do let(:create_params) { { name: @user.name, - password: 'watwatwatwat', + password: 'suChS3cuRi7y', username: @user.username, email: @user.email, user_fields: { @@ -804,7 +823,7 @@ describe UsersController do context "without values for the fields" do let(:create_params) { { name: @user.name, - password: 'watwatwatwat', + password: 'suChS3cuRi7y', username: @user.username, email: @user.email, } } @@ -1202,13 +1221,17 @@ describe UsersController do expect(user.custom_fields['test']).to eq 'it' expect(user.muted_users.pluck(:username).sort).to eq [user2.username,user3.username].sort + theme = Theme.create(name: "test", user_selectable: true, user_id: -1) + put :update, username: user.username, - muted_usernames: "" + muted_usernames: "", + theme_key: theme.key user.reload expect(user.muted_users.pluck(:username).sort).to be_empty + expect(user.user_option.theme_key).to eq(theme.key) end @@ -1390,7 +1413,7 @@ describe UsersController do context "when `enable_names` is false" do before do - SiteSetting.stubs(:enable_names?).returns(false) + SiteSetting.enable_names = false end it "returns names" do @@ -1406,9 +1429,11 @@ describe UsersController do context 'for an existing user' do let(:user) { Fabricate(:user, active: false) } - context 'for an activated account' do + context 'for an activated account with email confirmed' do it 'fails' do active_user = Fabricate(:user, active: true) + email_token = active_user.email_tokens.create(email: active_user.email).token + EmailToken.confirm(email_token) session[SessionController::ACTIVATE_USER_KEY] = active_user.id xhr :post, :send_activation_email, username: active_user.username @@ -1422,6 +1447,34 @@ describe UsersController do end end + context 'for an activated account with unconfirmed email' do + it 'should send an email' do + unconfirmed_email_user = Fabricate(:user, active: true) + unconfirmed_email_user.email_tokens.create(email: unconfirmed_email_user.email) + session[SessionController::ACTIVATE_USER_KEY] = unconfirmed_email_user.id + Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :signup)) + xhr :post, :send_activation_email, username: unconfirmed_email_user.username + + expect(response.status).to eq(200) + + expect(session[SessionController::ACTIVATE_USER_KEY]).to eq(nil) + end + end + + context "approval is enabled" do + before do + SiteSetting.must_approve_users = true + end + + it "should raise an error" do + unconfirmed_email_user = Fabricate(:user, active: true) + unconfirmed_email_user.email_tokens.create(email: unconfirmed_email_user.email) + session[SessionController::ACTIVATE_USER_KEY] = unconfirmed_email_user.id + xhr :post, :send_activation_email, username: unconfirmed_email_user.username + expect(response.status).to eq(403) + end + end + describe 'when user does not have a valid session' do it 'should not be valid' do user = Fabricate(:user) @@ -1498,13 +1551,13 @@ describe UsersController do end it "raises an error when sso_overrides_avatar is disabled" do - SiteSetting.stubs(:sso_overrides_avatar).returns(true) + SiteSetting.sso_overrides_avatar = true xhr :put, :pick_avatar, username: user.username, upload_id: upload.id, type: "custom" expect(response).to_not be_success end it "raises an error when selecting the custom/uploaded avatar and allow_uploaded_avatars is disabled" do - SiteSetting.stubs(:allow_uploaded_avatars).returns(false) + SiteSetting.allow_uploaded_avatars = false xhr :put, :pick_avatar, username: user.username, upload_id: upload.id, type: "custom" expect(response).to_not be_success end @@ -1773,4 +1826,239 @@ describe UsersController do end end + + describe ".confirm_admin" do + it "fails without a valid token" do + expect { + get :confirm_admin, token: 'invalid-token' + }.to raise_error(ActionController::UrlGenerationError) + end + + it "fails with a missing token" do + get :confirm_admin, token: 'a0a0a0a0a0' + expect(response).to_not be_success + end + + it "succeeds with a valid code as anonymous" do + user = Fabricate(:user) + ac = AdminConfirmation.new(user, Fabricate(:admin)) + ac.create_confirmation + get :confirm_admin, token: ac.token + expect(response).to be_success + + user.reload + expect(user.admin?).to eq(false) + end + + it "succeeds with a valid code when logged in as that user" do + admin = log_in(:admin) + user = Fabricate(:user) + + ac = AdminConfirmation.new(user, admin) + ac.create_confirmation + get :confirm_admin, token: ac.token + expect(response).to be_success + + user.reload + expect(user.admin?).to eq(false) + end + + it "fails if you're logged in as a different account" do + log_in(:admin) + user = Fabricate(:user) + + ac = AdminConfirmation.new(user, Fabricate(:admin)) + ac.create_confirmation + get :confirm_admin, token: ac.token + expect(response).to_not be_success + + user.reload + expect(user.admin?).to eq(false) + end + + describe "post" do + it "gives the user admin access when POSTed" do + user = Fabricate(:user) + ac = AdminConfirmation.new(user, Fabricate(:admin)) + ac.create_confirmation + post :confirm_admin, token: ac.token + expect(response).to be_success + + user.reload + expect(user.admin?).to eq(true) + end + end + + end + + + describe '.update_activation_email' do + + context "with a session variable" do + + it "raises an error with an invalid session value" do + session[SessionController::ACTIVATE_USER_KEY] = 1234 + xhr :put, :update_activation_email, { email: 'updatedemail@example.com' } + expect(response).to_not be_success + end + + it "raises an error for an active user" do + user = Fabricate(:walter_white) + session[SessionController::ACTIVATE_USER_KEY] = user.id + xhr :put, :update_activation_email, { email: 'updatedemail@example.com' } + expect(response).to_not be_success + end + + it "raises an error when logged in" do + moderator = log_in(:moderator) + session[SessionController::ACTIVATE_USER_KEY] = moderator.id + xhr :put, :update_activation_email, { email: 'updatedemail@example.com' } + expect(response).to_not be_success + end + + it "raises an error when the new email is taken" do + active_user = Fabricate(:user) + user = Fabricate(:inactive_user) + session[SessionController::ACTIVATE_USER_KEY] = user.id + xhr :put, :update_activation_email, { email: active_user.email } + expect(response).to_not be_success + end + + it "can be updated" do + user = Fabricate(:inactive_user) + token = user.email_tokens.first + + session[SessionController::ACTIVATE_USER_KEY] = user.id + xhr :put, :update_activation_email, { email: 'updatedemail@example.com' } + + expect(response).to be_success + + user.reload + expect(user.email).to eq('updatedemail@example.com') + expect(user.email_tokens.where(email: 'updatedemail@example.com', expired: false)).to be_present + + token.reload + expect(token.expired?).to eq(true) + end + end + + context "with a username and password" do + it "raises an error with an invalid username" do + xhr :put, :update_activation_email, { + username: 'eviltrout', + password: 'invalid-password', + email: 'updatedemail@example.com' + } + expect(response).to_not be_success + end + + it "raises an error with an invalid password" do + xhr :put, :update_activation_email, { + username: Fabricate(:inactive_user).username, + password: 'invalid-password', + email: 'updatedemail@example.com' + } + expect(response).to_not be_success + end + + it "raises an error for an active user" do + xhr :put, :update_activation_email, { + username: Fabricate(:walter_white).username, + password: 'letscook', + email: 'updatedemail@example.com' + } + expect(response).to_not be_success + end + + it "raises an error when logged in" do + log_in(:moderator) + + xhr :put, :update_activation_email, { + username: Fabricate(:inactive_user).username, + password: 'qwerqwer123', + email: 'updatedemail@example.com' + } + expect(response).to_not be_success + end + + it "raises an error when the new email is taken" do + user = Fabricate(:user) + + xhr :put, :update_activation_email, { + username: Fabricate(:inactive_user).username, + password: 'qwerqwer123', + email: user.email + } + expect(response).to_not be_success + end + + it "can be updated" do + user = Fabricate(:inactive_user) + token = user.email_tokens.first + + xhr :put, :update_activation_email, { + username: user.username, + password: 'qwerqwer123', + email: 'updatedemail@example.com' + } + + expect(response).to be_success + + user.reload + expect(user.email).to eq('updatedemail@example.com') + expect(user.email_tokens.where(email: 'updatedemail@example.com', expired: false)).to be_present + + token.reload + expect(token.expired?).to eq(true) + end + end + + end + + context "account_created" do + + it "returns a message when no session is present" do + get :account_created + created = assigns(:account_created) + expect(created).to be_present + expect(created[:message]).to eq(I18n.t('activation.missing_session')) + expect(created[:email]).to be_blank + expect(created[:username]).to be_blank + end + + it "redirects when the user is logged in" do + log_in(:user) + get :account_created + expect(response).to be_redirect + end + + context "when the user account is created" do + before do + session['user_created_message'] = "Donuts" + end + + it "returns the message when set in the session" do + get :account_created + created = assigns(:account_created) + expect(created).to be_present + expect(created[:message]).to eq('Donuts') + expect(created[:email]).to be_blank + expect(created[:username]).to be_blank + end + + it "includes user information when the session variable is present " do + user = Fabricate(:user, active: false) + session[SessionController::ACTIVATE_USER_KEY] = user.id + + get :account_created + created = assigns(:account_created) + expect(created).to be_present + expect(created[:message]).to eq('Donuts') + expect(created[:email]).to eq(user.email) + expect(created[:username]).to eq(user.username) + end + end + + end + end diff --git a/spec/controllers/users_email_controller_spec.rb b/spec/controllers/users_email_controller_spec.rb index 0a2bfe99ce..e43ad3befe 100644 --- a/spec/controllers/users_email_controller_spec.rb +++ b/spec/controllers/users_email_controller_spec.rb @@ -33,9 +33,17 @@ describe UsersEmailController do end it 'confirms with a correct token' do + user.user_stat.update_columns(bounce_score: 42, reset_bounce_score_after: 1.week.from_now) + get :confirm, token: user.email_tokens.last.token + expect(response).to be_success expect(assigns(:update_result)).to eq(:complete) + + user.reload + + expect(user.user_stat.bounce_score).to eq(0) + expect(user.user_stat.reset_bounce_score_after).to eq(nil) end end end diff --git a/spec/controllers/webhooks_controller_spec.rb b/spec/controllers/webhooks_controller_spec.rb index 6bf6404f31..ff329bf839 100644 --- a/spec/controllers/webhooks_controller_spec.rb +++ b/spec/controllers/webhooks_controller_spec.rb @@ -12,13 +12,14 @@ describe WebhooksController do SiteSetting.mailgun_api_key = "key-8221462f0c915af3f6f2e2df7aa5a493" user = Fabricate(:user, email: email) - email_log = Fabricate(:email_log, user: user, message_id: message_id) + email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email) WebhooksController.any_instance.expects(:mailgun_verify).returns(true) post :mailgun, "token" => "705a8ccd2ce932be8e98c221fe701c1b4a0afcb8bbd57726de", "timestamp" => Time.now.to_i, "event" => "dropped", + "recipient" => email, "Message-Id" => "<12345@il.com>" expect(response).to be_success @@ -34,7 +35,7 @@ describe WebhooksController do it "works" do user = Fabricate(:user, email: email) - email_log = Fabricate(:email_log, user: user, message_id: message_id) + email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email) post :sendgrid, "_json" => [ { @@ -58,10 +59,11 @@ describe WebhooksController do it "works" do user = Fabricate(:user, email: email) - email_log = Fabricate(:email_log, user: user, message_id: message_id) + email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email) post :mailjet, { "event" => "bounce", + "email" => email, "hard_bounce" => true, "CustomID" => message_id } @@ -79,11 +81,12 @@ describe WebhooksController do it "works" do user = Fabricate(:user, email: email) - email_log = Fabricate(:email_log, user: user, message_id: message_id) + email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email) post :mandrill, mandrill_events: [{ "event" => "hard_bounce", "msg" => { + "email" => email, "metadata" => { "message_id" => message_id } @@ -103,12 +106,13 @@ describe WebhooksController do it "works" do user = Fabricate(:user, email: email) - email_log = Fabricate(:email_log, user: user, message_id: message_id) + email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email) post :sparkpost, "_json" => [{ "msys" => { "message_event" => { "bounce_class" => 10, + "rcpt_to" => email, "rcpt_meta" => { "message_id" => message_id } diff --git a/spec/fabricators/category_fabricator.rb b/spec/fabricators/category_fabricator.rb index 0c668579c9..582619f23b 100644 --- a/spec/fabricators/category_fabricator.rb +++ b/spec/fabricators/category_fabricator.rb @@ -1,4 +1,31 @@ -Fabricator(:embeddable_host) do - host "eviltrout.com" - category +Fabricator(:category) do + name { sequence(:name) { |n| "Amazing Category #{n}" } } + user +end + +Fabricator(:diff_category, from: :category) do + name "Different Category" + user +end + +Fabricator(:happy_category, from: :category) do + name 'Happy Category' + slug 'happy' + user +end + +Fabricator(:private_category, from: :category) do + transient :group + + name 'Private Category' + slug 'private' + user + after_build do |cat, transients| + cat.update!(read_restricted: true) + cat.category_groups.build(group_id: transients[:group].id, permission_type: CategoryGroup.permission_types[:full]) + end +end + +Fabricator(:link_category, from: :category) do + before_validation { |category, transients| category.topic_featured_link_allowed = true } end diff --git a/spec/fabricators/color_scheme_fabricator.rb b/spec/fabricators/color_scheme_fabricator.rb index 09bde58ef2..111964527a 100644 --- a/spec/fabricators/color_scheme_fabricator.rb +++ b/spec/fabricators/color_scheme_fabricator.rb @@ -1,5 +1,4 @@ Fabricator(:color_scheme) do name { sequence(:name) {|i| "Palette #{i}" } } - enabled false color_scheme_colors(count: 2) { |attrs, i| Fabricate.build(:color_scheme_color, color_scheme: nil) } end diff --git a/spec/fabricators/embeddable_host_fabricator.rb b/spec/fabricators/embeddable_host_fabricator.rb index 582619f23b..0c668579c9 100644 --- a/spec/fabricators/embeddable_host_fabricator.rb +++ b/spec/fabricators/embeddable_host_fabricator.rb @@ -1,31 +1,4 @@ -Fabricator(:category) do - name { sequence(:name) { |n| "Amazing Category #{n}" } } - user -end - -Fabricator(:diff_category, from: :category) do - name "Different Category" - user -end - -Fabricator(:happy_category, from: :category) do - name 'Happy Category' - slug 'happy' - user -end - -Fabricator(:private_category, from: :category) do - transient :group - - name 'Private Category' - slug 'private' - user - after_build do |cat, transients| - cat.update!(read_restricted: true) - cat.category_groups.build(group_id: transients[:group].id, permission_type: CategoryGroup.permission_types[:full]) - end -end - -Fabricator(:link_category, from: :category) do - before_validation { |category, transients| category.topic_featured_link_allowed = true } +Fabricator(:embeddable_host) do + host "eviltrout.com" + category end diff --git a/spec/fabricators/post_fabricator.rb b/spec/fabricators/post_fabricator.rb index e462230efc..4a28dac1c0 100644 --- a/spec/fabricators/post_fabricator.rb +++ b/spec/fabricators/post_fabricator.rb @@ -126,8 +126,8 @@ Fabricator(:private_message_post, from: :post) do created_at: attrs[:created_at], subtype: TopicSubtype.user_to_user, topic_allowed_users: [ - Fabricate.build(:topic_allowed_user, user_id: attrs[:user].id), - Fabricate.build(:topic_allowed_user, user_id: Fabricate(:user).id) + Fabricate.build(:topic_allowed_user, user: attrs[:user]), + Fabricate.build(:topic_allowed_user, user: Fabricate(:user)) ] ) end diff --git a/spec/fabricators/topic_allowed_group_fabricator.rb b/spec/fabricators/topic_allowed_group_fabricator.rb new file mode 100644 index 0000000000..e864faf6f9 --- /dev/null +++ b/spec/fabricators/topic_allowed_group_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:topic_allowed_group) do + topic + group +end diff --git a/spec/fabricators/topic_allowed_user_fabricator.rb b/spec/fabricators/topic_allowed_user_fabricator.rb new file mode 100644 index 0000000000..27c08d78b3 --- /dev/null +++ b/spec/fabricators/topic_allowed_user_fabricator.rb @@ -0,0 +1,3 @@ +Fabricator(:topic_allowed_user) do + user +end diff --git a/spec/fabricators/topic_fabricator.rb b/spec/fabricators/topic_fabricator.rb index bd6eff9413..e603a1244f 100644 --- a/spec/fabricators/topic_fabricator.rb +++ b/spec/fabricators/topic_fabricator.rb @@ -12,9 +12,6 @@ Fabricator(:closed_topic, from: :topic) do closed true end -Fabricator(:topic_allowed_user) do -end - Fabricator(:banner_topic, from: :topic) do archetype Archetype.banner end @@ -25,7 +22,7 @@ Fabricator(:private_message_topic, from: :topic) do title { sequence(:title) { |i| "This is a private message #{i}" } } archetype "private_message" topic_allowed_users{|t| [ - Fabricate.build(:topic_allowed_user, user_id: t[:user].id), - Fabricate.build(:topic_allowed_user, user_id: Fabricate(:coding_horror).id) + Fabricate.build(:topic_allowed_user, user: t[:user]), + Fabricate.build(:topic_allowed_user, user: Fabricate(:coding_horror)) ]} end diff --git a/spec/fabricators/topic_timer_fabricator.rb b/spec/fabricators/topic_timer_fabricator.rb new file mode 100644 index 0000000000..64122b6607 --- /dev/null +++ b/spec/fabricators/topic_timer_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:topic_timer) do + user + topic + execute_at Time.zone.now + 1.hour + status_type TopicTimer.types[:close] +end diff --git a/spec/fabricators/upload_fabricator.rb b/spec/fabricators/upload_fabricator.rb index 96abf71405..a2afdf7af0 100644 --- a/spec/fabricators/upload_fabricator.rb +++ b/spec/fabricators/upload_fabricator.rb @@ -8,6 +8,10 @@ Fabricator(:upload) do url { sequence(:url) { |n| "/uploads/default/#{n}/1234567890123456.png" } } end +Fabricator(:upload_s3, from: :upload) do + url { sequence(:url) { |n| "#{Discourse.store.absolute_base_url}/uploads/default/#{n}/1234567890123456.png" } } +end + Fabricator(:attachment, from: :upload) do id 42 user diff --git a/spec/fabricators/user_fabricator.rb b/spec/fabricators/user_fabricator.rb index 5cad8011f9..5385b4f97a 100644 --- a/spec/fabricators/user_fabricator.rb +++ b/spec/fabricators/user_fabricator.rb @@ -36,6 +36,7 @@ Fabricator(:inactive_user, from: :user) do name 'Inactive User' username 'inactive_user' email 'inactive@idontexist.com' + password 'qwerqwer123' active false end @@ -94,7 +95,7 @@ Fabricator(:anonymous, from: :user) do trust_level TrustLevel[1] trust_level_locked true - after_create do |user| + before_create do |user| user.custom_fields["master_id"] = 1 user.save! end diff --git a/spec/fabricators/web_hook_fabricator.rb b/spec/fabricators/web_hook_fabricator.rb index 3d10b267a9..d9354fb59e 100644 --- a/spec/fabricators/web_hook_fabricator.rb +++ b/spec/fabricators/web_hook_fabricator.rb @@ -28,3 +28,11 @@ Fabricator(:topic_web_hook, from: :web_hook) do web_hook.web_hook_event_types = [transients[:topic_hook]] end end + +Fabricator(:user_web_hook, from: :web_hook) do + transient user_hook: WebHookEventType.find_by(name: 'user') + + after_build do |web_hook, transients| + web_hook.web_hook_event_types = [transients[:user_hook]] + end +end diff --git a/spec/fixtures/emails/inline_image.eml b/spec/fixtures/emails/inline_image.eml index 52188b2a78..af3283702a 100644 --- a/spec/fixtures/emails/inline_image.eml +++ b/spec/fixtures/emails/inline_image.eml @@ -21,8 +21,8 @@ After --001a114b2eccff1836052998ec67 Content-Type: text/html; charset=UTF-8 -
            Before

            内嵌图片 1
            -

            After
            +
            Before

            内嵌图片 1

            After +
            --001a114b2eccff1836052998ec67-- --001a114b2eccff183a052998ec68 diff --git a/spec/fixtures/emails/reply_with_8bit_encoding.eml b/spec/fixtures/emails/reply_with_8bit_encoding.eml new file mode 100644 index 0000000000..ed99f6752a --- /dev/null +++ b/spec/fixtures/emails/reply_with_8bit_encoding.eml @@ -0,0 +1,12 @@ +Return-Path: +From: Foo Bar +To: reply+4f97315cc828096c9cb34c6f1a0d6fe8@bar.com +Date: Fri, 15 Jan 2016 00:12:43 +0100 +Message-ID: <43@foo.bar.mail> +MIME-Version: 1.0 +Content-Type: text/plain; charset=iso-8859-1 +Content-Disposition: inline +Content-Transfer-Encoding: 8bit + +hab vergessen kritische zeichen einzufgen: + diff --git a/spec/fixtures/json/sam-s-simple-theme.dcstyle.json b/spec/fixtures/json/sam-s-simple-theme.dcstyle.json new file mode 100644 index 0000000000..e942c38689 --- /dev/null +++ b/spec/fixtures/json/sam-s-simple-theme.dcstyle.json @@ -0,0 +1 @@ +{"theme":{"id":2,"name":"Sam's Simple Theme","key":"6a390523-b662-4966-97d6-e731d292cd9d","created_at":"2017-05-08T06:17:14.510Z","updated_at":"2017-05-08T06:17:14.510Z","color_scheme":null,"color_scheme_id":null,"user_selectable":false,"remote_theme_id":1,"theme_fields":[{"name":"scss","target":"desktop","value":".topic-list \u003e tbody \u003e tr:nth-child(odd) {\n background-color: white;\n}\n\n\n.topic-list \u003e tbody tr:not(.last-visit):not(.topic-list-item-separator) td {\n border-bottom: 1px solid #eee;\n}\n\n.topic-list a.title:not(.badge-notification) {\n color: #3b5998;\n font-weight: normal;\n font-family: 'Helvetica Neue', Helvetica, Arial, Utkal, sans-serif;\n font-size: 18px;\n}\n\n.topic-list a.title:not(.badge-notification):hover {\n text-decoration: underline;\n}\n\n.topic-list .creator, .topic-list .editor {\n font-size: 13px;\n display: block;\n margin-top: 3px;\n}\n\n.topic-list .main-link {\n width: auto;\n}\n\n.topic-list {\n td, .creator a, .editor a {\n color: #888;\n }\n}\n\n.topic-list td {\n padding-bottom: 10px;\n}\n\n.topic-list th {\n color: black;\n background-color: #fafafa;\n}\n.topic-list {\n border-top: none;\n}\n\n.topic-list td:first-of-type {\n padding-bottom: 10px;\n padding-left: 10px;\n padding-top: 10px;\n}\n\n.topic-list tr.topic-list-item-separator td:first-of-type {\n padding: 0;\n}\n\n \n.topic-list {\n .fa-tag {\n opacity: 0.7;\n font-size: 10px;\n margin-right: -2px;\n }\n .num.posts-map {\n position: relative;\n font-size: 17px;\n a {\n color: #888 !important;\n font-weight: normal;\n }\n a.heatmap-high {\n color: #c66 !important; \n }\n width: 100px;\n }\n}\n\n.topic-list td.last-post, .topic-list th.activity {\n width: 160px;\n .relative-date {\n color: #666;\n }\n}\n\n.topic-list .creator .relative-date {\n margin-left: 8px;\n}\n\n.badge-category-bg {\n opacity: 0.8;\n}\n\n.topic-list .badge-wrapper.bar {\n .badge-category {\n color: #9a9a9a !important;\n font-weight: 600;\n font-size: 12px;\n padding: 2px 6px;\n }\n .badge-category-bg {\n top: -1px;\n }\n}\n\n.last-post {\n .poster-avatar {\n margin-right: 10px;\n }\n .poster-avatar, .poster-info {\n float: left;\n }\n}\n\n.topic-list .posts {\n width: auto;\n}\n\n/* topic page */\n#topic-progress {\n background-color: #f1f1f1;\n color: #666;\n font-weight: normal;\n .bg {\n background-color: #bee9e9;\n }\n}\n\nnav.post-controls button.has-like {\n color: #08c;\n}\n\n.title-wrapper .discourse-tag {\n border-radius: 5px !important;\n border: 0px solid #D0D0D0 !important;\n padding: 1px 6px !important;\n color: #1D1D1D !important;\n background-color: #F1F1F1 !important;\n}\n\n.badge-wrapper.bullet span.badge-category {\n color: #333 !important;\n}\n\n.creator .badge-wrapper.bullet span.badge-category {\n color: #888 !important;\n margin-top: -2px;\n}\n\nimg.avatar {\n border-radius: 3px;\n}\n\n#suggested-topics td.main-link {\n width: 500px;\n}\n\n.topic-list a.title.visited:not(.badge-notification) {\n color: #6644aa;\n}\n"},{"name":"header","target":"desktop","value":"\u003cscript type='text/x-handlebars' data-template-name='list/topic-list-item.raw'\u003e\n\n\u003ctd class='main-link clearfix'\u003e\n\n {{raw \"topic-status\" topic=topic}}\n {{topic-link topic}}\n {{#if controller.showTopicPostBadges}}\n {{raw \"topic-post-badges\" unread=topic.unread newPosts=topic.displayNewPosts unseen=topic.unseen url=topic.lastUnreadUrl}}\n {{/if}}\n\n\n {{raw \"list/topic-excerpt\" topic=model}}\n \u003cdiv class='creator'\u003e\n {{#if showCategory}}\n {{category-link topic.category}}\n {{/if}}\n {{~#if topic.creator ~}}\n \u003ca href=\"/users/{{topic.creator.username}}\" data-auto-route=\"true\" data-user-card=\"{{topic.creator.username}}\"\u003e{{topic.creator.username}}\u003c/a\u003e \u003ca href={{topic.url}}\u003e{{format-date topic.createdAt format=\"tiny\"}}\u003c/a\u003e\n {{~/if ~}}\n {{raw \"list/action-list\" topic=topic postNumbers=topic.liked_post_numbers className=\"likes\" icon=\"heart\"}}\n \u003c/div\u003e\n\u003c/td\u003e\n\n\n\n{{#if controller.showLikes}}\n\u003ctd class=\"num likes\"\u003e\n {{number topic.like_count}} \u003ci class='fa fa-heart'\u003e\u003c/i\u003e\n\u003c/td\u003e\n{{/if}}\n \n{{#if controller.showOpLikes}}\n\u003ctd class=\"num likes\"\u003e\n {{number topic.op_like_count}} \u003ci class='fa fa-heart'\u003e\u003c/i\u003e\n\u003c/td\u003e\n{{/if}}\n\n{{raw \"list/posts-count-column\" topic=topic}}\n\n\u003ctd class=\"last-post\"\u003e\n\u003cdiv class='poster-avatar'\u003e\n\u003ca href=\"{{topic.lastPostUr}}\" data-user-card=\"{{topic.last_poster_username}}\"\u003e{{avatar topic.lastPoster usernamePath=\"username\" imageSize=\"medium\"}}\u003c/a\u003e\n\u003c/div\u003e\n\u003cdiv class='poster-info'\u003e\n\u003ca href=\"{{topic.lastPostUrl}}\"\u003e\n{{format-date topic.bumpedAt format=\"tiny\"}}\n\u003c/a\u003e\n\u003cspan class='editor'\u003e\u003ca href=\"/users/{{topic.last_poster_username}}\" data-auto-route=\"true\" data-user-card=\"{{topic.last_poster_username}}\"\u003e{{topic.last_poster_username}}\u003c/a\u003e\u003c/span\u003e\n\u003c/div\u003e\n\u003c/td\u003e\n\u003c/script\u003e\n\n\u003cscript type='text/x-handlebars' data-template-name='topic-list-header.raw'\u003e\n {{raw \"topic-list-header-column\" order='posts' name='topic.title'}}\n \n {{#if showLikes}}\n {{raw \"topic-list-header-column\" sortable='true' order='likes' number='true' forceName='Likes'}}\n {{/if}}\n {{#if showOpLikes}}\n {{raw \"topic-list-header-column\" sortable='true' order='op_likes' number='true' forceName='Likes'}}\n {{/if}}\n {{raw \"topic-list-header-column\" sortable='true' number='true' order='posts' forceName='Replies'}}\n {{raw \"topic-list-header-column\" sortable='true' order='activity' forceName='Last Post'}}\n\u003c/script\u003e\n\n\u003cscript\u003e\n\n(function(){\n\nvar TopicListItemView = require('discourse/components/topic-list-item').default;\n\n\nTopicListItemView.reopen({\n showCategory: function(){\n return !this.get('controller.hideCategory') \u0026\u0026\n this.get('topic.creator') \u0026\u0026\n this.get('topic.category.name') !== 'uncategorized';\n }.property()\n});\n \n})();\n \n\u003c/script\u003e\n"}],"child_themes":[],"remote_theme":{"id":1,"remote_url":"https://github.com/SamSaffron/discourse-simple-theme.git","remote_version":"49986016d76be1f526d5dc44704e6701021db900","local_version":"49986016d76be1f526d5dc44704e6701021db900","about_url":"https://meta.discourse.org/t/sams-personal-minimal-topic-list-design/23552","license_url":"https://github.com/SamSaffron/discourse-simple-theme/blob/master/LICENSE.txt","commits_behind":0,"remote_updated_at":"2017-05-08T06:17:14.487Z","updated_at":"2017-05-08T06:17:14.501Z"}}} diff --git a/spec/fixtures/plugins/my_plugin/nested/plugin.rb b/spec/fixtures/plugins/my_plugin/nested/plugin.rb new file mode 100644 index 0000000000..e69de29bb2 diff --git a/spec/fixtures/woff2/fake.woff2 b/spec/fixtures/woff2/fake.woff2 new file mode 100644 index 0000000000..b4ebf2efe8 --- /dev/null +++ b/spec/fixtures/woff2/fake.woff2 @@ -0,0 +1 @@ +not a woff diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 7adf0c2baf..77b43be594 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -106,4 +106,10 @@ describe ApplicationHelper do end end + describe 'gsub_emoji_to_unicode' do + it "converts all emoji to unicode" do + expect(helper.gsub_emoji_to_unicode('Boat Talk: my :sailboat: boat: why is it so slow? :snail:')).to eq("Boat Talk: my ⛵ boat: why is it so slow? 🐌") + end + end + end diff --git a/spec/integration/admin/emojis_spec.rb b/spec/integration/admin/emojis_spec.rb new file mode 100644 index 0000000000..c0d72fcdfb --- /dev/null +++ b/spec/integration/admin/emojis_spec.rb @@ -0,0 +1,76 @@ +require 'rails_helper' + +RSpec.describe "Managing custom emojis" do + let(:admin) { Fabricate(:admin) } + let(:upload) { Fabricate(:upload) } + + before do + sign_in(admin) + end + + describe "creating a custom emoji" do + describe 'when upload is invalid' do + it 'should publish the right error' do + message = MessageBus.track_publish do + post("/admin/customize/emojis.json", { + name: 'test', + file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/fake.jpg") + }) + end.find{|m| m.channel == "/uploads/emoji"} + + expect(message.channel).to eq("/uploads/emoji") + expect(message.data["errors"]).to eq([I18n.t('upload.images.size_not_found')]) + end + end + + describe 'when emoji name already exists' do + it 'should publish the right error' do + CustomEmoji.create!(name: 'test', upload: upload) + + message = MessageBus.track_publish do + post("/admin/customize/emojis.json", { + name: 'test', + file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png") + }) + end.find{|m| m.channel == "/uploads/emoji"} + + expect(message.channel).to eq("/uploads/emoji") + + expect(message.data["errors"]).to eq([ + "Name #{I18n.t('activerecord.errors.models.custom_emoji.attributes.name.taken')}" + ]) + end + end + + it 'should allow an admin to add a custom emoji' do + Emoji.expects(:clear_cache) + + message = MessageBus.track_publish do + post("/admin/customize/emojis.json", { + name: 'test', + file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png") + }) + end.find{|m| m.channel == "/uploads/emoji"} + + custom_emoji = CustomEmoji.last + upload = custom_emoji.upload + + expect(upload.original_filename).to eq('logo.png') + expect(message.channel).to eq("/uploads/emoji") + expect(message.data["errors"]).to eq(nil) + expect(message.data["name"]).to eq(custom_emoji.name) + expect(message.data["url"]).to eq(upload.url) + end + end + + describe 'deleting a custom emoji' do + it 'should allow an admin to delete a custom emoji' do + custom_emoji = CustomEmoji.create!(name: 'test', upload: upload) + Emoji.clear_cache + + expect { delete "/admin/customize/emojis/#{custom_emoji.name}.json", name: 'test' } + .to change { Upload.count }.by(-1) + .and change { CustomEmoji.count }.by(-1) + end + end +end diff --git a/spec/integration/groups_spec.rb b/spec/integration/groups_spec.rb index bc721bf2e3..788d72c1b1 100644 --- a/spec/integration/groups_spec.rb +++ b/spec/integration/groups_spec.rb @@ -15,9 +15,9 @@ describe "Groups" do end context 'when group directory is disabled' do - site_setting(:enable_group_directory, false) - it 'should deny access' do + SiteSetting.enable_group_directory = false + get "/groups.json" expect(response).to be_forbidden end diff --git a/spec/integration/managing_topic_status_spec.rb b/spec/integration/managing_topic_status_spec.rb new file mode 100644 index 0000000000..0673c27959 --- /dev/null +++ b/spec/integration/managing_topic_status_spec.rb @@ -0,0 +1,116 @@ +require 'rails_helper' + +RSpec.describe "Managing a topic's status update", type: :request do + let(:topic) { Fabricate(:topic) } + let(:user) { Fabricate(:user) } + + context 'when a user is not logged in' do + it 'should return the right response' do + expect do + post "/t/#{topic.id}/timer.json", + time: '24', + status_type: TopicTimer.types[1] + end.to raise_error(Discourse::NotLoggedIn) + end + end + + context 'when does not have permission' do + it 'should return the right response' do + sign_in(user) + + post "/t/#{topic.id}/timer.json", + time: '24', + status_type: TopicTimer.types[1] + + expect(response.status).to eq(403) + expect(JSON.parse(response.body)["error_type"]).to eq('invalid_access') + end + end + + context 'when logged in as an admin' do + let(:admin) { Fabricate(:admin) } + + before do + sign_in(admin) + end + + it 'should be able to create a topic status update' do + time = 24 + + post "/t/#{topic.id}/timer.json", + time: 24, + status_type: TopicTimer.types[1] + + expect(response).to be_success + + topic_status_update = TopicTimer.last + + expect(topic_status_update.topic).to eq(topic) + + expect(topic_status_update.execute_at) + .to be_within(1.second).of(24.hours.from_now) + + json = JSON.parse(response.body) + + expect(DateTime.parse(json['execute_at'])) + .to be_within(1.seconds).of(DateTime.parse(topic_status_update.execute_at.to_s)) + + expect(json['duration']).to eq(topic_status_update.duration) + expect(json['closed']).to eq(topic.reload.closed) + end + + it 'should be able to delete a topic status update' do + topic.update!(topic_timers: [Fabricate(:topic_timer)]) + + post "/t/#{topic.id}/timer.json", + time: nil, + status_type: TopicTimer.types[1] + + expect(response).to be_success + expect(topic.reload.public_topic_timer).to eq(nil) + + json = JSON.parse(response.body) + + expect(json['execute_at']).to eq(nil) + expect(json['duration']).to eq(nil) + expect(json['closed']).to eq(topic.closed) + end + + describe 'publishing topic to category in the future' do + it 'should be able to create the topic status update' do + SiteSetting.queue_jobs = true + + post "/t/#{topic.id}/timer.json", + time: 24, + status_type: TopicTimer.types[3], + category_id: topic.category_id + + expect(response).to be_success + + topic_status_update = TopicTimer.last + + expect(topic_status_update.topic).to eq(topic) + + expect(topic_status_update.execute_at) + .to be_within(1.second).of(24.hours.from_now) + + expect(topic_status_update.status_type) + .to eq(TopicTimer.types[:publish_to_category]) + + json = JSON.parse(response.body) + + expect(json['category_id']).to eq(topic.category_id) + end + end + + describe 'invalid status type' do + it 'should raise the right error' do + expect do + post "/t/#{topic.id}/timer.json", + time: 10, + status_type: 'something' + end.to raise_error(Discourse::InvalidParameters) + end + end + end +end diff --git a/spec/integration/omniauth_callbacks_spec.rb b/spec/integration/omniauth_callbacks_spec.rb index 7549e90f77..d2616894f5 100644 --- a/spec/integration/omniauth_callbacks_spec.rb +++ b/spec/integration/omniauth_callbacks_spec.rb @@ -8,6 +8,7 @@ RSpec.describe "OmniAuth Callbacks" do end after do + Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:google_oauth2] = nil OmniAuth.config.test_mode = false end @@ -16,6 +17,13 @@ RSpec.describe "OmniAuth Callbacks" do SiteSetting.enable_google_oauth2_logins = true end + context "without an `omniauth.auth` env" do + it "should return a 404" do + get "/auth/eviltrout/callback" + expect(response.code).to eq("404") + end + end + describe 'when user has been verified' do before do OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new( diff --git a/spec/integration/spam_rules_spec.rb b/spec/integration/spam_rules_spec.rb index c1c22b4ff7..bb28573e8f 100644 --- a/spec/integration/spam_rules_spec.rb +++ b/spec/integration/spam_rules_spec.rb @@ -5,15 +5,17 @@ require 'rails_helper' describe SpamRulesEnforcer do describe 'auto-blocking users based on flagging' do - site_setting(:flags_required_to_hide_post, 0) - site_setting(:num_spam_flags_to_block_new_user, 2) - site_setting(:num_users_to_block_new_user, 2) - let!(:admin) { Fabricate(:admin) } # needed to send a system message let!(:moderator) { Fabricate(:moderator) } let(:user1) { Fabricate(:user) } let(:user2) { Fabricate(:user) } + before do + SiteSetting.flags_required_to_hide_post = 0 + SiteSetting.num_spam_flags_to_block_new_user = 2 + SiteSetting.num_users_to_block_new_user = 2 + end + context 'spammer is a new user' do let(:spammer) { Fabricate(:user, trust_level: TrustLevel[0]) } @@ -86,9 +88,8 @@ describe SpamRulesEnforcer do end context 'flags_required_to_hide_post takes effect too' do - site_setting(:flags_required_to_hide_post, 2) - it 'should block the spammer' do + SiteSetting.flags_required_to_hide_post = 2 PostAction.act(user2, spam_post, PostActionType.types[:spam]) expect(spammer.reload).to be_blocked expect(Guardian.new(spammer).can_create_topic?(nil)).to be false diff --git a/spec/integration/topic_auto_close_spec.rb b/spec/integration/topic_auto_close_spec.rb index c69d710832..75d2e07c2c 100644 --- a/spec/integration/topic_auto_close_spec.rb +++ b/spec/integration/topic_auto_close_spec.rb @@ -1,32 +1,12 @@ # encoding: UTF-8 require 'rails_helper' -require 'sidekiq/testing' describe Topic do - - def scheduled_jobs_for(job_name, params={}) - "Jobs::#{job_name.to_s.camelcase}".constantize.jobs.select do |job| - job_args = job['args'][0] - matched = true - params.each do |key, value| - unless job_args[key.to_s] == value - matched = false - break - end - end - matched - end - end + let(:job_klass) { Jobs::ToggleTopicClosed } before do - @original_value = SiteSetting.queue_jobs - SiteSetting.queue_jobs = true - Jobs::CloseTopic.jobs.clear - end - - after do - SiteSetting.queue_jobs = @original_value + job_klass.jobs.clear end context 'creating a topic without auto-close' do @@ -36,8 +16,8 @@ describe Topic do let(:category) { nil } it 'should not schedule the topic to auto-close' do - expect(topic.auto_close_at).to eq(nil) - expect(scheduled_jobs_for(:close_topic)).to be_empty + expect(topic.public_topic_timer).to eq(nil) + expect(job_klass.jobs).to eq([]) end end @@ -45,29 +25,36 @@ describe Topic do let(:category) { Fabricate(:category, auto_close_hours: nil) } it 'should not schedule the topic to auto-close' do - expect(topic.auto_close_at).to eq(nil) - expect(scheduled_jobs_for(:close_topic)).to be_empty + expect(topic.public_topic_timer).to eq(nil) + expect(job_klass.jobs).to eq([]) end end context 'jobs may be queued' do before do + SiteSetting.queue_jobs = true Timecop.freeze(Time.zone.now) end after do Timecop.return - Sidekiq::Extensions::DelayedClass.jobs.clear end context 'category has a default auto-close' do let(:category) { Fabricate(:category, auto_close_hours: 2.0) } it 'should schedule the topic to auto-close' do - expect(topic.auto_close_at).to be_within_one_second_of(2.hours.from_now) - expect(topic.auto_close_started_at).to eq(Time.zone.now) - expect(scheduled_jobs_for(:close_topic, {topic_id: topic.id}).size).to eq(1) - expect(scheduled_jobs_for(:close_topic, {topic_id: category.topic.id})).to be_empty + topic + + topic_status_update = TopicTimer.last + + expect(topic_status_update.topic).to eq(topic) + expect(topic.public_topic_timer.execute_at).to be_within_one_second_of(2.hours.from_now) + + args = job_klass.jobs.last['args'].first + + expect(args["topic_timer_id"]).to eq(topic.public_topic_timer.id) + expect(args["state"]).to eq(true) end context 'topic was created by staff user' do @@ -75,14 +62,30 @@ describe Topic do let(:staff_topic) { Fabricate(:topic, user: admin, category: category) } it 'should schedule the topic to auto-close' do - expect(scheduled_jobs_for(:close_topic, {topic_id: staff_topic.id, user_id: admin.id}).size).to eq(1) + staff_topic + + topic_status_update = TopicTimer.last + + expect(topic_status_update.topic).to eq(staff_topic) + expect(topic_status_update.execute_at).to be_within_one_second_of(2.hours.from_now) + expect(topic_status_update.user).to eq(admin) + + args = job_klass.jobs.last['args'].first + + expect(args["topic_timer_id"]).to eq(topic_status_update.id) + expect(args["state"]).to eq(true) end context 'topic is closed manually' do it 'should remove the schedule to auto-close the topic' do - staff_topic.update_status('closed', true, admin) - expect(staff_topic.reload.auto_close_at).to eq(nil) - expect(staff_topic.auto_close_started_at).to eq(nil) + Timecop.freeze do + topic_timer_id = staff_topic.public_topic_timer.id + + staff_topic.update_status('closed', true, admin) + + expect(TopicTimer.with_deleted.find(topic_timer_id).deleted_at) + .to be_within(1.second).of(Time.zone.now) + end end end end @@ -92,38 +95,20 @@ describe Topic do let(:regular_user_topic) { Fabricate(:topic, user: regular_user, category: category) } it 'should schedule the topic to auto-close' do - expect(scheduled_jobs_for(:close_topic, {topic_id: regular_user_topic.id, user_id: Discourse.system_user.id}).size).to eq(1) + regular_user_topic + + topic_status_update = TopicTimer.last + + expect(topic_status_update.topic).to eq(regular_user_topic) + expect(topic_status_update.execute_at).to be_within_one_second_of(2.hours.from_now) + expect(topic_status_update.user).to eq(Discourse.system_user) + + args = job_klass.jobs.last['args'].first + + expect(args["topic_timer_id"]).to eq(topic_status_update.id) + expect(args["state"]).to eq(true) end end - - context 'auto_close_hours of topic was set to 0' do - let(:dont_close_topic) { Fabricate(:topic, auto_close_hours: 0, category: category) } - - it 'should not schedule the topic to auto-close' do - expect(scheduled_jobs_for(:close_topic)).to be_empty - end - end - - context 'two topics in the category' do - let!(:other_topic) { Fabricate(:topic, category: category) } - - it 'should schedule the topic to auto-close' do - topic - - expect(scheduled_jobs_for(:close_topic).size).to eq(2) - end - end - end - - context 'a topic that has been auto-closed' do - let(:admin) { Fabricate(:admin) } - let!(:auto_closed_topic) { Fabricate(:topic, user: admin, closed: true, auto_close_at: 1.day.ago, auto_close_user_id: admin.id, auto_close_started_at: 6.days.ago) } - - it 'should set the right attributes' do - auto_closed_topic.update_status('closed', false, admin) - expect(auto_closed_topic.reload.auto_close_at).to eq(nil) - expect(auto_closed_topic.auto_close_started_at).to eq(nil) - end end end end diff --git a/spec/integration/users_spec.rb b/spec/integration/users_spec.rb index 7350c87ca5..46ea6bf661 100644 --- a/spec/integration/users_spec.rb +++ b/spec/integration/users_spec.rb @@ -6,7 +6,7 @@ RSpec.describe "Users" do describe "viewing a user" do it "should be able to view a user" do - get "/users/#{user.username}" + get "/u/#{user.username}" expect(response).to be_success expect(response.body).to include(user.username) @@ -18,7 +18,7 @@ RSpec.describe "Users" do end it "should be able to view a user" do - get "/users/#{user.username}" + get "/u/#{user.username}" expect(response).to be_success expect(response.body).to include(user.username) @@ -32,7 +32,7 @@ RSpec.describe "Users" do end it "should be able to update a user" do - put "/users/#{user.username}.json", { name: 'test.test' } + put "/u/#{user.username}.json", { name: 'test.test' } expect(response).to be_success expect(user.reload.name).to eq('test.test') @@ -44,7 +44,7 @@ RSpec.describe "Users" do end it "should be able to update a user" do - put "/users/#{user.username}.json", { name: 'testing123' } + put "/u/#{user.username}.json", { name: 'testing123' } expect(response).to be_success expect(user.reload.name).to eq('testing123') diff --git a/spec/integrity/i18n_spec.rb b/spec/integrity/i18n_spec.rb index 1d99795ed1..9dac7ebf68 100644 --- a/spec/integrity/i18n_spec.rb +++ b/spec/integrity/i18n_spec.rb @@ -1,127 +1,122 @@ -require 'rails_helper' -require 'locale_file_walker' +require "rails_helper" +require "i18n/duplicate_key_finder" + +def extract_locale(path) + path[/\.([^.]{2,})\.yml$/, 1] +end + +PLURALIZATION_KEYS ||= ['zero', 'one', 'two', 'few', 'many', 'other'] + +def find_pluralizations(hash, parent_key = '', pluralizations = Hash.new) + hash.each do |key, value| + if Hash === value + current_key = parent_key.blank? ? key : "#{parent_key}.#{key}" + find_pluralizations(value, current_key, pluralizations) + elsif PLURALIZATION_KEYS.include? key + pluralizations[parent_key] = hash + end + end + + pluralizations +end + +def is_yaml_compatible?(english, translated) + english.each do |k, v| + if translated.has_key?(k) + if Hash === v + if Hash === translated[k] + return false unless is_yaml_compatible?(v, translated[k]) + end + else + return false unless v.class == translated[k].class + end + end + end + + true +end + describe "i18n integrity checks" do - it 'should have an i18n key for all trust levels' 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 "needs an i18n key (description) for each Site Setting" do + it "has an i18n key for each Site Setting" do SiteSetting.all_settings.each do |s| - next if s[:setting] =~ /^test/ + next if s[:setting][/^test_/] expect(s[:description]).not_to match(/translation missing/) end end - it "has an i18n key for each badge description" do + it "has an i18n key for each Badge description" do Badge.where(system: true).each do |b| expect(b.long_description).to be_present expect(b.description).to be_present end end - it "has valid YAML for client" do - Dir["#{Rails.root}/config/locales/client.*.yml"].each do |f| - locale = /.*\.([^.]{2,})\.yml$/.match(f)[1] - client = YAML.load_file("#{Rails.root}/config/locales/client.#{locale}.yml") - expect(client.count).to eq(1) - expect(client[locale]).not_to eq(nil) - expect(client[locale]["js"]).not_to eq(nil) - expect(client[locale]["admin_js"]).not_to eq(nil) + Dir["#{Rails.root}/config/locales/client.*.yml"].each do |path| + it "has valid client YAML for '#{path}'" do + yaml = YAML.load_file(path) + locale = extract_locale(path) + + expect(yaml.keys).to eq([locale]) + + expect(yaml[locale]["js"]).to be + expect(yaml[locale]["admin_js"]).to be + # expect(yaml[locale]["wizard_js"]).to be end end - it "has valid YAML for server" do - Dir["#{Rails.root}/config/locales/server.*.yml"].each do |f| - locale = /.*\.([^.]{2,})\.yml$/.match(f)[1] - server = YAML.load_file("#{Rails.root}/config/locales/server.#{locale}.yml") - expect(server.count).to eq(1) - expect(server[locale]).not_to eq(nil) - end - end + Dir["#{Rails.root}/**/locale*/*.en.yml"].each do |english_path| + english_yaml = YAML.load_file(english_path)["en"] - it "does not overwrite another language" do - all = Dir["#{Rails.root}/config/locales/client.*.yml"] + Dir["#{Rails.root}/config/locales/server.*.yml"] - all.each do |f| - locale = /.*\.([^.]{2,})\.yml$/.match(f)[1] + ':' - IO.foreach(f) do |line| - line.strip! - next if line.start_with? "#" - next if line.start_with? "---" - next if line.blank? - expect(line).to eq locale - break - end - end - end - - describe 'English locale file' do - locale_files = ['config/locales', 'plugins/**/locales'] - .product(['server.en.yml', 'client.en.yml']) - .collect { |dir, filename| Dir["#{Rails.root}/#{dir}/#{filename}"] } - .flatten - .map { |path| Pathname.new(path).relative_path_from(Rails.root) } - - class DuplicateKeyFinder < LocaleFileWalker - def find_duplicates(filename) - @keys_with_count = {} - - document = Psych.parse_file(filename) - handle_document(document) - - @keys_with_count.delete_if { |key, count| count <= 1 }.keys + context(english_path) do + it "has no duplicate keys" do + english_duplicates = DuplicateKeyFinder.new.find_duplicates(english_path) + expect(english_duplicates).to be_empty end - protected + find_pluralizations(english_yaml).each do |key, hash| + next if key["messages.restrict_dependent_destroy"] - def handle_scalar(node, depth, parents) - super(node, depth, parents) - - key = parents.join('.') - @keys_with_count[key] = @keys_with_count.fetch(key, 0) + 1 - end - end - - module Pluralizations - def self.load(path) - whitelist = Regexp.union([/messages.restrict_dependent_destroy/]) - - yaml = YAML.load_file("#{Rails.root}/#{path}") - pluralizations = find_pluralizations(yaml['en']) - pluralizations.reject! { |key| key.match(whitelist) } - pluralizations - end - - def self.find_pluralizations(hash, parent_key = '', pluralizations = Hash.new) - hash.each do |key, value| - if value.is_a? Hash - current_key = parent_key.blank? ? key : "#{parent_key}.#{key}" - find_pluralizations(value, current_key, pluralizations) - elsif key == 'one' || key == 'other' - pluralizations[parent_key] = hash - end + it "has valid pluralizations for '#{key}'" do + expect(hash.keys).to contain_exactly("one", "other") end - - pluralizations end end - locale_files.each do |path| - context path do - it 'has no duplicate keys' do - duplicates = DuplicateKeyFinder.new.find_duplicates("#{Rails.root}/#{path}") + Dir[english_path.sub(".en.yml", ".*.yml")].each do |path| + next if path[".en.yml"] + + context(path) do + locale = extract_locale(path) + yaml = YAML.load_file(path) + + it "has no duplicate keys" do + duplicates = DuplicateKeyFinder.new.find_duplicates(path) expect(duplicates).to be_empty end - Pluralizations.load(path).each do |key, values| - it "key '#{key}' has valid pluralizations" do - expect(values.keys).to contain_exactly('one', 'other') - end + it "does not overwrite another locale" do + expect(yaml.keys).to eq([locale]) end + + unless path["transliterate"] + + it "is compatible with english" do + expect(is_yaml_compatible?(english_yaml, yaml)).to eq(true) + end + + end + end + end end + end diff --git a/spec/jobs/about_stats_spec.rb b/spec/jobs/about_stats_spec.rb index 312b345db0..2f761bf4e2 100644 --- a/spec/jobs/about_stats_spec.rb +++ b/spec/jobs/about_stats_spec.rb @@ -5,8 +5,8 @@ describe Jobs::AboutStats do begin stats = About.fetch_stats.to_json cache_key = About.stats_cache_key + $redis.del(cache_key) - expect($redis.get(cache_key)).to eq(nil) expect(described_class.new.execute({})).to eq(stats) expect($redis.get(cache_key)).to eq(stats) ensure diff --git a/spec/jobs/bulk_grant_trust_level_spec.rb b/spec/jobs/bulk_grant_trust_level_spec.rb new file mode 100644 index 0000000000..0c8112d386 --- /dev/null +++ b/spec/jobs/bulk_grant_trust_level_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' +require_dependency 'jobs/regular/bulk_grant_trust_level' + +describe Jobs::BulkGrantTrustLevel do + + it "raises an error when trust_level is missing" do + expect { Jobs::BulkGrantTrustLevel.new.execute(user_ids: [1,2]) }.to raise_error(Discourse::InvalidParameters) + end + + it "raises an error when user_ids are missing" do + expect { Jobs::BulkGrantTrustLevel.new.execute(trust_level: 0) }.to raise_error(Discourse::InvalidParameters) + end + + it "updates the trust_level" do + user1 = Fabricate(:user, email: "foo@wat.com", trust_level: 0) + user2 = Fabricate(:user, email: "foo@bar.com", trust_level: 2) + + Jobs::BulkGrantTrustLevel.new.execute(trust_level: 3, user_ids: [user1.id,user2.id]) + + user1.reload + user2.reload + expect(user1.trust_level).to eq(3) + expect(user2.trust_level).to eq(3) + end +end diff --git a/spec/jobs/clean_up_unused_staged_users.rb b/spec/jobs/clean_up_unused_staged_users.rb new file mode 100644 index 0000000000..2fd222c0e8 --- /dev/null +++ b/spec/jobs/clean_up_unused_staged_users.rb @@ -0,0 +1,37 @@ +require 'rails_helper' + +RSpec.describe Jobs::CleanUpUnusedStagedUsers do + let(:user) { Fabricate(:user) } + let(:staged_user) { Fabricate(:user, staged: true) } + + context 'when staged user is unused' do + it 'should clean up the staged user' do + user + staged_user.update!(created_at: 2.years.ago) + + expect { described_class.new.execute({}) }.to change { User.count }.by(-1) + expect(User.find_by(id: staged_user.id)).to eq(nil) + end + + describe 'when staged user is not old enough' do + it 'should not clean up the staged user' do + user + staged_user.update!(created_at: 5.months.ago) + + expect { described_class.new.execute({}) }.to_not change { User.count } + expect(User.find_by(id: staged_user.id)).to eq(staged_user) + end + end + end + + context 'when staged user is not unused' do + it 'should not clean up the staged user' do + user + Fabricate(:post, user: staged_user) + user.update!(created_at: 2.years.ago) + + expect { described_class.new.execute({}) }.to_not change { User.count } + expect(User.find_by(id: staged_user.id)).to eq(staged_user) + end + end +end diff --git a/spec/jobs/clean_up_uploads_spec.rb b/spec/jobs/clean_up_uploads_spec.rb index 7f0bf461ae..e9f649cbe1 100644 --- a/spec/jobs/clean_up_uploads_spec.rb +++ b/spec/jobs/clean_up_uploads_spec.rb @@ -140,4 +140,14 @@ describe Jobs::CleanUpUploads do expect(Upload.find_by(id: upload.id)).to eq(upload) end + it "does not delete custom emojis" do + upload = fabricate_upload + CustomEmoji.create!(name: 'test', upload: upload) + + Jobs::CleanUpUploads.new.execute(nil) + + expect(Upload.find_by(id: @upload.id)).to eq(nil) + expect(Upload.find_by(id: upload.id)).to eq(upload) + end + end diff --git a/spec/jobs/close_topic_spec.rb b/spec/jobs/close_topic_spec.rb deleted file mode 100644 index 9efb67ca48..0000000000 --- a/spec/jobs/close_topic_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -require 'rails_helper' -require_dependency 'jobs/base' - -describe Jobs::CloseTopic do - - let(:admin) { Fabricate.build(:admin) } - - it 'closes a topic that is set to auto-close' do - topic = Fabricate.build(:topic, auto_close_at: Time.zone.now, user: admin) - topic.expects(:update_status).with('autoclosed', true, admin) - Topic.stubs(:find_by).returns(topic) - User.stubs(:find_by).returns(admin) - Jobs::CloseTopic.new.execute( topic_id: 123, user_id: 234 ) - end - - shared_examples_for "cases when CloseTopic does nothing" do - it 'does nothing to the topic' do - topic.expects(:update_status).never - Topic.stubs(:find_by).returns(topic) - User.stubs(:find_by).returns(admin) - Jobs::CloseTopic.new.execute( topic_id: 123, user_id: 234 ) - end - end - - context 'when topic is not set to auto-close' do - subject(:topic) { Fabricate.build(:topic, auto_close_at: nil, user: admin) } - it_behaves_like 'cases when CloseTopic does nothing' - end - - context 'when user is not authorized to close topics' do - subject(:topic) { Fabricate.build(:topic, auto_close_at: 2.days.from_now, user: admin) } - before { Guardian.any_instance.stubs(:can_moderate?).returns(false) } - it_behaves_like 'cases when CloseTopic does nothing' - end - - context 'the topic is already closed' do - subject(:topic) { Fabricate.build(:topic, auto_close_at: 2.days.from_now, user: admin, closed: true) } - it_behaves_like 'cases when CloseTopic does nothing' - end - - context 'the topic has been deleted' do - subject(:topic) { Fabricate.build(:deleted_topic, auto_close_at: 2.days.from_now, user: admin) } - it_behaves_like 'cases when CloseTopic does nothing' - end - -end diff --git a/spec/jobs/delete_topic_spec.rb b/spec/jobs/delete_topic_spec.rb new file mode 100644 index 0000000000..11a49d7a29 --- /dev/null +++ b/spec/jobs/delete_topic_spec.rb @@ -0,0 +1,65 @@ +require 'rails_helper' + +describe Jobs::DeleteTopic do + let(:admin) { Fabricate(:admin) } + + let(:topic) do + Fabricate(:topic, + topic_timers: [Fabricate(:topic_timer, user: admin)] + ) + end + + let(:first_post) { create_post(topic: topic) } + + before do + SiteSetting.queue_jobs = true + end + + it "can delete a topic" do + first_post + + Timecop.freeze(2.hours.from_now) do + described_class.new.execute(topic_timer_id: topic.public_topic_timer.id) + expect(topic.reload).to be_trashed + expect(first_post.reload).to be_trashed + expect(topic.reload.public_topic_timer).to eq(nil) + end + end + + it "should do nothing if topic is already deleted" do + first_post + topic.trash! + Timecop.freeze(2.hours.from_now) do + Topic.any_instance.expects(:trash!).never + described_class.new.execute(topic_timer_id: topic.public_topic_timer.id) + end + end + + it "should do nothing if it's too early" do + t = Fabricate(:topic, + topic_timers: [Fabricate(:topic_timer, user: admin, execute_at: 5.hours.from_now)] + ) + create_post(topic: t) + Timecop.freeze(4.hours.from_now) do + described_class.new.execute(topic_timer_id: t.public_topic_timer.id) + expect(t.reload).to_not be_trashed + end + end + + describe "user isn't authorized to delete topics" do + let(:topic) { + Fabricate(:topic, + topic_timers: [Fabricate(:topic_timer, user: Fabricate(:user))] + ) + } + + it "shouldn't delete the topic" do + create_post(topic: topic) + Timecop.freeze(2.hours.from_now) do + described_class.new.execute(topic_timer_id: topic.public_topic_timer.id) + expect(topic.reload).to_not be_trashed + end + end + end + +end diff --git a/spec/jobs/emit_web_hook_event_spec.rb b/spec/jobs/emit_web_hook_event_spec.rb index c01ecb6569..3750fff8de 100644 --- a/spec/jobs/emit_web_hook_event_spec.rb +++ b/spec/jobs/emit_web_hook_event_spec.rb @@ -10,14 +10,10 @@ describe Jobs::EmitWebHookEvent do expect { subject.execute(event_type: 'post') }.to raise_error(Discourse::InvalidParameters) end - it 'raises an error when there is no event name' do + it 'raises an error when there is no event type' do expect { subject.execute(web_hook_id: 1) }.to raise_error(Discourse::InvalidParameters) end - it 'raises an error when event name is invalid' do - expect { subject.execute(web_hook_id: post_hook.id, event_type: 'post_random') }.to raise_error(Discourse::InvalidParameters) - end - it "doesn't emit when the hook is inactive" do Jobs::EmitWebHookEvent.any_instance.expects(:web_hook_request).never subject.execute(web_hook_id: inactive_hook.id, event_type: 'post', post_id: post.id) @@ -56,13 +52,9 @@ describe Jobs::EmitWebHookEvent do end describe '.web_hook_request' do - before(:all) { Excon.defaults[:mock] = true } - after(:all) { Excon.defaults[:mock] = false } - after(:each) { Excon.stubs.clear } - it 'creates delivery event record' do - Excon.stub({ url: "https://meta.discourse.org/webhook_listener" }, - { body: 'OK', status: 200 }) + stub_request(:post, "https://meta.discourse.org/webhook_listener") + .to_return(body: 'OK', status: 200) expect do subject.execute(web_hook_id: post_hook.id, event_type: 'post', post_id: post.id) @@ -76,8 +68,8 @@ describe Jobs::EmitWebHookEvent do end it 'sets up proper request headers' do - Excon.stub({ url: "https://meta.discourse.org/webhook_listener" }, - { headers: { test: 'string' }, body: 'OK', status: 200 }) + stub_request(:post, "https://meta.discourse.org/webhook_listener") + .to_return(headers: { test: 'string' }, body: 'OK', status: 200) subject.execute(web_hook_id: post_hook.id, event_type: 'ping', event_name: 'ping') event = WebHookEvent.last @@ -90,7 +82,7 @@ describe Jobs::EmitWebHookEvent do expect(headers['X-Discourse-Event-Signature']).to eq('sha256=162f107f6b5022353274eb1a7197885cfd35744d8d08e5bcea025d309386b7d6') expect(event.payload).to eq(MultiJson.dump({ping: 'OK'})) expect(event.status).to eq(200) - expect(MultiJson.load(event.response_headers)['test']).to eq('string') + expect(MultiJson.load(event.response_headers)['Test']).to eq('string') expect(event.response_body).to eq('OK') end end diff --git a/spec/jobs/enqueue_digest_emails_spec.rb b/spec/jobs/enqueue_digest_emails_spec.rb index f988629403..acb3f16403 100644 --- a/spec/jobs/enqueue_digest_emails_spec.rb +++ b/spec/jobs/enqueue_digest_emails_spec.rb @@ -6,7 +6,7 @@ describe Jobs::EnqueueDigestEmails do describe '#target_users' do context 'disabled digests' do - before { SiteSetting.stubs(:default_email_digest_frequency).returns(0) } + before { SiteSetting.default_email_digest_frequency = 0 } let!(:user_no_digests) { Fabricate(:active_user, last_emailed_at: 8.days.ago, last_seen_at: 10.days.ago) } it "doesn't return users with email disabled" do @@ -102,6 +102,15 @@ describe Jobs::EnqueueDigestEmails do end end + context 'too many bounces' do + let!(:bounce_user) { Fabricate(:active_user, last_seen_at: 6.month.ago) } + + it "doesn't return users with too many bounces" do + bounce_user.user_stat.update(bounce_score: SiteSetting.bounce_score_threshold + 1) + expect(Jobs::EnqueueDigestEmails.new.target_user_ids.include?(bounce_user.id)).to eq(false) + end + end + end describe '#execute' do @@ -120,13 +129,24 @@ describe Jobs::EnqueueDigestEmails do end end + context "private email" do + before do + Jobs::EnqueueDigestEmails.any_instance.expects(:target_user_ids).never + SiteSetting.private_email = true + Jobs.expects(:enqueue).with(:user_email, type: :digest, user_id: user.id).never + end + it "doesn't return users with email disabled" do + Jobs::EnqueueDigestEmails.new.execute({}) + end + end + context "digest emails are disabled" do before do Jobs::EnqueueDigestEmails.any_instance.expects(:target_user_ids).never + SiteSetting.disable_digest_emails = true end it "does not enqueue the digest email job" do - SiteSetting.stubs(:disable_digest_emails?).returns(true) Jobs.expects(:enqueue).with(:user_email, type: :digest, user_id: user.id).never Jobs::EnqueueDigestEmails.new.execute({}) end diff --git a/spec/jobs/enqueue_mailing_list_emails_spec.rb b/spec/jobs/enqueue_mailing_list_emails_spec.rb deleted file mode 100644 index 23a9c6eae4..0000000000 --- a/spec/jobs/enqueue_mailing_list_emails_spec.rb +++ /dev/null @@ -1,142 +0,0 @@ -require 'rails_helper' -require_dependency 'jobs/base' - -describe Jobs::EnqueueMailingListEmails do - - describe '#target_users' do - - context 'unapproved users' do - let!(:unapproved_user) { Fabricate(:active_user, approved: false, first_seen_at: 24.hours.ago) } - - before do - @original_value = SiteSetting.must_approve_users - SiteSetting.must_approve_users = true - end - - after do - SiteSetting.must_approve_users = @original_value - end - - it 'should enqueue the right emails' do - unapproved_user.user_option.update(mailing_list_mode: true, mailing_list_mode_frequency: 0) - expect(Jobs::EnqueueMailingListEmails.new.target_user_ids.include?(unapproved_user.id)).to eq(false) - - # As a moderator - unapproved_user.update_column(:moderator, true) - expect(Jobs::EnqueueMailingListEmails.new.target_user_ids.include?(unapproved_user.id)).to eq(true) - - # As an admin - unapproved_user.update_attributes(admin: true, moderator: false) - expect(Jobs::EnqueueMailingListEmails.new.target_user_ids.include?(unapproved_user.id)).to eq(true) - - # As an approved user - unapproved_user.update_attributes(admin: false, moderator: false, approved: true ) - expect(Jobs::EnqueueMailingListEmails.new.target_user_ids.include?(unapproved_user.id)).to eq(true) - end - end - - context 'staged users' do - let!(:staged_user) { Fabricate(:active_user, staged: true, last_emailed_at: 1.year.ago, last_seen_at: 1.year.ago) } - - it "doesn't return staged users" do - expect(Jobs::EnqueueMailingListEmails.new.target_user_ids.include?(staged_user.id)).to eq(false) - end - end - - context "inactive user" do - let!(:inactive_user) { Fabricate(:user, active: false) } - - it "doesn't return users who have been emailed recently" do - expect(Jobs::EnqueueMailingListEmails.new.target_user_ids.include?(inactive_user.id)).to eq(false) - end - end - - context "suspended user" do - let!(:suspended_user) { Fabricate(:user, suspended_till: 1.week.from_now, suspended_at: 1.day.ago) } - - it "doesn't return users who are suspended" do - expect(Jobs::EnqueueMailingListEmails.new.target_user_ids.include?(suspended_user.id)).to eq(false) - end - end - - context 'users with mailing list mode on' do - let(:user) { Fabricate(:active_user, first_seen_at: 24.hours.ago) } - let(:user_option) { user.user_option } - subject { Jobs::EnqueueMailingListEmails.new.target_user_ids } - before do - user_option.update(mailing_list_mode: true, mailing_list_mode_frequency: 0) - end - - it "returns a user whose first_seen_at matches the current hour" do - expect(subject).to include user.id - end - - it "returns a user seen multiple days ago" do - user.update(first_seen_at: 72.hours.ago) - expect(subject).to include user.id - end - - it "doesn't return a user who has never been seen" do - user.update(first_seen_at: nil) - expect(subject).to_not include user.id - end - - it "doesn't return users with mailing list mode off" do - user_option.update(mailing_list_mode: false) - expect(subject).to_not include user.id - end - - it "doesn't return users with mailing list mode set to 'individual'" do - user_option.update(mailing_list_mode_frequency: 1) - expect(subject).to_not include user.id - end - - it "doesn't return users with mailing list mode set to 'individual_excluding_own'" do - user_option.update(mailing_list_mode_frequency: 2) - expect(subject).to_not include user.id - end - - it "doesn't return a user who has received the mailing list summary earlier" do - user.update(first_seen_at: 5.hours.ago) - expect(subject).to_not include user.id - end - - it "doesn't return a user who was first seen today" do - user.update(first_seen_at: 2.minutes.ago) - expect(subject).to_not include user.id - end - end - - end - - describe '#execute' do - - let(:user) { Fabricate(:user) } - - context "mailing list emails are enabled" do - before do - Jobs::EnqueueMailingListEmails.any_instance.expects(:target_user_ids).returns([user.id]) - end - - it "enqueues the mailing list email job" do - Jobs.expects(:enqueue).with(:user_email, type: :mailing_list, user_id: user.id) - Jobs::EnqueueMailingListEmails.new.execute({}) - end - end - - context "mailing list emails are disabled" do - before do - Jobs::EnqueueMailingListEmails.any_instance.expects(:target_user_ids).never - end - - it "does not enqueue the mailing list email job" do - SiteSetting.disable_mailing_list_mode = true - Jobs.expects(:enqueue).with(:user_email, type: :mailing_list, user_id: user.id).never - Jobs::EnqueueMailingListEmails.new.execute({}) - end - end - - end - - -end diff --git a/spec/jobs/export_csv_file_spec.rb b/spec/jobs/export_csv_file_spec.rb index 2f89d34aaf..de188be272 100644 --- a/spec/jobs/export_csv_file_spec.rb +++ b/spec/jobs/export_csv_file_spec.rb @@ -9,7 +9,7 @@ describe Jobs::ExportCsvFile do end let :user_list_header do - ['id','name','username','email','title','created_at','last_seen_at','last_posted_at','last_emailed_at','trust_level','approved','suspended_at','suspended_till','blocked','active','admin','moderator','ip_address','topics_entered','posts_read_count','time_read','topic_count','post_count','likes_given','likes_received','external_id','external_email', 'external_username', 'external_name', 'external_avatar_url'] + ['id','name','username','email','title','created_at','last_seen_at','last_posted_at','last_emailed_at','trust_level','approved','suspended_at','suspended_till','blocked','active','admin','moderator','ip_address','topics_entered','posts_read_count','time_read','topic_count','post_count','likes_given','likes_received','location','website','views','external_id','external_email', 'external_username', 'external_name', 'external_avatar_url'] end let :user_list_export do @@ -23,10 +23,12 @@ describe Jobs::ExportCsvFile do it 'exports sso data' do SiteSetting.enable_sso = true user = Fabricate(:user) + user.user_profile.update_column(:location, "La La Land") user.create_single_sign_on_record(external_id: "123", last_payload: "xxx", external_email: 'test@test.com') user = to_hash(user_list_export.find{|u| u[0].to_i == user.id}) + expect(user["location"]).to eq("La La Land") expect(user["external_id"]).to eq("123") expect(user["external_email"]).to eq("test@test.com") end diff --git a/spec/jobs/grant_anniversary_badges_spec.rb b/spec/jobs/grant_anniversary_badges_spec.rb new file mode 100644 index 0000000000..a391b8c2f1 --- /dev/null +++ b/spec/jobs/grant_anniversary_badges_spec.rb @@ -0,0 +1,137 @@ +require 'rails_helper' +require_dependency 'jobs/scheduled/grant_anniversary_badges' + +describe Jobs::GrantAnniversaryBadges do + + let(:granter) { described_class.new } + + it "doesn't award to a user who is less than a year old" do + user = Fabricate(:user, created_at: 1.month.ago) + Fabricate(:post, user: user, created_at: 1.week.ago) + granter.execute({}) + + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge).to be_blank + end + + it "doesn't award to an inactive user" do + user = Fabricate(:user, created_at: 400.days.ago, active: false) + Fabricate(:post, user: user, created_at: 1.week.ago) + granter.execute({}) + + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge).to be_blank + end + + it "doesn't award to a blocked user" do + user = Fabricate(:user, created_at: 400.days.ago, blocked: true) + Fabricate(:post, user: user, created_at: 1.week.ago) + granter.execute({}) + + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge).to be_blank + end + + it "doesn't award when a post is deleted" do + user = Fabricate(:user, created_at: 400.days.ago) + Fabricate(:post, user: user, created_at: 1.week.ago, deleted_at: 1.day.ago) + granter.execute({}) + + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge).to be_blank + end + + it "doesn't award when a post is hidden" do + user = Fabricate(:user, created_at: 400.days.ago) + Fabricate(:post, user: user, created_at: 1.week.ago, hidden: true) + granter.execute({}) + + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge).to be_blank + end + + it "doesn't award to PMs" do + user = Fabricate(:user, created_at: 400.days.ago) + Fabricate(:private_message_post, user: user, created_at: 1.week.ago) + granter.execute({}) + + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge).to be_blank + end + + it "doesn't award to a user without a post" do + user = Fabricate(:user, created_at: 1.month.ago) + granter.execute({}) + + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge).to be_blank + end + + it "doesn't award when badges are disabled" do + SiteSetting.enable_badges = false + + user = Fabricate(:user, created_at: 400.days.ago) + Fabricate(:post, user: user, created_at: 1.week.ago) + + granter.execute({}) + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge.count).to eq(0) + end + + it "awards the badge to a user with a post active for a year" do + user = Fabricate(:user, created_at: 400.days.ago) + Fabricate(:post, user: user, created_at: 1.week.ago) + + granter.execute({}) + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge.count).to eq(1) + end + + context "repeated grants" do + it "won't award twice in the same year" do + user = Fabricate(:user, created_at: 400.days.ago) + Fabricate(:post, user: user, created_at: 1.week.ago) + + granter.execute({}) + granter.execute({}) + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge.count).to eq(1) + end + + it "will award again if a year has passed" do + user = Fabricate(:user, created_at: 800.days.ago) + Fabricate(:post, user: user, created_at: 450.days.ago) + + Timecop.freeze(400.days.ago) do + granter.execute({}) + end + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge.count).to eq(1) + + Fabricate(:post, user: user, created_at: 50.days.ago) + granter.execute({}) + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge.count).to eq(2) + end + + it "supports date ranges" do + user = Fabricate(:user, created_at: 3.years.ago) + Fabricate(:post, user: user, created_at: 750.days.ago) + + granter.execute(start_date: 800.days.ago) + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge.count).to eq(1) + + Fabricate(:post, user: user, created_at: 50.days.ago) + granter.execute(start_date: 800.days.ago) + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge.count).to eq(1) + + granter.execute(start_date: 60.days.ago) + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge.count).to eq(2) + end + end + + +end diff --git a/spec/jobs/grant_new_user_of_the_month_badges_spec.rb b/spec/jobs/grant_new_user_of_the_month_badges_spec.rb new file mode 100644 index 0000000000..7eef9cf1db --- /dev/null +++ b/spec/jobs/grant_new_user_of_the_month_badges_spec.rb @@ -0,0 +1,188 @@ +require 'rails_helper' +require_dependency 'jobs/scheduled/grant_new_user_of_the_month_badges' + +describe Jobs::GrantNewUserOfTheMonthBadges do + + let(:granter) { described_class.new } + + it "runs correctly" do + u0 = Fabricate(:user, created_at: 2.weeks.ago) + BadgeGranter.grant(Badge.find(Badge::NewUserOfTheMonth), u0, created_at: 1.month.ago) + + user = Fabricate(:user, created_at: 1.week.ago) + p = Fabricate(:post, user: user) + Fabricate(:post, user: user) + + old_user = Fabricate(:user, created_at: 6.months.ago) + PostAction.act(old_user, p, PostActionType.types[:like]) + old_user = Fabricate(:user, created_at: 6.months.ago) + PostAction.act(old_user, p, PostActionType.types[:like]) + + granter.execute({}) + + badge = user.user_badges.where(badge_id: Badge::NewUserOfTheMonth) + expect(badge).to be_present + end + + it "does nothing if badges are disabled" do + SiteSetting.enable_badges = false + + user = Fabricate(:user, created_at: 1.week.ago) + p = Fabricate(:post, user: user) + Fabricate(:post, user: user) + + old_user = Fabricate(:user, created_at: 6.months.ago) + PostAction.act(old_user, p, PostActionType.types[:like]) + old_user = Fabricate(:user, created_at: 6.months.ago) + PostAction.act(old_user, p, PostActionType.types[:like]) + + SystemMessage.any_instance.expects(:create).never + granter.execute({}) + + badge = user.user_badges.where(badge_id: Badge::NewUserOfTheMonth) + expect(badge).to be_blank + end + + it "does nothing if the badge is disabled" do + Badge.find(Badge::NewUserOfTheMonth).update_column(:enabled, false) + + user = Fabricate(:user, created_at: 1.week.ago) + p = Fabricate(:post, user: user) + Fabricate(:post, user: user) + + old_user = Fabricate(:user, created_at: 6.months.ago) + PostAction.act(old_user, p, PostActionType.types[:like]) + old_user = Fabricate(:user, created_at: 6.months.ago) + PostAction.act(old_user, p, PostActionType.types[:like]) + + SystemMessage.any_instance.expects(:create).never + granter.execute({}) + + badge = user.user_badges.where(badge_id: Badge::NewUserOfTheMonth) + expect(badge).to be_blank + end + + + it "does nothing if it's been awarded recently" do + u0 = Fabricate(:user, created_at: 2.weeks.ago) + BadgeGranter.grant(Badge.find(Badge::NewUserOfTheMonth), u0) + + user = Fabricate(:user, created_at: 1.week.ago) + p = Fabricate(:post, user: user) + Fabricate(:post, user: user) + + old_user = Fabricate(:user, created_at: 6.months.ago) + PostAction.act(old_user, p, PostActionType.types[:like]) + old_user = Fabricate(:user, created_at: 6.months.ago) + PostAction.act(old_user, p, PostActionType.types[:like]) + + granter.execute({}) + + badge = user.user_badges.where(badge_id: Badge::NewUserOfTheMonth) + expect(badge).to be_blank + end + + describe '.scores' do + + it "doesn't award it to accounts over a month old" do + user = Fabricate(:user, created_at: 2.months.ago) + Fabricate(:post, user: user) + p = Fabricate(:post, user: user) + old_user = Fabricate(:user, created_at: 6.months.ago) + PostAction.act(old_user, p, PostActionType.types[:like]) + old_user = Fabricate(:user, created_at: 6.months.ago) + PostAction.act(old_user, p, PostActionType.types[:like]) + + expect(granter.scores.keys).not_to include(user.id) + end + + it "doesn't score users who haven't posted in two topics" do + user = Fabricate(:user, created_at: 1.week.ago) + p = Fabricate(:post, user: user) + old_user = Fabricate(:user, created_at: 6.months.ago) + PostAction.act(old_user, p, PostActionType.types[:like]) + old_user = Fabricate(:user, created_at: 6.months.ago) + PostAction.act(old_user, p, PostActionType.types[:like]) + + expect(granter.scores.keys).not_to include(user.id) + end + + it "requires at least two likes to be considered" do + user = Fabricate(:user, created_at: 1.week.ago) + Fabricate(:post, user: user) + p = Fabricate(:post, user: user) + old_user = Fabricate(:user, created_at: 6.months.ago) + PostAction.act(old_user, p, PostActionType.types[:like]) + + expect(granter.scores.keys).not_to include(user.id) + end + + it "returns scores for accounts created within the last month" do + user = Fabricate(:user, created_at: 1.week.ago) + Fabricate(:post, user: user) + p = Fabricate(:post, user: user) + old_user = Fabricate(:user, created_at: 6.months.ago) + PostAction.act(old_user, p, PostActionType.types[:like]) + old_user = Fabricate(:user, created_at: 6.months.ago) + PostAction.act(old_user, p, PostActionType.types[:like]) + + expect(granter.scores.keys).to include(user.id) + end + + it "likes from older accounts are scored higher" do + user = Fabricate(:user, created_at: 1.week.ago) + p = Fabricate(:post, user: user) + Fabricate(:post, user: user) + + u0 = Fabricate(:user, trust_level: 0) + u1 = Fabricate(:user, trust_level: 1) + u2 = Fabricate(:user, trust_level: 2) + u3 = Fabricate(:user, trust_level: 3) + u4 = Fabricate(:user, trust_level: 4) + um = Fabricate(:user, trust_level: 3, moderator: true) + ua = Fabricate(:user, trust_level: 0, admin: true) + + PostAction.act(u0, p, PostActionType.types[:like]) + PostAction.act(u1, p, PostActionType.types[:like]) + PostAction.act(u2, p, PostActionType.types[:like]) + PostAction.act(u3, p, PostActionType.types[:like]) + PostAction.act(u4, p, PostActionType.types[:like]) + PostAction.act(um, p, PostActionType.types[:like]) + PostAction.act(ua, p, PostActionType.types[:like]) + PostAction.act(Discourse.system_user, p, PostActionType.types[:like]) + expect(granter.scores[user.id]).to eq(4.425) + + # It goes down the more they post + Fabricate(:post, user: user) + expect(granter.scores[user.id]).to eq(2.95) + end + + it "is limited to two accounts" do + u1 = Fabricate(:user, created_at: 1.week.ago) + u2 = Fabricate(:user, created_at: 2.weeks.ago) + u3 = Fabricate(:user, created_at: 3.weeks.ago) + + ou1 = Fabricate(:user, created_at: 6.months.ago) + ou2 = Fabricate(:user, created_at: 6.months.ago) + + p = Fabricate(:post, user: u1) + Fabricate(:post, user: u1) + PostAction.act(ou1, p, PostActionType.types[:like]) + PostAction.act(ou2, p, PostActionType.types[:like]) + + p = Fabricate(:post, user: u2) + Fabricate(:post, user: u2) + PostAction.act(ou1, p, PostActionType.types[:like]) + PostAction.act(ou2, p, PostActionType.types[:like]) + + p = Fabricate(:post, user: u3) + Fabricate(:post, user: u3) + PostAction.act(ou1, p, PostActionType.types[:like]) + PostAction.act(ou2, p, PostActionType.types[:like]) + + expect(granter.scores.keys.size).to eq(2) + end + + end + +end diff --git a/spec/jobs/jobs_spec.rb b/spec/jobs/jobs_spec.rb index 64de988b71..a5e6203ac3 100644 --- a/spec/jobs/jobs_spec.rb +++ b/spec/jobs/jobs_spec.rb @@ -1,4 +1,3 @@ -require "sidekiq/testing" require 'rails_helper' require_dependency 'jobs/base' @@ -123,4 +122,3 @@ describe Jobs do end end - diff --git a/spec/jobs/notify_mailing_list_subscribers_spec.rb b/spec/jobs/notify_mailing_list_subscribers_spec.rb index b4419e3059..1749b1df15 100644 --- a/spec/jobs/notify_mailing_list_subscribers_spec.rb +++ b/spec/jobs/notify_mailing_list_subscribers_spec.rb @@ -32,9 +32,8 @@ describe Jobs::NotifyMailingListSubscribers do before { SiteSetting.disable_mailing_list_mode = false } context "with an invalid post_id" do - it "throws an error" do - expect { Jobs::NotifyMailingListSubscribers.new.execute(post_id: -1) }.to raise_error(Discourse::InvalidParameters) - end + before { post.update(deleted_at: Time.now) } + include_examples "no emails" end context "with a deleted post" do @@ -79,11 +78,6 @@ describe Jobs::NotifyMailingListSubscribers do include_examples "no emails" end - context "to an user who has frequency set to 'daily'" do - before { mailing_list_user.user_option.update(mailing_list_mode_frequency: 0) } - include_examples "no emails" - end - context "to an user who has frequency set to 'always'" do before { mailing_list_user.user_option.update(mailing_list_mode_frequency: 1) } include_examples "one email" diff --git a/spec/jobs/pending_flags_reminder_spec.rb b/spec/jobs/pending_flags_reminder_spec.rb index e73038c0fb..25f6eb673b 100644 --- a/spec/jobs/pending_flags_reminder_spec.rb +++ b/spec/jobs/pending_flags_reminder_spec.rb @@ -4,15 +4,22 @@ describe Jobs::PendingFlagsReminder do context "notify_about_flags_after is 0" do before { SiteSetting.stubs(:notify_about_flags_after).returns(0) } - it "never emails" do + it "never notifies" do PostAction.stubs(:flagged_posts_count).returns(1) - Email::Sender.any_instance.expects(:send).never + PostCreator.expects(:create).never described_class.new.execute({}) end end context "notify_about_flags_after is 48" do - before { SiteSetting.stubs(:notify_about_flags_after).returns(48) } + before do + SiteSetting.notify_about_flags_after = 48 + $redis.del described_class.last_notified_key + end + + after do + $redis.del described_class.last_notified_key + end it "doesn't send message when flags are less than 48 hours old" do Fabricate(:flag, created_at: 47.hours.ago) @@ -27,5 +34,16 @@ describe Jobs::PendingFlagsReminder do PostCreator.expects(:create).once.returns(true) described_class.new.execute({}) end + + it "doesn't send a message if there are no new flags older than 48 hours old" do + old_flag = Fabricate(:flag, created_at: 50.hours.ago) + new_flag = Fabricate(:flag, created_at: 47.hours.ago) + PostAction.stubs(:flagged_posts_count).returns(2) + job = described_class.new + job.last_notified_id = old_flag.id + PostCreator.expects(:create).never + job.execute({}) + expect(job.last_notified_id).to eq(old_flag.id) + end end end diff --git a/spec/jobs/pending_queued_posts_reminder_spec.rb b/spec/jobs/pending_queued_posts_reminder_spec.rb index dd182686da..fe5a1c7956 100644 --- a/spec/jobs/pending_queued_posts_reminder_spec.rb +++ b/spec/jobs/pending_queued_posts_reminder_spec.rb @@ -12,7 +12,9 @@ describe Jobs::PendingQueuedPostReminder do end context "notify_about_queued_posts_after is 24" do - before { SiteSetting.stubs(:notify_about_queued_posts_after).returns(24) } + before do + SiteSetting.notify_about_queued_posts_after = 24 + end it "doesn't email if there are no queued posts" do described_class.any_instance.stubs(:should_notify_ids).returns([]) diff --git a/spec/jobs/poll_feed_spec.rb b/spec/jobs/poll_feed_spec.rb index b222991cb0..0fcfa7d02c 100644 --- a/spec/jobs/poll_feed_spec.rb +++ b/spec/jobs/poll_feed_spec.rb @@ -8,18 +8,22 @@ describe Jobs::PollFeed do let(:url) { "http://eviltrout.com" } let(:embed_by_username) { "eviltrout" } + before do + $redis.del("feed-polled-recently") + end + it "requires feed_polling_enabled?" do - SiteSetting.feed_polling_enabled = true - SiteSetting.feed_polling_url = nil - poller.expects(:poll_feed).never - poller.execute({}) + SiteSetting.feed_polling_enabled = true + SiteSetting.feed_polling_url = nil + poller.expects(:poll_feed).never + poller.execute({}) end it "requires feed_polling_url" do - SiteSetting.feed_polling_enabled = false - SiteSetting.feed_polling_url = nil - poller.expects(:poll_feed).never - poller.execute({}) + SiteSetting.feed_polling_enabled = false + SiteSetting.feed_polling_url = nil + poller.expects(:poll_feed).never + poller.execute({}) end it "delegates to poll_feed" do @@ -28,6 +32,15 @@ describe Jobs::PollFeed do poller.expects(:poll_feed).once poller.execute({}) end + + it "won't poll if it has polled recently" do + SiteSetting.feed_polling_enabled = true + SiteSetting.feed_polling_url = url + poller.expects(:poll_feed).once + poller.execute({}) + poller.execute({}) + end + end end diff --git a/spec/jobs/process_post_spec.rb b/spec/jobs/process_post_spec.rb index 35c6b8e244..e240f6ef9d 100644 --- a/spec/jobs/process_post_spec.rb +++ b/spec/jobs/process_post_spec.rb @@ -9,9 +9,7 @@ describe Jobs::ProcessPost do context 'with a post' do - let(:post) do - Fabricate(:post) - end + let(:post) { Fabricate(:post) } it 'does not erase posts when CookedPostProcessor malfunctions' do # Look kids, an actual reason why you want to use mocks @@ -35,7 +33,6 @@ describe Jobs::ProcessPost do end it 'processes posts' do - post = Fabricate(:post, raw: "") expect(post.cooked).to match(/http/) @@ -46,7 +43,25 @@ describe Jobs::ProcessPost do expect(post.cooked).not_to match(/http/) end + it "always re-extracts links on post process" do + post.update_columns(raw: "sam has a blog at https://samsaffron.com") + expect { Jobs::ProcessPost.new.execute(post_id: post.id) }.to change { TopicLink.count }.by(1) + end + + it "extracts links to quoted posts" do + quoted_post = Fabricate(:post, raw: "This is a post with a link to https://www.discourse.org", post_number: 42) + post.update_columns(raw: "This quote is the best\n\n[quote=\"#{quoted_post.user.username}, topic:#{quoted_post.topic_id}, post:#{quoted_post.post_number}\"]#{quoted_post.excerpt}[/quote]") + # when creating a quote, we also create the reflexion link + expect { Jobs::ProcessPost.new.execute(post_id: post.id) }.to change { TopicLink.count }.by(2) + end + + it "extracts links to oneboxed topics" do + oneboxed_post = Fabricate(:post) + post.update_columns(raw: "This post is the best\n\n#{oneboxed_post.full_url}") + # when creating a quote, we also create the reflexion link + expect { Jobs::ProcessPost.new.execute(post_id: post.id) }.to change { TopicLink.count }.by(2) + end + end - end diff --git a/spec/jobs/publish_topic_to_category_spec.rb b/spec/jobs/publish_topic_to_category_spec.rb new file mode 100644 index 0000000000..2cb97a1cca --- /dev/null +++ b/spec/jobs/publish_topic_to_category_spec.rb @@ -0,0 +1,87 @@ +require 'rails_helper' + +RSpec.describe Jobs::PublishTopicToCategory do + let(:category) { Fabricate(:category) } + let(:another_category) { Fabricate(:category) } + + let(:topic) do + Fabricate(:topic, category: category, topic_timers: [ + Fabricate(:topic_timer, + status_type: TopicTimer.types[:publish_to_category], + category_id: another_category.id + ) + ]) + end + + before do + SiteSetting.queue_jobs = true + end + + describe 'when topic_timer_id is invalid' do + it 'should raise the right error' do + expect { described_class.new.execute(topic_timer_id: -1) } + .to raise_error(Discourse::InvalidParameters) + end + end + + describe 'when topic has been deleted' do + it 'should not publish the topic to the new category' do + Timecop.travel(1.hour.ago) { topic } + topic.trash! + + described_class.new.execute(topic_timer_id: topic.public_topic_timer.id) + + topic.reload + expect(topic.category).to eq(category) + expect(topic.created_at).to be_within(1.second).of(Time.zone.now - 1.hour) + end + end + + it 'should publish the topic to the new category' do + Timecop.travel(1.hour.ago) { topic.update!(visible: false) } + + message = MessageBus.track_publish do + described_class.new.execute(topic_timer_id: topic.public_topic_timer.id) + end.first + + topic.reload + expect(topic.category).to eq(another_category) + expect(topic.visible).to eq(true) + expect(topic.public_topic_timer).to eq(nil) + + %w{created_at bumped_at updated_at last_posted_at}.each do |attribute| + expect(topic.public_send(attribute)).to be_within(1.second).of(Time.zone.now) + end + + expect(message.data[:reload_topic]).to be_present + expect(message.data[:refresh_stream]).to be_present + end + + describe 'when topic is a private message' do + before do + Timecop.travel(1.hour.ago) do + expect { topic.convert_to_private_message(Discourse.system_user) } + .to change { topic.private_message? }.to(true) + end + end + + + it 'should publish the topic to the new category' do + message = MessageBus.track_publish do + described_class.new.execute(topic_timer_id: topic.public_topic_timer.id) + end.last + + topic.reload + expect(topic.category).to eq(another_category) + expect(topic.visible).to eq(true) + expect(topic.private_message?).to eq(false) + + %w{created_at bumped_at updated_at last_posted_at}.each do |attribute| + expect(topic.public_send(attribute)).to be_within(1.second).of(Time.zone.now) + end + + expect(message.data[:reload_topic]).to be_present + expect(message.data[:refresh_stream]).to be_present + end + end +end diff --git a/spec/jobs/pull_hotlinked_images_spec.rb b/spec/jobs/pull_hotlinked_images_spec.rb new file mode 100644 index 0000000000..ed680f9983 --- /dev/null +++ b/spec/jobs/pull_hotlinked_images_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' +require 'jobs/regular/pull_hotlinked_images' + +describe Jobs::PullHotlinkedImages do + + before do + png = Base64.decode64("R0lGODlhAQABALMAAAAAAIAAAACAAICAAAAAgIAAgACAgMDAwICAgP8AAAD/AP//AAAA//8A/wD//wBiZCH5BAEAAA8ALAAAAAABAAEAAAQC8EUAOw==") + stub_request(:get, "http://wiki.mozilla.org/images/2/2e/Longcat1.png").to_return(body: png) + stub_request(:head, "http://wiki.mozilla.org/images/2/2e/Longcat1.png") + SiteSetting.download_remote_images_to_local = true + FastImage.expects(:size).returns([100, 100]).at_least_once + end + + it 'replaces image src' do + post = Fabricate(:post, raw: "") + + Jobs::PullHotlinkedImages.new.execute(post_id: post.id) + post.reload + + expect(post.raw).to match(/^") + + Jobs::PullHotlinkedImages.new.execute(post_id: post.id) + post.reload + + expect(post.raw).to match(/^some post with \":test:\" yay

            " + ) + + custom_emoji.destroy! + Emoji.clear_cache + described_class.new.execute(name: 'test') + + expect(post.reload.cooked).to eq('

            some post with :test: yay

            ') + end +end diff --git a/spec/jobs/toggle_topic_closed_spec.rb b/spec/jobs/toggle_topic_closed_spec.rb new file mode 100644 index 0000000000..2c4b5c6c8a --- /dev/null +++ b/spec/jobs/toggle_topic_closed_spec.rb @@ -0,0 +1,79 @@ +require 'rails_helper' + +describe Jobs::ToggleTopicClosed do + let(:admin) { Fabricate(:admin) } + + let(:topic) do + Fabricate(:topic, + topic_timers: [Fabricate(:topic_timer, user: admin)] + ) + end + + before do + SiteSetting.queue_jobs = true + end + + it 'should be able to close a topic' do + topic + + Timecop.travel(1.hour.from_now) do + described_class.new.execute( + topic_timer_id: topic.public_topic_timer.id, + state: true + ) + + expect(topic.reload.closed).to eq(true) + + expect(Post.last.raw).to eq(I18n.t( + 'topic_statuses.autoclosed_enabled_minutes', count: 60 + )) + end + end + + it 'should be able to open a topic' do + topic.update!(closed: true) + + Timecop.travel(1.hour.from_now) do + described_class.new.execute( + topic_timer_id: topic.public_topic_timer.id, + state: false + ) + + expect(topic.reload.closed).to eq(false) + + expect(Post.last.raw).to eq(I18n.t( + 'topic_statuses.autoclosed_disabled_minutes', count: 60 + )) + end + end + + describe 'when trying to close a topic that has been deleted' do + it 'should not do anything' do + topic.trash! + + Topic.any_instance.expects(:update_status).never + + described_class.new.execute( + topic_timer_id: topic.public_topic_timer.id, + state: true + ) + end + end + + describe 'when user is not authorized to close topics' do + let(:topic) do + Fabricate(:topic, + topic_timers: [Fabricate(:topic_timer, execute_at: 2.hours.from_now)] + ) + end + + it 'should not do anything' do + described_class.new.execute( + topic_timer_id: topic.public_topic_timer.id, + state: false + ) + + expect(topic.reload.closed).to eq(false) + end + end +end diff --git a/spec/jobs/topic_reminder_spec.rb b/spec/jobs/topic_reminder_spec.rb new file mode 100644 index 0000000000..fd8b71697e --- /dev/null +++ b/spec/jobs/topic_reminder_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' + +describe Jobs::TopicReminder do + let(:admin) { Fabricate(:admin) } + let(:topic) { Fabricate(:topic, topic_timers: [ + Fabricate(:topic_timer, user: admin, status_type: TopicTimer.types[:reminder]) + ]) } + + before do + SiteSetting.queue_jobs = true + end + + it "should be able to create a reminder" do + topic_timer = topic.topic_timers.first + Timecop.freeze(1.day.from_now) do + expect { + described_class.new.execute(topic_timer_id: topic_timer.id) + }.to change { Notification.count }.by(1) + expect( admin.notifications.where(notification_type: Notification.types[:topic_reminder]).first&.topic_id ).to eq(topic.id) + expect( TopicTimer.where(id: topic_timer.id).first ).to be_nil + end + end + + it "does nothing if it was trashed before the scheduled time" do + topic_timer = topic.topic_timers.first + topic_timer.trash!(Discourse.system_user) + Timecop.freeze(1.day.from_now) do + expect { + described_class.new.execute(topic_timer_id: topic_timer.id) + }.to_not change { Notification.count } + end + end + + it "does nothing if job runs too early" do + topic_timer = topic.topic_timers.first + topic_timer.update_attribute(:execute_at, 8.hours.from_now) + Timecop.freeze(6.hours.from_now) do + expect { + described_class.new.execute(topic_timer_id: topic_timer.id) + }.to_not change { Notification.count } + end + end + + it "does nothing if topic was deleted" do + topic_timer = topic.topic_timers.first + topic.trash! + Timecop.freeze(1.day.from_now) do + expect { + described_class.new.execute(topic_timer_id: topic_timer.id) + }.to_not change { Notification.count } + end + end + +end diff --git a/spec/jobs/update_gravatar_spec.rb b/spec/jobs/update_gravatar_spec.rb index 0c80a3afcf..403de9a360 100644 --- a/spec/jobs/update_gravatar_spec.rb +++ b/spec/jobs/update_gravatar_spec.rb @@ -8,7 +8,9 @@ describe Jobs::UpdateGravatar do expect(user.user_avatar.gravatar_upload_id).to eq(nil) png = Base64.decode64("R0lGODlhAQABALMAAAAAAIAAAACAAICAAAAAgIAAgACAgMDAwICAgP8AAAD/AP//AAAA//8A/wD//wBiZCH5BAEAAA8ALAAAAAABAAEAAAQC8EUAOw==") - FakeWeb.register_uri(:get, "http://www.gravatar.com/avatar/d10ca8d11301c2f4993ac2279ce4b930.png?s=500&d=404", body: png) + url = "http://www.gravatar.com/avatar/d10ca8d11301c2f4993ac2279ce4b930.png?s=360&d=404" + stub_request(:head, url).to_return(status: 200) + stub_request(:get, url).to_return(body: png) SiteSetting.automatically_download_gravatars = true diff --git a/spec/jobs/user_email_spec.rb b/spec/jobs/user_email_spec.rb index e596a63b31..b3cd3c7fe5 100644 --- a/spec/jobs/user_email_spec.rb +++ b/spec/jobs/user_email_spec.rb @@ -35,6 +35,20 @@ describe Jobs::UserEmail do Jobs::UserEmail.new.execute(type: :digest, user_id: staged.id) end + context "bounce score" do + + it "always sends critical emails when bounce score threshold has been reached" do + email_token = Fabricate(:email_token) + user.user_stat.update(bounce_score: SiteSetting.bounce_score_threshold + 1) + + Jobs::CriticalUserEmail.new.execute(type: "signup", user_id: user.id, email_token: email_token.token) + + email_log = EmailLog.where(user_id: user.id).last + expect(email_log.email_type).to eq("signup") + expect(email_log.skipped).to eq(false) + end + + end context 'to_address' do it 'overwrites a to_address when present' do @@ -208,15 +222,21 @@ describe Jobs::UserEmail do Jobs::UserEmail.new.execute(type: :user_mentioned, user_id: user.id, notification_id: notification.id) end - it "does not send notification if limit is reached" do - SiteSetting.max_emails_per_day_per_user = 2 + context 'max_emails_per_day_per_user limit is reached' do + before do + SiteSetting.max_emails_per_day_per_user = 2 + 2.times { Fabricate(:email_log, user: user, email_type: 'blah', to_address: user.email) } + end - user.email_logs.create(email_type: 'blah', to_address: user.email, user_id: user.id) - user.email_logs.create(email_type: 'blah', to_address: user.email, user_id: user.id) + it "does not send notification if limit is reached" do + Jobs::UserEmail.new.execute(type: :user_mentioned, user_id: user.id, notification_id: notification.id, post_id: post.id) + expect(EmailLog.where(user_id: user.id, skipped: true).count).to eq(1) + end - Jobs::UserEmail.new.execute(type: :user_mentioned, user_id: user.id, notification_id: notification.id, post_id: post.id) - - expect(EmailLog.where(user_id: user.id, skipped: true).count).to eq(1) + it "sends critical email" do + Jobs::UserEmail.new.execute(type: :forgot_password, user_id: user.id, notification_id: notification.id, post_id: post.id) + expect(EmailLog.where(user_id: user.id, skipped: true).count).to eq(0) + end end it "does not send notification if bounce threshold is reached" do diff --git a/spec/mailers/invite_mailer_spec.rb b/spec/mailers/invite_mailer_spec.rb index ff61f39987..68ce2b4a4a 100644 --- a/spec/mailers/invite_mailer_spec.rb +++ b/spec/mailers/invite_mailer_spec.rb @@ -67,13 +67,15 @@ describe InviteMailer do it 'renders invite link' do expect(custom_invite_mail.body.encoded).to match("#{Discourse.base_url}/invites/#{invite.invite_key}") end + end end end context "invite to topic" do - let(:topic) { Fabricate(:topic, excerpt: "Topic invite support is now available in Discourse!") } + let(:trust_level_2) { build(:user, trust_level: 2) } + let(:topic) { Fabricate(:topic, excerpt: "Topic invite support is now available in Discourse!", user: trust_level_2) } let(:invite) { topic.invite(topic.user, 'name@example.com') } context "default invite message" do @@ -110,6 +112,14 @@ describe InviteMailer do it 'renders topic title' do expect(invite_mail.body.encoded).to match(topic.title) end + + it "respects the private_email setting" do + SiteSetting.private_email = true + + message = invite_mail + expect(message.body.to_s).not_to include(topic.title) + expect(message.body.to_s).not_to include(topic.slug) + end end context "custom invite message" do diff --git a/spec/mailers/user_notifications_spec.rb b/spec/mailers/user_notifications_spec.rb index d6359818c9..39ed87a35d 100644 --- a/spec/mailers/user_notifications_spec.rb +++ b/spec/mailers/user_notifications_spec.rb @@ -47,7 +47,10 @@ describe UserNotifications do user.user_option.update_columns(email_previous_replies: UserOption.previous_replies_type[:always]) expect(UserNotifications.get_context_posts(post3, topic_user, user).count).to eq(2) + SiteSetting.private_email = true + expect(UserNotifications.get_context_posts(post3, topic_user, user).count).to eq(0) end + end describe ".signup" do @@ -76,70 +79,6 @@ describe UserNotifications do end - describe '.mailing_list' do - subject { UserNotifications.mailing_list(user) } - - context "without new posts" do - it "doesn't send the email" do - expect(subject.to).to be_blank - end - end - - context "with new posts" do - let(:user) { Fabricate(:user) } - let(:topic) { Fabricate(:topic, user: user) } - let!(:new_post) { Fabricate(:post, topic: topic, created_at: 2.hours.ago, raw: "Feel the Bern") } - let!(:old_post) { Fabricate(:post, topic: topic, created_at: 25.hours.ago, raw: "Make America Great Again") } - let(:old_topic) { Fabricate(:topic, user: user, created_at: 10.days.ago) } - let(:new_post_in_old_topic) { Fabricate(:post, topic: old_topic, created_at: 2.hours.ago, raw: "Yes We Can") } - let(:stale_post) { Fabricate(:post, topic: old_topic, created_at: 2.days.ago, raw: "A New American Century") } - - it "works" do - expect(subject.to).to eq([user.email]) - expect(subject.subject).to be_present - expect(subject.from).to eq([SiteSetting.notification_email]) - expect(subject.html_part.body.to_s).to include topic.title - expect(subject.text_part.body.to_s).to be_present - expect(subject.header["List-Unsubscribe"].to_s).to match(/\/email\/unsubscribe\/\h{64}/) - end - - it "includes posts less than 24 hours old" do - expect(subject.html_part.body.to_s).to include new_post.cooked - end - - it "does not include posts older than 24 hours old" do - expect(subject.html_part.body.to_s).to_not include old_post.cooked - end - - it "includes topics created over 24 hours ago which have new posts" do - new_post_in_old_topic - expect(subject.html_part.body.to_s).to include old_topic.title - expect(subject.html_part.body.to_s).to include new_post_in_old_topic.cooked - expect(subject.html_part.body.to_s).to_not include stale_post.cooked - end - - it "includes multiple topics" do - new_post_in_old_topic - expect(subject.html_part.body.to_s).to include topic.title - expect(subject.html_part.body.to_s).to include old_topic.title - end - - it "does not include topics not updated for the past 24 hours" do - stale_post - expect(subject.html_part.body.to_s).to_not include old_topic.title - expect(subject.html_part.body.to_s).to_not include stale_post.cooked - end - - it "includes email_prefix in email subject instead of site title" do - SiteSetting.email_prefix = "Try Discourse" - SiteSetting.title = "Discourse Meta" - - expect(subject.subject).to match(/Try Discourse/) - expect(subject.subject).not_to match(/Discourse Meta/) - end - end - end - describe '.digest' do subject { UserNotifications.digest(user) } @@ -217,6 +156,26 @@ describe UserNotifications do expect(html).to_not include hidden.raw expect(html).to_not include user_deleted.raw end + + it "uses theme color" do + cs = Fabricate(:color_scheme, name: 'Fancy', color_scheme_colors: [ + Fabricate(:color_scheme_color, name: 'header_primary', hex: 'F0F0F0'), + Fabricate(:color_scheme_color, name: 'header_background', hex: '1E1E1E'), + Fabricate(:color_scheme_color, name: 'tertiary', hex: '858585') + ]) + theme = Theme.create!( + name: 'my name', + user_id: Fabricate(:admin).id, + user_selectable: true, + color_scheme_id: cs.id + ) + theme.set_default! + + html = subject.html_part.body.to_s + expect(html).to include 'F0F0F0' + expect(html).to include '1E1E1E' + expect(html).to include '858585' + end end end @@ -276,8 +235,6 @@ describe UserNotifications do expect(mail.html_part.to_s.scan(/In Reply To/).count).to eq(0) - - SiteSetting.enable_names = true SiteSetting.display_name_on_posts = true SiteSetting.prioritize_username_in_ux = false @@ -309,6 +266,21 @@ describe UserNotifications do expect(mail_html.scan(/>Bob Marley/).count).to eq(0) expect(mail_html.scan(/>bobmarley/).count).to eq(1) end + + it "doesn't include details when private_email is enabled" do + SiteSetting.private_email = true + mail = UserNotifications.user_replied( + response.user, + post: response, + notification_type: notification.notification_type, + notification_data_hash: notification.data_hash + ) + + expect(mail.html_part.to_s).to_not include(response.raw) + expect(mail.html_part.to_s).to_not include(topic.url) + expect(mail.text_part.to_s).to_not include(response.raw) + expect(mail.text_part.to_s).to_not include(topic.url) + end end describe '.user_posted' do @@ -345,6 +317,19 @@ describe UserNotifications do tu = TopicUser.get(post.topic_id, response.user) expect(tu.last_emailed_post_number).to eq(response.post_number) end + + it "doesn't include details when private_email is enabled" do + SiteSetting.private_email = true + mail = UserNotifications.user_posted( + response.user, + post: response, + notification_type: notification.notification_type, + notification_data_hash: notification.data_hash + ) + + expect(mail.html_part.to_s).to_not include(response.raw) + expect(mail.text_part.to_s).to_not include(response.raw) + end end describe '.user_private_message' do @@ -382,6 +367,21 @@ describe UserNotifications do tu = TopicUser.get(topic.id, response.user) expect(tu.last_emailed_post_number).to eq(response.post_number) end + + it "doesn't include details when private_email is enabled" do + SiteSetting.private_email = true + mail = UserNotifications.user_private_message( + response.user, + post: response, + notification_type: notification.notification_type, + notification_data_hash: notification.data_hash + ) + + expect(mail.html_part.to_s).to_not include(response.raw) + expect(mail.html_part.to_s).to_not include(topic.url) + expect(mail.text_part.to_s).to_not include(response.raw) + expect(mail.text_part.to_s).to_not include(topic.url) + end end @@ -405,8 +405,8 @@ describe UserNotifications do # WARNING: you reached the limit of 100 email notifications per day. Further emails will be suppressed. # Consider watching less topics or disabling mailing list mode. - expect(mail.html_part.to_s).to match("WARNING: ") - expect(mail.body.to_s).to match("WARNING: ") + expect(mail.html_part.to_s).to match(I18n.t("user_notifications.reached_limit", count: 2)) + expect(mail.body.to_s).to match(I18n.t("user_notifications.reached_limit", count: 2)) end def expects_build_with(condition) @@ -434,6 +434,28 @@ describe UserNotifications do end end + shared_examples "respect for private_email" do + context "private_email" do + it "doesn't support reply by email" do + SiteSetting.private_email = true + mailer = UserNotifications.send( + mail_type, + user, + notification_type: Notification.types[notification.notification_type], + notification_data_hash: notification.data_hash, + post: notification.post + ) + message = mailer.message + + topic = notification.post.topic + expect(message.html_part.body.to_s).not_to include(topic.title) + expect(message.html_part.body.to_s).not_to include(topic.slug) + expect(message.text_part.body.to_s).not_to include(topic.title) + expect(message.text_part.body.to_s).not_to include(topic.slug) + end + end + end + # The parts of emails that are derived from templates are translated shared_examples "sets user locale" do context "set locale for translating templates" do @@ -456,7 +478,7 @@ describe UserNotifications do data: {original_username: username}.to_json ) end - describe '.user_mentioned' do + describe 'email building' do it "has a username" do expects_build_with(has_entry(:username, username)) end @@ -469,6 +491,10 @@ describe UserNotifications do expects_build_with(has_entry(:template, "user_notifications.#{mail_type}")) end + it "overrides the html part" do + expects_build_with(has_key(:html_override)) + end + it "has a message" do expects_build_with(has_key(:message)) end @@ -509,12 +535,25 @@ describe UserNotifications do User.any_instance.stubs(:suspended?).returns(true) expects_build_with(has_entry(:include_respond_instructions, false)) end + + context "when customized" do + let(:custom_body) { "You are now officially notified." } + + before do + TranslationOverride.upsert!("en", "user_notifications.user_#{notification_type}.text_body_template", custom_body) + end + + it "shouldn't use the default html_override" do + expects_build_with(Not(has_key(:html_override))) + end + end end end describe "user mentioned email" do include_examples "notification email building" do let(:notification_type) { :mentioned } + include_examples "respect for private_email" include_examples "supports reply by email" include_examples "sets user locale" end @@ -523,6 +562,7 @@ describe UserNotifications do describe "user replied" do include_examples "notification email building" do let(:notification_type) { :replied } + include_examples "respect for private_email" include_examples "supports reply by email" include_examples "sets user locale" end @@ -531,6 +571,7 @@ describe UserNotifications do describe "user quoted" do include_examples "notification email building" do let(:notification_type) { :quoted } + include_examples "respect for private_email" include_examples "supports reply by email" include_examples "sets user locale" end @@ -539,6 +580,7 @@ describe UserNotifications do describe "user posted" do include_examples "notification email building" do let(:notification_type) { :posted } + include_examples "respect for private_email" include_examples "supports reply by email" include_examples "sets user locale" end @@ -547,6 +589,7 @@ describe UserNotifications do describe "user invited to a private message" do include_examples "notification email building" do let(:notification_type) { :invited_to_private_message } + include_examples "respect for private_email" include_examples "no reply by email" include_examples "sets user locale" end @@ -555,6 +598,7 @@ describe UserNotifications do describe "user invited to a topic" do include_examples "notification email building" do let(:notification_type) { :invited_to_topic } + include_examples "respect for private_email" include_examples "no reply by email" include_examples "sets user locale" end @@ -563,6 +607,7 @@ describe UserNotifications do describe "watching first post" do include_examples "notification email building" do let(:notification_type) { :invited_to_topic } + include_examples "respect for private_email" include_examples "no reply by email" include_examples "sets user locale" end diff --git a/spec/models/admin_dashboard_data_spec.rb b/spec/models/admin_dashboard_data_spec.rb index 6a0cd5a3ca..f6e1a2c294 100644 --- a/spec/models/admin_dashboard_data_spec.rb +++ b/spec/models/admin_dashboard_data_spec.rb @@ -138,7 +138,7 @@ describe AdminDashboardData do SiteSetting.stubs(enable_setting).returns(true) end - it 'returns nil key and secret are set' do + it 'returns nil when key and secret are set' do SiteSetting.stubs(key).returns('12313213') SiteSetting.stubs(secret).returns('12312313123') expect(subject).to be_nil @@ -189,6 +189,98 @@ describe AdminDashboardData do end end + describe 's3_config_check' do + shared_examples 'problem detection for s3-dependent setting' do + subject { described_class.new.s3_config_check } + let(:access_keys) { [:s3_access_key_id, :s3_secret_access_key] } + let(:all_cred_keys) { access_keys + [:s3_use_iam_profile] } + let(:all_setting_keys) { all_cred_keys + [bucket_key] } + + def all_setting_permutations(keys) + ['a', ''].repeated_permutation(keys.size) do |*values| + hash = Hash[keys.zip(values)] + hash.each do |key,value| + SiteSetting.stubs(key).returns(value) + end + yield hash + end + end + + context 'when setting is enabled' do + let(:setting_enabled) { true } + before do + SiteSetting.stubs(setting_key).returns(setting_enabled) + SiteSetting.stubs(bucket_key).returns(bucket_value) + end + + context 'when bucket is blank' do + let(:bucket_value) { '' } + + it "always returns a string" do + all_setting_permutations(all_cred_keys) do + expect(subject).to_not be_nil + end + end + end + + context 'when bucket is filled in' do + let(:bucket_value) { 'a' } + before do + SiteSetting.stubs(:s3_use_iam_profile).returns(use_iam_profile) + end + + context 'when using iam profile' do + let(:use_iam_profile) { true } + + it 'always returns nil' do + all_setting_permutations(access_keys) do + expect(subject).to be_nil + end + end + end + + context 'when not using iam profile' do + let(:use_iam_profile) { false } + + it 'returns nil only if both access key fields are filled in' do + all_setting_permutations(access_keys) do |settings| + if settings.values.all? + expect(subject).to be_nil + else + expect(subject).to_not be_nil + end + end + end + end + end + end + + context 'when setting is not enabled' do + before do + SiteSetting.stubs(setting_key).returns(false) + end + + it "always returns nil" do + all_setting_permutations(all_setting_keys) do + expect(subject).to be_nil + end + end + end + end + + describe 'uploads' do + let(:setting_key) { :enable_s3_uploads } + let(:bucket_key) { :s3_upload_bucket } + include_examples 'problem detection for s3-dependent setting' + end + + describe 'backups' do + let(:setting_key) { :enable_s3_backups } + let(:bucket_key) { :s3_backup_bucket } + include_examples 'problem detection for s3-dependent setting' + end + end + describe 'stats cache' do include_examples 'stats cachable' end diff --git a/spec/models/category_featured_topic_spec.rb b/spec/models/category_featured_topic_spec.rb index d6ca194e13..b7fe537017 100644 --- a/spec/models/category_featured_topic_spec.rb +++ b/spec/models/category_featured_topic_spec.rb @@ -34,9 +34,7 @@ describe CategoryFeaturedTopic do it 'should feature stuff in the correct order' do - SiteSetting.stubs(:category_featured_topics).returns(2) - - category = Fabricate(:category) + category = Fabricate(:category, num_featured_topics: 2) t5 = Fabricate(:topic, category_id: category.id, bumped_at: 12.minutes.ago) t4 = Fabricate(:topic, category_id: category.id, bumped_at: 10.minutes.ago) t3 = Fabricate(:topic, category_id: category.id, bumped_at: 7.minutes.ago) @@ -46,7 +44,7 @@ describe CategoryFeaturedTopic do CategoryFeaturedTopic.feature_topics_for(category) - # Should find more than we need: pinned topics first, then category_featured_topics * 2 + # Should find more than we need: pinned topics first, then num_featured_topics * 2 expect( CategoryFeaturedTopic.where(category_id: category.id).pluck(:topic_id) ).to eq([pinned.id, t2.id, t1.id, t3.id, t4.id]) diff --git a/spec/models/category_list_spec.rb b/spec/models/category_list_spec.rb index a94d07d4a0..32da762796 100644 --- a/spec/models/category_list_spec.rb +++ b/spec/models/category_list_spec.rb @@ -51,7 +51,7 @@ describe CategoryList do context "with a category" do - let!(:topic_category) { Fabricate(:category) } + let!(:topic_category) { Fabricate(:category, num_featured_topics: 2) } context "with a topic in a category" do let!(:topic) { Fabricate(:topic, category: topic_category) } @@ -71,10 +71,6 @@ describe CategoryList do let!(:pinned) { Fabricate(:topic, category: topic_category, pinned_at: 10.minutes.ago, bumped_at: 10.minutes.ago) } let(:category) { category_list.categories.find{|c| c.id == topic_category.id} } - before do - SiteSetting.stubs(:category_featured_topics).returns(2) - end - it "returns pinned topic first" do expect(category.displayable_topics.map(&:id)).to eq([pinned.id, topic3.id]) end diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb index 71902361af..87e808c26c 100644 --- a/spec/models/category_spec.rb +++ b/spec/models/category_spec.rb @@ -310,6 +310,7 @@ describe Category do @category.update_attributes(name: 'Troutfishing') @topic.reload expect(@topic.title).to match(/Troutfishing/) + expect(@topic.fancy_title).to match(/Troutfishing/) end it "doesn't raise an error if there is no definition topic to rename (uncategorized)" do @@ -342,7 +343,7 @@ describe Category do it "should not set its description topic to auto-close" do category = Fabricate(:category, name: 'Closing Topics', auto_close_hours: 1) - expect(category.topic.auto_close_at).to be_nil + expect(category.topic.public_topic_timer).to eq(nil) end describe "creating a new category with the same slug" do diff --git a/spec/models/color_scheme_spec.rb b/spec/models/color_scheme_spec.rb index 34f75e4d94..a19f86dcfe 100644 --- a/spec/models/color_scheme_spec.rb +++ b/spec/models/color_scheme_spec.rb @@ -2,15 +2,30 @@ require 'rails_helper' describe ColorScheme do - let(:valid_params) { {name: "Best Colors Evar", enabled: true, colors: valid_colors} } + let(:valid_params) { {name: "Best Colors Evar", colors: valid_colors} } let(:valid_colors) { [ {name: '$primary_background_color', hex: 'FFBB00'}, {name: '$secondary_background_color', hex: '888888'} ]} + it "correctly invalidates theme css when changed" do + scheme = ColorScheme.create_from_base(name: 'Bob') + theme = Theme.new(name: 'Amazing Theme', color_scheme_id: scheme.id, user_id: -1) + theme.set_field(name: :scss, target: :desktop, value: '.bob {color: $primary;}') + theme.save! + + href = Stylesheet::Manager.stylesheet_href(:desktop_theme, theme.key) + + ColorSchemeRevisor.revise(scheme, colors: [{ name: 'primary', hex: 'bbb' }]) + + href2 = Stylesheet::Manager.stylesheet_href(:desktop_theme, theme.key) + + expect(href).not_to eq(href2) + end + describe "new" do it "can take colors" do - c = described_class.new(valid_params) + c = ColorScheme.new(valid_params) expect(c.colors.size).to eq valid_colors.size expect(c.colors.first).to be_a(ColorSchemeColor) expect { @@ -27,7 +42,7 @@ describe ColorScheme do Fabricate(:color_scheme_color, name: 'third_one', hex: base_colors[:third_one])]) } before do - described_class.stubs(:base).returns(base) + ColorScheme.stubs(:base).returns(base) end it "creates a new color scheme" do @@ -55,29 +70,4 @@ describe ColorScheme do end end end - - describe "destroy" do - it "also destroys old versions" do - c1 = described_class.create(valid_params.merge(version: 2)) - _c2 = described_class.create(valid_params.merge(versioned_id: c1.id, version: 1)) - _other = described_class.create(valid_params) - expect { - c1.destroy - }.to change { described_class.count }.by(-2) - end - end - - describe "#enabled" do - it "returns nil when there is no enabled record" do - expect(described_class.enabled).to eq nil - end - - it "returns the enabled color scheme" do - ColorScheme.hex_cache.clear - expect(described_class.hex_for_name('$primary_background_color')).to eq nil - c = described_class.create(valid_params.merge(enabled: true)) - expect(described_class.enabled.id).to eq c.id - expect(described_class.hex_for_name('$primary_background_color')).to eq "FFBB00" - end - end end diff --git a/spec/models/discourse_single_sign_on_spec.rb b/spec/models/discourse_single_sign_on_spec.rb index 990884fe0a..4c26f04bbb 100644 --- a/spec/models/discourse_single_sign_on_spec.rb +++ b/spec/models/discourse_single_sign_on_spec.rb @@ -1,5 +1,4 @@ require "rails_helper" -require 'sidekiq/testing' describe DiscourseSingleSignOn do before do @@ -27,6 +26,7 @@ describe DiscourseSingleSignOn do sso.moderator = false sso.suppress_welcome_message = false sso.require_activation = false + sso.title = "user title" sso.custom_fields["a"] = "Aa" sso.custom_fields["b.b"] = "B.b" sso @@ -45,6 +45,7 @@ describe DiscourseSingleSignOn do expect(parsed.moderator).to eq sso.moderator expect(parsed.suppress_welcome_message).to eq sso.suppress_welcome_message expect(parsed.require_activation).to eq false + expect(parsed.title).to eq sso.title expect(parsed.custom_fields["a"]).to eq "Aa" expect(parsed.custom_fields["b.b"]).to eq "B.b" end @@ -78,18 +79,24 @@ describe DiscourseSingleSignOn do end it "unstaged users" do + SiteSetting.sso_overrides_name = true + email = "staged@user.com" Fabricate(:user, staged: true, email: email) sso = DiscourseSingleSignOn.new sso.username = "staged" - sso.name = "Staged User" + sso.name = "Bob O'Bob" sso.email = email sso.external_id = "B" user = sso.lookup_or_create_user(ip_address) + user.reload + expect(user).to_not be_nil expect(user.staged).to be(false) + + expect(user.name).to eq("Bob O'Bob") end it "can set admin and moderator" do @@ -246,6 +253,28 @@ describe DiscourseSingleSignOn do expect(user.active).to eq(false) end + it 'deactivates accounts that have updated email address' do + + SiteSetting.sso_overrides_email = true + sso.require_activation = true + + user = sso.lookup_or_create_user(ip_address) + expect(user.active).to eq(false) + + old_email = user.email + + user.update_columns(active: true) + user = sso.lookup_or_create_user(ip_address) + expect(user.active).to eq(true) + + user.update_columns(email: 'xXx@themovie.com') + + user = sso.lookup_or_create_user(ip_address) + expect(user.email).to eq(old_email) + expect(user.active).to eq(false) + + end + end context 'welcome emails' do @@ -260,13 +289,40 @@ describe DiscourseSingleSignOn do it "sends a welcome email by default" do User.any_instance.expects(:enqueue_welcome_message).once - user = sso.lookup_or_create_user(ip_address) + _user = sso.lookup_or_create_user(ip_address) end it "suppresses the welcome email when asked to" do User.any_instance.expects(:enqueue_welcome_message).never sso.suppress_welcome_message = true + _user = sso.lookup_or_create_user(ip_address) + end + end + + context 'setting title for a user' do + let(:sso) { + sso = DiscourseSingleSignOn.new + sso.username = 'test' + sso.name = 'test' + sso.email = 'test@test.com' + sso.external_id = '100' + sso.title = "The User's Title" + sso + } + + it 'sets title correctly' do user = sso.lookup_or_create_user(ip_address) + expect(user.title).to eq(sso.title) + + sso.title = "farmer" + user = sso.lookup_or_create_user(ip_address) + + expect(user.title).to eq("farmer") + + sso.title = nil + user = sso.lookup_or_create_user(ip_address) + + expect(user.title).to eq("farmer") end end diff --git a/spec/models/email_log_spec.rb b/spec/models/email_log_spec.rb index 79608a8d8e..8717f958d3 100644 --- a/spec/models/email_log_spec.rb +++ b/spec/models/email_log_spec.rb @@ -14,7 +14,7 @@ describe EmailLog do user = post.user # skipped emails do not matter - user.email_logs.create(email_type: 'blah', post_id: post.id, to_address: user.email, user_id: user.id, skipped: true) + Fabricate(:email_log, user: user, email_type: 'blah', post_id: post.id, to_address: user.email, user_id: user.id, skipped: true) ran = EmailLog.unique_email_per_post(post, user) do @@ -23,7 +23,7 @@ describe EmailLog do expect(ran).to eq(true) - user.email_logs.create(email_type: 'blah', post_id: post.id, to_address: user.email, user_id: user.id) + Fabricate(:email_log, user: user, email_type: 'blah', post_id: post.id, to_address: user.email, user_id: user.id) ran = EmailLog.unique_email_per_post(post, user) do true @@ -45,7 +45,7 @@ describe EmailLog do it "doesn't update last_emailed_at if skipped is true" do expect { - user.email_logs.create(email_type: 'blah', to_address: user.email, skipped: true) + Fabricate(:email_log, user: user, email_type: 'blah', to_address: user.email, skipped: true) user.reload }.to_not change { user.last_emailed_at } end @@ -53,24 +53,31 @@ describe EmailLog do end describe '#reached_max_emails?' do - it "tracks when max emails are reached" do + before do SiteSetting.max_emails_per_day_per_user = 2 - user.email_logs.create(email_type: 'blah', to_address: user.email, user_id: user.id, skipped: true) - user.email_logs.create(email_type: 'blah', to_address: user.email, user_id: user.id) - user.email_logs.create(email_type: 'blah', to_address: user.email, user_id: user.id, created_at: 3.days.ago) + Fabricate(:email_log, user: user, email_type: 'blah', to_address: user.email, user_id: user.id, skipped: true) + Fabricate(:email_log, user: user, email_type: 'blah', to_address: user.email, user_id: user.id) + Fabricate(:email_log, user: user, email_type: 'blah', to_address: user.email, user_id: user.id, created_at: 3.days.ago) + end + it "tracks when max emails are reached" do expect(EmailLog.reached_max_emails?(user)).to eq(false) - user.email_logs.create(email_type: 'blah', to_address: user.email, user_id: user.id) - + Fabricate(:email_log, user: user, email_type: 'blah', to_address: user.email, user_id: user.id) expect(EmailLog.reached_max_emails?(user)).to eq(true) end + + it "returns false for critical email" do + Fabricate(:email_log, user: user, email_type: 'blah', to_address: user.email, user_id: user.id) + expect(EmailLog.reached_max_emails?(user, 'forgot_password')).to eq(false) + expect(EmailLog.reached_max_emails?(user, 'confirm_new_email')).to eq(false) + end end describe '#count_per_day' do it "counts sent emails" do - user.email_logs.create(email_type: 'blah', to_address: user.email) - user.email_logs.create(email_type: 'blah', to_address: user.email, skipped: true) + Fabricate(:email_log, user: user, email_type: 'blah', to_address: user.email) + Fabricate(:email_log, user: user, email_type: 'blah', to_address: user.email, skipped: true) expect(described_class.count_per_day(1.day.ago, Time.now).first[1]).to eq 1 end end diff --git a/spec/models/embeddable_host_spec.rb b/spec/models/embeddable_host_spec.rb index c8a47755de..700478871c 100644 --- a/spec/models/embeddable_host_spec.rb +++ b/spec/models/embeddable_host_spec.rb @@ -38,6 +38,12 @@ describe EmbeddableHost do expect(eh.host).to eq('localhost:8080') end + it "supports ports for ip addresses" do + eh = EmbeddableHost.new(host: '192.168.0.1:3000') + expect(eh).to be_valid + expect(eh.host).to eq('192.168.0.1:3000') + end + it "supports subdomains of localhost" do eh = EmbeddableHost.new(host: 'discourse.localhost') expect(eh).to be_valid @@ -49,12 +55,21 @@ describe EmbeddableHost do expect(eh).not_to be_valid end + describe "it works with ports" do + let!(:host) { Fabricate(:embeddable_host, host: 'localhost:8000') } + + it "works as expected" do + expect(EmbeddableHost.url_allowed?('http://localhost:8000/eviltrout')).to eq(true) + end + end + describe "url_allowed?" do let!(:host) { Fabricate(:embeddable_host) } it 'works as expected' do expect(EmbeddableHost.url_allowed?('http://eviltrout.com')).to eq(true) expect(EmbeddableHost.url_allowed?('https://eviltrout.com')).to eq(true) + expect(EmbeddableHost.url_allowed?('https://eviltrout.com/انگلیسی')).to eq(true) expect(EmbeddableHost.url_allowed?('https://not-eviltrout.com')).to eq(false) end @@ -77,6 +92,13 @@ describe EmbeddableHost do expect(EmbeddableHost.url_allowed?('http://eviltrout.com/fp?test=1')).to eq(false) expect(EmbeddableHost.url_allowed?('http://eviltrout.com/fp')).to eq(true) end + + it "allows multiple records with different paths" do + Fabricate(:embeddable_host, path_whitelist: '/rick/.*') + Fabricate(:embeddable_host, path_whitelist: '/morty/.*') + expect(EmbeddableHost.url_allowed?('http://eviltrout.com/rick/smith')).to eq(true) + expect(EmbeddableHost.url_allowed?('http://eviltrout.com/morty/sanchez')).to eq(true) + end end end diff --git a/spec/models/emoji_spec.rb b/spec/models/emoji_spec.rb index 33b2f9114d..40c214b40a 100644 --- a/spec/models/emoji_spec.rb +++ b/spec/models/emoji_spec.rb @@ -15,4 +15,17 @@ describe Emoji do expect(Emoji.replacement_code('robin')).to be_nil end + describe '.load_custom' do + describe 'when a custom emoji has an invalid upload_id' do + it 'should return the custom emoji without a URL' do + CustomEmoji.create!(name: 'test', upload_id: -1) + + emoji = Emoji.load_custom.first + + expect(emoji.name).to eq('test') + expect(emoji.url).to eq(nil) + end + end + end + end diff --git a/spec/models/global_setting_spec.rb b/spec/models/global_setting_spec.rb index dbe1b94c2e..bc1ea0303f 100644 --- a/spec/models/global_setting_spec.rb +++ b/spec/models/global_setting_spec.rb @@ -1,6 +1,66 @@ require 'rails_helper' require 'tempfile' +class GlobalSetting + def self.reset_secret_key_base! + @safe_secret_key_base = nil + end +end + +describe GlobalSetting do + + describe '.safe_secret_key_base' do + it 'sets redis token if it is somehow flushed after 30 seconds' do + + # we have to reset so we reset all times and test runs consistently + GlobalSetting.reset_secret_key_base! + + freeze_time Time.now + + token = GlobalSetting.safe_secret_key_base + $redis.without_namespace.del(GlobalSetting::REDIS_SECRET_KEY) + freeze_time Time.now + 20 + + GlobalSetting.safe_secret_key_base + new_token = $redis.without_namespace.get(GlobalSetting::REDIS_SECRET_KEY) + expect(new_token).to eq(nil) + + freeze_time Time.now + 11 + + GlobalSetting.safe_secret_key_base + + new_token = $redis.without_namespace.get(GlobalSetting::REDIS_SECRET_KEY) + expect(new_token).to eq(token) + + end + end + + describe '.redis_config' do + describe 'when slave config is not present' do + it "should not set any connector" do + expect(GlobalSetting.redis_config[:connector]).to eq(nil) + end + end + + describe 'when slave config is present' do + before do + GlobalSetting.reset_redis_config! + end + + after do + GlobalSetting.reset_redis_config! + end + + it "should set the right connector" do + GlobalSetting.expects(:redis_slave_port).returns(6379).at_least_once + GlobalSetting.expects(:redis_slave_host).returns('0.0.0.0').at_least_once + + expect(GlobalSetting.redis_config[:connector]).to eq(DiscourseRedis::Connector) + end + end + end +end + describe GlobalSetting::EnvProvider do it "can detect keys from env" do ENV['DISCOURSE_BLA'] = '1' @@ -9,6 +69,7 @@ describe GlobalSetting::EnvProvider do expect(GlobalSetting::EnvProvider.new.keys).to include(:bla_2) end end + describe GlobalSetting::FileProvider do it "can parse a simple file" do f = Tempfile.new('foo') @@ -48,5 +109,4 @@ describe GlobalSetting::FileProvider do f.unlink end - end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 8a6477ed73..c70426e04c 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' describe Group do + let(:admin) { Fabricate(:admin) } + let(:user) { Fabricate(:user) } describe '#builtin' do context "verify enum sequence" do @@ -154,9 +156,42 @@ describe Group do end - it "makes sure the everyone group is not visible" do - g = Group.refresh_automatic_group!(:everyone) - expect(g.visible).to eq(false) + describe '.refresh_automatic_group!' do + it "makes sure the everyone group is not visible" do + g = Group.refresh_automatic_group!(:everyone) + expect(g.visible).to eq(false) + end + + it "uses the localized name if name has not been taken" do + begin + default_locale = SiteSetting.default_locale + I18n.locale = SiteSetting.default_locale = 'de' + + group = Group.refresh_automatic_group!(:staff) + + expect(group.name).to_not eq('staff') + expect(group.name).to eq(I18n.t('groups.default_names.staff')) + ensure + I18n.locale = SiteSetting.default_locale = default_locale + end + end + + it "does not use the localized name if name has already been taken" do + begin + default_locale = SiteSetting.default_locale + I18n.locale = SiteSetting.default_locale = 'de' + + another_group = Fabricate(:group, + name: I18n.t('groups.default_names.staff').upcase + ) + + group = Group.refresh_automatic_group!(:staff) + + expect(group.name).to eq('staff') + ensure + I18n.locale = SiteSetting.default_locale = default_locale + end + end end it "Correctly handles removal of primary group" do @@ -375,4 +410,44 @@ describe Group do expect(group.bio_cooked).to include("unicorn.png") end + describe ".visible_groups" do + let(:group) { Fabricate(:group, visible: false) } + let(:group_2) { Fabricate(:group, visible: true) } + let(:admin) { Fabricate(:admin) } + let(:user) { Fabricate(:user) } + + before do + group + group_2 + end + + describe 'when user is an admin' do + it 'should return the right groups' do + expect(Group.visible_groups(admin).pluck(:id).sort) + .to eq([group.id, group_2.id].concat(Group::AUTO_GROUP_IDS.keys - [0]).sort) + end + end + + describe 'when user is owner of a group' do + it 'should return the right groups' do + group.add_owner(user) + + expect(Group.visible_groups(user).pluck(:id).sort) + .to eq([group.id, group_2.id]) + end + end + + describe 'when user is not the owner of any group' do + it 'should return the right groups' do + expect(Group.visible_groups(user).pluck(:id).sort) + .to eq([group_2.id]) + end + end + + describe 'user is nil' do + it 'should return the right groups' do + expect(Group.visible_groups(nil).pluck(:id).sort).to eq([group_2.id]) + end + end + end end diff --git a/spec/models/group_user_spec.rb b/spec/models/group_user_spec.rb new file mode 100644 index 0000000000..c7675984cf --- /dev/null +++ b/spec/models/group_user_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +describe GroupUser do + + it 'correctly sets notification level' do + moderator = Fabricate(:moderator) + + Group.refresh_automatic_groups!(:moderators) + gu = GroupUser.find_by(user_id: moderator.id, group_id: Group::AUTO_GROUPS[:moderators]) + + expect(gu.notification_level).to eq(NotificationLevels.all[:tracking]) + + group = Group.create!(name: 'bob') + group.add(moderator) + group.save + + gu = GroupUser.find_by(user_id: moderator.id, group_id: group.id) + expect(gu.notification_level).to eq(NotificationLevels.all[:watching]) + + group.remove(moderator) + group.save + + group.default_notification_level = 1 + group.save + + group.add(moderator) + group.save + + gu = GroupUser.find_by(user_id: moderator.id, group_id: group.id) + expect(gu.notification_level).to eq(NotificationLevels.all[:regular]) + end + +end diff --git a/spec/models/invite_redeemer_spec.rb b/spec/models/invite_redeemer_spec.rb index 4b5d1bcc44..d1d93fda6b 100644 --- a/spec/models/invite_redeemer_spec.rb +++ b/spec/models/invite_redeemer_spec.rb @@ -3,13 +3,32 @@ require 'rails_helper' describe InviteRedeemer do describe '#create_user_from_invite' do - let(:user) { InviteRedeemer.create_user_from_invite(Fabricate(:invite, email: 'walter.white@email.com'), 'walter', 'Walter White') } - it "should be created correctly" do + user = InviteRedeemer.create_user_from_invite(Fabricate(:invite, email: 'walter.white@email.com'), 'walter', 'Walter White') expect(user.username).to eq('walter') expect(user.name).to eq('Walter White') expect(user).to be_active expect(user.email).to eq('walter.white@email.com') + expect(user.approved).to eq(true) + end + + it "can set the password too" do + password = 's3cure5tpasSw0rD' + user = InviteRedeemer.create_user_from_invite(Fabricate(:invite, email: 'walter.white@email.com'), 'walter', 'Walter White', password) + expect(user).to have_password + expect(user.confirm_password?(password)).to eq(true) + expect(user.approved).to eq(true) + end + + it "raises exception with record and errors" do + error = nil + begin + InviteRedeemer.create_user_from_invite(Fabricate(:invite, email: 'walter.white@email.com'), 'walter', 'Walter White', 'aaa') + rescue ActiveRecord::RecordInvalid => e + error = e + end + expect(error).to be_present + expect(error.record.errors[:password]).to be_present end end @@ -17,9 +36,24 @@ describe InviteRedeemer do let(:invite) { Fabricate(:invite) } let(:name) { 'john snow' } let(:username) { 'kingofthenorth' } + let(:password) { 'know5nOthiNG'} let(:invite_redeemer) { InviteRedeemer.new(invite, username, name) } - it "should redeem the invite" do + it "should redeem the invite if invited by staff" do + SiteSetting.must_approve_users = true + inviter = invite.invited_by + inviter.admin = true + user = invite_redeemer.redeem + + expect(user.name).to eq(name) + expect(user.username).to eq(username) + expect(user.invited_by).to eq(inviter) + expect(inviter.notifications.count).to eq(1) + expect(user.approved).to eq(true) + end + + it "should redeem the invite if invited by non staff but not approve" do + SiteSetting.must_approve_users = true inviter = invite.invited_by user = invite_redeemer.redeem @@ -27,6 +61,18 @@ describe InviteRedeemer do expect(user.username).to eq(username) expect(user.invited_by).to eq(inviter) expect(inviter.notifications.count).to eq(1) + expect(user.approved).to eq(false) + end + + it "should redeem the invite if invited by non staff and approve if staff not required to approve" do + inviter = invite.invited_by + user = invite_redeemer.redeem + + expect(user.name).to eq(name) + expect(user.username).to eq(username) + expect(user.invited_by).to eq(inviter) + expect(inviter.notifications.count).to eq(1) + expect(user.approved).to eq(true) end it "should not blow up if invited_by user has been removed" do @@ -39,5 +85,13 @@ describe InviteRedeemer do expect(user.username).to eq(username) expect(user.invited_by).to eq(nil) end + + it "can set password" do + inviter = invite.invited_by + user = InviteRedeemer.new(invite, username, name, password).redeem + expect(user).to have_password + expect(user.confirm_password?(password)).to eq(true) + expect(user.approved).to eq(true) + end end end diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb index 17702be6b9..60c390a681 100644 --- a/spec/models/invite_spec.rb +++ b/spec/models/invite_spec.rb @@ -102,8 +102,15 @@ describe Invite do expect(topic.invite_by_email(inviter, 'ICEKING@adventuretime.ooo')).to eq(@invite) end + it 'updates timestamp of existing invite' do + @invite.created_at = 10.days.ago + @invite.save + resend_invite = topic.invite_by_email(inviter, 'iceking@adventuretime.ooo') + expect(resend_invite.created_at).to be_within(1.minute).of(Time.zone.now) + end + it 'returns a new invite if the other has expired' do - SiteSetting.stubs(:invite_expiry_days).returns(1) + SiteSetting.invite_expiry_days = 1 @invite.created_at = 2.days.ago @invite.save new_invite = topic.invite_by_email(inviter, 'iceking@adventuretime.ooo') @@ -152,11 +159,10 @@ describe Invite do context 'an existing user' do let(:topic) { Fabricate(:topic, category_id: nil, archetype: 'private_message') } let(:coding_horror) { Fabricate(:coding_horror) } - let!(:invite) { topic.invite_by_email(topic.user, coding_horror.email) } it "works" do # doesn't create an invite - expect(invite).to be_blank + expect { topic.invite_by_email(topic.user, coding_horror.email) }.to raise_error(Invite::UserExists) # gives the user permission to access the topic expect(topic.allowed_users.include?(coding_horror)).to eq(true) @@ -207,33 +213,6 @@ describe Invite do end - context 'enqueues a job to email "set password" instructions' do - - it 'does not enqueue an email if sso is enabled' do - SiteSetting.stubs(:enable_sso).returns(true) - Jobs.expects(:enqueue).with(:invite_password_instructions_email, has_key(:username)).never - invite.redeem - end - - it 'does not enqueue an email if local login is disabled' do - SiteSetting.stubs(:enable_local_logins).returns(false) - Jobs.expects(:enqueue).with(:invite_password_instructions_email, has_key(:username)).never - invite.redeem - end - - it 'does not enqueue an email if the user has already set password' do - Fabricate(:user, email: invite.email, password_hash: "7af7805c9ee3697ed1a83d5e3cb5a3a431d140933a87fdcdc5a42aeef9337f81") - Jobs.expects(:enqueue).with(:invite_password_instructions_email, has_key(:username)).never - invite.redeem - end - - it 'enqueues an email if all conditions are satisfied' do - Jobs.expects(:enqueue).with(:invite_password_instructions_email, has_key(:username)) - invite.redeem - end - - end - context "as a moderator" do it "will give the user a moderator flag" do invite.invited_by = Fabricate(:admin) @@ -273,6 +252,7 @@ describe Invite do context 'inviting when must_approve_users? is enabled' do it 'correctly activates accounts' do + invite.invited_by = Fabricate(:admin) SiteSetting.stubs(:must_approve_users).returns(true) user = invite.redeem expect(user.approved?).to eq(true) @@ -330,25 +310,24 @@ describe Invite do end context 'invited to topics' do - let!(:topic) { Fabricate(:private_message_topic) } + let(:tl2_user) { Fabricate(:user, trust_level: 2) } + let!(:topic) { Fabricate(:private_message_topic, user: tl2_user) } let!(:invite) { topic.invite(topic.user, 'jake@adventuretime.ooo') } context 'redeem topic invite' do - it 'adds the user to the topic_users' do user = invite.redeem topic.reload expect(topic.allowed_users.include?(user)).to eq(true) expect(Guardian.new(user).can_see?(topic)).to eq(true) end - end context 'invited by another user to the same topic' do - let(:coding_horror) { User.find_by(username: "CodingHorror") } - let!(:another_invite) { topic.invite(coding_horror, 'jake@adventuretime.ooo') } + let(:another_tl2_user) { Fabricate(:user, trust_level: 2) } + let!(:another_invite) { topic.invite(another_tl2_user, 'jake@adventuretime.ooo') } let!(:user) { invite.redeem } it 'adds the user to the topic_users' do @@ -358,25 +337,14 @@ describe Invite do end context 'invited by another user to a different topic' do - let!(:another_invite) { another_topic.invite(coding_horror, 'jake@adventuretime.ooo') } let!(:user) { invite.redeem } - - let(:coding_horror) { User.find_by(username: "CodingHorror") } - let(:another_topic) { Fabricate(:topic, category_id: nil, archetype: "private_message", user: coding_horror) } + let(:another_tl2_user) { Fabricate(:user, trust_level: 2) } + let(:another_topic) { Fabricate(:topic, user: another_tl2_user) } it 'adds the user to the topic_users of the first topic' do + expect(another_topic.invite(another_tl2_user, user.username)).to be_truthy # invited via username expect(topic.allowed_users.include?(user)).to eq(true) expect(another_topic.allowed_users.include?(user)).to eq(true) - duplicate_invite = Invite.find_by(id: another_invite.id) - expect(duplicate_invite).to be_nil - end - - context 'if they redeem the other invite afterwards' do - - it 'wont redeem a duplicate invite' do - expect(another_invite.redeem).to be_blank - end - end end end diff --git a/spec/models/mailing_list_mode_site_setting_spec.rb b/spec/models/mailing_list_mode_site_setting_spec.rb index d8ccc7e4aa..88267fde4d 100644 --- a/spec/models/mailing_list_mode_site_setting_spec.rb +++ b/spec/models/mailing_list_mode_site_setting_spec.rb @@ -3,15 +3,17 @@ require 'rails_helper' describe MailingListModeSiteSetting do describe 'valid_value?' do it 'returns true for a valid value as an int' do - expect(DigestEmailSiteSetting.valid_value?(0)).to eq true + expect(MailingListModeSiteSetting.valid_value?(1)).to eq(true) end - it 'returns false for a valid value as a string' do - expect(DigestEmailSiteSetting.valid_value?('0')).to eq true + it 'returns true for a valid value as a string' do + expect(MailingListModeSiteSetting.valid_value?('1')).to eq(true) end it 'returns false for an out of range value' do - expect(DigestEmailSiteSetting.valid_value?(3)).to eq false + [0, 3].each do |value| + expect(MailingListModeSiteSetting.valid_value?(value)).to eq(false) + end end end end diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb index 02ee99917c..fa45deb0e7 100644 --- a/spec/models/notification_spec.rb +++ b/spec/models/notification_spec.rb @@ -133,7 +133,7 @@ describe Notification do it 'updates the notification count on destroy' do Notification.any_instance.expects(:refresh_notification_count).returns(nil) - notification.destroy + notification.destroy! end end diff --git a/spec/models/post_action_spec.rb b/spec/models/post_action_spec.rb index aa7daba215..9e30886a39 100644 --- a/spec/models/post_action_spec.rb +++ b/spec/models/post_action_spec.rb @@ -147,6 +147,12 @@ describe PostAction do expect(PostAction.flagged_posts_count).to eq(0) end + it "should ignore flags on non-human users" do + post = create_post(user: Discourse.system_user) + PostAction.act(codinghorror, post, PostActionType.types[:off_topic]) + expect(PostAction.flagged_posts_count).to eq(0) + end + it "should ignore validated flags" do post = create_post @@ -447,11 +453,11 @@ describe PostAction do expect(post.hidden).to eq(false) end - it "will automatically close a topic due to large community flagging" do - SiteSetting.stubs(:flags_required_to_hide_post).returns(0) - - SiteSetting.stubs(:num_flags_to_close_topic).returns(3) - SiteSetting.stubs(:num_flaggers_to_close_topic).returns(2) + it "will automatically pause a topic due to large community flagging" do + SiteSetting.flags_required_to_hide_post = 0 + SiteSetting.num_flags_to_close_topic = 3 + SiteSetting.num_flaggers_to_close_topic = 2 + SiteSetting.num_hours_to_close_topic = 1 topic = Fabricate(:topic) post1 = create_post(topic: topic) @@ -490,6 +496,11 @@ describe PostAction do expect(topic.reload.closed).to eq(true) + topic_status_update = TopicTimer.last + + expect(topic_status_update.topic).to eq(topic) + expect(topic_status_update.execute_at).to be_within(1.second).of(1.hour.from_now) + expect(topic_status_update.status_type).to eq(TopicTimer.types[:open]) end end diff --git a/spec/models/post_mover_spec.rb b/spec/models/post_mover_spec.rb index bb606ef791..16d3a4f4fe 100644 --- a/spec/models/post_mover_spec.rb +++ b/spec/models/post_mover_spec.rb @@ -24,9 +24,18 @@ describe PostMover do let(:category) { Fabricate(:category, user: user) } let!(:topic) { Fabricate(:topic, user: user) } let!(:p1) { Fabricate(:post, topic: topic, user: user) } - let!(:p2) { Fabricate(:post, topic: topic, user: another_user, raw: "Has a link to [evil trout](http://eviltrout.com) which is a cool site.", reply_to_post_number: p1.post_number)} - let!(:p3) { Fabricate(:post, topic: topic, reply_to_post_number: p1.post_number, user: user)} - let!(:p4) { Fabricate(:post, topic: topic, reply_to_post_number: p2.post_number, user: user)} + + let!(:p2) do + Fabricate(:post, + topic: topic, + user: another_user, + raw: "Has a link to [evil trout](http://eviltrout.com) which is a cool site.", + reply_to_post_number: p1.post_number) + end + + let!(:p3) { Fabricate(:post, topic: topic, reply_to_post_number: p1.post_number, user: user) } + let!(:p4) { Fabricate(:post, topic: topic, reply_to_post_number: p2.post_number, user: user) } + let!(:p5) { Fabricate(:post) } before do p1.replies << p3 @@ -191,6 +200,14 @@ describe PostMover do topic.reload expect(topic.closed).to eq(true) end + + it 'does not move posts that do not belong to the existing topic' do + new_topic = topic.move_posts( + user, [p2.id, p3.id, p5.id], title: 'Logan is a pretty good movie' + ) + + expect(new_topic.posts.pluck(:id).sort).to eq([p2.id, p3.id].sort) + end end context "to an existing topic" do @@ -256,10 +273,7 @@ describe PostMover do new_topic = topic.move_posts(user, [p1.id, p2.id], title: "new testing topic name") expect(new_topic).to be_present - new_topic.posts.reload expect(new_topic.posts.by_post_number.first.raw).to eq(p1.raw) - - new_topic.reload expect(new_topic.posts_count).to eq(2) expect(new_topic.highest_post_number).to eq(2) @@ -267,7 +281,7 @@ describe PostMover do p1.reload expect(p1.sort_order).to eq(1) expect(p1.post_number).to eq(1) - p1.topic_id == topic.id + expect(p1.topic_id).to eq(topic.id) expect(p1.reply_count).to eq(0) # New first post @@ -278,7 +292,7 @@ describe PostMover do p2.reload expect(p2.post_number).to eq(2) expect(p2.sort_order).to eq(2) - p2.topic_id == new_topic.id + expect(p2.topic_id).to eq(new_topic.id) expect(p2.reply_to_post_number).to eq(1) expect(p2.reply_count).to eq(0) @@ -287,6 +301,17 @@ describe PostMover do expect(topic.highest_post_number).to eq(p4.post_number) end + it "preserves post actions in the new post" do + PostAction.act(another_user, p1, PostActionType.types[:like]) + + new_topic = topic.move_posts(user, [p1.id], title: "new testing topic name") + new_post = new_topic.posts.where(post_number: 1).first + + expect(new_topic.like_count).to eq(1) + expect(new_post.like_count).to eq(1) + expect(new_post.post_actions.size).to eq(1) + end + end context "to an existing topic with a deleted post" do diff --git a/spec/models/post_spec.rb b/spec/models/post_spec.rb index 1e827c0e6d..09d7e533be 100644 --- a/spec/models/post_spec.rb +++ b/spec/models/post_spec.rb @@ -493,7 +493,7 @@ describe Post do expect(Fabricate.build(:post, post_args)).to be_valid end - it 'treate blank posts as invalid' do + it 'create blank posts as invalid' do expect(Fabricate.build(:post, raw: "")).not_to be_valid end end @@ -952,7 +952,7 @@ describe Post do it "will unhide the post but will keep the topic invisible/unlisted" do hidden_topic = Fabricate(:topic, visible: false) - first_post = create_post(topic: hidden_topic) + create_post(topic: hidden_topic) second_post = create_post(topic: hidden_topic) second_post.update_columns(hidden: true, hidden_at: Time.now, hidden_reason_id: 1) diff --git a/spec/models/remote_theme_spec.rb b/spec/models/remote_theme_spec.rb new file mode 100644 index 0000000000..563056ac7f --- /dev/null +++ b/spec/models/remote_theme_spec.rb @@ -0,0 +1,134 @@ +require 'rails_helper' + +describe RemoteTheme do + context '#import_remote' do + def setup_git_repo(files) + dir = Dir.tmpdir + repo_dir = "#{dir}/#{SecureRandom.hex}" + `mkdir #{repo_dir}` + `cd #{repo_dir} && git init . ` + `cd #{repo_dir} && git config user.email 'someone@cool.com'` + `cd #{repo_dir} && git config user.name 'The Cool One'` + `cd #{repo_dir} && mkdir desktop mobile common assets` + files.each do |name, data| + File.write("#{repo_dir}/#{name}", data) + `cd #{repo_dir} && git add #{name}` + end + `cd #{repo_dir} && git commit -am 'first commit'` + repo_dir + end + + def about_json(options = {}) + options[:love] ||= "FAFAFA" + +< about_json, + "desktop/desktop.scss" => scss_data, + "common/header.html" => "I AM HEADER", + "common/random.html" => "I AM SILLY", + "common/embedded.scss" => "EMBED", + "assets/awesome.woff2" => "FAKE FONT", + ) + end + + after do + `rm -fr #{initial_repo}` + end + + it 'can correctly import a remote theme' do + + time = Time.new('2000') + freeze_time time + + @theme = RemoteTheme.import_theme(initial_repo) + remote = @theme.remote_theme + + expect(@theme.name).to eq('awesome theme') + expect(remote.remote_url).to eq(initial_repo) + expect(remote.remote_version).to eq(`cd #{initial_repo} && git rev-parse HEAD`.strip) + expect(remote.local_version).to eq(`cd #{initial_repo} && git rev-parse HEAD`.strip) + + expect(remote.about_url).to eq("https://www.site.com/about") + expect(remote.license_url).to eq("https://www.site.com/license") + + expect(@theme.theme_fields.length).to eq(6) + + mapped = Hash[*@theme.theme_fields.map{|f| ["#{f.target_id}-#{f.name}", f.value]}.flatten] + + expect(mapped["0-header"]).to eq("I AM HEADER") + expect(mapped["1-scss"]).to eq(scss_data) + expect(mapped["0-embedded_scss"]).to eq("EMBED") + + expect(mapped["1-color"]).to eq("#FEF") + expect(mapped["0-font"]).to eq("") + expect(mapped["0-name"]).to eq("sam") + + expect(mapped.length).to eq(6) + + expect(remote.remote_updated_at).to eq(time) + + scheme = ColorScheme.find_by(theme_id: @theme.id) + expect(scheme.name).to eq("Amazing") + expect(scheme.colors.find_by(name: 'love').hex).to eq('fafafa') + + File.write("#{initial_repo}/common/header.html", "I AM UPDATED") + File.write("#{initial_repo}/about.json", about_json(love: "EAEAEA")) + + `cd #{initial_repo} && git commit -am "update"` + + + time = Time.new('2001') + freeze_time time + + remote.update_remote_version + expect(remote.commits_behind).to eq(1) + expect(remote.remote_version).to eq(`cd #{initial_repo} && git rev-parse HEAD`.strip) + + + remote.update_from_remote + @theme.save + @theme.reload + + scheme = ColorScheme.find_by(theme_id: @theme.id) + expect(scheme.name).to eq("Amazing") + expect(scheme.colors.find_by(name: 'love').hex).to eq('eaeaea') + + mapped = Hash[*@theme.theme_fields.map{|f| ["#{f.target_id}-#{f.name}", f.value]}.flatten] + + expect(mapped["0-header"]).to eq("I AM UPDATED") + expect(mapped["1-scss"]).to eq(scss_data) + expect(remote.remote_updated_at).to eq(time) + + end + end +end diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb index 8ea96c9cfc..661cea8b2b 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -209,5 +209,15 @@ describe Report do end end end -end + describe 'posts counts' do + it "only counts regular posts" do + post = Fabricate(:post) + Fabricate(:moderator_post, topic: post.topic) + Fabricate.build(:post, post_type: Post.types[:whisper], topic: post.topic) + post.topic.add_small_action(Fabricate(:admin), "invited_group", 'coolkids') + r = Report.find('posts') + expect(r.total).to eq(1) + end + end +end diff --git a/spec/models/s3_region_site_setting_spec.rb b/spec/models/s3_region_site_setting_spec.rb index fb8b203350..9f36a09e28 100644 --- a/spec/models/s3_region_site_setting_spec.rb +++ b/spec/models/s3_region_site_setting_spec.rb @@ -14,7 +14,7 @@ describe S3RegionSiteSetting do describe 'values' do it 'returns all the S3 regions' do - expect(S3RegionSiteSetting.values.map {|x| x[:value]}.sort).to eq(['us-east-1', 'us-west-1', 'us-west-2', 'us-gov-west-1', 'eu-west-1', 'eu-central-1', 'ap-southeast-1', 'ap-southeast-2', 'ap-south-1', 'ap-northeast-1', 'ap-northeast-2', 'sa-east-1', 'cn-north-1'].sort) + expect(S3RegionSiteSetting.values.map {|x| x[:value]}.sort).to eq(['us-east-1', 'us-west-1', 'us-west-2', 'us-gov-west-1', 'eu-west-1', 'eu-west-2', 'eu-central-1', 'ap-southeast-1', 'ap-southeast-2', 'ap-south-1', 'ap-northeast-1', 'ap-northeast-2', 'sa-east-1', 'cn-north-1'].sort) end end diff --git a/spec/models/site_customization_spec.rb b/spec/models/site_customization_spec.rb deleted file mode 100644 index e762c78d54..0000000000 --- a/spec/models/site_customization_spec.rb +++ /dev/null @@ -1,155 +0,0 @@ -require 'rails_helper' - -describe SiteCustomization do - - before do - SiteCustomization.clear_cache! - end - - let :user do - Fabricate(:user) - end - - let :customization_params do - {name: 'my name', user_id: user.id, header: "my awesome header", stylesheet: "my awesome css", mobile_stylesheet: nil, mobile_header: nil} - end - - let :customization do - SiteCustomization.create!(customization_params) - end - - let :customization_with_mobile do - SiteCustomization.create!(customization_params.merge(mobile_stylesheet: ".mobile {better: true;}", mobile_header: "fancy mobile stuff")) - end - - it 'should set default key when creating a new customization' do - s = SiteCustomization.create!(name: 'my name', user_id: user.id) - expect(s.key).not_to eq(nil) - end - - it 'can enable more than one style at once' do - c1 = SiteCustomization.create!(name: '2', user_id: user.id, header: 'World', - enabled: true, mobile_header: 'hi', footer: 'footer', - stylesheet: '.hello{.world {color: blue;}}') - - SiteCustomization.create!(name: '1', user_id: user.id, header: 'Hello', - enabled: true, mobile_footer: 'mfooter', - mobile_stylesheet: '.hello{margin: 1px;}', - stylesheet: 'p{width: 1px;}' - ) - - expect(SiteCustomization.custom_header).to eq("Hello\nWorld") - expect(SiteCustomization.custom_header(nil, :mobile)).to eq("hi") - expect(SiteCustomization.custom_footer(nil, :mobile)).to eq("mfooter") - expect(SiteCustomization.custom_footer).to eq("footer") - - desktop_css = SiteCustomization.custom_stylesheet - expect(desktop_css).to match(Regexp.new("#{SiteCustomization::ENABLED_KEY}.css\\?target=desktop")) - - mobile_css = SiteCustomization.custom_stylesheet(nil, :mobile) - expect(mobile_css).to match(Regexp.new("#{SiteCustomization::ENABLED_KEY}.css\\?target=mobile")) - - expect(SiteCustomization.enabled_stylesheet_contents).to match(/\.hello \.world/) - - # cache expiry - c1.enabled = false - c1.save - - expect(SiteCustomization.custom_stylesheet).not_to eq(desktop_css) - expect(SiteCustomization.enabled_stylesheet_contents).not_to match(/\.hello \.world/) - end - - it 'should be able to look up stylesheets by key' do - c = SiteCustomization.create!(name: '2', user_id: user.id, - enabled: true, - stylesheet: '.hello{.world {color: blue;}}', - mobile_stylesheet: '.world{.hello{color: black;}}') - - expect(SiteCustomization.custom_stylesheet(c.key, :mobile)).to match(Regexp.new("#{c.key}.css\\?target=mobile")) - expect(SiteCustomization.custom_stylesheet(c.key)).to match(Regexp.new("#{c.key}.css\\?target=desktop")) - - end - - - it 'should allow including discourse styles' do - c = SiteCustomization.create!(user_id: user.id, name: "test", stylesheet: '@import "desktop";', mobile_stylesheet: '@import "mobile";') - expect(c.stylesheet_baked).not_to match(/Syntax error/) - expect(c.stylesheet_baked.length).to be > 1000 - expect(c.mobile_stylesheet_baked).not_to match(/Syntax error/) - expect(c.mobile_stylesheet_baked.length).to be > 1000 - end - - it 'should provide an awesome error on failure' do - c = SiteCustomization.create!(user_id: user.id, name: "test", stylesheet: "$black: #000; #a { color: $black; }\n\n\nboom", header: '') - expect(c.stylesheet_baked).to match(/Syntax error/) - expect(c.mobile_stylesheet_baked).not_to be_present - end - - it 'should provide an awesome error on failure for mobile too' do - c = SiteCustomization.create!(user_id: user.id, name: "test", stylesheet: '', header: '', mobile_stylesheet: "$black: #000; #a { color: $black; }\n\n\nboom", mobile_header: '') - expect(c.mobile_stylesheet_baked).to match(/Syntax error/) - expect(c.stylesheet_baked).not_to be_present - end - - it 'should correct bad html in body_tag_baked and head_tag_baked' do - c = SiteCustomization.create!(user_id: -1, name: "test", head_tag: "I am bold", body_tag: "I am bold") - expect(c.head_tag_baked).to eq("I am bold") - expect(c.body_tag_baked).to eq("I am bold") - end - - it 'should precompile fragments in body and head tags' do - with_template = < - {{hello}} - - -HTML - c = SiteCustomization.create!(user_id: -1, name: "test", head_tag: with_template, body_tag: with_template) - expect(c.head_tag_baked).to match(/HTMLBars/) - expect(c.body_tag_baked).to match(/HTMLBars/) - expect(c.body_tag_baked).to match(/raw-handlebars/) - expect(c.head_tag_baked).to match(/raw-handlebars/) - end - - it 'should create body_tag_baked on demand if needed' do - c = SiteCustomization.create!(user_id: -1, name: "test", head_tag: "test", enabled: true) - c.update_columns(head_tag_baked: nil) - expect(SiteCustomization.custom_head_tag).to match(/test<\/b>/) - end - - context "plugin api" do - def transpile(html) - c = SiteCustomization.create!(user_id: -1, name: "test", head_tag: html, body_tag: html) - c.head_tag_baked - end - - it "transpiles ES6 code" do - html = < - const x = 1; - -HTML - - transpiled = transpile(html) - expect(transpiled).to match(/\/) - expect(transpiled).to match(/var x = 1;/) - expect(transpiled).to match(/_registerPluginCode\('0.1'/) - end - - it "converts errors to a script type that is not evaluated" do - html = < - const x = 1; - x = 2; - -HTML - - transpiled = transpile(html) - expect(transpiled).to match(/text\/discourse-js-error/) - expect(transpiled).to match(/read-only/) - end - end - -end diff --git a/spec/models/site_spec.rb b/spec/models/site_spec.rb index d8bb189b34..8899269c80 100644 --- a/spec/models/site_spec.rb +++ b/spec/models/site_spec.rb @@ -2,6 +2,44 @@ require 'rails_helper' require_dependency 'site' describe Site do + + def expect_correct_themes(guardian) + json = Site.json_for(guardian) + parsed = JSON.parse(json) + + expected = Theme.where('key = :default OR user_selectable', + default: SiteSetting.default_theme_key) + .order(:name) + .pluck(:key, :name) + .map{|k,n| {"theme_key" => k, "name" => n, "default" => k == SiteSetting.default_theme_key}} + + expect(parsed["user_themes"]).to eq(expected) + end + + it "includes user themes and expires them as needed" do + default_theme = Theme.create!(user_id: -1, name: 'default') + SiteSetting.default_theme_key = default_theme.key + user_theme = Theme.create!(user_id: -1, name: 'user theme', user_selectable: true) + + anon_guardian = Guardian.new + user_guardian = Guardian.new(Fabricate(:user)) + + expect_correct_themes(anon_guardian) + expect_correct_themes(user_guardian) + + Theme.clear_default! + + expect_correct_themes(anon_guardian) + expect_correct_themes(user_guardian) + + user_theme.user_selectable = false + user_theme.save! + + expect_correct_themes(anon_guardian) + expect_correct_themes(user_guardian) + + end + it "omits categories users can not write to from the category list" do category = Fabricate(:category) user = Fabricate(:user) diff --git a/spec/models/stylesheet_cache_spec.rb b/spec/models/stylesheet_cache_spec.rb index eb52d07bcc..480fd86259 100644 --- a/spec/models/stylesheet_cache_spec.rb +++ b/spec/models/stylesheet_cache_spec.rb @@ -4,8 +4,10 @@ describe StylesheetCache do describe "add" do it "correctly cycles once MAX_TO_KEEP is hit" do + StylesheetCache.destroy_all + (StylesheetCache::MAX_TO_KEEP + 1).times do |i| - StylesheetCache.add("a", "d" + i.to_s, "c" + i.to_s) + StylesheetCache.add("a", "d" + i.to_s, "c" + i.to_s, "map") end expect(StylesheetCache.count).to eq StylesheetCache::MAX_TO_KEEP @@ -13,8 +15,10 @@ describe StylesheetCache do end it "does nothing if digest is set and already exists" do - StylesheetCache.add("a", "b", "c") - StylesheetCache.add("a", "b", "cc") + StylesheetCache.destroy_all + + StylesheetCache.add("a", "b", "c", "map") + StylesheetCache.add("a", "b", "cc", "map") expect(StylesheetCache.count).to eq 1 expect(StylesheetCache.first.content).to eq "c" diff --git a/spec/models/theme_field_spec.rb b/spec/models/theme_field_spec.rb new file mode 100644 index 0000000000..00c88834d8 --- /dev/null +++ b/spec/models/theme_field_spec.rb @@ -0,0 +1,31 @@ +# encoding: utf-8 + +require 'rails_helper' + +describe ThemeField do + it "correctly generates errors for transpiled js" do + html = < + badJavaScript(; + +HTML + field = ThemeField.create!(theme_id: 1, target_id: 0, name: "header", value: html) + expect(field.error).not_to eq(nil) + field.value = "" + field.save! + expect(field.error).to eq(nil) + end + + it "correctly generates errors for transpiled css" do + css = "body {" + field = ThemeField.create!(theme_id: 1, target_id: 0, name: "scss", value: css) + field.reload + expect(field.error).not_to eq(nil) + field.value = "body {color: blue};" + field.save! + field.reload + + expect(field.error).to eq(nil) + end + +end diff --git a/spec/models/theme_spec.rb b/spec/models/theme_spec.rb new file mode 100644 index 0000000000..5df2a2abf7 --- /dev/null +++ b/spec/models/theme_spec.rb @@ -0,0 +1,220 @@ +require 'rails_helper' + +describe Theme do + + before do + Theme.clear_cache! + end + + let :user do + Fabricate(:user) + end + + let :customization_params do + {name: 'my name', user_id: user.id, header: "my awesome header"} + end + + let :customization do + Theme.create!(customization_params) + end + + it 'should set default key when creating a new customization' do + s = Theme.create!(name: 'my name', user_id: user.id) + expect(s.key).not_to eq(nil) + end + + it 'can properly clean up color schemes' do + theme = Theme.create!(name: 'bob', user_id: -1) + scheme = ColorScheme.create!(theme_id: theme.id, name: 'test') + scheme2 = ColorScheme.create!(theme_id: theme.id, name: 'test2') + + Theme.create!(name: 'bob', user_id: -1, color_scheme_id: scheme2.id) + + theme.destroy! + scheme2.reload + + expect(scheme2).not_to eq(nil) + expect(scheme2.theme_id).to eq(nil) + expect(ColorScheme.find_by(id: scheme.id)).to eq(nil) + end + + it 'can support child themes' do + child = Theme.new(name: '2', user_id: user.id) + + child.set_field(target: :common, name: "header", value: "World") + child.set_field(target: :desktop, name: "header", value: "Desktop") + child.set_field(target: :mobile, name: "header", value: "Mobile") + + child.save! + + expect(Theme.lookup_field(child.key, :desktop, "header")).to eq("World\nDesktop") + expect(Theme.lookup_field(child.key, "mobile", :header)).to eq("World\nMobile") + + + child.set_field(target: :common, name: "header", value: "Worldie") + child.save! + + expect(Theme.lookup_field(child.key, :mobile, :header)).to eq("Worldie\nMobile") + + parent = Theme.new(name: '1', user_id: user.id) + + parent.set_field(target: :common, name: "header", value: "Common Parent") + parent.set_field(target: :mobile, name: "header", value: "Mobile Parent") + + parent.save! + + parent.add_child_theme!(child) + + expect(Theme.lookup_field(parent.key, :mobile, "header")).to eq("Common Parent\nMobile Parent\nWorldie\nMobile") + + end + + it 'can correctly find parent themes' do + grandchild = Theme.create!(name: 'grandchild', user_id: user.id) + child = Theme.create!(name: 'child', user_id: user.id) + theme = Theme.create!(name: 'theme', user_id: user.id) + + theme.add_child_theme!(child) + child.add_child_theme!(grandchild) + + expect(grandchild.dependant_themes.length).to eq(2) + end + + + it 'should correct bad html in body_tag_baked and head_tag_baked' do + theme = Theme.new(user_id: -1, name: "test") + theme.set_field(target: :common, name: "head_tag", value: "I am bold") + theme.save! + + expect(Theme.lookup_field(theme.key, :desktop, "head_tag")).to eq("I am bold") + end + + it 'should precompile fragments in body and head tags' do + with_template = < + {{hello}} + + +HTML + theme = Theme.new(user_id: -1, name: "test") + theme.set_field(target: :common, name: "header", value: with_template) + theme.save! + + baked = Theme.lookup_field(theme.key, :mobile, "header") + + expect(baked).to match(/HTMLBars/) + expect(baked).to match(/raw-handlebars/) + end + + it 'should create body_tag_baked on demand if needed' do + + theme = Theme.new(user_id: -1, name: "test") + theme.set_field(target: :common, name: :body_tag, value: "test") + theme.save + + ThemeField.update_all(value_baked: nil) + + expect(Theme.lookup_field(theme.key, :desktop, :body_tag)).to match(/test<\/b>/) + end + + context "plugin api" do + def transpile(html) + f = ThemeField.create!(target_id: Theme.targets[:mobile], theme_id: -1, name: "after_header", value: html) + f.value_baked + end + + it "transpiles ES6 code" do + html = < + const x = 1; + +HTML + + transpiled = transpile(html) + expect(transpiled).to match(/\/) + expect(transpiled).to match(/var x = 1;/) + expect(transpiled).to match(/_registerPluginCode\('0.1'/) + end + + it "converts errors to a script type that is not evaluated" do + html = < + const x = 1; + x = 2; + +HTML + + transpiled = transpile(html) + expect(transpiled).to match(/text\/discourse-js-error/) + expect(transpiled).to match(/read-only/) + end + end + + context 'theme vars' do + it 'can generate scss based off theme vars' do + theme = Theme.new(name: 'theme', user_id: -1) + theme.set_field(target: :common, name: :scss, value: 'body {color: $magic; content: quote($content)}') + theme.set_field(target: :common, name: :magic, value: 'red', type: :theme_var) + theme.set_field(target: :common, name: :content, value: 'Sam\'s Test', type: :theme_var) + theme.save + + scss,_map = Stylesheet::Compiler.compile('@import "theme_variables"; @import "desktop_theme"; ', "theme.scss", theme_id: theme.id) + expect(scss).to include("red") + expect(scss).to include('"Sam\'s Test"') + end + + let :image do + file_from_fixtures("logo.png") + end + + it 'can handle uploads based of ThemeField' do + theme = Theme.new(name: 'theme', user_id: -1) + upload = UploadCreator.new(image, "logo.png").create_for(-1) + theme.set_field(target: :common, name: :logo, upload_id: upload.id, type: :theme_upload_var) + theme.set_field(target: :common, name: :scss, value: 'body {background-image: url($logo)}') + theme.save! + + # make sure we do not nuke it + freeze_time (SiteSetting.clean_orphan_uploads_grace_period_hours + 1).hours.from_now + Jobs::CleanUpUploads.new.execute(nil) + + expect(Upload.where(id: upload.id)).to be_exist + + # no error for theme field + theme.reload + expect(theme.theme_fields.find_by(name: :scss).error).to eq(nil) + + scss,_map = Stylesheet::Compiler.compile('@import "theme_variables"; @import "desktop_theme"; ', "theme.scss", theme_id: theme.id) + expect(scss).to include(upload.url) + end + end + + it 'correctly caches theme keys' do + Theme.destroy_all + + theme = Theme.create!(name: "bob", user_id: -1) + + expect(Theme.theme_keys).to eq(Set.new([theme.key])) + expect(Theme.user_theme_keys).to eq(Set.new([])) + + theme.user_selectable = true + theme.save + + expect(Theme.user_theme_keys).to eq(Set.new([theme.key])) + + theme.user_selectable = false + theme.save + + theme.set_default! + expect(Theme.user_theme_keys).to eq(Set.new([theme.key])) + + theme.destroy + + expect(Theme.theme_keys).to eq(Set.new([])) + expect(Theme.user_theme_keys).to eq(Set.new([])) + end + + +end diff --git a/spec/models/topic_converter_spec.rb b/spec/models/topic_converter_spec.rb index 96bd61e915..cee779633b 100644 --- a/spec/models/topic_converter_spec.rb +++ b/spec/models/topic_converter_spec.rb @@ -5,13 +5,42 @@ describe TopicConverter do context 'convert_to_public_topic' do let(:admin) { Fabricate(:admin) } let(:author) { Fabricate(:user) } + let(:category) { Fabricate(:category) } let(:private_message) { Fabricate(:private_message_topic, user: author) } context 'success' do it "converts private message to regular topic" do - topic = private_message.convert_to_public_topic(admin) + SiteSetting.allow_uncategorized_topics = true + topic = described_class.new(private_message, admin).convert_to_public_topic + topic.reload + expect(topic).to be_valid expect(topic.archetype).to eq("regular") + expect(topic.category_id).to eq(SiteSetting.uncategorized_category_id) + end + + describe 'when uncategorized category is not allowed' do + before do + SiteSetting.allow_uncategorized_topics = false + category.update!(read_restricted: false) + end + + it 'should convert private message into the right category' do + topic = described_class.new(private_message, admin).convert_to_public_topic + topic.reload + + expect(topic).to be_valid + expect(topic.archetype).to eq("regular") + expect(topic.category_id).to eq(category.id) + end + end + + describe 'when a custom category_id is given' do + it 'should convert private message into the right category' do + topic = described_class.new(private_message, admin).convert_to_public_topic(category.id) + + expect(topic.reload.category).to eq(category) + end end it "updates user stats" do diff --git a/spec/models/topic_embed_spec.rb b/spec/models/topic_embed_spec.rb index c7bfa0d43a..7270c4fc7e 100644 --- a/spec/models/topic_embed_spec.rb +++ b/spec/models/topic_embed_spec.rb @@ -176,7 +176,23 @@ describe TopicEmbed do it 'img node doesn\'t have other class' do expect(response.body).to have_tag('img', without: { class: 'other' }) end + end + context "non-ascii URL" do + let(:url) { 'http://eviltrout.com/test/ماهی' } + let(:contents) { "سلاماین یک پاراگراف آزمون است." } + let!(:embeddable_host) { Fabricate(:embeddable_host) } + let!(:file) { StringIO.new } + + before do + file.stubs(:read).returns contents + TopicEmbed.stubs(:open).returns file + end + + it "doesn't throw an error" do + response = TopicEmbed.find_remote(url) + expect(response.title).to eq("سلام") + end end end diff --git a/spec/models/topic_link_spec.rb b/spec/models/topic_link_spec.rb index 6eb802dd0c..9ee96750f2 100644 --- a/spec/models/topic_link_spec.rb +++ b/spec/models/topic_link_spec.rb @@ -137,7 +137,7 @@ http://b.com/#{'a'*500} end context "link to a user on discourse" do - let(:post) { topic.posts.create(user: user, raw: "user") } + let(:post) { topic.posts.create(user: user, raw: "user") } before do TopicLink.extract_from(post) end diff --git a/spec/models/topic_list_spec.rb b/spec/models/topic_list_spec.rb index 319137f051..83290142cb 100644 --- a/spec/models/topic_list_spec.rb +++ b/spec/models/topic_list_spec.rb @@ -1,7 +1,12 @@ require 'rails_helper' describe TopicList do - let!(:topic) { Fabricate(:topic) } + let!(:topic) { + t = Fabricate(:topic) + t.allowed_user_ids = [t.user.id] + t + } + let(:user) { topic.user } let(:topic_list) { TopicList.new("liked", user, [topic]) } @@ -23,13 +28,21 @@ describe TopicList do end end - context "DiscourseTagging enabled" do - before do - SiteSetting.tagging_enabled = true - end + context "preload" do + it "allows preloading of data" do + preloaded_topic = false + preloader = lambda do |topics, topic_list| + expect(TopicList === topic_list).to eq(true) + expect(topics.length).to eq(1) + preloaded_topic = true + end - it "should add tags to preloaded custom fields" do - expect(topic_list.preloaded_custom_fields).to include(DiscourseTagging::TAGS_FIELD_NAME) + TopicList.on_preload(&preloader) + + topic_list.topics + expect(preloaded_topic).to eq(true) + + TopicList.cancel_preload(&preloader) end end diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 809f795429..1cee451baf 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -13,30 +13,33 @@ describe Topic do context "#title" do it { is_expected.to validate_presence_of :title } - describe 'censored words' do - site_setting(:censored_words, 'pineapple|pen') - site_setting(:censored_pattern, 'orange.*') - - describe 'when title contains censored words' do + describe 'censored pattern' do + describe 'when title matches censored pattern' do it 'should not be valid' do - topic.title = 'I have a Pineapple' + SiteSetting.censored_pattern = 'orange.*' + + topic.title = 'I have orangEjuice orange monkey orange stuff' expect(topic).to_not be_valid expect(topic.errors.full_messages.first).to include(I18n.t( - 'errors.messages.contains_censored_words', censored_words: SiteSetting.censored_words + 'errors.messages.matches_censored_pattern', censored_words: 'orangejuice orange monkey orange stuff' )) end end + end - describe 'when title matches censored pattern' do + describe 'censored words' do + describe 'when title contains censored words' do it 'should not be valid' do - topic.title = 'I have orangEjuice' + SiteSetting.censored_words = 'pineapple|pen' + + topic.title = 'pen PinEapple apple pen ' expect(topic).to_not be_valid expect(topic.errors.full_messages.first).to include(I18n.t( - 'errors.messages.matches_censored_pattern', censored_pattern: SiteSetting.censored_pattern + 'errors.messages.contains_censored_words', censored_words: 'pen, pineapple' )) end end @@ -48,6 +51,23 @@ describe Topic do expect(topic).to be_valid end end + + describe 'escape special characters in censored words' do + before do + SiteSetting.censored_words = 'co(onut|coconut|a**le' + end + + it 'should not valid' do + topic.title = "I have a co(onut a**le" + + expect(topic.valid?).to eq(false) + + expect(topic.errors.full_messages.first).to include(I18n.t( + 'errors.messages.contains_censored_words', + censored_words: 'co(onut, a**le' + )) + end + end end end end @@ -237,11 +257,16 @@ describe Topic do let(:topic_bold) { build_topic_with_title("Topic with bold text in its title" ) } let(:topic_image) { build_topic_with_title("Topic with image in its title" ) } let(:topic_script) { build_topic_with_title("Topic with script in its title" ) } + let(:topic_emoji) { build_topic_with_title("I 💖 candy alot") } it "escapes script contents" do expect(topic_script.fancy_title).to eq("Topic with <script>alert(‘title’)</script> script in its title") end + it "expands emojis" do + expect(topic_emoji.fancy_title).to eq("I :sparkling_heart: candy alot") + end + it "escapes bold contents" do expect(topic_bold.fancy_title).to eq("Topic with <b>bold</b> text in its title") end @@ -482,28 +507,33 @@ describe Topic do end - it "rate limits topic invitations" do - SiteSetting.stubs(:max_topic_invitations_per_day).returns(2) - RateLimiter.stubs(:disabled?).returns(false) - RateLimiter.clear_all! + context 'rate limits' do - start = Time.now.tomorrow.beginning_of_day - freeze_time(start) + it "rate limits topic invitations" do + SiteSetting.stubs(:max_topic_invitations_per_day).returns(2) + RateLimiter.stubs(:disabled?).returns(false) + RateLimiter.clear_all! - user = Fabricate(:user) - topic = Fabricate(:topic) + start = Time.now.tomorrow.beginning_of_day + freeze_time(start) - freeze_time(start + 10.minutes) - topic.invite(topic.user, user.username) + user = Fabricate(:user) + trust_level_2 = Fabricate(:user, trust_level: 2) + topic = Fabricate(:topic, user: trust_level_2) - freeze_time(start + 20.minutes) - topic.invite(topic.user, "walter@white.com") + freeze_time(start + 10.minutes) + topic.invite(topic.user, user.username) - freeze_time(start + 30.minutes) + freeze_time(start + 20.minutes) + topic.invite(topic.user, "walter@white.com") + + freeze_time(start + 30.minutes) + + expect { + topic.invite(topic.user, "user@example.com") + }.to raise_error(RateLimiter::LimitExceeded) + end - expect { - topic.invite(topic.user, "user@example.com") - }.to raise_error(RateLimiter::LimitExceeded) end context 'bumping topics' do @@ -664,14 +694,16 @@ describe Topic do context 'archived' do context 'disable' do before do - @topic.update_status('archived', false, @user) - @topic.reload + @archived_topic = Fabricate(:topic, archived: true, bumped_at: 1.hour.ago) + @original_bumped_at = @archived_topic.bumped_at.to_f + @archived_topic.update_status('archived', false, @user) + @archived_topic.reload end it 'should archive correctly' do - expect(@topic).not_to be_archived - expect(@topic.bumped_at.to_f).to eq(@original_bumped_at) - expect(@topic.moderator_posts_count).to eq(1) + expect(@archived_topic).not_to be_archived + expect(@archived_topic.bumped_at.to_f).to be_within(0.1).of(@original_bumped_at) + expect(@archived_topic.moderator_posts_count).to eq(1) end end @@ -694,14 +726,16 @@ describe Topic do shared_examples_for 'a status that closes a topic' do context 'disable' do before do - @topic.update_status(status, false, @user) - @topic.reload + @closed_topic = Fabricate(:topic, closed: true, bumped_at: 1.hour.ago) + @original_bumped_at = @closed_topic.bumped_at.to_f + @closed_topic.update_status(status, false, @user) + @closed_topic.reload end it 'should not be pinned' do - expect(@topic).not_to be_closed - expect(@topic.moderator_posts_count).to eq(1) - expect(@topic.bumped_at.to_f).not_to eq(@original_bumped_at) + expect(@closed_topic).not_to be_closed + expect(@closed_topic.moderator_posts_count).to eq(1) + expect(@closed_topic.bumped_at.to_f).not_to eq(@original_bumped_at) end end @@ -717,6 +751,7 @@ describe Topic do expect(@topic).to be_closed expect(@topic.bumped_at.to_f).to eq(@original_bumped_at) expect(@topic.moderator_posts_count).to eq(1) + expect(@topic.topic_timers.first).to eq(nil) end end end @@ -732,24 +767,28 @@ describe Topic do context 'topic was set to close when it was created' do it 'puts the autoclose duration in the moderator post' do - freeze_time(Time.new(2000,1,1)) do - @topic.created_at = 3.days.ago - @topic.update_status(status, true, @user) - expect(@topic.posts.last.raw).to include "closed after 3 days" - end + freeze_time(Time.new(2000,1,1)) + @topic.created_at = 3.days.ago + @topic.update_status(status, true, @user) + expect(@topic.posts.last.raw).to include "closed after 3 days" end end context 'topic was set to close after it was created' do it 'puts the autoclose duration in the moderator post' do - freeze_time(Time.new(2000,1,1)) do - @topic.created_at = 7.days.ago - freeze_time(2.days.ago) do - @topic.set_auto_close(48) - end - @topic.update_status(status, true, @user) - expect(@topic.posts.last.raw).to include "closed after 2 days" - end + freeze_time(Time.new(2000,1,1)) + + @topic.created_at = 7.days.ago + + freeze_time(2.days.ago) + + @topic.set_or_create_timer(TopicTimer.types[:close], 48) + @topic.save! + + freeze_time(2.days.from_now) + + @topic.update_status(status, true, @user) + expect(@topic.posts.last.raw).to include "closed after 2 days" end end end @@ -784,6 +823,14 @@ describe Topic do expect(Topic.where(archetype: Archetype.banner).count).to eq(1) end + it "removes any dismissed banner keys" do + user.user_profile.update_column(:dismissed_banner_key, topic.id) + + topic.make_banner!(user) + user.user_profile.reload + expect(user.user_profile.dismissed_banner_key).to be_nil + end + end describe "remove_banner!" do @@ -919,74 +966,92 @@ describe Topic do end end - describe 'change_category' do - - before do - @topic = Fabricate(:topic) - @category = Fabricate(:category, user: @topic.user) - @user = @topic.user - end + describe '#change_category_to_id' do + let(:topic) { Fabricate(:topic) } + let(:user) { topic.user } + let(:category) { Fabricate(:category, user: user) } describe 'without a previous category' do - - it 'should not change the topic_count when not changed' do - expect { @topic.change_category_to_id(@topic.category.id); @category.reload }.not_to change(@category, :topic_count) + it 'changes the category' do + topic.change_category_to_id(category.id) + category.reload + expect(topic.category).to eq(category) + expect(category.topic_count).to eq(1) end - describe 'changed category' do - before do - @topic.change_category_to_id(@category.id) - @category.reload - end - - it 'changes the category' do - expect(@topic.category).to eq(@category) - expect(@category.topic_count).to eq(1) - end - + it 'should not change the topic_count when not changed' do + expect { topic.change_category_to_id(topic.category.id); category.reload }.not_to change(category, :topic_count) end it "doesn't change the category when it can't be found" do - @topic.change_category_to_id(12312312) - expect(@topic.category_id).to eq(SiteSetting.uncategorized_category_id) + topic.change_category_to_id(12312312) + expect(topic.category_id).to eq(SiteSetting.uncategorized_category_id) end end describe 'with a previous category' do before do - @topic.change_category_to_id(@category.id) - @topic.reload - @category.reload + topic.change_category_to_id(category.id) + topic.reload + category.reload end it 'increases the topic_count' do - expect(@category.topic_count).to eq(1) + expect(category.topic_count).to eq(1) end it "doesn't change the topic_count when the value doesn't change" do - expect { @topic.change_category_to_id(@category.id); @category.reload }.not_to change(@category, :topic_count) + expect { topic.change_category_to_id(category.id); category.reload }.not_to change(category, :topic_count) end - it "doesn't reset the category when given a name that doesn't exist" do - @topic.change_category_to_id(55556) - expect(@topic.category_id).to be_present + it "doesn't reset the category when an id that doesn't exist" do + topic.change_category_to_id(55556) + expect(topic.category_id).to eq(category.id) end describe 'to a different category' do - before do - @new_category = Fabricate(:category, user: @user, name: '2nd category') - @topic.change_category_to_id(@new_category.id) - @topic.reload - @new_category.reload - @category.reload + let(:new_category) { Fabricate(:category, user: user, name: '2nd category') } + + it 'should work' do + topic.change_category_to_id(new_category.id) + + expect(topic.reload.category).to eq(new_category) + expect(new_category.reload.topic_count).to eq(1) + expect(category.reload.topic_count).to eq(0) end - it "should increase the new category's topic count" do - expect(@new_category.topic_count).to eq(1) - end + describe 'when new category is set to auto close by default' do + before do + new_category.update!(auto_close_hours: 5) + end - it "should lower the original category's topic count" do - expect(@category.topic_count).to eq(0) + it 'should set a topic timer' do + expect { topic.change_category_to_id(new_category.id) } + .to change { TopicTimer.count }.by(1) + + expect(topic.reload.category).to eq(new_category) + + topic_timer = TopicTimer.last + + expect(topic_timer.topic).to eq(topic) + expect(topic_timer.execute_at).to be_within(1.second).of(Time.zone.now + 5.hours) + end + + describe 'when topic has an existing topic timer' do + let(:topic_timer) { Fabricate(:topic_timer, topic: topic) } + + it "should not inherit category's auto close hours" do + topic_timer + topic.change_category_to_id(new_category.id) + + expect(topic.reload.category).to eq(new_category) + + expect(topic.public_topic_timer).to eq(topic_timer) + + expect(topic.public_topic_timer.execute_at) + .to be_within(1.second).of(topic_timer.execute_at) + end + end end end @@ -1004,13 +1069,13 @@ describe Topic do describe 'when the category exists' do before do - @topic.change_category_to_id(nil) - @category.reload + topic.change_category_to_id(nil) + category.reload end it "resets the category" do - expect(@topic.category_id).to eq(SiteSetting.uncategorized_category_id) - expect(@category.topic_count).to eq(0) + expect(topic.category_id).to eq(SiteSetting.uncategorized_category_id) + expect(category.topic_count).to eq(0) end end @@ -1058,297 +1123,176 @@ describe Topic do end end - describe 'auto-close' do - context 'a new topic' do - context 'auto_close_at is set' do - it 'queues a job to close the topic' do - Timecop.freeze(now) do - Jobs.expects(:enqueue_at).with(7.hours.from_now, :close_topic, all_of( has_key(:topic_id), has_key(:user_id) )) - topic = Fabricate(:topic, user: Fabricate(:admin)) - topic.set_auto_close(7).save - end - end + describe '#set_or_create_timer' do + let(:topic) { Fabricate.build(:topic) } - it 'when auto_close_user_id is nil, it will use the topic creator as the topic closer' do - topic_creator = Fabricate(:admin) - Jobs.expects(:enqueue_at).with do |datetime, job_name, job_args| - job_args[:user_id] == topic_creator.id - end - topic = Fabricate(:topic, user: topic_creator) - topic.set_auto_close(7).save - end - - it 'when auto_close_user_id is set, it will use it as the topic closer' do - topic_creator = Fabricate(:admin) - topic_closer = Fabricate(:user, admin: true) - Jobs.expects(:enqueue_at).with do |datetime, job_name, job_args| - job_args[:user_id] == topic_closer.id - end - topic = Fabricate(:topic, user: topic_creator) - topic.set_auto_close(7, {by_user: topic_closer}).save - end - - it "ignores the category's default auto-close" do - Timecop.freeze(now) do - Jobs.expects(:enqueue_at).with(7.hours.from_now, :close_topic, all_of( has_key(:topic_id), has_key(:user_id) )) - topic = Fabricate(:topic, user: Fabricate(:admin), ignore_category_auto_close: true, category_id: Fabricate(:category, auto_close_hours: 2).id) - topic.set_auto_close(7).save - end - end - - it 'sets the time when auto_close timer starts' do - Timecop.freeze(now) do - topic = Fabricate(:topic, user: Fabricate(:admin)) - topic.set_auto_close(7).save - expect(topic.auto_close_started_at).to eq(now) - end - end - end + let(:closing_topic) do + Fabricate(:topic, + topic_timers: [Fabricate(:topic_timer, execute_at: 5.hours.from_now)] + ) end - context 'an existing topic' do - it 'when auto_close_at is set, it queues a job to close the topic' do - Timecop.freeze(now) do - topic = Fabricate(:topic) - Jobs.expects(:enqueue_at).with(12.hours.from_now, :close_topic, has_entries(topic_id: topic.id, user_id: topic.user_id)) - topic.auto_close_at = 12.hours.from_now - expect(topic.save).to eq(true) - end - end - - it 'when auto_close_at and auto_closer_user_id are set, it queues a job to close the topic' do - Timecop.freeze(now) do - topic = Fabricate(:topic) - closer = Fabricate(:admin) - Jobs.expects(:enqueue_at).with(12.hours.from_now, :close_topic, has_entries(topic_id: topic.id, user_id: closer.id)) - topic.auto_close_at = 12.hours.from_now - topic.auto_close_user = closer - expect(topic.save).to eq(true) - end - end - - it 'when auto_close_at is removed, it cancels the job to close the topic' do - Jobs.stubs(:enqueue_at).returns(true) - topic = Fabricate(:topic, auto_close_at: 1.day.from_now) - Jobs.expects(:cancel_scheduled_job).with(:close_topic, {topic_id: topic.id}) - topic.auto_close_at = nil - expect(topic.save).to eq(true) - expect(topic.auto_close_user).to eq(nil) - end - - it 'when auto_close_user is removed, it updates the job' do - Timecop.freeze(now) do - Jobs.stubs(:enqueue_at).with(1.day.from_now, :close_topic, anything).returns(true) - topic = Fabricate(:topic, auto_close_at: 1.day.from_now, auto_close_user: Fabricate(:admin)) - Jobs.expects(:cancel_scheduled_job).with(:close_topic, {topic_id: topic.id}) - Jobs.expects(:enqueue_at).with(1.day.from_now, :close_topic, has_entries(topic_id: topic.id, user_id: topic.user_id)) - topic.auto_close_user = nil - expect(topic.save).to eq(true) - end - end - - it 'when auto_close_at value is changed, it reschedules the job' do - Timecop.freeze(now) do - Jobs.stubs(:enqueue_at).returns(true) - topic = Fabricate(:topic, auto_close_at: 1.day.from_now) - Jobs.expects(:cancel_scheduled_job).with(:close_topic, {topic_id: topic.id}) - Jobs.expects(:enqueue_at).with(3.days.from_now, :close_topic, has_entry(topic_id: topic.id)) - topic.auto_close_at = 3.days.from_now - expect(topic.save).to eq(true) - end - end - - it 'when auto_close_user_id is changed, it updates the job' do - Timecop.freeze(now) do - admin = Fabricate(:admin) - Jobs.stubs(:enqueue_at).returns(true) - topic = Fabricate(:topic, auto_close_at: 1.day.from_now) - Jobs.expects(:cancel_scheduled_job).with(:close_topic, {topic_id: topic.id}) - Jobs.expects(:enqueue_at).with(1.day.from_now, :close_topic, has_entries(topic_id: topic.id, user_id: admin.id)) - topic.auto_close_user = admin - expect(topic.save).to eq(true) - end - end - - it 'when auto_close_at and auto_close_user_id are not changed, it should not schedule another CloseTopic job' do - Timecop.freeze(now) do - Jobs.expects(:enqueue_at).with(1.day.from_now, :close_topic, has_key(:topic_id)).once.returns(true) - Jobs.expects(:cancel_scheduled_job).never - topic = Fabricate(:topic, auto_close_at: 1.day.from_now) - topic.title = 'A new title that is long enough' - expect(topic.save).to eq(true) - end - end - - it "ignores the category's default auto-close" do - Timecop.freeze(now) do - mod = Fabricate(:moderator) - # NOTE, only moderators can auto-close, if missing system user is used - topic = Fabricate(:topic, category: Fabricate(:category, auto_close_hours: 14), user: mod) - Jobs.expects(:enqueue_at).with(12.hours.from_now, :close_topic, has_entries(topic_id: topic.id, user_id: topic.user_id)) - topic.auto_close_at = 12.hours.from_now - topic.save - - topic.reload - expect(topic.closed).to eq(false) - - Timecop.freeze(24.hours.from_now) do - Topic.auto_close - topic.reload - expect(topic.closed).to eq(true) - end - - end - end - end - end - - describe 'set_auto_close' do - let(:topic) { Fabricate.build(:topic) } - let(:closing_topic) { Fabricate.build(:topic, auto_close_hours: 5, auto_close_at: 5.hours.from_now, auto_close_started_at: 5.hours.from_now) } - let(:admin) { Fabricate.build(:user, id: 123) } - let(:trust_level_4) { Fabricate.build(:trust_level_4) } + let(:admin) { Fabricate(:admin) } + let(:trust_level_4) { Fabricate(:trust_level_4) } before { Discourse.stubs(:system_user).returns(admin) } it 'can take a number of hours as an integer' do Timecop.freeze(now) do - topic.set_auto_close(72, {by_user: admin}) - expect(topic.auto_close_at).to eq(3.days.from_now) + topic.set_or_create_timer(TopicTimer.types[:close], 72, by_user: admin) + expect(topic.topic_timers.first.execute_at).to eq(3.days.from_now) end end it 'can take a number of hours as an integer, with timezone offset' do Timecop.freeze(now) do - topic.set_auto_close(72, {by_user: admin, timezone_offset: 240}) - expect(topic.auto_close_at).to eq(3.days.from_now) + topic.set_or_create_timer(TopicTimer.types[:close], 72, {by_user: admin, timezone_offset: 240}) + expect(topic.topic_timers.first.execute_at).to eq(3.days.from_now) end end it 'can take a number of hours as a string' do Timecop.freeze(now) do - topic.set_auto_close('18', {by_user: admin}) - expect(topic.auto_close_at).to eq(18.hours.from_now) + topic.set_or_create_timer(TopicTimer.types[:close], '18', by_user: admin) + expect(topic.topic_timers.first.execute_at).to eq(18.hours.from_now) end end it 'can take a number of hours as a string, with timezone offset' do Timecop.freeze(now) do - topic.set_auto_close('18', {by_user: admin, timezone_offset: 240}) - expect(topic.auto_close_at).to eq(18.hours.from_now) + topic.set_or_create_timer(TopicTimer.types[:close], '18', {by_user: admin, timezone_offset: 240}) + expect(topic.topic_timers.first.execute_at).to eq(18.hours.from_now) end end - it "can take a time later in the day" do + it 'can take a number of hours as a string and can handle based on last post' do Timecop.freeze(now) do - topic.set_auto_close('13:00', {by_user: admin}) - expect(topic.auto_close_at).to eq(Time.zone.local(2013,11,20,13,0)) - end - end - - it "can take a time later in the day, with timezone offset" do - Timecop.freeze(now) do - topic.set_auto_close('13:00', {by_user: admin, timezone_offset: 240}) - expect(topic.auto_close_at).to eq(Time.zone.local(2013,11,20,17,0)) - end - end - - it "can take a time for the next day" do - Timecop.freeze(now) do - topic.set_auto_close('5:00', {by_user: admin}) - expect(topic.auto_close_at).to eq(Time.zone.local(2013,11,21,5,0)) - end - end - - it "can take a time for the next day, with timezone offset" do - Timecop.freeze(now) do - topic.set_auto_close('1:00', {by_user: admin, timezone_offset: 240}) - expect(topic.auto_close_at).to eq(Time.zone.local(2013,11,21,5,0)) + topic.set_or_create_timer(TopicTimer.types[:close], '18', {by_user: admin, based_on_last_post: true}) + expect(topic.topic_timers.first.execute_at).to eq(18.hours.from_now) end end it "can take a timestamp for a future time" do Timecop.freeze(now) do - topic.set_auto_close('2013-11-22 5:00', {by_user: admin}) - expect(topic.auto_close_at).to eq(Time.zone.local(2013,11,22,5,0)) + topic.set_or_create_timer(TopicTimer.types[:close], '2013-11-22 5:00', {by_user: admin}) + expect(topic.topic_timers.first.execute_at).to eq(Time.zone.local(2013,11,22,5,0)) end end it "can take a timestamp for a future time, with timezone offset" do Timecop.freeze(now) do - topic.set_auto_close('2013-11-22 5:00', {by_user: admin, timezone_offset: 240}) - expect(topic.auto_close_at).to eq(Time.zone.local(2013,11,22,9,0)) + topic.set_or_create_timer(TopicTimer.types[:close], '2013-11-22 5:00', {by_user: admin, timezone_offset: 240}) + expect(topic.topic_timers.first.execute_at).to eq(Time.zone.local(2013,11,22,9,0)) end end it "sets a validation error when given a timestamp in the past" do Timecop.freeze(now) do - topic.set_auto_close('2013-11-19 5:00', {by_user: admin}) - expect(topic.auto_close_at).to eq(Time.zone.local(2013,11,19,5,0)) - expect(topic.errors[:auto_close_at]).to be_present + topic.set_or_create_timer(TopicTimer.types[:close], '2013-11-19 5:00', {by_user: admin}) + + expect(topic.topic_timers.first.execute_at).to eq(Time.zone.local(2013,11,19,5,0)) + expect(topic.topic_timers.first.errors[:execute_at]).to be_present end end it "can take a timestamp with timezone" do Timecop.freeze(now) do - topic.set_auto_close('2013-11-25T01:35:00-08:00', {by_user: admin}) - expect(topic.auto_close_at).to eq(Time.utc(2013,11,25,9,35)) + topic.set_or_create_timer(TopicTimer.types[:close], '2013-11-25T01:35:00-08:00', {by_user: admin}) + expect(topic.topic_timers.first.execute_at).to eq(Time.utc(2013,11,25,9,35)) end end - it 'sets auto_close_user to given user if it is a staff or TL4 user' do - topic.set_auto_close(3, {by_user: admin}) - expect(topic.auto_close_user_id).to eq(admin.id) + it 'sets topic status update user to given user if it is a staff or TL4 user' do + topic.set_or_create_timer(TopicTimer.types[:close], 3, {by_user: admin}) + expect(topic.topic_timers.first.user).to eq(admin) end - it 'sets auto_close_user to given user if it is a TL4 user' do - topic.set_auto_close(3, {by_user: trust_level_4}) - expect(topic.auto_close_user_id).to eq(trust_level_4.id) + it 'sets topic status update user to given user if it is a TL4 user' do + topic.set_or_create_timer(TopicTimer.types[:close], 3, {by_user: trust_level_4}) + expect(topic.topic_timers.first.user).to eq(trust_level_4) end - it 'sets auto_close_user to system user if given user is not staff or a TL4 user' do - topic.set_auto_close(3, {by_user: Fabricate.build(:user, id: 444)}) - expect(topic.auto_close_user_id).to eq(admin.id) + it 'sets topic status update user to system user if given user is not staff or a TL4 user' do + topic.set_or_create_timer(TopicTimer.types[:close], 3, {by_user: Fabricate.build(:user, id: 444)}) + expect(topic.topic_timers.first.user).to eq(admin) end - it 'sets auto_close_user to system user if user is not given and topic creator is not staff nor TL4 user' do - topic.set_auto_close(3) - expect(topic.auto_close_user_id).to eq(admin.id) + it 'sets topic status update user to system user if user is not given and topic creator is not staff nor TL4 user' do + topic.set_or_create_timer(TopicTimer.types[:close], 3) + expect(topic.topic_timers.first.user).to eq(admin) end - it 'sets auto_close_user to topic creator if it is a staff user' do + it 'sets topic status update user to topic creator if it is a staff user' do staff_topic = Fabricate.build(:topic, user: Fabricate.build(:admin, id: 999)) - staff_topic.set_auto_close(3) - expect(staff_topic.auto_close_user_id).to eq(999) + staff_topic.set_or_create_timer(TopicTimer.types[:close], 3) + expect(staff_topic.topic_timers.first.user_id).to eq(999) end - it 'sets auto_close_user to topic creator if it is a TL4 user' do + it 'sets topic status update user to topic creator if it is a TL4 user' do tl4_topic = Fabricate.build(:topic, user: Fabricate.build(:trust_level_4, id: 998)) - tl4_topic.set_auto_close(3) - expect(tl4_topic.auto_close_user_id).to eq(998) + tl4_topic.set_or_create_timer(TopicTimer.types[:close], 3) + expect(tl4_topic.topic_timers.first.user_id).to eq(998) end - it 'clears auto_close_at if arg is nil' do - closing_topic.set_auto_close(nil) - expect(closing_topic.auto_close_at).to be_nil + it 'removes close topic status update if arg is nil' do + closing_topic.set_or_create_timer(TopicTimer.types[:close], nil) + closing_topic.reload + expect(closing_topic.topic_timers.first).to be_nil end - it 'clears auto_close_started_at if arg is nil' do - closing_topic.set_auto_close(nil) - expect(closing_topic.auto_close_started_at).to be_nil - end - - it 'updates auto_close_at if it was already set to close' do + it 'updates topic status update execute_at if it was already set to close' do Timecop.freeze(now) do - closing_topic.set_auto_close(48) - expect(closing_topic.auto_close_at).to eq(2.days.from_now) + closing_topic.set_or_create_timer(TopicTimer.types[:close], 48) + expect(closing_topic.reload.public_topic_timer.execute_at).to eq(2.days.from_now) end end - it 'does not update auto_close_started_at if it was already set to close' do + it "does not update topic's topic status created_at it was already set to close" do expect{ - closing_topic.set_auto_close(14) - }.to_not change(closing_topic, :auto_close_started_at) + closing_topic.set_or_create_timer(TopicTimer.types[:close], 14) + }.to_not change { closing_topic.topic_timers.first.created_at } + end + + describe "when category's default auto close is set" do + let(:category) { Fabricate(:category, auto_close_hours: 4) } + let(:topic) { Fabricate(:topic, category: category) } + + it "should be able to override category's default auto close" do + expect(topic.topic_timers.first.duration).to eq(4) + + topic.set_or_create_timer(TopicTimer.types[:close], 2, by_user: admin) + + expect(topic.reload.closed).to eq(false) + + Timecop.travel(3.hours.from_now) do + TopicTimer.ensure_consistency! + expect(topic.reload.closed).to eq(true) + end + end + end + + describe "private status type" do + let(:topic) { Fabricate(:topic) } + let(:reminder) { Fabricate(:topic_timer, user: admin, topic: topic, status_type: TopicTimer.types[:reminder]) } + let(:other_admin) { Fabricate(:admin) } + + it "lets two users have their own record" do + reminder + expect { + topic.set_or_create_timer(TopicTimer.types[:reminder], 2, by_user: other_admin) + }.to change { TopicTimer.count }.by(1) + end + + it "can update a user's existing record" do + Timecop.freeze(now) do + reminder + expect { + topic.set_or_create_timer(TopicTimer.types[:reminder], 11, by_user: admin) + }.to_not change { TopicTimer.count } + reminder.reload + expect(reminder.execute_at).to eq(11.hours.from_now) + end + end end end @@ -1417,7 +1361,7 @@ describe Topic do user = Fabricate(:user) tag = Fabricate(:tag) TagUser.change(user.id, tag.id, TagUser.notification_levels[:muted]) - topic = Fabricate(:topic, tags: [tag]) + Fabricate(:topic, tags: [tag]) expect(Topic.for_digest(user, 1.year.ago, top_order: true)).to be_blank end @@ -1568,6 +1512,15 @@ describe Topic do expect { topic.trash!(moderator) }.to_not change { category.reload.topic_count } end end + + it "trashes topic embed record" do + topic = Fabricate(:topic) + post = Fabricate(:post, topic: topic, post_number: 1) + topic_embed = TopicEmbed.create!(topic_id: topic.id, embed_url: "https://blog.codinghorror.com/password-rules-are-bullshit", post_id: post.id) + topic.trash! + topic_embed.reload + expect(topic_embed.deleted_at).not_to eq(nil) + end end describe 'recover!' do @@ -1584,6 +1537,15 @@ describe Topic do expect { topic.recover! }.to_not change { category.reload.topic_count } end end + + it "recovers topic embed record" do + topic = Fabricate(:topic, deleted_at: 1.day.ago) + post = Fabricate(:post, topic: topic, post_number: 1) + topic_embed = TopicEmbed.create!(topic_id: topic.id, embed_url: "https://blog.codinghorror.com/password-rules-are-bullshit", post_id: post.id, deleted_at: 1.day.ago) + topic.recover! + topic_embed.reload + expect(topic_embed.deleted_at).to eq(nil) + end end context "new user limits" do @@ -1735,8 +1697,7 @@ describe Topic do it "should add user to the group" do expect(Guardian.new(walter).can_see?(group_private_topic)).to be_falsey - invite = group_private_topic.invite(group_manager, walter.email) - expect(invite).to be_nil + expect { group_private_topic.invite(group_manager, walter.email) }.to raise_error(Invite::UserExists) expect(walter.groups).to include(group) expect(Guardian.new(walter).can_see?(group_private_topic)).to be_truthy end @@ -1835,4 +1796,144 @@ describe Topic do end end end + + describe '#time_to_first_response' do + it "should have no results if no topics in range" do + expect(Topic.time_to_first_response_per_day(5.days.ago, Time.zone.now).count).to eq(0) + end + + it "should have no results if there is only a topic with no replies" do + topic = Fabricate(:topic, created_at: 1.hour.ago) + Fabricate(:post, topic: topic, user: topic.user, post_number: 1) + expect(Topic.time_to_first_response_per_day(5.days.ago, Time.zone.now).count).to eq(0) + expect(Topic.time_to_first_response_total).to eq(0) + end + + it "should have no results if reply is from first poster" do + topic = Fabricate(:topic, created_at: 1.hour.ago) + Fabricate(:post, topic: topic, user: topic.user, post_number: 1) + Fabricate(:post, topic: topic, user: topic.user, post_number: 2) + expect(Topic.time_to_first_response_per_day(5.days.ago, Time.zone.now).count).to eq(0) + expect(Topic.time_to_first_response_total).to eq(0) + end + + it "should have results if there's a topic with replies" do + topic = Fabricate(:topic, created_at: 3.hours.ago) + Fabricate(:post, topic: topic, user: topic.user, post_number: 1, created_at: 3.hours.ago) + Fabricate(:post, topic: topic, post_number: 2, created_at: 2.hours.ago) + r = Topic.time_to_first_response_per_day(5.days.ago, Time.zone.now) + expect(r.count).to eq(1) + expect(r[0]["hours"].to_f.round).to eq(1) + expect(Topic.time_to_first_response_total).to eq(1) + end + + it "should only count regular posts as the first response" do + topic = Fabricate(:topic, created_at: 5.hours.ago) + Fabricate(:post, topic: topic, user: topic.user, post_number: 1, created_at: 5.hours.ago) + Fabricate(:post, topic: topic, post_number: 2, created_at: 4.hours.ago, post_type: Post.types[:whisper]) + Fabricate(:post, topic: topic, post_number: 3, created_at: 3.hours.ago, post_type: Post.types[:moderator_action]) + Fabricate(:post, topic: topic, post_number: 4, created_at: 2.hours.ago, post_type: Post.types[:small_action]) + Fabricate(:post, topic: topic, post_number: 5, created_at: 1.hour.ago) + r = Topic.time_to_first_response_per_day(5.days.ago, Time.zone.now) + expect(r.count).to eq(1) + expect(r[0]["hours"].to_f.round).to eq(4) + expect(Topic.time_to_first_response_total).to eq(4) + end + end + + describe '#with_no_response' do + it "returns nothing with no topics" do + expect(Topic.with_no_response_per_day(5.days.ago, Time.zone.now).count).to eq(0) + end + + it "returns 1 with one topic that has no replies" do + topic = Fabricate(:topic, created_at: 5.hours.ago) + Fabricate(:post, topic: topic, user: topic.user, post_number: 1, created_at: 5.hours.ago) + expect(Topic.with_no_response_per_day(5.days.ago, Time.zone.now).count).to eq(1) + expect(Topic.with_no_response_total).to eq(1) + end + + it "returns 1 with one topic that has no replies and author was changed on first post" do + topic = Fabricate(:topic, created_at: 5.hours.ago) + Fabricate(:post, topic: topic, user: Fabricate(:user), post_number: 1, created_at: 5.hours.ago) + expect(Topic.with_no_response_per_day(5.days.ago, Time.zone.now).count).to eq(1) + expect(Topic.with_no_response_total).to eq(1) + end + + it "returns 1 with one topic that has a reply by the first poster" do + topic = Fabricate(:topic, created_at: 5.hours.ago) + Fabricate(:post, topic: topic, user: topic.user, post_number: 1, created_at: 5.hours.ago) + Fabricate(:post, topic: topic, user: topic.user, post_number: 2, created_at: 2.hours.ago) + expect(Topic.with_no_response_per_day(5.days.ago, Time.zone.now).count).to eq(1) + expect(Topic.with_no_response_total).to eq(1) + end + + it "returns 0 with a topic with 1 reply" do + topic = Fabricate(:topic, created_at: 5.hours.ago) + post1 = Fabricate(:post, topic: topic, user: topic.user, post_number: 1, created_at: 5.hours.ago) + post1 = Fabricate(:post, topic: topic, post_number: 2, created_at: 2.hours.ago) + expect(Topic.with_no_response_per_day(5.days.ago, Time.zone.now).count).to eq(0) + expect(Topic.with_no_response_total).to eq(0) + end + + it "returns 1 with one topic that doesn't have regular replies" do + topic = Fabricate(:topic, created_at: 5.hours.ago) + Fabricate(:post, topic: topic, user: topic.user, post_number: 1, created_at: 5.hours.ago) + Fabricate(:post, topic: topic, post_number: 2, created_at: 4.hours.ago, post_type: Post.types[:whisper]) + Fabricate(:post, topic: topic, post_number: 3, created_at: 3.hours.ago, post_type: Post.types[:moderator_action]) + Fabricate(:post, topic: topic, post_number: 4, created_at: 2.hours.ago, post_type: Post.types[:small_action]) + expect(Topic.with_no_response_per_day(5.days.ago, Time.zone.now).count).to eq(1) + expect(Topic.with_no_response_total).to eq(1) + end + end + + describe '#pm_with_non_human_user?' do + let(:robot) { Fabricate(:user, id: -3) } + let(:user) { Fabricate(:user) } + + let(:topic) do + Fabricate(:private_message_topic, topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: robot), + Fabricate.build(:topic_allowed_user, user: user) + ]) + end + + describe 'when PM is between a human and a non human user' do + it 'should return true' do + expect(topic.pm_with_non_human_user?).to be(true) + end + end + + describe 'when PM contains 2 human users and a non human user' do + it 'should return false' do + Fabricate(:topic_allowed_user, topic: topic, user: Fabricate(:user)) + + expect(topic.pm_with_non_human_user?).to be(false) + end + end + + describe 'when PM only contains a user' do + it 'should return true' do + topic.topic_allowed_users.first.destroy! + + expect(topic.reload.pm_with_non_human_user?).to be(true) + end + end + + describe 'when PM contains a group' do + it 'should return false' do + Fabricate(:topic_allowed_group, topic: topic) + + expect(topic.pm_with_non_human_user?).to be(false) + end + end + + describe 'when topic is not a PM' do + it 'should return false' do + topic.convert_to_public_topic(Fabricate(:admin)) + + expect(topic.pm_with_non_human_user?).to be(false) + end + end + end end diff --git a/spec/models/topic_status_update_spec.rb b/spec/models/topic_status_update_spec.rb deleted file mode 100644 index 549adb9e87..0000000000 --- a/spec/models/topic_status_update_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -# encoding: UTF-8 - -require 'rails_helper' -require_dependency 'post_destroyer' - -# TODO - test pinning, create_moderator_post - -describe TopicStatusUpdate do - - let(:user) { Fabricate(:user) } - let(:admin) { Fabricate(:admin) } - - it "avoids notifying on automatically closed topics" do - # TODO: TopicStatusUpdate should suppress message bus updates from the users it "pretends to read" - post = PostCreator.create(user, - raw: "this is a test post 123 this is a test post", - title: "hello world title", - ) - # TODO needed so counts sync up, PostCreator really should not give back out-of-date Topic - post.topic.set_auto_close('10') - post.topic.reload - - TopicStatusUpdate.new(post.topic, admin).update!("autoclosed", true) - - expect(post.topic.posts.count).to eq(2) - - tu = TopicUser.find_by(user_id: user.id) - expect(tu.last_read_post_number).to eq(2) - end - - it "adds an autoclosed message" do - topic = create_topic - topic.set_auto_close('10') - - TopicStatusUpdate.new(topic, admin).update!("autoclosed", true) - - last_post = topic.posts.last - expect(last_post.post_type).to eq(Post.types[:small_action]) - expect(last_post.action_code).to eq('autoclosed.enabled') - expect(last_post.raw).to eq(I18n.t("topic_statuses.autoclosed_enabled_minutes", count: 0)) - end - - it "adds an autoclosed message based on last post" do - topic = create_topic - topic.auto_close_based_on_last_post = true - topic.set_auto_close('10') - - TopicStatusUpdate.new(topic, admin).update!("autoclosed", true) - - last_post = topic.posts.last - expect(last_post.post_type).to eq(Post.types[:small_action]) - expect(last_post.action_code).to eq('autoclosed.enabled') - expect(last_post.raw).to eq(I18n.t("topic_statuses.autoclosed_enabled_lastpost_hours", count: 10)) - end - -end diff --git a/spec/models/topic_timer_spec.rb b/spec/models/topic_timer_spec.rb new file mode 100644 index 0000000000..7ffd78d2d6 --- /dev/null +++ b/spec/models/topic_timer_spec.rb @@ -0,0 +1,302 @@ +require 'rails_helper' + +RSpec.describe TopicTimer, type: :model do + let(:topic_timer) { Fabricate(:topic_timer) } + let(:topic) { Fabricate(:topic) } + let(:admin) { Fabricate(:admin) } + + before do + Jobs::ToggleTopicClosed.jobs.clear + end + + context "validations" do + describe '#status_type' do + it 'should ensure that only one active public topic status update exists' do + topic_timer.update!(topic: topic) + Fabricate(:topic_timer, deleted_at: Time.zone.now, topic: topic) + + expect { Fabricate(:topic_timer, topic: topic) } + .to raise_error(ActiveRecord::RecordInvalid) + end + + it 'should ensure that only one active private topic timer exists per user' do + Fabricate(:topic_timer, topic: topic, user: admin, status_type: TopicTimer.types[:reminder]) + + expect { Fabricate(:topic_timer, topic: topic, user: admin, status_type: TopicTimer.types[:reminder]) } + .to raise_error(ActiveRecord::RecordInvalid) + end + + it 'should allow users to have their own private topic timer' do + expect do + Fabricate(:topic_timer, + topic: topic, + user: Fabricate(:admin), + status_type: TopicTimer.types[:reminder] + ) + end.to_not raise_error + end + end + + describe '#execute_at' do + describe 'when #execute_at is greater than #created_at' do + it 'should be valid' do + topic_timer = Fabricate.build(:topic_timer, + execute_at: Time.zone.now + 1.hour, + user: Fabricate(:user), + topic: Fabricate(:topic) + ) + + expect(topic_timer).to be_valid + end + end + + describe 'when #execute_at is smaller than #created_at' do + it 'should not be valid' do + topic_timer = Fabricate.build(:topic_timer, + execute_at: Time.zone.now - 1.hour, + created_at: Time.zone.now, + user: Fabricate(:user), + topic: Fabricate(:topic) + ) + + expect(topic_timer).to_not be_valid + end + end + end + + describe '#category_id' do + describe 'when #status_type is publish_to_category' do + describe 'when #category_id is not present' do + it 'should not be valid' do + topic_timer = Fabricate.build(:topic_timer, + status_type: TopicTimer.types[:publish_to_category] + ) + + expect(topic_timer).to_not be_valid + expect(topic_timer.errors.keys).to include(:category_id) + end + end + + describe 'when #category_id is present' do + it 'should be valid' do + topic_timer = Fabricate.build(:topic_timer, + status_type: TopicTimer.types[:publish_to_category], + category_id: Fabricate(:category).id, + user: Fabricate(:user), + topic: Fabricate(:topic) + ) + + expect(topic_timer).to be_valid + end + end + end + end + end + + context 'callbacks' do + describe 'when #execute_at and #user_id are not changed' do + it 'should not schedule another to update topic' do + Jobs.expects(:enqueue_at).with( + topic_timer.execute_at, + :toggle_topic_closed, + topic_timer_id: topic_timer.id, + state: true + ).once + + topic_timer + + Jobs.expects(:cancel_scheduled_job).never + + topic_timer.update!(topic: Fabricate(:topic)) + end + end + + describe 'when #execute_at value is changed' do + it 'reschedules the job' do + Timecop.freeze do + topic_timer + + Jobs.expects(:cancel_scheduled_job).with( + :toggle_topic_closed, topic_timer_id: topic_timer.id + ) + + Jobs.expects(:enqueue_at).with( + 3.days.from_now, :toggle_topic_closed, + topic_timer_id: topic_timer.id, + state: true + ) + + topic_timer.update!(execute_at: 3.days.from_now, created_at: Time.zone.now) + end + end + + describe 'when execute_at is smaller than the current time' do + it 'should enqueue the job immediately' do + Timecop.freeze do + topic_timer + + Jobs.expects(:enqueue_at).with( + Time.zone.now, :toggle_topic_closed, + topic_timer_id: topic_timer.id, + state: true + ) + + topic_timer.update!( + execute_at: Time.zone.now - 1.hour, + created_at: Time.zone.now - 2.hour + ) + end + end + end + end + + describe 'when user is changed' do + it 'should update the job' do + Timecop.freeze do + topic_timer + + Jobs.expects(:cancel_scheduled_job).with( + :toggle_topic_closed, topic_timer_id: topic_timer.id + ) + + admin = Fabricate(:admin) + + Jobs.expects(:enqueue_at).with( + topic_timer.execute_at, + :toggle_topic_closed, + topic_timer_id: topic_timer.id, + state: true + ) + + topic_timer.update!(user: admin) + end + end + end + + describe 'when a open topic status update is created for an open topic' do + let(:topic) { Fabricate(:topic, closed: false) } + + let(:topic_timer) do + Fabricate(:topic_timer, + status_type: described_class.types[:open], + topic: topic + ) + end + + it 'should close the topic' do + topic_timer + expect(topic.reload.closed).to eq(true) + end + + describe 'when topic has been deleted' do + it 'should not queue the job' do + topic.trash! + topic_timer + + expect(Jobs::ToggleTopicClosed.jobs).to eq([]) + end + end + end + + describe 'when a close topic status update is created for a closed topic' do + let(:topic) { Fabricate(:topic, closed: true) } + + let(:topic_timer) do + Fabricate(:topic_timer, + status_type: described_class.types[:close], + topic: topic + ) + end + + it 'should open the topic' do + topic_timer + expect(topic.reload.closed).to eq(false) + end + + describe 'when topic has been deleted' do + it 'should not queue the job' do + topic.trash! + topic_timer + + expect(Jobs::ToggleTopicClosed.jobs).to eq([]) + end + end + end + + describe '#public_type' do + [:close, :open, :delete].each do |public_type| + it "is true for #{public_type}" do + timer = Fabricate(:topic_timer, status_type: described_class.types[public_type]) + expect(timer.public_type).to eq(true) + end + end + + it "is true for publish_to_category" do + timer = Fabricate(:topic_timer, status_type: described_class.types[:publish_to_category], category: Fabricate(:category)) + expect(timer.public_type).to eq(true) + end + + described_class.private_types.keys.each do |private_type| + it "is false for #{private_type}" do + timer = Fabricate(:topic_timer, status_type: described_class.types[private_type]) + expect(timer.public_type).to be_falsey + end + end + end + end + + describe '.ensure_consistency!' do + before do + SiteSetting.queue_jobs = true + Jobs::ToggleTopicClosed.jobs.clear + end + + it 'should enqueue jobs that have been missed' do + close_topic_timer = Fabricate(:topic_timer, + execute_at: Time.zone.now - 1.hour, + created_at: Time.zone.now - 2.hour + ) + + open_topic_timer = Fabricate(:topic_timer, + status_type: described_class.types[:open], + execute_at: Time.zone.now - 1.hour, + created_at: Time.zone.now - 2.hour + ) + + Fabricate(:topic_timer) + + Fabricate(:topic_timer, + execute_at: Time.zone.now - 1.hour, + created_at: Time.zone.now - 2.hour + ).topic.trash! + + expect { described_class.ensure_consistency! } + .to change { Jobs::ToggleTopicClosed.jobs.count }.by(2) + + job_args = Jobs::ToggleTopicClosed.jobs.first["args"].first + + expect(job_args["topic_timer_id"]).to eq(close_topic_timer.id) + expect(job_args["state"]).to eq(true) + + job_args = Jobs::ToggleTopicClosed.jobs.last["args"].first + + expect(job_args["topic_timer_id"]).to eq(open_topic_timer.id) + expect(job_args["state"]).to eq(false) + end + + # intermittent failures + # it "should enqueue remind me jobs that have been missed" do + # reminder = Fabricate(:topic_timer, + # status_type: described_class.types[:reminder], + # execute_at: Time.zone.now - 1.hour, + # created_at: Time.zone.now - 2.hour + # ) + + # expect { described_class.ensure_consistency! } + # .to change { Jobs::TopicReminder.jobs.count }.by(1) + + # job_args = Jobs::TopicReminder.jobs.first["args"].first + # expect(job_args["topic_timer_id"]).to eq(reminder.id) + # end + end +end diff --git a/spec/models/topic_user_spec.rb b/spec/models/topic_user_spec.rb index 04c3c1440f..bcc481c9c8 100644 --- a/spec/models/topic_user_spec.rb +++ b/spec/models/topic_user_spec.rb @@ -1,6 +1,17 @@ require 'rails_helper' describe TopicUser do + let :watching do + TopicUser.notification_levels[:watching] + end + + let :regular do + TopicUser.notification_levels[:regular] + end + + let :tracking do + TopicUser.notification_levels[:tracking] + end describe "#unwatch_categories!" do it "correctly unwatches categories" do @@ -10,9 +21,6 @@ describe TopicUser do tracked_topic = Fabricate(:topic) user = op_topic.user - watching = TopicUser.notification_levels[:watching] - regular = TopicUser.notification_levels[:regular] - tracking = TopicUser.notification_levels[:tracking] TopicUser.change(user.id, op_topic, notification_level: watching) TopicUser.change(user.id, another_topic, notification_level: watching) @@ -72,7 +80,7 @@ describe TopicUser do guardian = Guardian.new(u) TopicCreator.create(u, guardian, title: "this is my topic title") } - let(:topic_user) { TopicUser.get(topic,user) } + let(:topic_user) { TopicUser.get(topic, user) } let(:topic_creator_user) { TopicUser.get(topic, topic.user) } let(:post) { Fabricate(:post, topic: topic, user: user) } @@ -99,6 +107,18 @@ describe TopicUser do end describe 'notifications' do + it 'should trigger the right DiscourseEvent' do + begin + called = false + DiscourseEvent.on(:topic_notification_level_changed) { called = true } + + TopicUser.change(user.id, topic.id, notification_level: TopicUser.notification_levels[:tracking]) + + expect(called).to eq(true) + ensure + DiscourseEvent.off(:topic_notification_level_changed) { called = true } + end + end it 'should be set to tracking if auto_track_topics is enabled' do user.user_option.update_column(:auto_track_topics_after_msecs, 0) @@ -152,27 +172,28 @@ describe TopicUser do describe 'visited at' do - before do - TopicUser.track_visit!(topic.id, user.id) - end - it 'set upon initial visit' do - freeze_time yesterday do - expect(topic_user.first_visited_at.to_i).to eq(yesterday.to_i) - expect(topic_user.last_visited_at.to_i).to eq(yesterday.to_i) - end + freeze_time yesterday + + TopicUser.track_visit!(topic.id, user.id) + + expect(topic_user.first_visited_at.to_i).to eq(yesterday.to_i) + expect(topic_user.last_visited_at.to_i).to eq(yesterday.to_i) end it 'updates upon repeat visit' do - today = yesterday.tomorrow + freeze_time yesterday + + TopicUser.track_visit!(topic.id, user.id) + + freeze_time Time.zone.now + + TopicUser.track_visit!(topic.id, user.id) + # reload is a no go + topic_user = TopicUser.get(topic,user) + expect(topic_user.first_visited_at.to_i).to eq(yesterday.to_i) + expect(topic_user.last_visited_at.to_i).to eq(Time.zone.now.to_i) - freeze_time today do - TopicUser.track_visit!(topic.id, user.id) - # reload is a no go - topic_user = TopicUser.get(topic,user) - expect(topic_user.first_visited_at.to_i).to eq(yesterday.to_i) - expect(topic_user.last_visited_at.to_i).to eq(today.to_i) - end end end @@ -180,29 +201,35 @@ describe TopicUser do context "without auto tracking" do - before do - TopicUser.update_last_read(user, topic.id, 1, 0) - end - let(:topic_user) { TopicUser.get(topic,user) } it 'should create a new record for a visit' do - freeze_time yesterday do - expect(topic_user.last_read_post_number).to eq(1) - expect(topic_user.last_visited_at.to_i).to eq(yesterday.to_i) - expect(topic_user.first_visited_at.to_i).to eq(yesterday.to_i) - end + freeze_time yesterday + + TopicUser.update_last_read(user, topic.id, 1, 0) + + expect(topic_user.last_read_post_number).to eq(1) + expect(topic_user.last_visited_at.to_i).to eq(yesterday.to_i) + expect(topic_user.first_visited_at.to_i).to eq(yesterday.to_i) end it 'should update the record for repeat visit' do - freeze_time yesterday do - Fabricate(:post, topic: topic, user: user) - TopicUser.update_last_read(user, topic.id, 2, 0) - topic_user = TopicUser.get(topic,user) - expect(topic_user.last_read_post_number).to eq(2) - expect(topic_user.last_visited_at.to_i).to eq(yesterday.to_i) - expect(topic_user.first_visited_at.to_i).to eq(yesterday.to_i) - end + + today = Time.zone.now + freeze_time Time.zone.now + + TopicUser.update_last_read(user, topic.id, 1, 0) + + tomorrow = 1.day.from_now + freeze_time tomorrow + + Fabricate(:post, topic: topic, user: user) + TopicUser.update_last_read(user, topic.id, 2, 0) + topic_user = TopicUser.get(topic,user) + + expect(topic_user.last_read_post_number).to eq(2) + expect(topic_user.last_visited_at.to_i).to eq(today.to_i) + expect(topic_user.first_visited_at.to_i).to eq(today.to_i) end end @@ -231,6 +258,50 @@ describe TopicUser do expect(topic_new_user.notifications_reason_id).to eq(TopicUser.notification_reasons[:created_post]) end + it 'should update tracking state when you reply' do + new_user.user_option.update_column(:notification_level_when_replying, 3) + post_creator.create + TopicUser.exec_sql("UPDATE topic_users set notification_level=2 + WHERE topic_id = :topic_id AND user_id = :user_id", topic_id: topic_new_user.topic_id, user_id: topic_new_user.user_id) + TopicUser.auto_notification(topic_new_user.user_id, topic_new_user.topic_id, TopicUser.notification_reasons[:created_post], TopicUser.notification_levels[:watching]) + + tu = TopicUser.find_by(user_id: topic_new_user.user_id, topic_id: topic_new_user.topic_id) + expect(tu.notification_level).to eq(TopicUser.notification_levels[:watching]) + end + + it 'should not update tracking state when you reply' do + new_user.user_option.update_column(:notification_level_when_replying, 3) + post_creator.create + TopicUser.exec_sql("UPDATE topic_users set notification_level=3 + WHERE topic_id = :topic_id AND user_id = :user_id", topic_id: topic_new_user.topic_id, user_id: topic_new_user.user_id) + TopicUser.auto_notification(topic_new_user.user_id, topic_new_user.topic_id, TopicUser.notification_reasons[:created_post], TopicUser.notification_levels[:tracking]) + + tu = TopicUser.find_by(user_id: topic_new_user.user_id, topic_id: topic_new_user.topic_id) + expect(tu.notification_level).to eq(TopicUser.notification_levels[:watching]) + end + + it 'should not update tracking state when state manually set to normal you reply' do + new_user.user_option.update_column(:notification_level_when_replying, 3) + post_creator.create + TopicUser.exec_sql("UPDATE topic_users set notification_level=1 + WHERE topic_id = :topic_id AND user_id = :user_id", topic_id: topic_new_user.topic_id, user_id: topic_new_user.user_id) + TopicUser.auto_notification(topic_new_user.user_id, topic_new_user.topic_id, TopicUser.notification_reasons[:created_post], TopicUser.notification_levels[:tracking]) + + tu = TopicUser.find_by(user_id: topic_new_user.user_id, topic_id: topic_new_user.topic_id) + expect(tu.notification_level).to eq(TopicUser.notification_levels[:regular]) + end + + it 'should not update tracking state when state manually set to muted you reply' do + new_user.user_option.update_column(:notification_level_when_replying, 3) + post_creator.create + TopicUser.exec_sql("UPDATE topic_users set notification_level=0 + WHERE topic_id = :topic_id AND user_id = :user_id", topic_id: topic_new_user.topic_id, user_id: topic_new_user.user_id) + TopicUser.auto_notification(topic_new_user.user_id, topic_new_user.topic_id, TopicUser.notification_reasons[:created_post], TopicUser.notification_levels[:tracking]) + + tu = TopicUser.find_by(user_id: topic_new_user.user_id, topic_id: topic_new_user.topic_id) + expect(tu.notification_level).to eq(TopicUser.notification_levels[:muted]) + end + it 'should not automatically track topics you reply to and have set state manually' do post_creator.create TopicUser.change(new_user, topic, notification_level: TopicUser.notification_levels[:regular]) diff --git a/spec/models/upload_spec.rb b/spec/models/upload_spec.rb index 4cf0ad64e0..c187ddbe67 100644 --- a/spec/models/upload_spec.rb +++ b/spec/models/upload_spec.rb @@ -46,91 +46,6 @@ describe Upload do end - context "#create_for" do - - before do - Upload.stubs(:fix_image_orientation) - ImageOptim.any_instance.stubs(:optimize_image!) - end - - it "does not create another upload if it already exists" do - Upload.expects(:find_by).with(sha1: image_sha1).returns(upload) - Upload.expects(:save).never - expect(Upload.create_for(user_id, image, image_filename, image_filesize)).to eq(upload) - end - - it "ensures images isn't huge before processing it" do - Upload.expects(:fix_image_orientation).never - upload = Upload.create_for(user_id, huge_image, huge_image_filename, huge_image_filesize) - expect(upload.errors.size).to be > 0 - end - - it "fix image orientation" do - Upload.expects(:fix_image_orientation).with(image.path) - Upload.create_for(user_id, image, image_filename, image_filesize) - end - - it "computes width & height for images" do - ImageSizer.expects(:resize) - Upload.create_for(user_id, image, image_filename, image_filesize) - end - - it "does not compute width & height for non-image" do - FastImage.any_instance.expects(:size).never - upload = Upload.create_for(user_id, attachment, attachment_filename, attachment_filesize) - expect(upload.errors.size).to be > 0 - end - - it "generates an error when the image is too large" do - SiteSetting.stubs(:max_image_size_kb).returns(1) - upload = Upload.create_for(user_id, image, image_filename, image_filesize) - expect(upload.errors.size).to be > 0 - end - - it "generates an error when the attachment is too large" do - SiteSetting.stubs(:max_attachment_size_kb).returns(1) - upload = Upload.create_for(user_id, attachment, attachment_filename, attachment_filesize) - expect(upload.errors.size).to be > 0 - end - - it "saves proper information" do - store = {} - Discourse.expects(:store).returns(store) - store.expects(:store_upload).returns(url) - - upload = Upload.create_for(user_id, image, image_filename, image_filesize) - - expect(upload.user_id).to eq(user_id) - expect(upload.original_filename).to eq(image_filename) - expect(upload.filesize).to eq(image_filesize) - expect(upload.width).to eq(244) - expect(upload.height).to eq(66) - expect(upload.url).to eq(url) - end - - context "when svg is authorized" do - - before { SiteSetting.stubs(:authorized_extensions).returns("svg") } - - it "consider SVG as an image" do - store = {} - Discourse.expects(:store).returns(store) - store.expects(:store_upload).returns(url) - - upload = Upload.create_for(user_id, image_svg, image_svg_filename, image_svg_filesize) - - expect(upload.user_id).to eq(user_id) - expect(upload.original_filename).to eq(image_svg_filename) - expect(upload.filesize).to eq(image_svg_filesize) - expect(upload.width).to eq(100) - expect(upload.height).to eq(50) - expect(upload.url).to eq(url) - end - - end - - end - context ".get_from_url" do let(:url) { "/uploads/default/original/3X/1/0/10f73034616a796dfd70177dc54b6def44c4ba6f.png" } let(:upload) { Fabricate(:upload, url: url) } diff --git a/spec/models/user_action_spec.rb b/spec/models/user_action_spec.rb index 5aeaa25781..73ccd9a067 100644 --- a/spec/models/user_action_spec.rb +++ b/spec/models/user_action_spec.rb @@ -105,7 +105,7 @@ describe UserAction do describe 'when user likes' do - let!(:post) { Fabricate(:post) } + let(:post) { Fabricate(:post) } let(:likee) { post.user } let(:liker) { Fabricate(:coding_horror) } @@ -140,6 +140,23 @@ describe UserAction do expect(liker.user_stat.reload.likes_given).to eq(0) end + context 'private message' do + let(:post) { Fabricate(:private_message_post) } + let(:likee) { post.topic.topic_allowed_users.first.user } + let(:liker) { post.topic.topic_allowed_users.last.user } + + it 'should not increase user stats' do + expect(@liker_action).not_to eq(nil) + expect(liker.user_stat.reload.likes_given).to eq(0) + expect(@likee_action).not_to eq(nil) + expect(likee.user_stat.reload.likes_received).to eq(0) + + PostAction.remove_act(liker, post, PostActionType.types[:like]) + expect(liker.user_stat.reload.likes_given).to eq(0) + expect(likee.user_stat.reload.likes_received).to eq(0) + end + end + end context "liking a private message" do diff --git a/spec/models/user_auth_token_spec.rb b/spec/models/user_auth_token_spec.rb new file mode 100644 index 0000000000..11ed5f8212 --- /dev/null +++ b/spec/models/user_auth_token_spec.rb @@ -0,0 +1,274 @@ +require 'rails_helper' + +describe UserAuthToken do + + it "can remove old expired tokens" do + + SiteSetting.verbose_auth_token_logging = true + + freeze_time Time.zone.now + SiteSetting.maximum_session_age = 1 + + user = Fabricate(:user) + token = UserAuthToken.generate!(user_id: user.id, + user_agent: "some user agent 2", + client_ip: "1.1.2.3") + + freeze_time 1.hour.from_now + UserAuthToken.cleanup! + + expect(UserAuthToken.where(id: token.id).count).to eq(1) + + freeze_time 1.second.from_now + UserAuthToken.cleanup! + + expect(UserAuthToken.where(id: token.id).count).to eq(1) + + freeze_time UserAuthToken::ROTATE_TIME.from_now + UserAuthToken.cleanup! + + expect(UserAuthToken.where(id: token.id).count).to eq(0) + + end + + it "can lookup both hashed and unhashed" do + user = Fabricate(:user) + + token = UserAuthToken.generate!(user_id: user.id, + user_agent: "some user agent 2", + client_ip: "1.1.2.3") + + lookup_token = UserAuthToken.lookup(token.unhashed_auth_token) + + expect(user.id).to eq(lookup_token.user.id) + + lookup_token = UserAuthToken.lookup(token.auth_token) + + expect(lookup_token).to eq(nil) + + token.update_columns(legacy: true) + + lookup_token = UserAuthToken.lookup(token.auth_token) + + expect(user.id).to eq(lookup_token.user.id) + end + + it "can validate token was seen at lookup time" do + + user = Fabricate(:user) + + user_token = UserAuthToken.generate!(user_id: user.id, + user_agent: "some user agent 2", + client_ip: "1.1.2.3") + + expect(user_token.auth_token_seen).to eq(false) + + UserAuthToken.lookup(user_token.unhashed_auth_token, seen: true) + + user_token.reload + expect(user_token.auth_token_seen).to eq(true) + + end + + it "can rotate with no params maintaining data" do + + user = Fabricate(:user) + + user_token = UserAuthToken.generate!(user_id: user.id, + user_agent: "some user agent 2", + client_ip: "1.1.2.3") + + user_token.update_columns(auth_token_seen: true) + expect(user_token.rotate!).to eq(true) + user_token.reload + expect(user_token.client_ip.to_s).to eq("1.1.2.3") + expect(user_token.user_agent).to eq("some user agent 2") + end + + it "expires correctly" do + + user = Fabricate(:user) + + user_token = UserAuthToken.generate!(user_id: user.id, + user_agent: "some user agent 2", + client_ip: "1.1.2.3") + + UserAuthToken.lookup(user_token.unhashed_auth_token, seen: true) + + freeze_time (SiteSetting.maximum_session_age.hours - 1).from_now + + user_token.reload + + user_token.rotate! + UserAuthToken.lookup(user_token.unhashed_auth_token, seen: true) + + freeze_time (SiteSetting.maximum_session_age.hours - 1).from_now + + still_good = UserAuthToken.lookup(user_token.unhashed_auth_token, seen: true) + expect(still_good).not_to eq(nil) + + freeze_time 2.hours.from_now + + not_good = UserAuthToken.lookup(user_token.unhashed_auth_token, seen: true) + expect(not_good).to eq(nil) + end + + it "can properly rotate tokens" do + + user = Fabricate(:user) + + user_token = UserAuthToken.generate!(user_id: user.id, + user_agent: "some user agent 2", + client_ip: "1.1.2.3") + + prev_auth_token = user_token.auth_token + unhashed_prev = user_token.unhashed_auth_token + + rotated = user_token.rotate!(user_agent: "a new user agent", client_ip: "1.1.2.4") + expect(rotated).to eq(false) + + user_token.update_columns(auth_token_seen: true) + + rotated = user_token.rotate!(user_agent: "a new user agent", client_ip: "1.1.2.4") + expect(rotated).to eq(true) + + user_token.reload + + expect(user_token.rotated_at).to be_within(5.second).of(Time.zone.now) + expect(user_token.client_ip).to eq("1.1.2.4") + expect(user_token.user_agent).to eq("a new user agent") + expect(user_token.auth_token_seen).to eq(false) + expect(user_token.seen_at).to eq(nil) + expect(user_token.prev_auth_token).to eq(prev_auth_token) + + # ability to auth using an old token + freeze_time + + looked_up = UserAuthToken.lookup(user_token.unhashed_auth_token, seen: true) + expect(looked_up.id).to eq(user_token.id) + expect(looked_up.auth_token_seen).to eq(true) + expect(looked_up.seen_at).to be_within(1.second).of(Time.zone.now) + + looked_up = UserAuthToken.lookup(unhashed_prev, seen: true) + expect(looked_up.id).to eq(user_token.id) + + freeze_time(2.minute.from_now) + + looked_up = UserAuthToken.lookup(unhashed_prev) + expect(looked_up).not_to eq(nil) + + looked_up.reload + expect(looked_up.auth_token_seen).to eq(false) + + rotated = user_token.rotate!(user_agent: "a new user agent", client_ip: "1.1.2.4") + expect(rotated).to eq(true) + user_token.reload + expect(user_token.seen_at).to eq(nil) + end + + it "keeps prev token valid for 1 minute after it is confirmed" do + + user = Fabricate(:user) + + token = UserAuthToken.generate!(user_id: user.id, + user_agent: "some user agent", + client_ip: "1.1.2.3") + + UserAuthToken.lookup(token.unhashed_auth_token, seen: true) + + freeze_time(10.minutes.from_now) + + prev_token = token.unhashed_auth_token + + token.rotate!(user_agent: "firefox", client_ip: "1.1.1.1") + + freeze_time(10.minutes.from_now) + + expect(UserAuthToken.lookup(token.unhashed_auth_token, seen: true)).not_to eq(nil) + expect(UserAuthToken.lookup(prev_token, seen: true)).not_to eq(nil) + + end + + it "can correctly log auth tokens" do + SiteSetting.verbose_auth_token_logging = true + + user = Fabricate(:user) + + token = UserAuthToken.generate!(user_id: user.id, + user_agent: "some user agent", + client_ip: "1.1.2.3") + + expect(UserAuthTokenLog.where( + action: 'generate', + user_id: user.id, + user_agent: "some user agent", + client_ip: "1.1.2.3", + user_auth_token_id: token.id, + ).count).to eq(1) + + UserAuthToken.lookup(token.unhashed_auth_token, + seen: true, + user_agent: "something diff", + client_ip: "1.2.3.3" + ) + + UserAuthToken.lookup(token.unhashed_auth_token, + seen: true, + user_agent: "something diff2", + client_ip: "1.2.3.3" + ) + + expect(UserAuthTokenLog.where( + action: "seen token", + user_id: user.id, + auth_token: token.auth_token, + client_ip: "1.2.3.3", + user_auth_token_id: token.id + ).count).to eq(1) + + fake_token = SecureRandom.hex + UserAuthToken.lookup(fake_token, + seen: true, + user_agent: "bob", + client_ip: "127.0.0.1", + path: "/path" + ) + + expect(UserAuthTokenLog.where( + action: "miss token", + auth_token: UserAuthToken.hash_token(fake_token), + user_agent: "bob", + client_ip: "127.0.0.1", + path: "/path" + ).count).to eq(1) + + + freeze_time(UserAuthToken::ROTATE_TIME.from_now) + + token.rotate!(user_agent: "firefox", client_ip: "1.1.1.1") + + expect(UserAuthTokenLog.where( + action: "rotate", + auth_token: token.auth_token, + user_agent: "firefox", + client_ip: "1.1.1.1", + user_auth_token_id: token.id + ).count).to eq(1) + + end + + it "will not mark token unseen when prev and current are the same" do + user = Fabricate(:user) + + token = UserAuthToken.generate!(user_id: user.id, + user_agent: "some user agent", + client_ip: "1.1.2.3") + + + lookup = UserAuthToken.lookup(token.unhashed_auth_token, seen: true) + lookup = UserAuthToken.lookup(token.unhashed_auth_token, seen: true) + lookup.reload + expect(lookup.auth_token_seen).to eq(true) + end + +end diff --git a/spec/models/user_avatar_spec.rb b/spec/models/user_avatar_spec.rb index 066cb97ab6..46269b6a34 100644 --- a/spec/models/user_avatar_spec.rb +++ b/spec/models/user_avatar_spec.rb @@ -1,10 +1,8 @@ require 'rails_helper' describe UserAvatar do - let(:avatar){ - user = Fabricate(:user) - user.create_user_avatar! - } + let(:user) { Fabricate(:user) } + let(:avatar) { user.create_user_avatar! } it 'can update gravatars' do temp = Tempfile.new('test') @@ -18,7 +16,7 @@ describe UserAvatar do expect(avatar.gravatar_upload).not_to eq(nil) end - context '#import_url_for_user' do + context '.import_url_for_user' do it 'creates user_avatar record if missing' do user = Fabricate(:user) @@ -44,7 +42,20 @@ describe UserAvatar do user.reload expect(user.uploaded_avatar_id).to eq(1) - expect(user.user_avatar.custom_upload_id).not_to eq(nil) + expect(user.user_avatar.custom_upload_id).to eq(Upload.last.id) + end + + describe 'when avatar url returns an invalid status code' do + it 'should not do anything' do + url = "http://thisfakesomething.something.com/" + stub_request(:head, url).to_return(status: 404) + UserAvatar.import_url_for_user(url, user) + + user.reload + + expect(user.uploaded_avatar_id).to eq(nil) + expect(user.user_avatar.custom_upload_id).to eq(nil) + end end end end diff --git a/spec/models/user_search_spec.rb b/spec/models/user_search_spec.rb index 47621ef60f..0f8bfd63ba 100644 --- a/spec/models/user_search_spec.rb +++ b/spec/models/user_search_spec.rb @@ -43,6 +43,16 @@ describe UserSearch do expect(search_for("under_").length).to eq(1) end + it 'allows filtering by group' do + group = Fabricate(:group) + sam = Fabricate(:user, username: 'sam') + _samantha = Fabricate(:user, username: 'samantha') + group.add(sam) + + results = search_for("sam", group: group) + expect(results.count).to eq(1) + end + # this is a seriously expensive integration test, # re-creating this entire test db is too expensive reuse it "operates correctly" do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 216231d482..8100e92782 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -190,6 +190,8 @@ describe User do it "has correct settings" do expect(subject.email_tokens).to be_present + expect(subject.user_stat).to be_present + expect(subject.user_profile).to be_present expect(subject.user_option.email_private_messages).to eq(true) expect(subject.user_option.email_direct).to eq(true) end @@ -457,7 +459,7 @@ describe User do end end - context '.username_available?' do + describe '.username_available?' do it "returns true for a username that is available" do expect(User.username_available?('BruceWayne')).to eq(true) end @@ -469,9 +471,33 @@ describe User do it 'returns false when a username is reserved' do SiteSetting.reserved_usernames = 'test|donkey' - expect(User.username_available?('donkey')).to eq(false) - expect(User.username_available?('DonKey')).to eq(false) - expect(User.username_available?('test')).to eq(false) + expect(User.username_available?('tESt')).to eq(false) + end + end + + describe '.reserved_username?' do + it 'returns true when a username is reserved' do + SiteSetting.reserved_usernames = 'test|donkey' + + expect(User.reserved_username?('donkey')).to eq(true) + expect(User.reserved_username?('DonKey')).to eq(true) + expect(User.reserved_username?('test')).to eq(true) + end + + it 'should not allow usernames matched against an expession' do + SiteSetting.reserved_usernames = 'test)|*admin*|foo*|*bar|abc.def' + + expect(User.reserved_username?('test')).to eq(false) + expect(User.reserved_username?('abc9def')).to eq(false) + + expect(User.reserved_username?('admin')).to eq(true) + expect(User.reserved_username?('foo')).to eq(true) + expect(User.reserved_username?('bar')).to eq(true) + + expect(User.reserved_username?('admi')).to eq(false) + expect(User.reserved_username?('bar.foo')).to eq(false) + expect(User.reserved_username?('foo.bar')).to eq(true) + expect(User.reserved_username?('baz.bar')).to eq(true) end end @@ -592,21 +618,18 @@ describe User do @user.password = "ilovepasta" @user.save! - @user.auth_token = SecureRandom.hex(16) - @user.save! - expect(@user.active).to eq(false) expect(@user.confirm_password?("ilovepasta")).to eq(true) - email_token = @user.email_tokens.create(email: 'pasta@delicious.com') - old_token = @user.auth_token + UserAuthToken.generate!(user_id: @user.id) + @user.password = "passwordT" @user.save! # must expire old token on password change - expect(@user.auth_token).to_not eq(old_token) + expect(@user.user_auth_tokens.count).to eq(0) email_token.reload expect(email_token.expired).to eq(true) @@ -1132,6 +1155,7 @@ describe User do describe "refresh_avatar" do it "enqueues the update_gravatar job when automatically downloading gravatars" do SiteSetting.automatically_download_gravatars = true + SiteSetting.queue_jobs = true user = Fabricate(:user) @@ -1183,9 +1207,38 @@ describe User do describe "automatic group membership" do + let!(:group) { + Fabricate(:group, + automatic_membership_email_domains: "bar.com|wat.com", + grant_trust_level: 1, + title: "bars and wats", + primary_group: true + ) + } + + it "doesn't automatically add inactive users" do + inactive_user = Fabricate(:user, active: false, email: "wat@wat.com") + group.reload + expect(group.users.include?(inactive_user)).to eq(false) + end + + it "doesn't automatically add users with unconfirmed email" do + unconfirmed_email_user = Fabricate(:user, active: true, email: "wat@wat.com") + unconfirmed_email_user.email_tokens.create(email: unconfirmed_email_user.email) + group.reload + expect(group.users.include?(unconfirmed_email_user)).to eq(false) + end + + it "doesn't automatically add staged users" do + staged_user = Fabricate(:user, active: true, staged: true, email: "wat@wat.com") + group.reload + expect(group.users.include?(staged_user)).to eq(false) + end + it "is automatically added to a group when the email matches" do - group = Fabricate(:group, automatic_membership_email_domains: "bar.com|wat.com") - user = Fabricate(:user, email: "foo@bar.com") + user = Fabricate(:user, active: true, email: "foo@bar.com") + email_token = user.email_tokens.create(email: user.email).token + EmailToken.confirm(email_token) group.reload expect(group.users.include?(user)).to eq(true) @@ -1197,16 +1250,23 @@ describe User do end it "get attributes from the group" do - group = Fabricate(:group, automatic_membership_email_domains: "bar.com|wat.com", grant_trust_level: 1, title: "bars and wats", primary_group: true) - user = Fabricate.build(:user, trust_level: 0, email: "foo@bar.com", password: "strongpassword4Uguys") + user = Fabricate.build(:user, + active: true, + trust_level: 0, + email: "foo@bar.com", + password: "strongpassword4Uguys" + ) + user.password_required! - expect(user.save).to eq(true) + user.save! + email_token = user.email_tokens.create(email: user.email).token + EmailToken.confirm(email_token) user.reload + expect(user.title).to eq("bars and wats") expect(user.trust_level).to eq(1) expect(user.trust_level_locked).to eq(true) end - end describe "number_of_flags_given" do @@ -1376,10 +1436,18 @@ describe User do end end - describe 'when user is not trust level 0' do + describe 'when user is trust level 1' do it 'should return the right value' do user.update_attributes!(trust_level: TrustLevel[1]) + expect(user.read_first_notification?).to eq(false) + end + end + + describe 'when user is trust level 2' do + it 'should return the right value' do + user.update_attributes!(trust_level: TrustLevel[2]) + expect(user.read_first_notification?).to eq(true) end end @@ -1406,4 +1474,43 @@ describe User do expect(user.featured_user_badges.length).to eq(1) end end + + describe ".clear_global_notice_if_needed" do + + let(:user) { Fabricate(:user) } + let(:admin) { Fabricate(:admin) } + + before do + SiteSetting.has_login_hint = true + SiteSetting.global_notice = "some notice" + end + + it "doesn't clear the login hint when a regular user is saved" do + user.save + expect(SiteSetting.has_login_hint).to eq(true) + expect(SiteSetting.global_notice).to eq("some notice") + end + + it "doesn't clear the notice when a system user is saved" do + Discourse.system_user.save + expect(SiteSetting.has_login_hint).to eq(true) + expect(SiteSetting.global_notice).to eq("some notice") + end + + it "clears the notice when the admin is saved" do + admin.save + expect(SiteSetting.has_login_hint).to eq(false) + expect(SiteSetting.global_notice).to eq("") + end + + end + + describe '.human_users' do + it 'should only return users with a positive primary key' do + Fabricate(:user, id: -2) + user = Fabricate(:user) + + expect(User.human_users).to eq([user]) + end + end end diff --git a/spec/models/user_stat_spec.rb b/spec/models/user_stat_spec.rb index e977d3b08a..f440d78b21 100644 --- a/spec/models/user_stat_spec.rb +++ b/spec/models/user_stat_spec.rb @@ -2,8 +2,6 @@ require 'rails_helper' describe UserStat do - it { is_expected.to belong_to :user } - it "is created automatically when a user is created" do user = Fabricate(:evil_trout) expect(user.user_stat).to be_present diff --git a/spec/models/user_visit_spec.rb b/spec/models/user_visit_spec.rb index ea87011ff3..658d1a43c2 100644 --- a/spec/models/user_visit_spec.rb +++ b/spec/models/user_visit_spec.rb @@ -24,18 +24,18 @@ describe UserVisit do describe '#by_day' do before(:each) do Timecop.freeze - user.user_visits.create(visited_at: Time.now) + user.user_visits.create(visited_at: Time.zone.now) user.user_visits.create(visited_at: 1.day.ago) other_user.user_visits.create(visited_at: 1.day.ago) user.user_visits.create(visited_at: 2.days.ago) user.user_visits.create(visited_at: 4.days.ago) end after(:each) { Timecop.return } - let(:visits_by_day) { {1.day.ago.to_date => 2, 2.days.ago.to_date => 1, Time.now.to_date => 1 } } + let(:visits_by_day) { {1.day.ago.to_date => 2, 2.days.ago.to_date => 1, Time.zone.now.to_date => 1 } } it 'collect closed interval visits' do - expect(UserVisit.by_day(2.days.ago, Time.now)).to include(visits_by_day) - expect(UserVisit.by_day(2.days.ago, Time.now)).not_to include({4.days.ago.to_date => 1}) + expect(UserVisit.by_day(2.days.ago, Time.zone.now)).to include(visits_by_day) + expect(UserVisit.by_day(2.days.ago, Time.zone.now)).not_to include({4.days.ago.to_date => 1}) end end end diff --git a/spec/models/web_hook_event_type_spec.rb b/spec/models/web_hook_event_type_spec.rb deleted file mode 100644 index bd6b8ef41b..0000000000 --- a/spec/models/web_hook_event_type_spec.rb +++ /dev/null @@ -1,5 +0,0 @@ -require 'rails_helper' - -describe WebHookEventType do - it { is_expected.to validate_presence_of :name } -end diff --git a/spec/models/web_hook_spec.rb b/spec/models/web_hook_spec.rb index 6c4c8fdfaa..d65641ea7e 100644 --- a/spec/models/web_hook_spec.rb +++ b/spec/models/web_hook_spec.rb @@ -100,49 +100,110 @@ describe WebHook do end describe 'enqueues hooks' do - let!(:post_hook) { Fabricate(:web_hook) } - let!(:topic_hook) { Fabricate(:topic_web_hook) } let(:user) { Fabricate(:user) } let(:admin) { Fabricate(:admin) } let(:topic) { Fabricate(:topic, user: user) } let(:post) { Fabricate(:post, topic: topic, user: user) } - let(:post2) { Fabricate(:post, topic: topic, user: user) } + + before do + SiteSetting.queue_jobs = true + end it 'should enqueue the right hooks for topic events' do - WebHook.expects(:enqueue_topic_hooks).once - PostCreator.create(user, { raw: 'post', title: 'topic', skip_validations: true }) + Fabricate(:topic_web_hook) - WebHook.expects(:enqueue_topic_hooks).once - PostDestroyer.new(user, post).destroy + Sidekiq::Testing.fake! do + post = PostCreator.create(user, { raw: 'post', title: 'topic', skip_validations: true }) + topic_id = post.topic_id + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first - WebHook.expects(:enqueue_topic_hooks).once - PostDestroyer.new(user, post).recover + expect(job_args["event_name"]).to eq("topic_created") + expect(job_args["topic_id"]).to eq(topic_id) + + PostDestroyer.new(user, post).destroy + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("topic_destroyed") + expect(job_args["topic_id"]).to eq(topic_id) + + PostDestroyer.new(user, post).recover + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("topic_recovered") + expect(job_args["topic_id"]).to eq(topic_id) + end end it 'should enqueue the right hooks for post events' do - WebHook.expects(:enqueue_post_hooks).once - PostCreator.create(user, { raw: 'post', topic_id: topic.id, reply_to_post_number: 1, skip_validations: true }) + Fabricate(:web_hook) - # post destroy or recover triggers a moderator post - WebHook.expects(:enqueue_post_hooks).twice - PostDestroyer.new(user, post2).destroy + Sidekiq::Testing.fake! do + user + topic - WebHook.expects(:enqueue_post_hooks).twice - PostDestroyer.new(user, post2).recover + post = PostCreator.create(user, + raw: 'post', + topic_id: topic.id, + reply_to_post_number: 1, + skip_validations: true + ) + + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + Sidekiq::Worker.clear_all + + expect(job_args["event_name"]).to eq("post_created") + expect(job_args["post_id"]).to eq(post.id) + + # post destroy or recover triggers a moderator post + expect { PostDestroyer.new(user, post).destroy } + .to change { Jobs::EmitWebHookEvent.jobs.count }.by(2) + + job_args = Jobs::EmitWebHookEvent.jobs.first["args"].first + + expect(job_args["event_name"]).to eq("post_edited") + expect(job_args["post_id"]).to eq(post.id) + + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("post_destroyed") + expect(job_args["post_id"]).to eq(post.id) + + PostDestroyer.new(user, post).recover + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("post_recovered") + expect(job_args["post_id"]).to eq(post.id) + end end it 'should enqueue the right hooks for user events' do - WebHook.expects(:enqueue_hooks).once - user + user_web_hook = Fabricate(:user_web_hook, active: true) - WebHook.expects(:enqueue_hooks).once - admin + Sidekiq::Testing.fake! do + user + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first - WebHook.expects(:enqueue_hooks).once - user.approve(admin) + expect(job_args["event_name"]).to eq("user_created") + expect(job_args["user_id"]).to eq(user.id) - WebHook.expects(:enqueue_hooks).once - UserUpdater.new(admin, user).update(username: 'testing123') + admin + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("user_created") + expect(job_args["user_id"]).to eq(admin.id) + + user.approve(admin) + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("user_approved") + expect(job_args["user_id"]).to eq(user.id) + + UserUpdater.new(admin, user).update(username: 'testing123') + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("user_updated") + expect(job_args["user_id"]).to eq(user.id) + end end end end diff --git a/spec/phantom_js/smoke_test.js b/spec/phantom_js/smoke_test.js index 6cac5534f5..04d0984399 100644 --- a/spec/phantom_js/smoke_test.js +++ b/spec/phantom_js/smoke_test.js @@ -9,9 +9,14 @@ if (system.args.length !== 2) { phantom.exit(1); } -var TIMEOUT = 15000; +var TIMEOUT = 25000; var page = require("webpage").create(); +if (system.env["AUTH_USER"] && system.env["AUTH_PASSWORD"]) { + page.settings.userName = system.env["AUTH_USER"]; + page.settings.password = system.env["AUTH_PASSWORD"]; +} + page.viewportSize = { width: 1366, height: 768 @@ -46,6 +51,7 @@ page.waitFor = function(desc, fn, cb) { if (diff > TIMEOUT) { console.log("FAILED: " + desc + " - " + diff + "ms"); page.render('/tmp/failed.png'); + console.log('Content:' + page.content); cb(false); } else { setTimeout(check, 25); @@ -104,7 +110,7 @@ function run() { }); } else if (action.exec) { console.log("EXEC: " + action.desc); - page.evaluate(action.exec); + page.evaluate(action.exec, system); performNextAction(); } else if (action.execAsync) { console.log("EXEC ASYNC: " + action.desc + " - " + action.delay + "ms"); @@ -134,14 +140,26 @@ function run() { var runTests = function() { - test("expect a log in button", function() { - return $(".login-button").text().trim() === "Log In"; + test("expect a log in button in the header", function() { + return $("header .login-button").length; + }); + + execAsync("go to latest page", 500, function(){ + window.location = "/latest"; }); test("at least one topic shows up", function() { return $(".topic-list tbody tr").length; }); + execAsync("go to categories page", 500, function(){ + window.location = "/categories"; + }); + + test("can see categories on the page", function() { + return $('.category-list').length; + }); + execAsync("navigate to 1st topic", 500, function() { $(".main-link a.title:first").click(); }); @@ -168,9 +186,9 @@ var runTests = function() { return $(".login-modal").length; }); - exec("type in credentials & log in", function() { - $("#login-account-name").val("smoke_user").trigger("change"); - $("#login-account-password").val("P4ssw0rd").trigger("change"); + exec("type in credentials & log in", function(system) { + $("#login-account-name").val(system.env['DISCOURSE_USERNAME'] || 'smoke_user').trigger("change"); + $("#login-account-password").val(system.env["DISCOURSE_PASSWORD"] || 'P4ssw0rd').trigger("change"); $(".login-modal .btn-primary").click(); }); @@ -179,7 +197,8 @@ var runTests = function() { }); exec("go home", function() { - $('#site-logo').click(); + if ($('#site-logo').length) $('#site-logo').click(); + if ($('#site-text-logo').length) $('#site-text-logo').click(); }); test("it shows a topic list", function() { diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index c57a421704..6354862d9e 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -9,28 +9,33 @@ require 'rbtrace' #uncomment the following line to use spork with the debugger #require 'spork/ext/ruby-debug' -require 'fakeweb' -FakeWeb.allow_net_connect = false - Spork.prefork do # Loading more in this block will cause your tests to run faster. However, # if you change any configuration or code from libraries loaded here, you'll # need to restart spork for it take effect. require 'fabrication' require 'mocha/api' - require 'fakeweb' require 'certified' + require 'webmock/rspec' ENV["RAILS_ENV"] ||= 'test' require File.expand_path("../../config/environment", __FILE__) require 'rspec/rails' require 'shoulda' + require 'sidekiq/testing' # Requires supporting ruby files with custom matchers and macros, etc, # in spec/support/ and its subdirectories. Dir[Rails.root.join("spec/support/**/*.rb")].each {|f| require f} Dir[Rails.root.join("spec/fabricators/*.rb")].each {|f| require f} + # Require plugin helpers at plugin/[plugin]/spec/plugin_helper.rb (includes symlinked plugins). + if ENV['LOAD_PLUGINS'] == "1" + Dir[Rails.root.join("plugins/*/spec/plugin_helper.rb")].each do |f| + require f + end + end + # let's not run seed_fu every test SeedFu.quiet = true if SeedFu.respond_to? :quiet @@ -81,6 +86,8 @@ Spork.prefork do require_dependency 'site_settings/local_process_provider' SiteSetting.provider = SiteSettings::LocalProcessProvider.new + + WebMock.disable_net_connect! end class DiscourseMockRedis < MockRedis @@ -139,6 +146,10 @@ Spork.prefork do datetime = DateTime.parse(now.to_s) time = Time.parse(now.to_s) + if block_given? + raise "Don't use a block with freeze_time" + end + DateTime.stubs(:now).returns(datetime) Time.stubs(:now).returns(time) end diff --git a/spec/serializers/basic_group_serializer.rb b/spec/serializers/basic_group_serializer.rb new file mode 100644 index 0000000000..712f06fa7f --- /dev/null +++ b/spec/serializers/basic_group_serializer.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +describe BasicGroupSerializer do + subject { described_class.new(group, scope: Guardian.new, root: false) } + + describe '#display_name' do + describe 'automatic group' do + let(:group) { Group.find(1) } + + it 'should include the display name' do + expect(subject.display_name).to eq(I18n.t('groups.default_names.admins')) + end + end + + describe 'normal group' do + let(:group) { Fabricate(:group) } + + it 'should not include the display name' do + expect(subject.display_name).to eq(nil) + end + end + end +end diff --git a/spec/serializers/web_hook_post_serializer_spec.rb b/spec/serializers/web_hook_post_serializer_spec.rb new file mode 100644 index 0000000000..2f1f487060 --- /dev/null +++ b/spec/serializers/web_hook_post_serializer_spec.rb @@ -0,0 +1,24 @@ +require 'rails_helper' + +RSpec.describe WebHookPostSerializer do + let(:admin) { Fabricate(:admin) } + let(:post) { Fabricate(:post) } + let(:serializer) { WebHookPostSerializer.new(post, scope: Guardian.new(admin), root: false) } + + it 'should only include the required keys' do + count = serializer.as_json.keys.count + difference = count - 40 + + expect(difference).to eq(0), lambda { + message = "" + + if difference < 0 + message << "#{difference * -1} key(s) have been removed from this serializer." + else + message << "#{difference} key(s) have been added to this serializer." + end + + message << "\nPlease verify if those key(s) are required as part of the web hook's payload." + } + end +end diff --git a/spec/serializers/web_hook_user_serializer_spec.rb b/spec/serializers/web_hook_user_serializer_spec.rb new file mode 100644 index 0000000000..ad854ca13e --- /dev/null +++ b/spec/serializers/web_hook_user_serializer_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe WebHookUserSerializer do + let(:user) { Fabricate(:user) } + let(:admin) { Fabricate(:admin) } + + subject { described_class.new(user, scope: Guardian.new(admin), root: false) } + + it "should include the user's email" do + payload = subject.as_json + + expect(payload[:email]).to eq(user.email) + end +end diff --git a/spec/services/color_scheme_revisor_spec.rb b/spec/services/color_scheme_revisor_spec.rb index d9f8a3519a..c2dc09d2c7 100644 --- a/spec/services/color_scheme_revisor_spec.rb +++ b/spec/services/color_scheme_revisor_spec.rb @@ -3,62 +3,42 @@ require 'rails_helper' describe ColorSchemeRevisor do let(:color) { Fabricate.build(:color_scheme_color, hex: 'FFFFFF', color_scheme: nil) } - let(:color_scheme) { Fabricate(:color_scheme, enabled: false, created_at: 1.day.ago, updated_at: 1.day.ago, color_scheme_colors: [color]) } - let(:valid_params) { { name: color_scheme.name, enabled: color_scheme.enabled, colors: nil } } + let(:color_scheme) { Fabricate(:color_scheme, created_at: 1.day.ago, updated_at: 1.day.ago, color_scheme_colors: [color]) } + let(:valid_params) { { name: color_scheme.name, colors: nil } } describe "revise" do it "does nothing if there are no changes" do expect { - described_class.revise(color_scheme, valid_params.merge(colors: nil)) + ColorSchemeRevisor.revise(color_scheme, valid_params.merge(colors: nil)) }.to_not change { color_scheme.reload.updated_at } end it "can change the name" do - described_class.revise(color_scheme, valid_params.merge(name: "Changed Name")) + ColorSchemeRevisor.revise(color_scheme, valid_params.merge(name: "Changed Name")) expect(color_scheme.reload.name).to eq("Changed Name") end - it "can update the theme_id" do - described_class.revise(color_scheme, valid_params.merge(theme_id: 'test')) - expect(color_scheme.reload.theme_id).to eq('test') + it "can update the base_scheme_id" do + ColorSchemeRevisor.revise(color_scheme, valid_params.merge(base_scheme_id: 'test')) + expect(color_scheme.reload.base_scheme_id).to eq('test') end - it "can enable and disable" do - described_class.revise(color_scheme, valid_params.merge(enabled: true)) - expect(color_scheme.reload).to be_enabled - described_class.revise(color_scheme, valid_params.merge(enabled: false)) - expect(color_scheme.reload).not_to be_enabled - end - - def test_color_change(color_scheme_arg, expected_enabled) - described_class.revise(color_scheme_arg, valid_params.merge(colors: [ - {name: color.name, hex: 'BEEF99'} + it 'can change colors' do + ColorSchemeRevisor.revise(color_scheme, valid_params.merge(colors: [ + {name: color.name, hex: 'BEEF99'}, + {name: 'bob', hex: 'AAAAAA'} ])) - color_scheme_arg.reload - expect(color_scheme_arg.enabled).to eq(expected_enabled) - expect(color_scheme_arg.colors.size).to eq(1) - expect(color_scheme_arg.colors.first.hex).to eq('BEEF99') - end + color_scheme.reload - it "can change colors of a color scheme that's not enabled" do - test_color_change(color_scheme, false) - end - - it "can change colors of the enabled color scheme" do - color_scheme.update_attribute(:enabled, true) - test_color_change(color_scheme, true) - end - - it "disables other color scheme before enabling" do - prev_enabled = Fabricate(:color_scheme, enabled: true) - described_class.revise(color_scheme, valid_params.merge(enabled: true)) - expect(prev_enabled.reload.enabled).to eq(false) - expect(color_scheme.reload.enabled).to eq(true) + expect(color_scheme.version).to eq(2) + expect(color_scheme.colors.size).to eq(2) + expect(color_scheme.colors.find_by(name: color.name).hex).to eq('BEEF99') + expect(color_scheme.colors.find_by(name: 'bob').hex).to eq('AAAAAA') end it "doesn't make changes when a color is invalid" do expect { - cs = described_class.revise(color_scheme, valid_params.merge(colors: [ + cs = ColorSchemeRevisor.revise(color_scheme, valid_params.merge(colors: [ {name: color.name, hex: 'OOPS'} ])) expect(cs).not_to be_valid @@ -66,72 +46,6 @@ describe ColorSchemeRevisor do }.to_not change { color_scheme.reload.version } expect(color_scheme.colors.first.hex).to eq(color.hex) end - - describe "versions" do - it "doesn't create a new version if colors is not given" do - expect { - described_class.revise(color_scheme, valid_params.merge(name: "Changed Name")) - }.to_not change { color_scheme.reload.version } - end - - it "creates a new version if colors have changed" do - old_hex = color.hex - expect { - described_class.revise(color_scheme, valid_params.merge(colors: [ - {name: color.name, hex: 'BEEF99'} - ])) - }.to change { color_scheme.reload.version }.by(1) - old_version = ColorScheme.find_by(versioned_id: color_scheme.id, version: (color_scheme.version - 1)) - expect(old_version).not_to eq(nil) - expect(old_version.colors.count).to eq(color_scheme.colors.count) - expect(old_version.colors_by_name[color.name].hex).to eq(old_hex) - expect(color_scheme.colors_by_name[color.name].hex).to eq('BEEF99') - end - - it "doesn't create a new version if colors have not changed" do - expect { - described_class.revise(color_scheme, valid_params.merge(colors: [ - {name: color.name, hex: color.hex} - ])) - }.to_not change { color_scheme.reload.version } - end - end - end - - describe "revert" do - context "when there are no previous versions" do - it "does nothing" do - expect { - expect(described_class.revert(color_scheme)).to eq(color_scheme) - }.to_not change { color_scheme.reload.version } - end - end - - context 'when there are previous versions' do - let(:new_color_params) { {name: color.name, hex: 'BEEF99'} } - - before do - @prev_hex = color.hex - described_class.revise(color_scheme, valid_params.merge(colors: [ new_color_params ])) - end - - it "reverts the colors to the previous version" do - expect(color_scheme.colors_by_name[new_color_params[:name]].hex).to eq(new_color_params[:hex]) - expect { - described_class.revert(color_scheme) - }.to change { color_scheme.reload.version }.by(-1) - expect(color_scheme.colors.size).to eq(1) - expect(color_scheme.colors.first.hex).to eq(@prev_hex) - expect(color_scheme.colors_by_name[new_color_params[:name]].hex).to eq(@prev_hex) - end - - it "destroys the old version's record" do - expect { - described_class.revert(color_scheme) - }.to change { ColorScheme.count }.by(-1) - expect(color_scheme.reload.previous_version).to eq(nil) - end - end end end diff --git a/spec/services/group_mentions_updater_spec.rb b/spec/services/group_mentions_updater_spec.rb new file mode 100644 index 0000000000..301fa134d5 --- /dev/null +++ b/spec/services/group_mentions_updater_spec.rb @@ -0,0 +1,52 @@ +require 'rails_helper' + +RSpec.describe GroupMentionsUpdater do + let(:post) { Fabricate(:post) } + + describe '.update' do + it 'should update valid group mentions' do + new_group_name = 'awesome_team' + old_group_name = 'team' + + [ + ["@#{old_group_name} is awesome!", "@#{new_group_name} is awesome!"], + ["This @#{old_group_name} is awesome!", "This @#{new_group_name} is awesome!"], + ["Mention us @ @#{old_group_name}", "Mention us @ @#{new_group_name}"], + ].each do |raw, expected_raw| + group = Fabricate(:group, name: old_group_name) + post.update!(raw: raw) + group.update!(name: new_group_name) + post.reload + + expect(post.raw_mentions).to eq([new_group_name]) + expect(post.raw).to eq(expected_raw) + + group.destroy! + end + end + + it 'should not update invalid group mentions' do + group = Fabricate(:group, name: 'team') + post.update!(raw: 'This is not valid@team.com') + + expect(post.reload.raw_mentions).to eq([]) + + group.update!(name: 'new_team_name') + + expect(post.reload.raw_mentions).to eq([]) + end + + it "should ignore validations" do + Fabricate(:group, name: "awesome_team") + Fabricate(:group, name: "pro_team") + post.update!(raw: "@awesome_team is cool and so is @pro_team") + + SiteSetting.max_mentions_per_post = 1 + GroupMentionsUpdater.update('cool_team', 'awesome_team') + + post.reload + expect(post.raw_mentions).to match_array(['cool_team', 'pro_team']) + end + + end +end diff --git a/spec/services/post_alerter_spec.rb b/spec/services/post_alerter_spec.rb index 0b724d09a1..c53e47977e 100644 --- a/spec/services/post_alerter_spec.rb +++ b/spec/services/post_alerter_spec.rb @@ -343,7 +343,6 @@ describe PostAlerter do push_url: "https://site2.com/push") end - # should only happen once even though we are using 2 keys RestClient.expects(:post).never mention_post end diff --git a/spec/services/post_owner_changer_spec.rb b/spec/services/post_owner_changer_spec.rb index bdbb36541a..10fb1cebec 100644 --- a/spec/services/post_owner_changer_spec.rb +++ b/spec/services/post_owner_changer_spec.rb @@ -63,11 +63,21 @@ describe PostOwnerChanger do let(:p2user) { p2.user } before do - topic.user_id = p1user.id - topic.save! + topic.update!(user_id: p1user.id) - p1user.user_stat.update_attributes(topic_count: 1, post_count: 1, first_post_created_at: p1.created_at, topic_reply_count: 0) - p2user.user_stat.update_attributes(topic_count: 0, post_count: 1, first_post_created_at: p2.created_at, topic_reply_count: 1) + p1user.user_stat.update!( + topic_count: 1, + post_count: 1, + first_post_created_at: p1.created_at, + topic_reply_count: 0 + ) + + p2user.user_stat.update!( + topic_count: 0, + post_count: 1, + first_post_created_at: p2.created_at, + topic_reply_count: 1 + ) UserAction.create!( action_type: UserAction::NEW_TOPIC, user_id: p1user.id, acting_user_id: p1user.id, target_post_id: -1, target_topic_id: p1.topic_id, created_at: p1.created_at ) @@ -78,9 +88,19 @@ describe PostOwnerChanger do UserActionCreator.enable end - subject(:change_owners) { described_class.new(post_ids: [p1.id, p2.id], topic_id: topic.id, new_owner: user_a, acting_user: editor).change_owner! } + subject(:change_owners) do + described_class.new( + post_ids: [p1.id, p2.id], + topic_id: topic.id, + new_owner: user_a, + acting_user: editor + ).change_owner! + end it "updates users' topic and post counts" do + PostAction.act(p2user, p1, PostActionType.types[:like]) + expect(p1user.user_stat.reload.likes_received).to eq(1) + change_owners p1user.reload; p2user.reload; user_a.reload @@ -90,11 +110,38 @@ describe PostOwnerChanger do expect(p2user.post_count).to eq(0) expect(user_a.topic_count).to eq(1) expect(user_a.post_count).to eq(2) - expect(p1user.user_stat.first_post_created_at).to eq(nil) - expect(p2user.user_stat.first_post_created_at).to eq(nil) - expect(p1user.user_stat.topic_reply_count).to eq(0) - expect(p2user.user_stat.topic_reply_count).to eq(0) - expect(user_a.user_stat.first_post_created_at).to be_present + + p1_user_stat = p1user.user_stat + + expect(p1_user_stat.first_post_created_at).to eq(nil) + expect(p1_user_stat.topic_reply_count).to eq(0) + expect(p1_user_stat.likes_received).to eq(0) + + p2_user_stat = p2user.user_stat + + expect(p2_user_stat.first_post_created_at).to eq(nil) + expect(p2_user_stat.topic_reply_count).to eq(0) + + user_a_stat = user_a.user_stat + + expect(user_a_stat.first_post_created_at).to be_present + expect(user_a_stat.likes_received).to eq(1) + end + + context 'private message topic' do + let(:topic) { Fabricate(:private_message_topic) } + + it "should update users' counts" do + PostAction.act(p2user, p1, PostActionType.types[:like]) + + change_owners + + expect(p1user.user_stat.likes_received).to eq(0) + + user_a_stat = user_a.user_stat + expect(user_a_stat.first_post_created_at).to be_present + expect(user_a_stat.likes_received).to eq(0) + end end it "updates UserAction records" do diff --git a/spec/services/post_timestamp_changer_spec.rb b/spec/services/post_timestamp_changer_spec.rb deleted file mode 100644 index bc0b246f16..0000000000 --- a/spec/services/post_timestamp_changer_spec.rb +++ /dev/null @@ -1,57 +0,0 @@ -require 'rails_helper' - -describe PostTimestampChanger do - describe "change!" do - let(:old_timestamp) { Time.zone.now } - let(:new_timestamp) { old_timestamp + 1.day } - let!(:topic) { Fabricate(:topic, created_at: old_timestamp) } - let!(:p1) { Fabricate(:post, topic: topic, created_at: old_timestamp) } - let!(:p2) { Fabricate(:post, topic: topic, created_at: old_timestamp + 1.day) } - let(:params) { { topic_id: topic.id, timestamp: new_timestamp.to_f } } - - it 'changes the timestamp of the topic and opening post' do - PostTimestampChanger.new(params).change! - - topic.reload - [:created_at, :updated_at, :bumped_at].each do |column| - expect(topic.public_send(column)).to be_within_one_second_of(new_timestamp) - end - - p1.reload - [:created_at, :updated_at].each do |column| - expect(p1.public_send(column)).to be_within_one_second_of(new_timestamp) - end - - expect(topic.last_posted_at).to be_within_one_second_of(p2.reload.created_at) - end - - describe 'predated timestamp' do - it 'updates the timestamp of posts in the topic with the time difference applied' do - PostTimestampChanger.new(params).change! - - p2.reload - [:created_at, :updated_at].each do |column| - expect(p2.public_send(column)).to be_within_one_second_of(old_timestamp + 2.day) - end - end - end - - describe 'backdated timestamp' do - let(:new_timestamp) { old_timestamp - 1.day } - - it 'updates the timestamp of posts in the topic with the time difference applied' do - PostTimestampChanger.new(params).change! - - p2.reload - [:created_at, :updated_at].each do |column| - expect(p2.public_send(column)).to be_within_one_second_of(old_timestamp) - end - end - end - - it 'deletes the stats cache' do - $redis.expects(:del).twice - PostTimestampChanger.new(params).change! - end - end -end diff --git a/spec/services/staff_action_logger_spec.rb b/spec/services/staff_action_logger_spec.rb index b614faa874..b6cdc0a85b 100644 --- a/spec/services/staff_action_logger_spec.rb +++ b/spec/services/staff_action_logger_spec.rb @@ -120,9 +120,8 @@ describe StaffActionLogger do describe "log_site_setting_change" do it "raises an error when params are invalid" do - SiteSetting.stubs(:respond_to?).with('abc').returns(false) expect { logger.log_site_setting_change(nil, '1', '2') }.to raise_error(Discourse::InvalidParameters) - expect { logger.log_site_setting_change('abc', '1', '2') }.to raise_error(Discourse::InvalidParameters) + expect { logger.log_site_setting_change('i_am_a_site_setting_that_will_never_exist', '1', '2') }.to raise_error(Discourse::InvalidParameters) end it "creates a new UserHistory record" do @@ -130,46 +129,56 @@ describe StaffActionLogger do end end - describe "log_site_customization_change" do - let(:valid_params) { {name: 'Cool Theme', stylesheet: "body {\n background-color: blue;\n}\n", header: "h1 {color: white;}"} } + describe "log_theme_change" do it "raises an error when params are invalid" do - expect { logger.log_site_customization_change(nil, nil) }.to raise_error(Discourse::InvalidParameters) + expect { logger.log_theme_change(nil, nil) }.to raise_error(Discourse::InvalidParameters) + end + + let :theme do + Theme.new(name: 'bob', user_id: -1) end it "logs new site customizations" do - log_record = logger.log_site_customization_change(nil, valid_params) - expect(log_record.subject).to eq(valid_params[:name]) + + log_record = logger.log_theme_change(nil, theme) + expect(log_record.subject).to eq(theme.name) expect(log_record.previous_value).to eq(nil) expect(log_record.new_value).to be_present + json = ::JSON.parse(log_record.new_value) - expect(json['stylesheet']).to be_present - expect(json['header']).to be_present + expect(json['name']).to eq(theme.name) end it "logs updated site customizations" do - existing = SiteCustomization.new(name: 'Banana', stylesheet: "body {color: yellow;}", header: "h1 {color: brown;}") - log_record = logger.log_site_customization_change(existing, valid_params) + old_json = ThemeSerializer.new(theme, root:false).to_json + + theme.set_field(target: :common, name: :scss, value: "body{margin: 10px;}") + + log_record = logger.log_theme_change(old_json, theme) + expect(log_record.previous_value).to be_present - json = ::JSON.parse(log_record.previous_value) - expect(json['stylesheet']).to eq(existing.stylesheet) - expect(json['header']).to eq(existing.header) + + json = ::JSON.parse(log_record.new_value) + expect(json['theme_fields']).to eq([{"name" => "scss", "target" => "common", "value" => "body{margin: 10px;}", "type_id" => 1}]) end end - describe "log_site_customization_destroy" do + describe "log_theme_destroy" do it "raises an error when params are invalid" do - expect { logger.log_site_customization_destroy(nil) }.to raise_error(Discourse::InvalidParameters) + expect { logger.log_theme_destroy(nil) }.to raise_error(Discourse::InvalidParameters) end it "creates a new UserHistory record" do - site_customization = SiteCustomization.new(name: 'Banana', stylesheet: "body {color: yellow;}", header: "h1 {color: brown;}") - log_record = logger.log_site_customization_destroy(site_customization) + theme = Theme.new(name: 'Banana') + theme.set_field(target: :common, name: :scss, value: "body{margin: 10px;}") + + log_record = logger.log_theme_destroy(theme) expect(log_record.previous_value).to be_present expect(log_record.new_value).to eq(nil) json = ::JSON.parse(log_record.previous_value) - expect(json['stylesheet']).to eq(site_customization.stylesheet) - expect(json['header']).to eq(site_customization.header) + + expect(json['theme_fields']).to eq([{"name" => "scss", "target" => "common", "value" => "body{margin: 10px;}", "type_id" => 1}]) end end diff --git a/spec/services/topic_status_updater_spec.rb b/spec/services/topic_status_updater_spec.rb new file mode 100644 index 0000000000..0d1dafa897 --- /dev/null +++ b/spec/services/topic_status_updater_spec.rb @@ -0,0 +1,115 @@ +# encoding: UTF-8 + +require 'rails_helper' +require_dependency 'post_destroyer' + +# TODO - test pinning, create_moderator_post + +describe TopicStatusUpdater do + + let(:user) { Fabricate(:user) } + let(:admin) { Fabricate(:admin) } + + it "avoids notifying on automatically closed topics" do + # TODO: TopicStatusUpdater should suppress message bus updates from the users it "pretends to read" + post = PostCreator.create(user, + raw: "this is a test post 123 this is a test post", + title: "hello world title", + ) + # TODO needed so counts sync up, PostCreator really should not give back out-of-date Topic + post.topic.set_or_create_timer(TopicTimer.types[:close], '10') + post.topic.reload + + TopicStatusUpdater.new(post.topic, admin).update!("autoclosed", true) + + expect(post.topic.posts.count).to eq(2) + + tu = TopicUser.find_by(user_id: user.id) + expect(tu.last_read_post_number).to eq(2) + end + + it "adds an autoclosed message" do + topic = create_topic + topic.set_or_create_timer(TopicTimer.types[:close], '10') + + TopicStatusUpdater.new(topic, admin).update!("autoclosed", true) + + last_post = topic.posts.last + expect(last_post.post_type).to eq(Post.types[:small_action]) + expect(last_post.action_code).to eq('autoclosed.enabled') + expect(last_post.raw).to eq(I18n.t("topic_statuses.autoclosed_enabled_minutes", count: 0)) + end + + it "adds an autoclosed message based on last post" do + topic = create_topic + Fabricate(:post, topic: topic) + + topic.set_or_create_timer( + TopicTimer.types[:close], '10', based_on_last_post: true + ) + + TopicStatusUpdater.new(topic, admin).update!("autoclosed", true) + + last_post = topic.posts.last + expect(last_post.post_type).to eq(Post.types[:small_action]) + expect(last_post.action_code).to eq('autoclosed.enabled') + expect(last_post.raw).to eq(I18n.t("topic_statuses.autoclosed_enabled_lastpost_hours", count: 10)) + end + + describe "repeat actions" do + + shared_examples "an action that doesn't repeat" do + it "does not perform the update twice" do + topic = Fabricate(:topic, status_name => false) + updated = TopicStatusUpdater.new(topic, admin).update!(status_name, true) + expect(updated).to eq(true) + expect(topic.send("#{status_name}?")).to eq(true) + + updated = TopicStatusUpdater.new(topic, admin).update!(status_name, true) + expect(updated).to eq(false) + expect(topic.posts.where(post_type: Post.types[:small_action]).count).to eq(1) + + updated = TopicStatusUpdater.new(topic, admin).update!(status_name, false) + expect(updated).to eq(true) + expect(topic.send("#{status_name}?")).to eq(false) + + updated = TopicStatusUpdater.new(topic, admin).update!(status_name, false) + expect(updated).to eq(false) + expect(topic.posts.where(post_type: Post.types[:small_action]).count).to eq(2) + end + + end + + it_behaves_like "an action that doesn't repeat" do + let(:status_name) { "closed" } + end + + it_behaves_like "an action that doesn't repeat" do + let(:status_name) { "visible" } + end + + it_behaves_like "an action that doesn't repeat" do + let(:status_name) { "archived" } + end + + it "updates autoclosed" do + topic = Fabricate(:topic) + updated = TopicStatusUpdater.new(topic, admin).update!('autoclosed', true) + expect(updated).to eq(true) + expect(topic.closed?).to eq(true) + + updated = TopicStatusUpdater.new(topic, admin).update!('autoclosed', true) + expect(updated).to eq(false) + expect(topic.posts.where(post_type: Post.types[:small_action]).count).to eq(1) + + updated = TopicStatusUpdater.new(topic, admin).update!('autoclosed', false) + expect(updated).to eq(true) + expect(topic.closed?).to eq(false) + + updated = TopicStatusUpdater.new(topic, admin).update!('autoclosed', false) + expect(updated).to eq(false) + expect(topic.posts.where(post_type: Post.types[:small_action]).count).to eq(2) + end + + end +end diff --git a/spec/services/topic_timestamp_changer_spec.rb b/spec/services/topic_timestamp_changer_spec.rb new file mode 100644 index 0000000000..1e9821adeb --- /dev/null +++ b/spec/services/topic_timestamp_changer_spec.rb @@ -0,0 +1,75 @@ +require 'rails_helper' + +describe TopicTimestampChanger do + describe "change!" do + let(:old_timestamp) { Time.zone.now } + let(:new_timestamp) { old_timestamp + 1.day } + let(:topic) { Fabricate(:topic, created_at: old_timestamp) } + let!(:p1) { Fabricate(:post, topic: topic, created_at: old_timestamp) } + let!(:p2) { Fabricate(:post, topic: topic, created_at: old_timestamp + 1.day) } + + context 'new timestamp is in the future' do + let(:new_timestamp) { old_timestamp + 2.day } + + it 'should raise the right error' do + expect { TopicTimestampChanger.new(topic: topic, timestamp: new_timestamp.to_f).change! } + .to raise_error(TopicTimestampChanger::InvalidTimestampError) + end + end + + context 'new timestamp is in the past' do + let(:new_timestamp) { old_timestamp - 2.day } + + it 'changes the timestamp of the topic and opening post' do + Timecop.freeze do + TopicTimestampChanger.new(topic: topic, timestamp: new_timestamp.to_f).change! + + topic.reload + [:created_at, :updated_at, :bumped_at].each do |column| + expect(topic.public_send(column)).to be_within(1.second).of(new_timestamp) + end + + p1.reload + [:created_at, :updated_at].each do |column| + expect(p1.public_send(column)).to be_within(1.second).of(new_timestamp) + end + + p2.reload + [:created_at, :updated_at].each do |column| + expect(p2.public_send(column)).to be_within(1.second).of(new_timestamp + 1.day) + end + + expect(topic.last_posted_at).to be_within(1.second).of(p2.reload.created_at) + end + end + + describe 'when posts have timestamps in the future' do + let(:new_timestamp) { Time.zone.now } + let(:p3) { Fabricate(:post, topic: topic, created_at: new_timestamp + 3.day) } + + it 'should set the new timestamp as the default timestamp' do + Timecop.freeze do + p3 + TopicTimestampChanger.new(topic: topic, timestamp: new_timestamp.to_f).change! + + p3.reload + + [:created_at, :updated_at].each do |column| + expect(p3.public_send(column)).to be_within(1.second).of(new_timestamp) + end + end + end + end + end + + it 'deletes the stats cache' do + $redis.set AdminDashboardData.stats_cache_key, "X" + $redis.set About.stats_cache_key, "X" + + TopicTimestampChanger.new(topic: topic, timestamp: Time.zone.now.to_f).change! + + expect($redis.get(AdminDashboardData.stats_cache_key)).to eq(nil) + expect($redis.get(About.stats_cache_key)).to eq(nil) + end + end +end diff --git a/spec/services/trust_level_granter_spec.rb b/spec/services/trust_level_granter_spec.rb new file mode 100644 index 0000000000..b85222c7be --- /dev/null +++ b/spec/services/trust_level_granter_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +describe TrustLevelGranter do + + describe 'grant' do + + it 'grants trust level' do + user = Fabricate(:user, email: "foo@bar.com", trust_level: 0) + TrustLevelGranter.grant(3, user) + + user.reload + expect(user.trust_level).to eq(3) + end + end +end diff --git a/spec/services/user_anonymizer_spec.rb b/spec/services/user_anonymizer_spec.rb index 6e7a0227a5..5d463e2fcc 100644 --- a/spec/services/user_anonymizer_spec.rb +++ b/spec/services/user_anonymizer_spec.rb @@ -4,7 +4,7 @@ describe UserAnonymizer do describe "make_anonymous" do let(:admin) { Fabricate(:admin) } - let(:user) { Fabricate(:user, username: "edward", auth_token: "mysecretauthtoken") } + let(:user) { Fabricate(:user, username: "edward") } subject(:make_anonymous) { described_class.make_anonymous(user, admin) } @@ -51,6 +51,8 @@ describe UserAnonymizer do prev_username = user.username + UserAuthToken.generate!(user_id: user.id) + make_anonymous user.reload @@ -58,7 +60,7 @@ describe UserAnonymizer do expect(user.name).not_to be_present expect(user.date_of_birth).to eq(nil) expect(user.title).not_to be_present - expect(user.auth_token).to eq(nil) + expect(user.user_auth_tokens.count).to eq(0) profile = user.user_profile(true) expect(profile.location).to eq(nil) diff --git a/spec/services/user_blocker_spec.rb b/spec/services/user_blocker_spec.rb index 6761c44b3d..34c0b99fbd 100644 --- a/spec/services/user_blocker_spec.rb +++ b/spec/services/user_blocker_spec.rb @@ -118,6 +118,16 @@ describe UserBlocker do expect(post.reload).to_not be_hidden expect(post.topic.reload).to be_visible end + + it "only hides posts from the past 24 hours" do + old_post = Fabricate(:post, user: user, created_at: 2.days.ago) + subject.block + expect(post.reload).to be_hidden + expect(post.topic.reload).to_not be_visible + old_post.reload + expect(old_post).to_not be_hidden + expect(old_post.topic).to be_visible + end end end diff --git a/spec/services/user_destroyer_spec.rb b/spec/services/user_destroyer_spec.rb index a31a8ffaaa..55b94ff3fb 100644 --- a/spec/services/user_destroyer_spec.rb +++ b/spec/services/user_destroyer_spec.rb @@ -82,7 +82,25 @@ describe UserDestroyer do UserDestroyer.new(admin).destroy(user) expect(QueuedPost.where(user_id: user.id).count).to eq(0) end + end + context "with a directory item record" do + let(:user) { Fabricate(:user) } + let(:admin) { Fabricate(:admin) } + + it "removes the directory item" do + DirectoryItem.create!( + user: user, + period_type: 1, + likes_received: 0, + likes_given: 0, + topics_entered: 0, + topic_count: 0, + post_count: 0 + ) + UserDestroyer.new(admin).destroy(user) + expect(DirectoryItem.where(user_id: user.id).count).to eq(0) + end end context "with a draft" do @@ -146,7 +164,7 @@ describe UserDestroyer do it "deletes topics started by the deleted user" do spammer_topic = Fabricate(:topic, user: @user) - spammer_post = Fabricate(:post, user: @user, topic: spammer_topic) + Fabricate(:post, user: @user, topic: spammer_topic) destroy expect(spammer_topic.reload.deleted_at).not_to eq(nil) expect(spammer_topic.user_id).to eq(nil) diff --git a/spec/services/user_updater_spec.rb b/spec/services/user_updater_spec.rb index 0a1b538ab4..0d025c8cc4 100644 --- a/spec/services/user_updater_spec.rb +++ b/spec/services/user_updater_spec.rb @@ -66,6 +66,10 @@ describe UserUpdater do updater = UserUpdater.new(acting_user, user) date_of_birth = Time.zone.now + theme = Theme.create!(user_id: -1, name: "test", user_selectable: true) + + seq = user.user_option.theme_key_seq + val = updater.update(bio_raw: 'my new bio', email_always: 'true', mailing_list_mode: true, @@ -74,7 +78,8 @@ describe UserUpdater do auto_track_topics_after_msecs: 101, notification_level_when_replying: 3, email_in_reply_to: false, - date_of_birth: date_of_birth + date_of_birth: date_of_birth, + theme_key: theme.key ) expect(val).to be_truthy @@ -88,6 +93,8 @@ describe UserUpdater do expect(user.user_option.auto_track_topics_after_msecs).to eq 101 expect(user.user_option.notification_level_when_replying).to eq 3 expect(user.user_option.email_in_reply_to).to eq false + expect(user.user_option.theme_key).to eq theme.key + expect(user.user_option.theme_key_seq).to eq(seq+1) expect(user.date_of_birth).to eq(date_of_birth.to_date) end @@ -149,7 +156,7 @@ describe UserUpdater do guardian = stub guardian.stubs(:can_grant_title?).with(user).returns(false) Guardian.stubs(:new).with(acting_user).returns(guardian) - updater = described_class.new(acting_user, user) + updater = UserUpdater.new(acting_user, user) updater.update(title: 'Minion') @@ -160,7 +167,7 @@ describe UserUpdater do context 'when website includes http' do it 'does not add http before updating' do user = Fabricate(:user) - updater = described_class.new(acting_user, user) + updater = UserUpdater.new(acting_user, user) updater.update(website: 'http://example.com') @@ -171,7 +178,7 @@ describe UserUpdater do context 'when website does not include http' do it 'adds http before updating' do user = Fabricate(:user) - updater = described_class.new(acting_user, user) + updater = UserUpdater.new(acting_user, user) updater.update(website: 'example.com') @@ -184,11 +191,20 @@ describe UserUpdater do user = Fabricate(:user) user.custom_fields = {'import_username' => 'my_old_username'} user.save - updater = described_class.new(acting_user, user) + updater = UserUpdater.new(acting_user, user) updater.update(website: 'example.com', custom_fields: '') expect(user.reload.custom_fields).to eq({'import_username' => 'my_old_username'}) end end + + it "logs the action" do + user_without_name = Fabricate(:user, name: nil) + user = Fabricate(:user, name: 'Billy Bob') + expect { UserUpdater.new(acting_user, user).update(name: 'Jim Tom') }.to change { UserHistory.count }.by(1) + expect { UserUpdater.new(acting_user, user).update(name: 'Jim Tom') }.to change { UserHistory.count }.by(0) # make sure it does not log a dupe + expect { UserUpdater.new(acting_user, user_without_name).update(bio_raw: 'foo bar') }.to change { UserHistory.count }.by(0) # make sure user without name (name = nil) does not raise an error + expect { UserUpdater.new(acting_user, user_without_name).update(name: 'Jim Tom') }.to change { UserHistory.count }.by(1) + end end end diff --git a/spec/services/username_changer_spec.rb b/spec/services/username_changer_spec.rb index cf79494f26..79e18091c9 100644 --- a/spec/services/username_changer_spec.rb +++ b/spec/services/username_changer_spec.rb @@ -9,7 +9,7 @@ describe UsernameChanger do let(:new_username) { "#{user.username}1234" } before do - @result = described_class.change(user, new_username) + @result = UsernameChanger.change(user, new_username) end it 'returns true' do @@ -33,7 +33,7 @@ describe UsernameChanger do let(:username_lower_before_change) { user.username_lower } before do - @result = described_class.change(user, wrong_username) + @result = UsernameChanger.change(user, wrong_username) end it 'returns false' do @@ -55,13 +55,18 @@ describe UsernameChanger do let!(:myself) { Fabricate(:user, username: 'hansolo') } it 'should return true' do - expect(described_class.change(myself, "HanSolo")).to eq(true) + expect(UsernameChanger.change(myself, "HanSolo")).to eq(true) end it 'should change the username' do - described_class.change(myself, "HanSolo") + UsernameChanger.change(myself, "HanSolo") expect(myself.reload.username).to eq('HanSolo') end + + it "logs the action" do + expect { UsernameChanger.change(myself, "HanSolo", myself) }.to change { UserHistory.count }.by(1) + expect { UsernameChanger.change(myself, "HanSolo", myself) }.to change { UserHistory.count }.by(0) # make sure it does not log a dupe + end end describe 'allow custom minimum username length from site settings' do @@ -71,17 +76,17 @@ describe UsernameChanger do end it 'should allow a shorter username than default' do - result = described_class.change(user, 'a' * @custom_min) + result = UsernameChanger.change(user, 'a' * @custom_min) expect(result).not_to eq(false) end it 'should not allow a shorter username than limit' do - result = described_class.change(user, 'a' * (@custom_min - 1)) + result = UsernameChanger.change(user, 'a' * (@custom_min - 1)) expect(result).to eq(false) end it 'should not allow a longer username than limit' do - result = described_class.change(user, 'a' * (User.username_length.end + 1)) + result = UsernameChanger.change(user, 'a' * (User.username_length.end + 1)) expect(result).to eq(false) end end diff --git a/spec/support/helpers.rb b/spec/support/helpers.rb index 5b7c124463..9238cf9771 100644 --- a/spec/support/helpers.rb +++ b/spec/support/helpers.rb @@ -1,20 +1,6 @@ module Helpers extend ActiveSupport::Concern - class_methods do - def site_setting(setting_name, value) - original_value = SiteSetting.public_send(setting_name.to_sym) - - self.before do - SiteSetting.public_send("#{setting_name}=", value) - end - - self.after do - SiteSetting.public_send("#{setting_name}=", original_value) - end - end - end - def self.next_seq @next_seq = (@next_seq || 0) + 1 end diff --git a/spec/tasks/posts_spec.rb b/spec/tasks/posts_spec.rb new file mode 100644 index 0000000000..788dd18797 --- /dev/null +++ b/spec/tasks/posts_spec.rb @@ -0,0 +1,18 @@ +require 'rails_helper' + +RSpec.describe "Post rake tasks" do + before do + Discourse::Application.load_tasks + IO.any_instance.stubs(:puts) + end + + describe 'remap' do + it 'should remap posts' do + post = Fabricate(:post, raw: "The quick brown fox jumps over the lazy dog") + + Rake::Task['posts:remap'].invoke("brown","red") + post.reload + expect(post.raw).to eq('The quick red fox jumps over the lazy dog') + end + end +end diff --git a/test/javascripts/acceptance/account-created-test.js.es6 b/test/javascripts/acceptance/account-created-test.js.es6 new file mode 100644 index 0000000000..661dc6197c --- /dev/null +++ b/test/javascripts/acceptance/account-created-test.js.es6 @@ -0,0 +1,97 @@ +import { acceptance } from "helpers/qunit-helpers"; +import PreloadStore from 'preload-store'; + +acceptance("Account Created"); + +test("account created - message", assert => { + PreloadStore.store('accountCreated', { + message: "Hello World", + }); + visit("/u/account-created"); + + andThen(() => { + assert.ok(exists('.account-created')); + assert.equal( + find('.account-created .ac-message').text().trim(), + "Hello World", + "it displays the message" + ); + assert.notOk(exists('.activation-controls')); + }); +}); + +test("account created - resend email", assert => { + PreloadStore.store('accountCreated', { + message: "Hello World", + username: 'eviltrout', + email: 'eviltrout@example.com', + show_controls: true + }); + visit("/u/account-created"); + + andThen(() => { + assert.ok(exists('.account-created')); + assert.equal( + find('.account-created .ac-message').text().trim(), + "Hello World", + "it displays the message" + ); + }); + + click('.activation-controls .resend'); + andThen(() => { + assert.equal(currentPath(), "account-created.resent"); + const email = find('.account-created .ac-message b').text(); + assert.equal(email, 'eviltrout@example.com'); + }); + +}); + +test("account created - update email - cancel", assert => { + PreloadStore.store('accountCreated', { + message: "Hello World", + username: 'eviltrout', + email: 'eviltrout@example.com', + show_controls: true + }); + visit("/u/account-created"); + + click('.activation-controls .edit-email'); + andThen(() => { + assert.equal(currentPath(), "account-created.edit-email"); + assert.ok(find('.activation-controls .btn-primary:disabled').length); + }); + + click('.activation-controls .edit-cancel'); + andThen(() => { + assert.equal(currentPath(), "account-created.index"); + }); +}); + +test("account created - update email - submit", assert => { + PreloadStore.store('accountCreated', { + message: "Hello World", + username: 'eviltrout', + email: 'eviltrout@example.com', + show_controls: true + }); + visit("/u/account-created"); + + click('.activation-controls .edit-email'); + andThen(() => { + assert.ok(find('.activation-controls .btn-primary:disabled').length); + }); + + fillIn('.activate-new-email', 'newemail@example.com'); + andThen(() => { + assert.notOk(find('.activation-controls .btn-primary:disabled').length); + }); + + click('.activation-controls .btn-primary'); + andThen(() => { + assert.equal(currentPath(), "account-created.resent"); + const email = find('.account-created .ac-message b').text(); + assert.equal(email, 'newemail@example.com'); + }); + +}); diff --git a/test/javascripts/acceptance/category-edit-test.js.es6 b/test/javascripts/acceptance/category-edit-test.js.es6 index 389919018e..185cb7b216 100644 --- a/test/javascripts/acceptance/category-edit-test.js.es6 +++ b/test/javascripts/acceptance/category-edit-test.js.es6 @@ -57,3 +57,28 @@ test("Error Saving", assert => { assert.equal(find('#modal-alert').html(), "duplicate email"); }); }); + +test("Subcategory list settings", () => { + visit("/c/bug"); + + click('.edit-category'); + click('.edit-category-settings'); + + andThen(() => { + ok(!visible(".subcategory-list-style-field"), "subcategory list style isn't visible by default"); + }); + + click(".show-subcategory-list-field input[type=checkbox]"); + andThen(() => { + ok(visible(".subcategory-list-style-field"), "subcategory list style is shown if show subcategory list is checked"); + }); + + click('.edit-category-general'); + selectDropdown('.edit-category-tab-general .category-combobox', 2); + + click('.edit-category-settings'); + andThen(() => { + ok(!visible(".show-subcategory-list-field"), "show subcategory list isn't visible for child categories"); + ok(!visible(".subcategory-list-style-field"), "subcategory list style isn't visible for child categories"); + }); +}); diff --git a/test/javascripts/acceptance/composer-topic-links-test.js.es6 b/test/javascripts/acceptance/composer-topic-links-test.js.es6 index 2ff236abe4..3876fcc5cc 100644 --- a/test/javascripts/acceptance/composer-topic-links-test.js.es6 +++ b/test/javascripts/acceptance/composer-topic-links-test.js.es6 @@ -3,7 +3,8 @@ import { acceptance } from "helpers/qunit-helpers"; acceptance("Composer topic featured links", { loggedIn: true, settings: { - topic_featured_link_enabled: true + topic_featured_link_enabled: true, + max_topic_title_length: 80 } }); @@ -35,8 +36,31 @@ test("no onebox result", () => { click('#create-topic'); fillIn('#reply-title', "http://www.example.com/nope-onebox.html"); andThen(() => { - equal(find('.d-editor-preview').html().trim().indexOf('onebox'), -1, "link isn't put into the post"); - equal(find('.d-editor-input').val().length, 0, "link isn't put into the post"); + ok(find('.d-editor-preview').html().trim().indexOf('onebox') > 0, "it pastes the link into the body and previews it"); + ok(exists('.d-editor-textarea-wrapper .popup-tip.good'), 'link is pasted into body'); equal(find('.title-input input').val(), "http://www.example.com/nope-onebox.html", "title is unchanged"); }); }); + +test("ignore internal links", () => { + visit("/"); + click('#create-topic'); + const title = "http://" + window.location.hostname + "/internal-page.html"; + fillIn('#reply-title', title); + andThen(() => { + equal(find('.d-editor-preview').html().trim().indexOf('onebox'), -1, "onebox preview doesn't show"); + equal(find('.d-editor-input').val().length, 0, "link isn't put into the post"); + equal(find('.title-input input').val(), title, "title is unchanged"); + }); +}); + +test("link is longer than max title length", () => { + visit("/"); + click('#create-topic'); + fillIn('#reply-title', "http://www.example.com/has-title-and-a-url-that-is-more-than-80-characters-because-thats-good-for-seo-i-guess.html"); + andThen(() => { + ok(find('.d-editor-preview').html().trim().indexOf('onebox') > 0, "it pastes the link into the body and previews it"); + ok(exists('.d-editor-textarea-wrapper .popup-tip.good'), 'the body is now good'); + equal(find('.title-input input').val(), "An interesting article", "title is from the oneboxed article"); + }); +}); diff --git a/test/javascripts/acceptance/group-edit-test.js.es6 b/test/javascripts/acceptance/group-edit-test.js.es6 index e4498bf793..023d9c95f4 100644 --- a/test/javascripts/acceptance/group-edit-test.js.es6 +++ b/test/javascripts/acceptance/group-edit-test.js.es6 @@ -1,10 +1,11 @@ -import { acceptance } from "helpers/qunit-helpers"; +import { acceptance, logIn } from "helpers/qunit-helpers"; -acceptance("Editing Group", { - loggedIn: true -}); +acceptance("Editing Group"); test("Editing group", () => { + logIn(); + Discourse.reset(); + visit("/groups/discourse/edit"); andThen(() => { @@ -29,3 +30,11 @@ test("Editing group", () => { ok(find('.group-edit-public[disabled]').length === 1, 'it should disable group public input'); }); }); + +test("Editing group as an anonymous user", () => { + visit("/groups/discourse/edit"); + + andThen(() => { + ok(count('.group-members tr') > 0, "it should redirect to members page for an anonymous user"); + }); +}); diff --git a/test/javascripts/acceptance/group-logs-test.js.es6 b/test/javascripts/acceptance/group-logs-test.js.es6 index 478955549d..58a1acb7be 100644 --- a/test/javascripts/acceptance/group-logs-test.js.es6 +++ b/test/javascripts/acceptance/group-logs-test.js.es6 @@ -18,9 +18,9 @@ acceptance("Group Logs", { // Workaround while awaiting https://github.com/tildeio/route-recognizer/issues/53 server.get('/groups/snorlax/logs.json', request => { // eslint-disable-line no-undef if (request.queryParams["filters[action]"]) { - return response({"logs":[{"action":"change_group_setting","subject":"title","prev_value":null,"new_value":"Team Snorlax","created_at":"2016-12-12T08:27:46.408Z","acting_user":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"},"target_user":null}],"all_loaded":true}); + return response({"logs":[{"action":"change_group_setting","subject":"title","prev_value":null,"new_value":"Team Snorlax","created_at":"2016-12-12T08:27:46.408Z","acting_user":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"},"target_user":null}],"all_loaded":true}); } else { - return response({"logs":[{"action":"change_group_setting","subject":"title","prev_value":null,"new_value":"Team Snorlax","created_at":"2016-12-12T08:27:46.408Z","acting_user":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"},"target_user":null},{"action":"add_user_to_group","subject":null,"prev_value":null,"new_value":null,"created_at":"2016-12-12T08:27:27.725Z","acting_user":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"},"target_user":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"}}],"all_loaded":true}); + return response({"logs":[{"action":"change_group_setting","subject":"title","prev_value":null,"new_value":"Team Snorlax","created_at":"2016-12-12T08:27:46.408Z","acting_user":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"},"target_user":null},{"action":"add_user_to_group","subject":null,"prev_value":null,"new_value":null,"created_at":"2016-12-12T08:27:27.725Z","acting_user":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"},"target_user":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"}}],"all_loaded":true}); } }); } diff --git a/test/javascripts/acceptance/invite-accept-test.js.es6 b/test/javascripts/acceptance/invite-accept-test.js.es6 new file mode 100644 index 0000000000..f89d5329bc --- /dev/null +++ b/test/javascripts/acceptance/invite-accept-test.js.es6 @@ -0,0 +1,50 @@ +import { acceptance } from "helpers/qunit-helpers"; +import PreloadStore from 'preload-store'; + +acceptance("Invite Accept", { + settings: { + full_name_required: true + } +}); + +test("Invite Acceptance Page", () => { + PreloadStore.store('invite_info', { + invited_by: {"id":123,"username":"neil","avatar_template":"/user_avatar/localhost/neil/{size}/25_1.png","name":"Neil Lalonde","title":"team"}, + email: "invited@asdf.com", + username: "invited" + }); + + visit("/invites/myvalidinvitetoken"); + andThen(() => { + ok(exists("#new-account-username"), "shows the username input"); + equal(find("#new-account-username").val(), "invited", "username is prefilled"); + ok(exists("#new-account-name"), "shows the name input"); + ok(exists("#new-account-password"), "shows the password input"); + ok(exists('.invites-show .btn-primary:disabled'), 'submit is disabled because name is not filled'); + }); + + fillIn("#new-account-name", 'John Doe'); + andThen(() => { + not(exists('.invites-show .btn-primary:disabled'), 'submit is enabled'); + }); + + fillIn("#new-account-username", 'a'); + andThen(() => { + ok(exists(".username-input .bad"), "username is not valid"); + ok(exists('.invites-show .btn-primary:disabled'), 'submit is disabled'); + }); + + fillIn("#new-account-password", 'aaa'); + andThen(() => { + ok(exists(".password-input .bad"), "password is not valid"); + ok(exists('.invites-show .btn-primary:disabled'), 'submit is disabled'); + }); + + fillIn("#new-account-username", 'validname'); + fillIn("#new-account-password", 'secur3ty4Y0uAndMe'); + andThen(() => { + ok(exists(".username-input .good"), "username is valid"); + ok(exists(".password-input .good"), "password is valid"); + not(exists('.invites-show .btn-primary:disabled'), 'submit is enabled'); + }); +}); diff --git a/test/javascripts/acceptance/login-required-test.js.es6 b/test/javascripts/acceptance/login-required-test.js.es6 index abdb2eea94..4bbec697a4 100644 --- a/test/javascripts/acceptance/login-required-test.js.es6 +++ b/test/javascripts/acceptance/login-required-test.js.es6 @@ -26,19 +26,4 @@ test("redirect", () => { andThen(() => { ok(invisible('.login-modal'), "it closes the login modal"); }); - - click('#search-button'); - andThen(() => { - ok(exists('.login-modal'), "clicking search opens the login modal"); - }); - - click('.modal-header .close'); - andThen(() => { - ok(invisible('.login-modal'), "it closes the login modal"); - }); - - click('#toggle-hamburger-menu'); - andThen(() => { - ok(exists('.login-modal'), "site map opens the login modal"); - }); }); diff --git a/test/javascripts/acceptance/password-reset-test.js.es6 b/test/javascripts/acceptance/password-reset-test.js.es6 new file mode 100644 index 0000000000..c8eaf488a2 --- /dev/null +++ b/test/javascripts/acceptance/password-reset-test.js.es6 @@ -0,0 +1,62 @@ +import { acceptance } from "helpers/qunit-helpers"; +import PreloadStore from 'preload-store'; +import { parsePostData } from "helpers/create-pretender"; + +acceptance("Password Reset", { + setup() { + const response = (object) => { + return [ + 200, + {"Content-Type": "application/json"}, + object + ]; + }; + + server.get('/u/confirm-email-token/myvalidtoken.json', () => { //eslint-disable-line + return response({success: "OK"}); + }); + + server.put('/u/password-reset/myvalidtoken.json', request => { //eslint-disable-line + const body = parsePostData(request.requestBody); + if (body.password === "jonesyAlienSlayer") { + return response({success: false, errors: {password: ["is the name of your cat"]}}); + } else { + return response({success: "OK", message: I18n.t('password_reset.success')}); + } + }); + } +}); + +test("Password Reset Page", () => { + PreloadStore.store('password_reset', {is_developer: false}); + + visit("/u/password-reset/myvalidtoken"); + andThen(() => { + ok(exists(".password-reset input"), "shows the input"); + }); + + fillIn('.password-reset input', 'perf3ctly5ecur3'); + andThen(() => { + ok(exists(".password-reset .tip.good"), "input looks good"); + }); + + fillIn('.password-reset input', '123'); + andThen(() => { + ok(exists(".password-reset .tip.bad"), "input is not valid"); + ok(find(".password-reset .tip.bad").html().indexOf(I18n.t('user.password.too_short')) > -1, "password too short"); + }); + + fillIn('.password-reset input', 'jonesyAlienSlayer'); + click('.password-reset form button'); + andThen(() => { + ok(exists(".password-reset .tip.bad"), "input is not valid"); + ok(find(".password-reset .tip.bad").html().indexOf("is the name of your cat") > -1, "server validation error message shows"); + }); + + fillIn('.password-reset input', 'perf3ctly5ecur3'); + click('.password-reset form button'); + andThen(() => { + ok(!exists(".password-reset form"), "form is gone"); + }); +}); + diff --git a/test/javascripts/acceptance/plugin-outlet-connector-class-test.js.es6 b/test/javascripts/acceptance/plugin-outlet-connector-class-test.js.es6 index 82b34f7ca7..60a85e3cc7 100644 --- a/test/javascripts/acceptance/plugin-outlet-connector-class-test.js.es6 +++ b/test/javascripts/acceptance/plugin-outlet-connector-class-test.js.es6 @@ -35,7 +35,7 @@ acceptance("Plugin Outlet - Connector Class", { }); test("Renders a template into the outlet", assert => { - visit("/users/eviltrout"); + visit("/u/eviltrout"); andThen(() => { assert.ok(find('.user-profile-primary-outlet.hello').length === 1, 'it has class names'); assert.ok(!find('.user-profile-primary-outlet.dont-render').length, "doesn't render"); diff --git a/test/javascripts/acceptance/plugin-outlet-multi-template-test.js.es6 b/test/javascripts/acceptance/plugin-outlet-multi-template-test.js.es6 index 0379b2d8e5..b74b46395b 100644 --- a/test/javascripts/acceptance/plugin-outlet-multi-template-test.js.es6 +++ b/test/javascripts/acceptance/plugin-outlet-multi-template-test.js.es6 @@ -19,7 +19,7 @@ acceptance("Plugin Outlet - Multi Template", { }); test("Renders a template into the outlet", assert => { - visit("/users/eviltrout"); + visit("/u/eviltrout"); andThen(() => { assert.ok(find('.user-profile-primary-outlet.hello').length === 1, 'it has class names'); assert.ok(find('.user-profile-primary-outlet.goodbye').length === 1, 'it has class names'); diff --git a/test/javascripts/acceptance/plugin-outlet-single-template-test.js.es6 b/test/javascripts/acceptance/plugin-outlet-single-template-test.js.es6 index ee236ed851..63a05b3629 100644 --- a/test/javascripts/acceptance/plugin-outlet-single-template-test.js.es6 +++ b/test/javascripts/acceptance/plugin-outlet-single-template-test.js.es6 @@ -14,7 +14,7 @@ acceptance("Plugin Outlet - Single Template", { }); test("Renders a template into the outlet", assert => { - visit("/users/eviltrout"); + visit("/u/eviltrout"); andThen(() => { assert.ok(find('.user-profile-primary-outlet.hello').length === 1, 'it has class names'); assert.equal(find('.hello-username').text(), 'eviltrout', 'it renders into the outlet'); diff --git a/test/javascripts/acceptance/preferences-test.js.es6 b/test/javascripts/acceptance/preferences-test.js.es6 index 9c597df2e0..ccd2aff7b4 100644 --- a/test/javascripts/acceptance/preferences-test.js.es6 +++ b/test/javascripts/acceptance/preferences-test.js.es6 @@ -2,39 +2,67 @@ import { acceptance } from "helpers/qunit-helpers"; acceptance("User Preferences", { loggedIn: true }); test("update some fields", () => { - visit("/users/eviltrout/preferences"); + visit("/u/eviltrout/preferences"); andThen(() => { ok($('body.user-preferences-page').length, "has the body class"); - equal(currentURL(), '/users/eviltrout/preferences', "it doesn't redirect"); + equal(currentURL(), '/u/eviltrout/preferences/account', "defaults to account tab"); ok(exists('.user-preferences'), 'it shows the preferences'); }); - fillIn("#edit-location", "Westeros"); + const savePreferences = () => { + click('.save-user'); + ok(!exists('.saved-user'), "it hasn't been saved yet"); + andThen(() => { + ok(exists('.saved-user'), 'it displays the saved message'); + }); + }; - click('.save-user'); - ok(!exists('.saved-user'), "it hasn't been saved yet"); - andThen(() => { - ok(exists('.saved-user'), 'it displays the saved message'); - }); + fillIn(".pref-name input[type=text]", "Jon Snow"); + savePreferences(); + + click(".preferences-nav .nav-profile a"); + fillIn("#edit-location", "Westeros"); + savePreferences(); + + click(".preferences-nav .nav-emails a"); + click(".pref-activity-summary input[type=checkbox]"); + savePreferences(); + + click(".preferences-nav .nav-notifications a"); + selectDropdown('.control-group.notifications select.combobox', 1440); + savePreferences(); + + click(".preferences-nav .nav-categories a"); + fillIn('.category-controls .category-selector', 'faq'); + savePreferences(); + + ok(!exists('.preferences-nav .nav-tags a'), "tags tab isn't there when tags are disabled"); + + // Error: Unhandled request in test environment: /themes/assets/10d71596-7e4e-4dc0-b368-faa3b6f1ce6d?_=1493833562388 (GET) + // click(".preferences-nav .nav-interface a"); + // click('.control-group.other input[type=checkbox]:first'); + // savePreferences(); + + ok(!exists('.preferences-nav .nav-apps a'), "apps tab isn't there when you have no authorized apps"); }); test("username", () => { - visit("/users/eviltrout/preferences/username"); + visit("/u/eviltrout/preferences/username"); andThen(() => { ok(exists("#change_username"), "it has the input element"); }); }); test("about me", () => { - visit("/users/eviltrout/preferences/about-me"); + visit("/u/eviltrout/preferences/about-me"); andThen(() => { ok(exists(".raw-bio"), "it has the input element"); }); }); test("email", () => { - visit("/users/eviltrout/preferences/email"); + visit("/u/eviltrout/preferences/email"); andThen(() => { ok(exists("#change-email"), "it has the input element"); }); diff --git a/test/javascripts/acceptance/raw-plugin-outlet-test.js.es6 b/test/javascripts/acceptance/raw-plugin-outlet-test.js.es6 index 3611bf1cdd..e21de0a31e 100644 --- a/test/javascripts/acceptance/raw-plugin-outlet-test.js.es6 +++ b/test/javascripts/acceptance/raw-plugin-outlet-test.js.es6 @@ -1,6 +1,6 @@ import { acceptance } from "helpers/qunit-helpers"; -const CONNECTOR = 'javascripts/raw-test/connectors/topic-list-tags/lala'; +const CONNECTOR = 'javascripts/raw-test/connectors/topic-list-before-status/lala'; acceptance("Raw Plugin Outlet", { setup() { Discourse.RAW_TEMPLATES[CONNECTOR] = Handlebars.compile( @@ -20,4 +20,3 @@ test("Renders the raw plugin outlet", assert => { assert.equal(find('.topic-lala:eq(0)').text(), '11557', 'it has the topic id'); }); }); - diff --git a/test/javascripts/acceptance/search-full-test.js.es6 b/test/javascripts/acceptance/search-full-test.js.es6 index 47fdbc8bf6..d8050d72f4 100644 --- a/test/javascripts/acceptance/search-full-test.js.es6 +++ b/test/javascripts/acceptance/search-full-test.js.es6 @@ -14,9 +14,9 @@ acceptance("Search - Full Page", { return response({results: [{text: 'monkey', count: 1}]}); }); - server.get('/users/search/users', () => { //eslint-disable-line + server.get('/u/search/users', () => { //eslint-disable-line return response({users: [{username: "admin", name: "admin", - avatar_template: "/letter_avatar_proxy/v2/letter/a/3ec8ea/{size}.png"}]}); + avatar_template: "/images/avatar.png"}]}); }); server.get('/admin/groups.json', () => { //eslint-disable-line @@ -233,15 +233,18 @@ test("update in:private filter through advanced search ui", assert => { }); }); -test("update in:wiki filter through advanced search ui", assert => { +test("update in:seen filter through advanced search ui", assert => { visit("/search"); fillIn('.search input.full-page-search', 'none'); click('.search-advanced-btn'); - click('.search-advanced-options .in-wiki'); + click('.search-advanced-options .in-seen'); andThen(() => { - assert.ok(exists('.search-advanced-options .in-wiki:checked'), 'has "are wiki" populated'); - assert.equal(find('.search input.full-page-search').val(), "none in:wiki", 'has updated search term to "none in:wiki"'); + assert.ok(exists('.search-advanced-options .in-seen:checked'), 'it should check the right checkbox'); + + assert.equal(find('.search input.full-page-search').val(), "none in:seen", + 'it should update the search term' + ); }); }); diff --git a/test/javascripts/acceptance/search-test.js.es6 b/test/javascripts/acceptance/search-test.js.es6 index 12243b05c3..b80552708b 100644 --- a/test/javascripts/acceptance/search-test.js.es6 +++ b/test/javascripts/acceptance/search-test.js.es6 @@ -24,3 +24,52 @@ test("search", (assert) => { assert.ok(exists('.search-advanced-options'), 'advanced search is expanded'); }); }); + +test("search scope checkbox", () => { + visit("/c/bug"); + click('#search-button'); + andThen(() => { + ok(exists('.search-context input:checked'), 'scope to category checkbox is checked'); + }); + click('#search-button'); + + visit("/t/internationalization-localization/280"); + click('#search-button'); + andThen(() => { + not(exists('.search-context input:checked'), 'scope to topic checkbox is not checked'); + }); + click('#search-button'); + + visit("/u/eviltrout"); + click('#search-button'); + andThen(() => { + ok(exists('.search-context input:checked'), 'scope to user checkbox is checked'); + }); +}); + +test("Search with context", assert => { + visit("/t/internationalization-localization/280/1"); + + click('#search-button'); + fillIn('#search-term', 'dev'); + click(".search-context input[type='checkbox']"); + keyEvent('#search-term', 'keyup', 16); + + andThen(() => { + assert.ok(exists('.search-menu .results ul li'), 'it shows results'); + }); + + visit("/"); + click('#search-button'); + + andThen(() => { + assert.ok(!exists(".search-context input[type='checkbox']")); + }); + + visit("/t/internationalization-localization/280/1"); + click('#search-button'); + + andThen(() => { + assert.ok(!$('.search-context input[type=checkbox]').is(":checked")); + }); +}); diff --git a/test/javascripts/acceptance/sign-in-test.js.es6 b/test/javascripts/acceptance/sign-in-test.js.es6 index d8efb4be18..21403770ba 100644 --- a/test/javascripts/acceptance/sign-in-test.js.es6 +++ b/test/javascripts/acceptance/sign-in-test.js.es6 @@ -41,16 +41,40 @@ test("sign in - not activated", () => { ok(!exists('.modal-body small'), 'it escapes the email address'); }); - click('.modal-body .resend-link'); + click('.modal-footer button.resend'); andThen(() => { equal(find('.modal-body b').text(), 'current@example.com'); ok(!exists('.modal-body small'), 'it escapes the email address'); }); - - }); }); +test("sign in - not activated - edit email", () => { + visit("/"); + andThen(() => { + click("header .login-button"); + andThen(() => { + ok(exists('.login-modal'), "it shows the login modal"); + }); + + fillIn('#login-account-name', 'eviltrout'); + fillIn('#login-account-password', 'not-activated-edit'); + click('.modal-footer .btn-primary'); + click('.modal-footer button.edit-email'); + andThen(() => { + equal(find('.activate-new-email').val(), 'current@example.com'); + equal(find('.modal-footer .btn-primary:disabled').length, 1, "must change email"); + }); + fillIn('.activate-new-email', 'different@example.com'); + andThen(() => { + equal(find('.modal-footer .btn-primary:disabled').length, 0); + }); + click(".modal-footer .btn-primary"); + andThen(() => { + equal(find('.modal-body b').text(), 'different@example.com'); + }); + }); +}); test("create account", () => { visit("/"); diff --git a/test/javascripts/acceptance/topic-discovery-test.js.es6 b/test/javascripts/acceptance/topic-discovery-test.js.es6 index d6d1798d29..c0767e8282 100644 --- a/test/javascripts/acceptance/topic-discovery-test.js.es6 +++ b/test/javascripts/acceptance/topic-discovery-test.js.es6 @@ -13,6 +13,7 @@ test("Visit Discovery Pages", () => { andThen(() => { ok(exists(".topic-list"), "The list of topics was rendered"); ok(exists('.topic-list .topic-list-item'), "has topics"); + ok(!exists('.category-list'), "doesn't render subcategories"); ok($('body.category-bug').length, "has a custom css class for the category id on the body"); }); @@ -29,4 +30,17 @@ test("Visit Discovery Pages", () => { ok($('body.categories-list').length === 0, "removes the `categories-list` class"); ok(exists('.topic-list .topic-list-item'), "has topics"); }); + + visit("/c/feature"); + andThen(() => { + ok(exists(".topic-list"), "The list of topics was rendered"); + ok(exists(".category-boxes"), "The list of subcategories were rendered with box style"); + }); + + visit("/c/dev"); + andThen(() => { + ok(exists(".topic-list"), "The list of topics was rendered"); + ok(exists(".category-boxes-with-topics"), "The list of subcategories were rendered with box-with-featured-topics style"); + ok(exists(".category-boxes-with-topics .featured-topics"), "The featured topics are there too"); + }); }); diff --git a/test/javascripts/acceptance/topic-notifications-button-test.js.es6 b/test/javascripts/acceptance/topic-notifications-button-test.js.es6 new file mode 100644 index 0000000000..4689b86bb0 --- /dev/null +++ b/test/javascripts/acceptance/topic-notifications-button-test.js.es6 @@ -0,0 +1,47 @@ +import { acceptance } from "helpers/qunit-helpers"; +acceptance("Topic Notifications button", { + loggedIn: true, + setup() { + const response = object => { + return [ + 200, + { "Content-Type": "application/json" }, + object + ]; + }; + + server.post('/t/280/notifications', () => { // eslint-disable-line no-undef + return response({}); + }); + } +}); + +test("Updating topic notification level", () => { + visit("/t/internationalization-localization/280"); + + const notificationOptions = "#topic-footer-buttons .notification-options"; + + andThen(() => { + ok( + exists(`${notificationOptions} .tracking`), + "it should display the notification options button in the topic's footer" + ); + }); + + click(`${notificationOptions} .tracking`); + click(`${notificationOptions} .dropdown-menu .watching`); + + andThen(() => { + ok( + exists(`${notificationOptions} .watching`), + "it should display the right notification level" + ); + + // TODO: tgxworld I can't figure out why the topic timeline doesn't show when + // running the tests in phantomjs + // ok( + // exists(".timeline-footer-controls .notification-options .watching"), + // 'it should display the right notification level in topic timeline' + // ); + }); +}); diff --git a/test/javascripts/acceptance/topic-test.js.es6 b/test/javascripts/acceptance/topic-test.js.es6 index b5cc9e892a..10843ea169 100644 --- a/test/javascripts/acceptance/topic-test.js.es6 +++ b/test/javascripts/acceptance/topic-test.js.es6 @@ -60,3 +60,80 @@ test("Updating the topic title and category", () => { equal(find('.fancy-title').text().trim(), 'this is the new title', 'it displays the new title'); }); }); + +test("Marking a topic as wiki", () => { + server.put('/posts/398/wiki', () => { // eslint-disable-line no-undef + return [ + 200, + { "Content-Type": "application/json" }, + {} + ]; + }); + + visit("/t/internationalization-localization/280"); + + andThen(() => { + ok(find('a.wiki').length === 0, 'it does not show the wiki icon'); + }); + + click('.topic-post:eq(0) button.show-more-actions'); + click('.topic-post:eq(0) button.show-post-admin-menu'); + click('.btn.wiki'); + + andThen(() => { + ok(find('a.wiki').length === 1, 'it shows the wiki icon'); + }); +}); + +test("Reply as new topic", () => { + visit("/t/internationalization-localization/280"); + click("button.share:eq(0)"); + click(".reply-as-new-topic a"); + + andThen(() => { + ok(exists('.d-editor-input'), 'the composer input is visible'); + + equal( + find('.d-editor-input').val().trim(), + `Continuing the discussion from [Internationalization / localization](${window.location.origin}/t/internationalization-localization/280):`, + "it fills composer with the ring string" + ); + equal( + find('.category-combobox').select2('data').text, "feature", + "it fills category selector with the right category" + ); + }); +}); + +test("Reply as new message", () => { + visit("/t/pm-for-testing/12"); + click("button.share:eq(0)"); + click(".reply-as-new-topic a"); + + andThen(() => { + ok(exists('.d-editor-input'), 'the composer input is visible'); + + equal( + find('.d-editor-input').val().trim(), + `Continuing the discussion from [PM for testing](${window.location.origin}/t/pm-for-testing/12):`, + "it fills composer with the ring string" + ); + + const targets = find('.item span', '.composer-fields'); + + equal( + $(targets[0]).text(), "someguy", + "it fills up the composer with the right user to start the PM to" + ); + + equal( + $(targets[1]).text(), "test", + "it fills up the composer with the right user to start the PM to" + ); + + equal( + $(targets[2]).text(), "Group", + "it fills up the composer with the right group to start the PM to" + ); + }); +}); diff --git a/test/javascripts/acceptance/user-anonymous-test.js.es6 b/test/javascripts/acceptance/user-anonymous-test.js.es6 index 6d41bffc6a..ae9b7c149f 100644 --- a/test/javascripts/acceptance/user-anonymous-test.js.es6 +++ b/test/javascripts/acceptance/user-anonymous-test.js.es6 @@ -16,28 +16,29 @@ function hasTopicList() { } test("Root URL", () => { - visit("/users/eviltrout"); + visit("/u/eviltrout"); andThen(() => { - equal(currentPath(), 'user.userActivity.index', "it defaults to activity"); + ok($('body.user-summary-page').length, "has the body class"); + equal(currentPath(), 'user.summary', "it defaults to summary"); }); }); test("Filters", () => { - visit("/users/eviltrout/activity"); + visit("/u/eviltrout/activity"); andThen(() => { ok($('body.user-activity-page').length, "has the body class"); }); hasStream(); - visit("/users/eviltrout/activity/topics"); + visit("/u/eviltrout/activity/topics"); hasTopicList(); - visit("/users/eviltrout/activity/replies"); + visit("/u/eviltrout/activity/replies"); hasStream(); }); test("Badges", () => { - visit("/users/eviltrout/badges"); + visit("/u/eviltrout/badges"); andThen(() => { ok($('body.user-badges-page').length, "has the body class"); ok(exists(".user-badges-list .badge-card"), "shows a badge"); @@ -45,9 +46,9 @@ test("Badges", () => { }); test("Restricted Routes", () => { - visit("/users/eviltrout/preferences"); + visit("/u/eviltrout/preferences"); andThen(() => { - equal(currentURL(), '/users/eviltrout/activity', "it redirects from preferences"); + equal(currentURL(), '/u/eviltrout/activity', "it redirects from preferences"); }); }); diff --git a/test/javascripts/acceptance/user-test.js.es6 b/test/javascripts/acceptance/user-test.js.es6 index 36bc916295..84269cbe08 100644 --- a/test/javascripts/acceptance/user-test.js.es6 +++ b/test/javascripts/acceptance/user-test.js.es6 @@ -3,31 +3,31 @@ import { acceptance } from "helpers/qunit-helpers"; acceptance("User", {loggedIn: true}); test("Invites", () => { - visit("/users/eviltrout/invited/pending"); + visit("/u/eviltrout/invited/pending"); andThen(() => { ok($('body.user-invites-page').length, "has the body class"); }); }); test("Messages", () => { - visit("/users/eviltrout/messages"); + visit("/u/eviltrout/messages"); andThen(() => { ok($('body.user-messages-page').length, "has the body class"); }); }); test("Notifications", () => { - visit("/users/eviltrout/notifications"); + visit("/u/eviltrout/notifications"); andThen(() => { ok($('body.user-notifications-page').length, "has the body class"); }); }); test("Root URL - Viewing Self", () => { - visit("/users/eviltrout"); + visit("/u/eviltrout"); andThen(() => { - ok($('body.user-summary-page').length, "has the body class"); - equal(currentPath(), 'user.summary', "it defaults to summary"); + ok($('body.user-activity-page').length, "has the body class"); + equal(currentPath(), 'user.userActivity.index', "it defaults to activity"); ok(exists('.container.viewing-self'), "has the viewing-self class"); }); }); diff --git a/test/javascripts/admin/controllers/admin-customize-themes-test.js.es6 b/test/javascripts/admin/controllers/admin-customize-themes-test.js.es6 new file mode 100644 index 0000000000..2c70b5b12f --- /dev/null +++ b/test/javascripts/admin/controllers/admin-customize-themes-test.js.es6 @@ -0,0 +1,32 @@ +import { mapRoutes } from 'discourse/mapping-router'; +import Theme from 'admin/models/theme'; + +moduleFor('controller:admin-customize-themes', { + setup() { + this.registry.register('router:main', mapRoutes()); + }, + needs: ['controller:adminUser'] +}); + +test("can list sorted themes", function() { + + const defaultTheme = Theme.create({id: 2, 'default': true, name: 'default'}); + const userTheme = Theme.create({id: 3, 'user_selectable': true, name: 'name'}); + const strayTheme1 = Theme.create({id: 4, name: 'stray1'}); + const strayTheme2 = Theme.create({id: 5, name: 'stray2'}); + + const controller = this.subject({ + model: + { + content: [strayTheme2, strayTheme1, userTheme, defaultTheme] + } + }); + + + deepEqual(controller.get('sortedThemes').map(t=>t.get('name')), [ + defaultTheme, + userTheme, + strayTheme1, + strayTheme2 + ].map(t=>t.get('name')), "sorts themes correctly"); +}); diff --git a/test/javascripts/admin/models/theme-test.js.es6 b/test/javascripts/admin/models/theme-test.js.es6 new file mode 100644 index 0000000000..88d9d62947 --- /dev/null +++ b/test/javascripts/admin/models/theme-test.js.es6 @@ -0,0 +1,17 @@ +import Theme from 'admin/models/theme'; + +module("model:theme"); + +test('can add an upload correctly', function(assert) { + let theme = Theme.create(); + + assert.equal(theme.get("uploads.length"), 0, "uploads should be an empty array"); + + theme.setField('common', 'bob', '', 999, 2); + let fields = theme.get("theme_fields"); + assert.equal(fields.length, 1, 'expecting 1 theme field'); + assert.equal(fields[0].upload_id, 999, 'expecting upload id to be set'); + assert.equal(fields[0].type_id, 2, 'expecting type id to be set'); + + assert.equal(theme.get("uploads.length"), 1, "expecting an upload"); +}); diff --git a/test/javascripts/components/combo-box-test.js.es6 b/test/javascripts/components/combo-box-test.js.es6 index 174905548e..39232362ff 100644 --- a/test/javascripts/components/combo-box-test.js.es6 +++ b/test/javascripts/components/combo-box-test.js.es6 @@ -56,3 +56,19 @@ componentTest('with none', { assert.equal(this.$("select option:eq(2)").text(), 'trout'); } }); + +componentTest('with Object none', { + template: '{{combo-box content=items none=none value=value selected="something"}}', + setup() { + this.set('none', { id: 'something', name: 'none' }); + this.set('items', ['evil', 'trout', 'hat']); + }, + + test(assert) { + assert.equal(this.get('value'), 'something'); + assert.equal(this.$("select option:eq(0)").text(), 'none'); + assert.equal(this.$("select option:eq(0)").val(), 'something'); + assert.equal(this.$("select option:eq(1)").text(), 'evil'); + assert.equal(this.$("select option:eq(2)").text(), 'trout'); + } +}); diff --git a/test/javascripts/components/d-editor-test.js.es6 b/test/javascripts/components/d-editor-test.js.es6 index 1a594cd2da..cddd81828d 100644 --- a/test/javascripts/components/d-editor-test.js.es6 +++ b/test/javascripts/components/d-editor-test.js.es6 @@ -62,6 +62,19 @@ function testCase(title, testFunc) { }); } +function composerTestCase(title, testFunc) { + componentTest(title, { + template: '{{d-editor value=value composerEvents=true}}', + setup() { + this.set('value', 'hello world.'); + }, + test(assert) { + const textarea = jumpEnd(this.$('textarea.d-editor-input')[0]); + testFunc.call(this, assert, textarea); + } + }); +} + testCase(`selecting the space before a word`, function(assert, textarea) { textarea.selectionStart = 5; textarea.selectionEnd = 7; @@ -760,8 +773,19 @@ componentTest('emoji', { } }); -testCase("replace-text event", function(assert) { +testCase("replace-text event by default", function(assert) { + this.set('value', "red green blue"); + andThen(() => { + this.container.lookup('app-events:main').trigger('composer:replace-text', 'green', 'yellow'); + }); + + andThen(() => { + assert.equal(this.get('value'), 'red green blue'); + }); +}); + +composerTestCase("replace-text event for composer", function(assert) { this.set('value', "red green blue"); andThen(() => { @@ -773,6 +797,7 @@ testCase("replace-text event", function(assert) { }); }); + (() => { // Tests to check cursor/selection after replace-text event. const BEFORE = 'red green blue'; @@ -849,7 +874,7 @@ testCase("replace-text event", function(assert) { for (let i = 0; i < CASES.length; i++) { const CASE = CASES[i]; - testCase(`replace-text event: ${CASE.description}`, function(assert, textarea) { + composerTestCase(`replace-text event: ${CASE.description}`, function(assert, textarea) { this.set('value', BEFORE); setSelection(textarea, CASE.before); andThen(() => { diff --git a/test/javascripts/components/group-membership-button-test.js.es6 b/test/javascripts/components/group-membership-button-test.js.es6 index b1796078d8..930b7270b4 100644 --- a/test/javascripts/components/group-membership-button-test.js.es6 +++ b/test/javascripts/components/group-membership-button-test.js.es6 @@ -1,5 +1,3 @@ -import { currentUser } from "helpers/qunit-helpers"; - moduleFor('component:group-membership-button'); test('canJoinGroup', function() { @@ -18,24 +16,6 @@ test('canJoinGroup', function() { equal(this.subject().get("canJoinGroup"), true, "can't join group when not logged in"); }); -test('canRequestMembership', function() { - this.subject().setProperties({ - model: { allow_membership_requests: false, alias_level: 0 } - }); - - equal(this.subject().get('canRequestMembership'), false); - - this.subject().setProperties({ - currentUser: currentUser(), model: { allow_membership_requests: true, alias_level: 99 } - }); - - equal(this.subject().get('canRequestMembership'), true); - - this.subject().set("model.alias_level", 0); - - equal(this.subject().get('canRequestMembership'), false); -}); - test('userIsGroupUser', function() { this.subject().setProperties({ model: { is_group_user: true } @@ -58,4 +38,11 @@ test('userIsGroupUser', function() { this.subject().set('groupUserIds', undefined); equal(this.subject().get('userIsGroupUser'), false); + + this.subject().setProperties({ + groupUserIds: [1, 3], + model: { id: 1, is_group_user: false } + }); + + equal(this.subject().get('userIsGroupUser'), false); }); diff --git a/test/javascripts/controllers/create-account-test.js.es6 b/test/javascripts/controllers/create-account-test.js.es6 index 91156d4e52..11a9045a13 100644 --- a/test/javascripts/controllers/create-account-test.js.es6 +++ b/test/javascripts/controllers/create-account-test.js.es6 @@ -11,7 +11,7 @@ test('basicUsernameValidation', function() { var subject = this.subject; var testInvalidUsername = function(username, expectedReason) { - var controller = subject(); + var controller = subject({ siteSettings: Discourse.SiteSettings }); controller.set('accountUsername', username); equal(controller.get('basicUsernameValidation.failed'), true, 'username should be invalid: ' + username); equal(controller.get('basicUsernameValidation.reason'), expectedReason, 'username validation reason: ' + username + ', ' + expectedReason); @@ -21,7 +21,7 @@ test('basicUsernameValidation', function() { testInvalidUsername('x', I18n.t('user.username.too_short')); testInvalidUsername('123456789012345678901', I18n.t('user.username.too_long')); - var controller = subject(); + var controller = subject({ siteSettings: Discourse.SiteSettings }); controller.set('accountUsername', 'porkchops'); controller.set('prefilledUsername', 'porkchops'); equal(controller.get('basicUsernameValidation.ok'), true, 'Prefilled username is valid'); @@ -31,7 +31,7 @@ test('basicUsernameValidation', function() { test('passwordValidation', function() { var subject = this.subject; - var controller = subject(); + var controller = subject({ siteSettings: Discourse.SiteSettings }); controller.set('passwordRequired', true); controller.set('accountEmail', 'pork@chops.com'); controller.set('accountUsername', 'porkchops'); @@ -42,7 +42,7 @@ test('passwordValidation', function() { equal(controller.get('passwordValidation.reason'), I18n.t('user.password.ok'), 'Password is valid'); var testInvalidPassword = function(password, expectedReason) { - var c = subject(); + var c = subject({ siteSettings: Discourse.SiteSettings }); c.set('accountPassword', password); equal(c.get('passwordValidation.failed'), true, 'password should be invalid: ' + password); equal(c.get('passwordValidation.reason'), expectedReason, 'password validation reason: ' + password + ', ' + expectedReason); diff --git a/test/javascripts/controllers/history-test.js.es6 b/test/javascripts/controllers/history-test.js.es6 new file mode 100644 index 0000000000..fc8bbe1f5c --- /dev/null +++ b/test/javascripts/controllers/history-test.js.es6 @@ -0,0 +1,28 @@ +moduleFor("controller:history"); + +test("displayEdit", function() { + const HistoryController = this.subject(); + + HistoryController.setProperties({ + model: { last_revision: 3, current_revision: 3, can_edit: false } + }); + + equal( + HistoryController.get("displayEdit"), false, + "it should not display edit button when user cannot edit the post" + ); + + HistoryController.set("model.can_edit", true); + + equal( + HistoryController.get("displayEdit"), true, + "it should display edit button when user can edit the post" + ); + + HistoryController.set("model.current_revision", 2); + + equal( + HistoryController.get("displayEdit"), false, + "it should only display the edit button on the latest revision" + ); +}); diff --git a/test/javascripts/controllers/topic-test.js.es6 b/test/javascripts/controllers/topic-test.js.es6 index c3f2ad102b..a8e3609e34 100644 --- a/test/javascripts/controllers/topic-test.js.es6 +++ b/test/javascripts/controllers/topic-test.js.es6 @@ -101,7 +101,7 @@ test("Automating setting of allPostsSelected", function() { test("Select Replies when present", function() { var topic = buildTopic(), - tc = this.subject({ model: topic }), + tc = this.subject({ model: topic, appEvents: AppEvents.create() }), p1 = Discourse.Post.create({id: 1, post_number: 1, reply_count: 1}), p2 = Discourse.Post.create({id: 2, post_number: 2}), p3 = Discourse.Post.create({id: 2, post_number: 3, reply_to_post_number: 1}); diff --git a/test/javascripts/fixtures/about.js.es6 b/test/javascripts/fixtures/about.js.es6 index 90459eff5b..4261dd2a48 100644 --- a/test/javascripts/fixtures/about.js.es6 +++ b/test/javascripts/fixtures/about.js.es6 @@ -1,3 +1,3 @@ export default { - "about.json": {"about":{"stats":{"topic_count":5969,"post_count":65860,"user_count":10858,"topics_7_days":112,"posts_7_days":1302,"users_7_days":111,"like_count":37747,"likes_7_days":1143},"description":"Discussion about the next-generation open source Discourse forum software","title":"Discourse Meta","locale":"en","version":"0.9.9.16","moderators":[{"id":3,"username":"supermathie","uploaded_avatar_id":5247,"avatar_template":"/user_avatar/meta.discourse.org/supermathie/{size}/5247.png"},{"id":32,"username":"codinghorror","uploaded_avatar_id":5297,"avatar_template":"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png"},{"id":19,"username":"eviltrout","uploaded_avatar_id":5275,"avatar_template":"/user_avatar/meta.discourse.org/eviltrout/{size}/5275.png"},{"id":2,"username":"neil","uploaded_avatar_id":5245,"avatar_template":"/user_avatar/meta.discourse.org/neil/{size}/5245.png"},{"id":1,"username":"sam","uploaded_avatar_id":5243,"avatar_template":"/user_avatar/meta.discourse.org/sam/{size}/5243.png"},{"id":1995,"username":"zogstrip","uploaded_avatar_id":8630,"avatar_template":"/user_avatar/meta.discourse.org/zogstrip/{size}/8630.png"}],"admins":[{"id":3,"username":"supermathie","uploaded_avatar_id":5247,"avatar_template":"/user_avatar/meta.discourse.org/supermathie/{size}/5247.png"},{"id":32,"username":"codinghorror","uploaded_avatar_id":5297,"avatar_template":"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png"},{"id":19,"username":"eviltrout","uploaded_avatar_id":5275,"avatar_template":"/user_avatar/meta.discourse.org/eviltrout/{size}/5275.png"},{"id":38,"username":"frandallfarmer","uploaded_avatar_id":5307,"avatar_template":"/user_avatar/meta.discourse.org/frandallfarmer/{size}/5307.png"},{"id":6626,"username":"riking","uploaded_avatar_id":9779,"avatar_template":"/user_avatar/meta.discourse.org/riking/{size}/9779.png"},{"id":2,"username":"neil","uploaded_avatar_id":5245,"avatar_template":"/user_avatar/meta.discourse.org/neil/{size}/5245.png"},{"id":1,"username":"sam","uploaded_avatar_id":5243,"avatar_template":"/user_avatar/meta.discourse.org/sam/{size}/5243.png"},{"id":1995,"username":"zogstrip","uploaded_avatar_id":8630,"avatar_template":"/user_avatar/meta.discourse.org/zogstrip/{size}/8630.png"}]}} + "about.json": {"about":{"stats":{"topic_count":5969,"post_count":65860,"user_count":10858,"topics_7_days":112,"posts_7_days":1302,"users_7_days":111,"like_count":37747,"likes_7_days":1143},"description":"Discussion about the next-generation open source Discourse forum software","title":"Discourse Meta","locale":"en","version":"0.9.9.16","moderators":[{"id":3,"username":"supermathie","uploaded_avatar_id":5247,"avatar_template":"/images/avatar.png"},{"id":32,"username":"codinghorror","uploaded_avatar_id":5297,"avatar_template":"/images/avatar.png"},{"id":19,"username":"eviltrout","uploaded_avatar_id":5275,"avatar_template":"/images/avatar.png"},{"id":2,"username":"neil","uploaded_avatar_id":5245,"avatar_template":"/images/avatar.png"},{"id":1,"username":"sam","uploaded_avatar_id":5243,"avatar_template":"/images/avatar.png"},{"id":1995,"username":"zogstrip","uploaded_avatar_id":8630,"avatar_template":"/images/avatar.png"}],"admins":[{"id":3,"username":"supermathie","uploaded_avatar_id":5247,"avatar_template":"/images/avatar.png"},{"id":32,"username":"codinghorror","uploaded_avatar_id":5297,"avatar_template":"/images/avatar.png"},{"id":19,"username":"eviltrout","uploaded_avatar_id":5275,"avatar_template":"/images/avatar.png"},{"id":38,"username":"frandallfarmer","uploaded_avatar_id":5307,"avatar_template":"/images/avatar.png"},{"id":6626,"username":"riking","uploaded_avatar_id":9779,"avatar_template":"/images/avatar.png"},{"id":2,"username":"neil","uploaded_avatar_id":5245,"avatar_template":"/images/avatar.png"},{"id":1,"username":"sam","uploaded_avatar_id":5243,"avatar_template":"/images/avatar.png"},{"id":1995,"username":"zogstrip","uploaded_avatar_id":8630,"avatar_template":"/images/avatar.png"}]}} }; diff --git a/test/javascripts/fixtures/badges_fixture.js.es6 b/test/javascripts/fixtures/badges_fixture.js.es6 index 6081a6b992..9dcab162e7 100644 --- a/test/javascripts/fixtures/badges_fixture.js.es6 +++ b/test/javascripts/fixtures/badges_fixture.js.es6 @@ -429,577 +429,577 @@ export default { "id": 11209, "username": "icaroperseo", "uploaded_avatar_id": 33076, - "avatar_template": "\/user_avatar\/meta.discourse.org\/icaroperseo\/{size}\/33076.png" + "avatar_template":"/images/avatar.png" }, { "id": -1, "username": "system", "uploaded_avatar_id": 5241, - "avatar_template": "\/user_avatar\/meta.discourse.org\/system\/{size}\/5241.png" + "avatar_template":"/images/avatar.png" }, { "id": 11234, "username": "allard", "uploaded_avatar_id": 33117, - "avatar_template": "\/user_avatar\/meta.discourse.org\/allard\/{size}\/33117.png" + "avatar_template":"/images/avatar.png" }, { "id": 8944, "username": "hunterboerner", "uploaded_avatar_id": 33072, - "avatar_template": "\/user_avatar\/meta.discourse.org\/hunterboerner\/{size}\/33072.png" + "avatar_template":"/images/avatar.png" }, { "id": 11232, "username": "daydreamer", "uploaded_avatar_id": 33101, - "avatar_template": "\/user_avatar\/meta.discourse.org\/daydreamer\/{size}\/33101.png" + "avatar_template":"/images/avatar.png" }, { "id": 11160, "username": "boomzilla", "uploaded_avatar_id": 33029, - "avatar_template": "\/user_avatar\/meta.discourse.org\/boomzilla\/{size}\/33029.png" + "avatar_template":"/images/avatar.png" }, { "id": 5303, "username": "ybart", "uploaded_avatar_id": 14132, - "avatar_template": "\/user_avatar\/meta.discourse.org\/ybart\/{size}\/14132.png" + "avatar_template":"/images/avatar.png" }, { "id": 11142, "username": "Fluffy", "uploaded_avatar_id": 32957, - "avatar_template": "\/user_avatar\/meta.discourse.org\/fluffy\/{size}\/32957.png" + "avatar_template":"/images/avatar.png" }, { "id": 8843, "username": "timoroso", "uploaded_avatar_id": 19114, - "avatar_template": "\/user_avatar\/meta.discourse.org\/timoroso\/{size}\/19114.png" + "avatar_template":"/images/avatar.png" }, { "id": 10990, "username": "Nagesh", "uploaded_avatar_id": 32736, - "avatar_template": "\/user_avatar\/meta.discourse.org\/nagesh\/{size}\/32736.png" + "avatar_template":"/images/avatar.png" }, { "id": 11027, "username": "dullroar", "uploaded_avatar_id": 32801, - "avatar_template": "\/user_avatar\/meta.discourse.org\/dullroar\/{size}\/32801.png" + "avatar_template":"/images/avatar.png" }, { "id": 10481, "username": "Air_Cooled_Nut", "uploaded_avatar_id": 31833, - "avatar_template": "\/user_avatar\/meta.discourse.org\/air_cooled_nut\/{size}\/31833.png" + "avatar_template":"/images/avatar.png" }, { "id": 10977, "username": "stevebridger", "uploaded_avatar_id": 32705, - "avatar_template": "\/user_avatar\/meta.discourse.org\/stevebridger\/{size}\/32705.png" + "avatar_template":"/images/avatar.png" }, { "id": 10921, "username": "lnikkila", "uploaded_avatar_id": 32627, - "avatar_template": "\/user_avatar\/meta.discourse.org\/lnikkila\/{size}\/32627.png" + "avatar_template":"/images/avatar.png" }, { "id": 8493, "username": "PJH", "uploaded_avatar_id": 33082, - "avatar_template": "\/user_avatar\/meta.discourse.org\/pjh\/{size}\/33082.png" + "avatar_template":"/images/avatar.png" }, { "id": 10635, "username": "Ganzuelo", "uploaded_avatar_id": 32217, - "avatar_template": "\/user_avatar\/meta.discourse.org\/ganzuelo\/{size}\/32217.png" + "avatar_template":"/images/avatar.png" }, { "id": 8300, "username": "cpradio", "uploaded_avatar_id": 4970, - "avatar_template": "\/user_avatar\/meta.discourse.org\/cpradio\/{size}\/4970.png" + "avatar_template":"/images/avatar.png" }, { "id": 8571, "username": "tobiaseigen", "uploaded_avatar_id": 9785, - "avatar_template": "\/user_avatar\/meta.discourse.org\/tobiaseigen\/{size}\/9785.png" + "avatar_template":"/images/avatar.png" }, { "id": 4263, "username": "mcwumbly", "uploaded_avatar_id": 9796, - "avatar_template": "\/user_avatar\/meta.discourse.org\/mcwumbly\/{size}\/9796.png" + "avatar_template":"/images/avatar.png" }, { "id": 471, "username": "BhaelOchon", "uploaded_avatar_id": 6069, - "avatar_template": "\/user_avatar\/meta.discourse.org\/bhaelochon\/{size}\/6069.png" + "avatar_template":"/images/avatar.png" }, { "id": 5249, "username": "cawas", "uploaded_avatar_id": 14043, - "avatar_template": "\/user_avatar\/meta.discourse.org\/cawas\/{size}\/14043.png" + "avatar_template":"/images/avatar.png" }, { "id": 5461, "username": "thepractice", "uploaded_avatar_id": 2397, - "avatar_template": "\/user_avatar\/meta.discourse.org\/thepractice\/{size}\/2397.png" + "avatar_template":"/images/avatar.png" }, { "id": 10467, "username": "chris18890", "uploaded_avatar_id": 31806, - "avatar_template": "\/user_avatar\/meta.discourse.org\/chris18890\/{size}\/31806.png" + "avatar_template":"/images/avatar.png" }, { "id": 375, "username": "weirdcanada", "uploaded_avatar_id": 5902, - "avatar_template": "\/user_avatar\/meta.discourse.org\/weirdcanada\/{size}\/5902.png" + "avatar_template":"/images/avatar.png" }, { "id": 8617, "username": "Mittineague", "uploaded_avatar_id": 4462, - "avatar_template": "\/user_avatar\/meta.discourse.org\/mittineague\/{size}\/4462.png" + "avatar_template":"/images/avatar.png" }, { "id": 5962, "username": "TheMarkus", "uploaded_avatar_id": 15186, - "avatar_template": "\/user_avatar\/meta.discourse.org\/themarkus\/{size}\/15186.png" + "avatar_template":"/images/avatar.png" }, { "id": 2806, "username": "fayimora", "uploaded_avatar_id": 10007, - "avatar_template": "\/user_avatar\/meta.discourse.org\/fayimora\/{size}\/10007.png" + "avatar_template":"/images/avatar.png" }, { "id": 8364, "username": "codetricity", "uploaded_avatar_id": 3773, - "avatar_template": "\/user_avatar\/meta.discourse.org\/codetricity\/{size}\/3773.png" + "avatar_template":"/images/avatar.png" }, { "id": 3752, "username": "liberatiluca", "uploaded_avatar_id": 11568, - "avatar_template": "\/user_avatar\/meta.discourse.org\/liberatiluca\/{size}\/11568.png" + "avatar_template":"/images/avatar.png" }, { "id": 3483, "username": "Packetknife", "uploaded_avatar_id": 11144, - "avatar_template": "\/user_avatar\/meta.discourse.org\/packetknife\/{size}\/11144.png" + "avatar_template":"/images/avatar.png" }, { "id": 32, "username": "codinghorror", "uploaded_avatar_id": 5297, - "avatar_template": "\/user_avatar\/meta.discourse.org\/codinghorror\/{size}\/5297.png" + "avatar_template":"/images/avatar.png" }, { "id": 19, "username": "eviltrout", "uploaded_avatar_id": 5275, - "avatar_template": "\/user_avatar\/meta.discourse.org\/eviltrout\/{size}\/5275.png" + "avatar_template":"/images/avatar.png" }, { "id": 7229, "username": "DavidGNavas", "uploaded_avatar_id": 17081, - "avatar_template": "\/user_avatar\/meta.discourse.org\/davidgnavas\/{size}\/17081.png" + "avatar_template":"/images/avatar.png" }, { "id": 1219, "username": "Gweebz", "uploaded_avatar_id": 7304, - "avatar_template": "\/user_avatar\/meta.discourse.org\/gweebz\/{size}\/7304.png" + "avatar_template":"/images/avatar.png" }, { "id": 7743, "username": "ZeroFlux", "uploaded_avatar_id": 2256, - "avatar_template": "\/user_avatar\/meta.discourse.org\/zeroflux\/{size}\/2256.png" + "avatar_template":"/images/avatar.png" }, { "id": 8510, "username": "tannerfilip", "uploaded_avatar_id": 18674, - "avatar_template": "\/user_avatar\/meta.discourse.org\/tannerfilip\/{size}\/18674.png" + "avatar_template":"/images/avatar.png" }, { "id": 1496, "username": "cfstras", "uploaded_avatar_id": 7776, - "avatar_template": "\/user_avatar\/meta.discourse.org\/cfstras\/{size}\/7776.png" + "avatar_template":"/images/avatar.png" }, { "id": 3986, "username": "creativetech", "uploaded_avatar_id": 11955, - "avatar_template": "\/user_avatar\/meta.discourse.org\/creativetech\/{size}\/11955.png" + "avatar_template":"/images/avatar.png" }, { "id": 3800, "username": "stealthii", "uploaded_avatar_id": 11645, - "avatar_template": "\/user_avatar\/meta.discourse.org\/stealthii\/{size}\/11645.png" + "avatar_template":"/images/avatar.png" }, { "id": 6613, "username": "haiku", "uploaded_avatar_id": 9781, - "avatar_template": "\/user_avatar\/meta.discourse.org\/haiku\/{size}\/9781.png" + "avatar_template":"/images/avatar.png" }, { "id": 5351, "username": "erlend_sh", "uploaded_avatar_id": 9794, - "avatar_template": "\/user_avatar\/meta.discourse.org\/erlend_sh\/{size}\/9794.png" + "avatar_template":"/images/avatar.png" }, { "id": 5983, "username": "JohnSReid", "uploaded_avatar_id": 32238, - "avatar_template": "\/user_avatar\/meta.discourse.org\/johnsreid\/{size}\/32238.png" + "avatar_template":"/images/avatar.png" }, { "id": 701, "username": "johncoder", "uploaded_avatar_id": 6447, - "avatar_template": "\/user_avatar\/meta.discourse.org\/johncoder\/{size}\/6447.png" + "avatar_template":"/images/avatar.png" }, { "id": 5707, "username": "trident", "uploaded_avatar_id": 31178, - "avatar_template": "\/user_avatar\/meta.discourse.org\/trident\/{size}\/31178.png" + "avatar_template":"/images/avatar.png" }, { "id": 255, "username": "uwe_keim", "uploaded_avatar_id": 5697, - "avatar_template": "\/user_avatar\/meta.discourse.org\/uwe_keim\/{size}\/5697.png" + "avatar_template":"/images/avatar.png" }, { "id": 9931, "username": "Frank", "uploaded_avatar_id": 32861, - "avatar_template": "\/user_avatar\/meta.discourse.org\/frank\/{size}\/32861.png" + "avatar_template":"/images/avatar.png" }, { "id": 5543, "username": "trevor", "uploaded_avatar_id": 14507, - "avatar_template": "\/user_avatar\/meta.discourse.org\/trevor\/{size}\/14507.png" + "avatar_template":"/images/avatar.png" }, { "id": 3987, "username": "Sander78", "uploaded_avatar_id": 9787, - "avatar_template": "\/user_avatar\/meta.discourse.org\/sander78\/{size}\/9787.png" + "avatar_template":"/images/avatar.png" }, { "id": 7850, "username": "tudorv", "uploaded_avatar_id": 2568, - "avatar_template": "\/user_avatar\/meta.discourse.org\/tudorv\/{size}\/2568.png" + "avatar_template":"/images/avatar.png" }, { "id": 6653, "username": "amitfrid", "uploaded_avatar_id": 16262, - "avatar_template": "\/user_avatar\/meta.discourse.org\/amitfrid\/{size}\/16262.png" + "avatar_template":"/images/avatar.png" }, { "id": 4419, "username": "sasivarnakumar", "uploaded_avatar_id": 12661, - "avatar_template": "\/user_avatar\/meta.discourse.org\/sasivarnakumar\/{size}\/12661.png" + "avatar_template":"/images/avatar.png" }, { "id": 5710, "username": "elvanja", "uploaded_avatar_id": 14781, - "avatar_template": "\/user_avatar\/meta.discourse.org\/elvanja\/{size}\/14781.png" + "avatar_template":"/images/avatar.png" }, { "id": 5401, "username": "nilaykumar", "uploaded_avatar_id": 14275, - "avatar_template": "\/user_avatar\/meta.discourse.org\/nilaykumar\/{size}\/14275.png" + "avatar_template":"/images/avatar.png" }, { "id": 6809, "username": "buster", "uploaded_avatar_id": 31175, - "avatar_template": "\/user_avatar\/meta.discourse.org\/buster\/{size}\/31175.png" + "avatar_template":"/images/avatar.png" }, { "id": 169, "username": "blowmage", "uploaded_avatar_id": 5545, - "avatar_template": "\/user_avatar\/meta.discourse.org\/blowmage\/{size}\/5545.png" + "avatar_template":"/images/avatar.png" }, { "id": 766, "username": "dworthley", "uploaded_avatar_id": 6561, - "avatar_template": "\/user_avatar\/meta.discourse.org\/dworthley\/{size}\/6561.png" + "avatar_template":"/images/avatar.png" }, { "id": 1612, "username": "trottier", "uploaded_avatar_id": 7977, - "avatar_template": "\/user_avatar\/meta.discourse.org\/trottier\/{size}\/7977.png" + "avatar_template":"/images/avatar.png" }, { "id": 6019, "username": "mandie", "uploaded_avatar_id": 15273, - "avatar_template": "\/user_avatar\/meta.discourse.org\/mandie\/{size}\/15273.png" + "avatar_template":"/images/avatar.png" }, { "id": 3724, "username": "Manikin75", "uploaded_avatar_id": 11520, - "avatar_template": "\/user_avatar\/meta.discourse.org\/manikin75\/{size}\/11520.png" + "avatar_template":"/images/avatar.png" }, { "id": 1556, "username": "OfferKaye", "uploaded_avatar_id": 7878, - "avatar_template": "\/user_avatar\/meta.discourse.org\/offerkaye\/{size}\/7878.png" + "avatar_template":"/images/avatar.png" }, { "id": 4063, "username": "blanco", "uploaded_avatar_id": 12082, - "avatar_template": "\/user_avatar\/meta.discourse.org\/blanco\/{size}\/12082.png" + "avatar_template":"/images/avatar.png" }, { "id": 1621, "username": "bnb", "uploaded_avatar_id": 7992, - "avatar_template": "\/user_avatar\/meta.discourse.org\/bnb\/{size}\/7992.png" + "avatar_template":"/images/avatar.png" }, { "id": 3095, "username": "ayush", "uploaded_avatar_id": 10504, - "avatar_template": "\/user_avatar\/meta.discourse.org\/ayush\/{size}\/10504.png" + "avatar_template":"/images/avatar.png" }, { "id": 754, "username": "danneu", "uploaded_avatar_id": 6540, - "avatar_template": "\/user_avatar\/meta.discourse.org\/danneu\/{size}\/6540.png" + "avatar_template":"/images/avatar.png" }, { "id": 6548, "username": "michaeld", "uploaded_avatar_id": 1594, - "avatar_template": "\/user_avatar\/meta.discourse.org\/michaeld\/{size}\/1594.png" + "avatar_template":"/images/avatar.png" }, { "id": 4457, "username": "Lee_Ars", "uploaded_avatar_id": 1597, - "avatar_template": "\/user_avatar\/meta.discourse.org\/lee_ars\/{size}\/1597.png" + "avatar_template":"/images/avatar.png" }, { "id": 5160, "username": "eriko", "uploaded_avatar_id": 1915, - "avatar_template": "\/user_avatar\/meta.discourse.org\/eriko\/{size}\/1915.png" + "avatar_template":"/images/avatar.png" }, { "id": 10150, "username": "ampburner", "uploaded_avatar_id": 5103, - "avatar_template": "\/user_avatar\/meta.discourse.org\/ampburner\/{size}\/5103.png" + "avatar_template":"/images/avatar.png" }, { "id": 1, "username": "sam", "uploaded_avatar_id": 5243, - "avatar_template": "\/user_avatar\/meta.discourse.org\/sam\/{size}\/5243.png" + "avatar_template":"/images/avatar.png" }, { "id": 1995, "username": "zogstrip", "uploaded_avatar_id": 8630, - "avatar_template": "\/user_avatar\/meta.discourse.org\/zogstrip\/{size}\/8630.png" + "avatar_template":"/images/avatar.png" }, { "id": 9536, "username": "nahtnam", "uploaded_avatar_id": 20077, - "avatar_template": "\/user_avatar\/meta.discourse.org\/nahtnam\/{size}\/20077.png" + "avatar_template":"/images/avatar.png" }, { "id": 5559, "username": "downey", "uploaded_avatar_id": 14532, - "avatar_template": "\/user_avatar\/meta.discourse.org\/downey\/{size}\/14532.png" + "avatar_template":"/images/avatar.png" }, { "id": 6626, "username": "riking", "uploaded_avatar_id": 9779, - "avatar_template": "\/user_avatar\/meta.discourse.org\/riking\/{size}\/9779.png" + "avatar_template":"/images/avatar.png" }, { "id": 562, "username": "nightpool", "uploaded_avatar_id": 6220, - "avatar_template": "\/user_avatar\/meta.discourse.org\/nightpool\/{size}\/6220.png" + "avatar_template":"/images/avatar.png" }, { "id": 2770, "username": "awesomerobot", "uploaded_avatar_id": 32393, - "avatar_template": "\/user_avatar\/meta.discourse.org\/awesomerobot\/{size}\/32393.png" + "avatar_template":"/images/avatar.png" }, { "id": 4385, "username": "jeans", "uploaded_avatar_id": 12606, - "avatar_template": "\/user_avatar\/meta.discourse.org\/jeans\/{size}\/12606.png" + "avatar_template":"/images/avatar.png" }, { "id": 8222, "username": "techAPJ", "uploaded_avatar_id": 3281, - "avatar_template": "\/user_avatar\/meta.discourse.org\/techapj\/{size}\/3281.png" + "avatar_template":"/images/avatar.png" }, { "id": 1274, "username": "binaryphile", "uploaded_avatar_id": 7399, - "avatar_template": "\/user_avatar\/meta.discourse.org\/binaryphile\/{size}\/7399.png" + "avatar_template":"/images/avatar.png" }, { "id": 15, "username": "Hanzo", "uploaded_avatar_id": 5267, - "avatar_template": "\/user_avatar\/meta.discourse.org\/hanzo\/{size}\/5267.png" + "avatar_template":"/images/avatar.png" }, { "id": 5199, "username": "sefier", "uploaded_avatar_id": 31207, - "avatar_template": "\/user_avatar\/meta.discourse.org\/sefier\/{size}\/31207.png" + "avatar_template":"/images/avatar.png" }, { "id": 2316, "username": "pakl", "uploaded_avatar_id": 9157, - "avatar_template": "\/user_avatar\/meta.discourse.org\/pakl\/{size}\/9157.png" + "avatar_template":"/images/avatar.png" }, { "id": 393, "username": "freney", "uploaded_avatar_id": 5932, - "avatar_template": "\/user_avatar\/meta.discourse.org\/freney\/{size}\/5932.png" + "avatar_template":"/images/avatar.png" }, { "id": 8492, "username": "Onaldan", "uploaded_avatar_id": 18651, - "avatar_template": "\/user_avatar\/meta.discourse.org\/onaldan\/{size}\/18651.png" + "avatar_template":"/images/avatar.png" }, { "id": 5002, "username": "jakeberger", "uploaded_avatar_id": 13630, - "avatar_template": "\/user_avatar\/meta.discourse.org\/jakeberger\/{size}\/13630.png" + "avatar_template":"/images/avatar.png" }, { "id": 2544, "username": "davideyre", "uploaded_avatar_id": 9543, - "avatar_template": "\/user_avatar\/meta.discourse.org\/davideyre\/{size}\/9543.png" + "avatar_template":"/images/avatar.png" }, { "id": 8342, "username": "sethuv", "uploaded_avatar_id": 3036, - "avatar_template": "\/user_avatar\/meta.discourse.org\/sethuv\/{size}\/3036.png" + "avatar_template":"/images/avatar.png" }, { "id": 1128, "username": "Tigraine", "uploaded_avatar_id": 7152, - "avatar_template": "\/user_avatar\/meta.discourse.org\/tigraine\/{size}\/7152.png" + "avatar_template":"/images/avatar.png" }, { "id": 2477, "username": "billybonks", "uploaded_avatar_id": 9430, - "avatar_template": "\/user_avatar\/meta.discourse.org\/billybonks\/{size}\/9430.png" + "avatar_template":"/images/avatar.png" }, { "id": 4549, "username": "davidcelis", "uploaded_avatar_id": 12882, - "avatar_template": "\/user_avatar\/meta.discourse.org\/davidcelis\/{size}\/12882.png" + "avatar_template":"/images/avatar.png" }, { "id": 7264, "username": "etrowbridge", "uploaded_avatar_id": 31199, - "avatar_template": "\/user_avatar\/meta.discourse.org\/etrowbridge\/{size}\/31199.png" + "avatar_template":"/images/avatar.png" }, { "id": 413, "username": "adam_baldwin", "uploaded_avatar_id": 5962, - "avatar_template": "\/user_avatar\/meta.discourse.org\/adam_baldwin\/{size}\/5962.png" + "avatar_template":"/images/avatar.png" }, { "id": 8658, "username": "Datachick", "uploaded_avatar_id": 18865, - "avatar_template": "\/user_avatar\/meta.discourse.org\/datachick\/{size}\/18865.png" + "avatar_template":"/images/avatar.png" }, { "id": 5294, "username": "madbomber", "uploaded_avatar_id": 14118, - "avatar_template": "\/user_avatar\/meta.discourse.org\/madbomber\/{size}\/14118.png" + "avatar_template":"/images/avatar.png" }, { "id": 4750, "username": "dainbinder", "uploaded_avatar_id": 13220, - "avatar_template": "\/user_avatar\/meta.discourse.org\/dainbinder\/{size}\/13220.png" + "avatar_template":"/images/avatar.png" }, { "id": 2735, "username": "royce_williams", "uploaded_avatar_id": 9887, - "avatar_template": "\/user_avatar\/meta.discourse.org\/royce_williams\/{size}\/9887.png" + "avatar_template":"/images/avatar.png" }, { "id": 9089, "username": "Keezer", "uploaded_avatar_id": 31186, - "avatar_template": "\/user_avatar\/meta.discourse.org\/keezer\/{size}\/31186.png" + "avatar_template":"/images/avatar.png" } ], "user_badges": [ @@ -1677,4 +1677,4 @@ export default { } ] } -} +}; diff --git a/test/javascripts/fixtures/directory-fixtures.js.es6 b/test/javascripts/fixtures/directory-fixtures.js.es6 index cb70d2aa7e..82ae8d6090 100644 --- a/test/javascripts/fixtures/directory-fixtures.js.es6 +++ b/test/javascripts/fixtures/directory-fixtures.js.es6 @@ -1,3 +1,3 @@ export default { - "directory_items": {"directory_items":[{"id":32,"username":"codinghorror","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/codinghorror/{size}/2.png","time_read":"55d","likes_received":9370,"likes_given":7725,"topics_entered":11453,"topic_count":184,"post_count":12263},{"id":1,"username":"sam","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/sam/{size}/2.png","time_read":"52d","likes_received":7834,"likes_given":2693,"topics_entered":11024,"topic_count":276,"post_count":7802},{"id":19,"username":"eviltrout","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/eviltrout/{size}/2.png","time_read":"25d","likes_received":2383,"likes_given":319,"topics_entered":8041,"topic_count":34,"post_count":1602},{"id":6626,"username":"riking","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/riking/{size}/2.png","time_read":"17d","likes_received":2101,"likes_given":2756,"topics_entered":9055,"topic_count":163,"post_count":2548},{"id":1995,"username":"zogstrip","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/zogstrip/{size}/2.png","time_read":"32d","likes_received":1838,"likes_given":4588,"topics_entered":10823,"topic_count":16,"post_count":2050},{"id":8300,"username":"cpradio","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/cpradio/{size}/2.png","time_read":"11d","likes_received":1538,"likes_given":1001,"topics_entered":6121,"topic_count":111,"post_count":1430},{"id":2,"username":"neil","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/neil/{size}/2.png","time_read":"24d","likes_received":1238,"likes_given":684,"topics_entered":3250,"topic_count":27,"post_count":969},{"id":4263,"username":"mcwumbly","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/mcwumbly/{size}/2.png","time_read":"15d","likes_received":1223,"likes_given":1296,"topics_entered":5924,"topic_count":81,"post_count":1031},{"id":5351,"username":"erlend_sh","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/erlend_sh/{size}/2.png","time_read":"9d","likes_received":1115,"likes_given":747,"topics_entered":3260,"topic_count":154,"post_count":721},{"id":5559,"username":"downey","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/downey/{size}/2.png","time_read":"5d","likes_received":983,"likes_given":1713,"topics_entered":2995,"topic_count":131,"post_count":850},{"id":2770,"username":"awesomerobot","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/awesomerobot/{size}/2.png","time_read":"9d","likes_received":952,"likes_given":195,"topics_entered":2411,"topic_count":13,"post_count":402},{"id":9775,"username":"elberet","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/elberet/{size}/2.png","time_read":"7d","likes_received":930,"likes_given":159,"topics_entered":4077,"topic_count":28,"post_count":755},{"id":8222,"username":"techAPJ","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/techapj/{size}/2.png","time_read":"12d","likes_received":791,"likes_given":1005,"topics_entered":3691,"topic_count":43,"post_count":463},{"id":6060,"username":"lightyear","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/lightyear/{size}/2.png","time_read":"3d","likes_received":708,"likes_given":330,"topics_entered":1717,"topic_count":34,"post_count":312},{"id":8,"username":"geek","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/geek/{size}/2.png","time_read":"20d","likes_received":634,"likes_given":152,"topics_entered":920,"topic_count":48,"post_count":298},{"id":464,"username":"DeanMarkTaylor","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/deanmarktaylor/{size}/2.png","time_read":"10d","likes_received":578,"likes_given":299,"topics_entered":2976,"topic_count":116,"post_count":485},{"id":11160,"username":"boomzilla","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/boomzilla/{size}/2.png","time_read":"1d","likes_received":561,"likes_given":398,"topics_entered":822,"topic_count":23,"post_count":185},{"id":4457,"username":"Lee_Ars","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/lee_ars/{size}/2.png","time_read":"3d","likes_received":530,"likes_given":163,"topics_entered":2250,"topic_count":46,"post_count":327},{"id":8571,"username":"tobiaseigen","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/tobiaseigen/{size}/2.png","time_read":"6d","likes_received":524,"likes_given":1275,"topics_entered":2545,"topic_count":140,"post_count":435},{"id":3,"username":"supermathie","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/supermathie/{size}/2.png","time_read":"20d","likes_received":510,"likes_given":312,"topics_entered":1733,"topic_count":62,"post_count":438},{"id":8493,"username":"PJH","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/pjh/{size}/2.png","time_read":"3d","likes_received":458,"likes_given":96,"topics_entered":1219,"topic_count":74,"post_count":318},{"id":8617,"username":"Mittineague","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/mittineague/{size}/2.png","time_read":"16d","likes_received":415,"likes_given":291,"topics_entered":6662,"topic_count":22,"post_count":757},{"id":10778,"username":"Lid","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/lid/{size}/2.png","time_read":"8d","likes_received":398,"likes_given":296,"topics_entered":1771,"topic_count":82,"post_count":307},{"id":6548,"username":"michaeld","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/michaeld/{size}/2.png","time_read":"3d","likes_received":387,"likes_given":165,"topics_entered":1407,"topic_count":48,"post_count":330},{"id":471,"username":"BhaelOchon","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/bhaelochon/{size}/2.png","time_read":"22d","likes_received":386,"likes_given":765,"topics_entered":8051,"topic_count":55,"post_count":486},{"id":4881,"username":"gerhard","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/gerhard/{size}/2.png","time_read":"6d","likes_received":360,"likes_given":393,"topics_entered":3030,"topic_count":57,"post_count":250},{"id":5707,"username":"trident","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/trident/{size}/2.png","time_read":"6d","likes_received":344,"likes_given":181,"topics_entered":4905,"topic_count":2,"post_count":549},{"id":3987,"username":"Sander78","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/sander78/{size}/2.png","time_read":"3d","likes_received":326,"likes_given":276,"topics_entered":3613,"topic_count":94,"post_count":392},{"id":3415,"username":"radq","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/radq/{size}/2.png","time_read":"3d","likes_received":299,"likes_given":110,"topics_entered":2503,"topic_count":16,"post_count":157},{"id":2989,"username":"meglio","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/meglio/{size}/2.png","time_read":"3d","likes_received":280,"likes_given":436,"topics_entered":1086,"topic_count":198,"post_count":458},{"id":4,"username":"stienman","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/stienman/{size}/2.png","time_read":"15d","likes_received":276,"likes_given":100,"topics_entered":291,"topic_count":13,"post_count":132},{"id":10855,"username":"abarker","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/abarker/{size}/2.png","time_read":"1d","likes_received":270,"likes_given":131,"topics_entered":703,"topic_count":14,"post_count":77},{"id":9653,"username":"TechnoBear","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/technobear/{size}/2.png","time_read":"4d","likes_received":263,"likes_given":507,"topics_entered":2931,"topic_count":51,"post_count":220},{"id":7948,"username":"probus","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/probus/{size}/2.png","time_read":"6d","likes_received":261,"likes_given":71,"topics_entered":2399,"topic_count":51,"post_count":206},{"id":9741,"username":"chapel","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/chapel/{size}/2.png","time_read":"2d","likes_received":239,"likes_given":169,"topics_entered":1228,"topic_count":11,"post_count":167},{"id":8810,"username":"fantasticfears","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/fantasticfears/{size}/2.png","time_read":"4d","likes_received":213,"likes_given":184,"topics_entered":2161,"topic_count":29,"post_count":227},{"id":38,"username":"frandallfarmer","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/frandallfarmer/{size}/2.png","time_read":"19d","likes_received":212,"likes_given":104,"topics_entered":3169,"topic_count":6,"post_count":114},{"id":8085,"username":"watchmanmonitor","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/watchmanmonitor/{size}/2.png","time_read":"2d","likes_received":202,"likes_given":654,"topics_entered":1453,"topic_count":73,"post_count":278},{"id":8909,"username":"AdamCapriola","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/adamcapriola/{size}/2.png","time_read":"3d","likes_received":200,"likes_given":179,"topics_entered":1689,"topic_count":49,"post_count":169},{"id":14,"username":"clay","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/clay/{size}/2.png","time_read":"14d","likes_received":183,"likes_given":103,"topics_entered":780,"topic_count":24,"post_count":97},{"id":6613,"username":"haiku","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/haiku/{size}/2.png","time_read":"3d","likes_received":183,"likes_given":308,"topics_entered":1919,"topic_count":33,"post_count":188},{"id":13132,"username":"purldator","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/purldator/{size}/2.png","time_read":"4d","likes_received":178,"likes_given":685,"topics_entered":1891,"topic_count":20,"post_count":299},{"id":810,"username":"ChrisHanel","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/chrishanel/{size}/2.png","time_read":"16d","likes_received":169,"likes_given":42,"topics_entered":639,"topic_count":9,"post_count":94},{"id":2625,"username":"kpfleming","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/kpfleming/{size}/2.png","time_read":"10d","likes_received":165,"likes_given":288,"topics_entered":2539,"topic_count":15,"post_count":233},{"id":7717,"username":"lake54","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/lake54/{size}/2.png","time_read":"2d","likes_received":148,"likes_given":440,"topics_entered":1604,"topic_count":33,"post_count":194},{"id":8018,"username":"shivermetimbers","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/shivermetimbers/{size}/2.png","time_read":"15h","likes_received":139,"likes_given":40,"topics_entered":185,"topic_count":30,"post_count":181},{"id":2316,"username":"pakl","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/pakl/{size}/2.png","time_read":"14d","likes_received":135,"likes_given":198,"topics_entered":1034,"topic_count":46,"post_count":130},{"id":3681,"username":"Ajarn","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/ajarn/{size}/2.png","time_read":"3d","likes_received":126,"likes_given":664,"topics_entered":1893,"topic_count":46,"post_count":291},{"id":7229,"username":"DavidGNavas","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/davidgnavas/{size}/2.png","time_read":"4d","likes_received":124,"likes_given":210,"topics_entered":2032,"topic_count":17,"post_count":60},{"id":3062,"username":"Sailsman63","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/sailsman63/{size}/2.png","time_read":"8d","likes_received":124,"likes_given":139,"topics_entered":4257,"topic_count":10,"post_count":146}],"total_rows_directory_items":12546,"load_more_directory_items":"/directory_items?id=all&order=likes_received&page=1"} + "directory_items": {"directory_items":[{"id":32,"username":"codinghorror","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"55d","likes_received":9370,"likes_given":7725,"topics_entered":11453,"topic_count":184,"post_count":12263},{"id":1,"username":"sam","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"52d","likes_received":7834,"likes_given":2693,"topics_entered":11024,"topic_count":276,"post_count":7802},{"id":19,"username":"eviltrout","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"25d","likes_received":2383,"likes_given":319,"topics_entered":8041,"topic_count":34,"post_count":1602},{"id":6626,"username":"riking","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"17d","likes_received":2101,"likes_given":2756,"topics_entered":9055,"topic_count":163,"post_count":2548},{"id":1995,"username":"zogstrip","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"32d","likes_received":1838,"likes_given":4588,"topics_entered":10823,"topic_count":16,"post_count":2050},{"id":8300,"username":"cpradio","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"11d","likes_received":1538,"likes_given":1001,"topics_entered":6121,"topic_count":111,"post_count":1430},{"id":2,"username":"neil","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"24d","likes_received":1238,"likes_given":684,"topics_entered":3250,"topic_count":27,"post_count":969},{"id":4263,"username":"mcwumbly","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"15d","likes_received":1223,"likes_given":1296,"topics_entered":5924,"topic_count":81,"post_count":1031},{"id":5351,"username":"erlend_sh","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"9d","likes_received":1115,"likes_given":747,"topics_entered":3260,"topic_count":154,"post_count":721},{"id":5559,"username":"downey","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"5d","likes_received":983,"likes_given":1713,"topics_entered":2995,"topic_count":131,"post_count":850},{"id":2770,"username":"awesomerobot","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"9d","likes_received":952,"likes_given":195,"topics_entered":2411,"topic_count":13,"post_count":402},{"id":9775,"username":"elberet","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"7d","likes_received":930,"likes_given":159,"topics_entered":4077,"topic_count":28,"post_count":755},{"id":8222,"username":"techAPJ","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"12d","likes_received":791,"likes_given":1005,"topics_entered":3691,"topic_count":43,"post_count":463},{"id":6060,"username":"lightyear","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"3d","likes_received":708,"likes_given":330,"topics_entered":1717,"topic_count":34,"post_count":312},{"id":8,"username":"geek","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"20d","likes_received":634,"likes_given":152,"topics_entered":920,"topic_count":48,"post_count":298},{"id":464,"username":"DeanMarkTaylor","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"10d","likes_received":578,"likes_given":299,"topics_entered":2976,"topic_count":116,"post_count":485},{"id":11160,"username":"boomzilla","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"1d","likes_received":561,"likes_given":398,"topics_entered":822,"topic_count":23,"post_count":185},{"id":4457,"username":"Lee_Ars","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"3d","likes_received":530,"likes_given":163,"topics_entered":2250,"topic_count":46,"post_count":327},{"id":8571,"username":"tobiaseigen","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"6d","likes_received":524,"likes_given":1275,"topics_entered":2545,"topic_count":140,"post_count":435},{"id":3,"username":"supermathie","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"20d","likes_received":510,"likes_given":312,"topics_entered":1733,"topic_count":62,"post_count":438},{"id":8493,"username":"PJH","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"3d","likes_received":458,"likes_given":96,"topics_entered":1219,"topic_count":74,"post_count":318},{"id":8617,"username":"Mittineague","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"16d","likes_received":415,"likes_given":291,"topics_entered":6662,"topic_count":22,"post_count":757},{"id":10778,"username":"Lid","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"8d","likes_received":398,"likes_given":296,"topics_entered":1771,"topic_count":82,"post_count":307},{"id":6548,"username":"michaeld","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"3d","likes_received":387,"likes_given":165,"topics_entered":1407,"topic_count":48,"post_count":330},{"id":471,"username":"BhaelOchon","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"22d","likes_received":386,"likes_given":765,"topics_entered":8051,"topic_count":55,"post_count":486},{"id":4881,"username":"gerhard","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"6d","likes_received":360,"likes_given":393,"topics_entered":3030,"topic_count":57,"post_count":250},{"id":5707,"username":"trident","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"6d","likes_received":344,"likes_given":181,"topics_entered":4905,"topic_count":2,"post_count":549},{"id":3987,"username":"Sander78","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"3d","likes_received":326,"likes_given":276,"topics_entered":3613,"topic_count":94,"post_count":392},{"id":3415,"username":"radq","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"3d","likes_received":299,"likes_given":110,"topics_entered":2503,"topic_count":16,"post_count":157},{"id":2989,"username":"meglio","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"3d","likes_received":280,"likes_given":436,"topics_entered":1086,"topic_count":198,"post_count":458},{"id":4,"username":"stienman","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"15d","likes_received":276,"likes_given":100,"topics_entered":291,"topic_count":13,"post_count":132},{"id":10855,"username":"abarker","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"1d","likes_received":270,"likes_given":131,"topics_entered":703,"topic_count":14,"post_count":77},{"id":9653,"username":"TechnoBear","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"4d","likes_received":263,"likes_given":507,"topics_entered":2931,"topic_count":51,"post_count":220},{"id":7948,"username":"probus","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"6d","likes_received":261,"likes_given":71,"topics_entered":2399,"topic_count":51,"post_count":206},{"id":9741,"username":"chapel","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"2d","likes_received":239,"likes_given":169,"topics_entered":1228,"topic_count":11,"post_count":167},{"id":8810,"username":"fantasticfears","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"4d","likes_received":213,"likes_given":184,"topics_entered":2161,"topic_count":29,"post_count":227},{"id":38,"username":"frandallfarmer","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"19d","likes_received":212,"likes_given":104,"topics_entered":3169,"topic_count":6,"post_count":114},{"id":8085,"username":"watchmanmonitor","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"2d","likes_received":202,"likes_given":654,"topics_entered":1453,"topic_count":73,"post_count":278},{"id":8909,"username":"AdamCapriola","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"3d","likes_received":200,"likes_given":179,"topics_entered":1689,"topic_count":49,"post_count":169},{"id":14,"username":"clay","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"14d","likes_received":183,"likes_given":103,"topics_entered":780,"topic_count":24,"post_count":97},{"id":6613,"username":"haiku","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"3d","likes_received":183,"likes_given":308,"topics_entered":1919,"topic_count":33,"post_count":188},{"id":13132,"username":"purldator","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"4d","likes_received":178,"likes_given":685,"topics_entered":1891,"topic_count":20,"post_count":299},{"id":810,"username":"ChrisHanel","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"16d","likes_received":169,"likes_given":42,"topics_entered":639,"topic_count":9,"post_count":94},{"id":2625,"username":"kpfleming","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"10d","likes_received":165,"likes_given":288,"topics_entered":2539,"topic_count":15,"post_count":233},{"id":7717,"username":"lake54","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"2d","likes_received":148,"likes_given":440,"topics_entered":1604,"topic_count":33,"post_count":194},{"id":8018,"username":"shivermetimbers","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"15h","likes_received":139,"likes_given":40,"topics_entered":185,"topic_count":30,"post_count":181},{"id":2316,"username":"pakl","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"14d","likes_received":135,"likes_given":198,"topics_entered":1034,"topic_count":46,"post_count":130},{"id":3681,"username":"Ajarn","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"3d","likes_received":126,"likes_given":664,"topics_entered":1893,"topic_count":46,"post_count":291},{"id":7229,"username":"DavidGNavas","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"4d","likes_received":124,"likes_given":210,"topics_entered":2032,"topic_count":17,"post_count":60},{"id":3062,"username":"Sailsman63","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","time_read":"8d","likes_received":124,"likes_given":139,"topics_entered":4257,"topic_count":10,"post_count":146}],"total_rows_directory_items":12546,"load_more_directory_items":"/directory_items?id=all&order=likes_received&page=1"} }; diff --git a/test/javascripts/fixtures/discovery_fixtures.js.es6 b/test/javascripts/fixtures/discovery_fixtures.js.es6 index 5b3f65abe6..27bc78bd0c 100644 --- a/test/javascripts/fixtures/discovery_fixtures.js.es6 +++ b/test/javascripts/fixtures/discovery_fixtures.js.es6 @@ -1,7 +1,9 @@ /*jshint maxlen:10000000 */ export default { -"/latest.json": {"users":[{"id":7204,"username":"reyman64","avatar_template":"//www.gravatar.com/avatar/8efbecf0936eecea60da339fe33d3308.png?s={size}&r=pg&d=identicon"},{"id":1,"username":"sam","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon"},{"id":5481,"username":"f0rkz","avatar_template":"//www.gravatar.com/avatar/88fdabd9abac2a4a52034b955de3009f.png?s={size}&r=pg&d=identicon"},{"id":6473,"username":"jkf","avatar_template":"//www.gravatar.com/avatar/b58b357a352eda178941fd2dfd5c6d5d.png?s={size}&r=pg&d=identicon"},{"id":6973,"username":"stellarhopper","avatar_template":"//www.gravatar.com/avatar/b7c236cc7222b5646f94e05c7c8fe985.png?s={size}&r=pg&d=identicon"},{"id":19,"username":"eviltrout","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon"},{"id":14,"username":"clay","avatar_template":"//www.gravatar.com/avatar/e371bbd32ba2e9b27842e60ef5952d47.png?s={size}&r=pg&d=identicon"},{"id":32,"username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon"},{"id":1917,"username":"sil","avatar_template":"//www.gravatar.com/avatar/72a9ebaed35f880abb3418fe96ae6604.png?s={size}&r=pg&d=identicon"},{"id":7197,"username":"peeja","avatar_template":"//www.gravatar.com/avatar/d069ac0170dc6c93bad77734258fadae.png?s={size}&r=pg&d=identicon"},{"id":1995,"username":"zogstrip","avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon"},{"id":8021,"username":"Abhishek_Gupta","avatar_template":"//www.gravatar.com/avatar/bdab7e61b3191e483492fd680f563fed.png?s={size}&r=pg&d=identicon"},{"id":2291,"username":"PabloC","avatar_template":"//www.gravatar.com/avatar/82c793022ec1bce6ea7573bc27b2340b.png?s={size}&r=pg&d=identicon"},{"id":791,"username":"srid","avatar_template":"//www.gravatar.com/avatar/22b3cb05a29a72327beebdbeb81b71c0.png?s={size}&r=pg&d=identicon"},{"id":1580,"username":"ABillionSuns","avatar_template":"//www.gravatar.com/avatar/3b0a7729f7a3b5e5dfa6a6968670ae3a.png?s={size}&r=pg&d=identicon"},{"id":7270,"username":"mhurwi","avatar_template":"//www.gravatar.com/avatar/9d7ef290cb87ca79dd8ea7879c465dfb.png?s={size}&r=pg&d=identicon"},{"id":6695,"username":"illspirit","avatar_template":"//www.gravatar.com/avatar/20c057f893dc884e455f8c6798bda75b.png?s={size}&r=pg&d=identicon"},{"id":6929,"username":"BCHK","avatar_template":"//www.gravatar.com/avatar/8ff631bfa8be06bcf7bb4df99ecad0a5.png?s={size}&r=pg&d=identicon"},{"id":4385,"username":"jeans","avatar_template":"//www.gravatar.com/avatar/31ef0f1b48c6387a898ef685a21ad450.png?s={size}&r=pg&d=identicon"},{"id":7073,"username":"5an1ty","avatar_template":"//www.gravatar.com/avatar/2c346c47486696df101694f766c45527.png?s={size}&r=pg&d=identicon"},{"id":6626,"username":"riking","avatar_template":"//www.gravatar.com/avatar/5120fc4e345db0d1a964888272073819.png?s={size}&r=pg&d=identicon"},{"id":4457,"username":"Lee_Ars","avatar_template":"//localhost:3000/uploads/default/avatars/95a/06d/c337428568/{size}.png"},{"id":4263,"username":"mcwumbly","avatar_template":"//www.gravatar.com/avatar/e217128117fe24525c7af5ebc5e45745.png?s={size}&r=pg&d=identicon"},{"id":8134,"username":"iontishina","avatar_template":"//www.gravatar.com/avatar/fd21735919ef17cdb2a38416928a7d5c.png?s={size}&r=pg&d=identicon"},{"id":2072,"username":"nXqd","avatar_template":"//localhost:3000/uploads/default/avatars/139/21a/f9b00ec8d8/{size}.jpg"},{"id":4983,"username":"hey_julien","avatar_template":"//www.gravatar.com/avatar/3740fd652ab706c6b89b6f754448841a.png?s={size}&r=pg&d=identicon"},{"id":3657,"username":"steelmaiden","avatar_template":"//www.gravatar.com/avatar/ee057e3db79dbbf327ee1e2d3af3320d.png?s={size}&r=pg&d=identicon"},{"id":2624,"username":"BowlingX","avatar_template":"//www.gravatar.com/avatar/eb757280e318f17b5f642dead439b5af.png?s={size}&r=pg&d=identicon"},{"id":8085,"username":"watchmanmonitor","avatar_template":"//www.gravatar.com/avatar/035d12bad251759d8fbc9fb10574d1f6.png?s={size}&r=pg&d=identicon"},{"id":4612,"username":"Iszi","avatar_template":"//www.gravatar.com/avatar/8f8571493d71202986f2a6ab0dbd7c23.png?s={size}&r=pg&d=identicon"},{"id":8018,"username":"shivermetimbers","avatar_template":"//www.gravatar.com/avatar/9e3265239b765fddac477d206524e890.png?s={size}&r=pg&d=identicon"},{"id":6060,"username":"lightyear","avatar_template":"//www.gravatar.com/avatar/038e2caac4482e97ba6b24c3a88b86ff.png?s={size}&r=pg&d=identicon"},{"id":2,"username":"neil","avatar_template":"//www.gravatar.com/avatar/42776c4982dff1fa45ee8248532f8ad0.png?s={size}&r=pg&d=identicon"},{"id":8037,"username":"printec","avatar_template":"//www.gravatar.com/avatar/8d03345c5bf3aa1be8088e5e941b9a07.png?s={size}&r=pg&d=identicon"},{"id":3415,"username":"radq","avatar_template":"//www.gravatar.com/avatar/7739a4187adb56e033b41ce0f9ccad32.png?s={size}&r=pg&d=identicon"},{"id":6283,"username":"hrishikesh","avatar_template":"//www.gravatar.com/avatar/5b0cfe9c41209bc737445f199167d3ec.png?s={size}&r=pg&d=identicon"},{"id":471,"username":"BhaelOchon","avatar_template":"//www.gravatar.com/avatar/413ef976f0d2ca993005c9aee4769254.png?s={size}&r=pg&d=identicon"},{"id":6548,"username":"michaeld","avatar_template":"//localhost:3000/uploads/default/avatars/886/ea8/e533d87fd9/{size}.png"},{"id":7286,"username":"mrotsnahoj","avatar_template":"//www.gravatar.com/avatar/bb411d222dde32adf9a33bfb5219f1de.png?s={size}&r=pg&d=identicon"},{"id":3169,"username":"dgw","avatar_template":"//www.gravatar.com/avatar/f14e2f41c74347c49889cd87188e68b7.png?s={size}&r=pg&d=identicon"},{"id":926,"username":"martinnormark","avatar_template":"//www.gravatar.com/avatar/b1e46c0cd5af901b44d3c5fdeba5fd66.png?s={size}&r=pg&d=identicon"},{"id":2003,"username":"taylor","avatar_template":"//www.gravatar.com/avatar/b33e8a75c925b361be8ff9568e35b26c.png?s={size}&r=pg&d=identicon"},{"id":369,"username":"CvX","avatar_template":"//www.gravatar.com/avatar/040f75103040d887e6e32d607cb940a3.png?s={size}&r=pg&d=identicon"},{"id":562,"username":"nightpool","avatar_template":"//www.gravatar.com/avatar/d73164d2180b4cf6099526e42e33a7fd.png?s={size}&r=pg&d=identicon"},{"id":6653,"username":"amitfrid","avatar_template":"//www.gravatar.com/avatar/4033448216096fe8ce1344ddf49f911b.png?s={size}&r=pg&d=identicon"},{"id":6677,"username":"Tropnevad","avatar_template":"//www.gravatar.com/avatar/fcdf445ac4790ba630e59e3006156c39.png?s={size}&r=pg&d=identicon"},{"id":5048,"username":"SneakySly","avatar_template":"//www.gravatar.com/avatar/c062c74a11a5281e22a7f90fd080f3f1.png?s={size}&r=pg&d=identicon"},{"id":7333,"username":"Jong","avatar_template":"//www.gravatar.com/avatar/1ddb211471b2f128ecdad91d47b5cbd8.png?s={size}&r=pg&d=identicon"},{"id":3124,"username":"sipp11","avatar_template":"//www.gravatar.com/avatar/0598cfd42f00fa82223eff562a410ad5.png?s={size}&r=pg&d=identicon"},{"id":7604,"username":"citkane","avatar_template":"//localhost:3000/uploads/default/avatars/2a8/a3c/8fddcac642/{size}.jpg"},{"id":3929,"username":"ScotterC","avatar_template":"//www.gravatar.com/avatar/8441ad0b5f1b724aa9932691007afecb.png?s={size}&r=pg&d=identicon"},{"id":6680,"username":"cdman","avatar_template":"//www.gravatar.com/avatar/afc7fc83e87cf6bf786e93a1f658ebf8.png?s={size}&r=pg&d=identicon"},{"id":500,"username":"aeid","avatar_template":"//www.gravatar.com/avatar/048825f1d4395fca3184d8fb7820075c.png?s={size}&r=pg&d=identicon"},{"id":8,"username":"geek","avatar_template":"//www.gravatar.com/avatar/b0b1ce3a4e0a77abd157ec0309b72922.png?s={size}&r=pg&d=identicon"},{"id":606,"username":"Cafeine","avatar_template":"//www.gravatar.com/avatar/fc493376b162362a0580d1bd05aca740.png?s={size}&r=pg&d=identicon"}],"topic_list":{"can_create_topic":false,"more_topics_url":"/latest.json?page=1","draft":null,"draft_key":"new_topic","draft_sequence":null,"topics":[{"id":11557,"title":"Error after upgrade to 0.9.7.9+","fancy_title":"Error after upgrade to 0.9.7.9+","slug":"error-after-upgrade-to-0-9-7-9","posts_count":83,"reply_count":58,"highest_post_number":85,"image_url":null,"created_at":"2013-12-22T17:12:05.000-05:00","last_posted_at":"2014-01-16T00:52:30.000-05:00","bumped":true,"bumped_at":"2014-01-16T00:52:30.000-05:00","unseen":false,"pinned":true,"excerpt":"Hi, \n\nI'm using webfaction postgresql specific private instance to run discourse (custom port already configured for discourse 0.9.7.6). \n\nThis is not my first update, but this time i have an error. Impossible to upgrade…","visible":true,"closed":false,"archived":false,"views":1230,"like_count":40,"has_summary":true,"archetype":"regular","last_poster_username":"stellarhopper","category_id":17,"posters":[{"extras":null,"description":"Original Poster","user_id":7204},{"extras":null,"description":"Most Posts","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":5481},{"extras":null,"description":"Frequent Poster","user_id":6473},{"extras":"latest","description":"Most Recent Poster","user_id":6973}]},{"id":1,"title":"Welcome to meta.discourse.org","fancy_title":"Welcome to meta.discourse.org","slug":"welcome-to-meta-discourse-org","posts_count":5,"reply_count":5,"highest_post_number":23,"image_url":null,"created_at":"2013-01-31T23:52:28.000-05:00","last_posted_at":"2013-02-07T16:50:41.000-05:00","bumped":true,"bumped_at":"2013-02-07T11:57:34.000-05:00","unseen":false,"pinned":true,"excerpt":"Welcome to meta, the official site for discussing the next-gen open source Discourse forum software. You'll find topics on features, bugs, hosting, development, and general support here. \n\nDiscourse is early beta softwar…","visible":true,"closed":true,"archived":false,"views":13792,"like_count":108,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":17,"posters":[{"extras":null,"description":"Original Poster","user_id":1},{"extras":null,"description":"Most Posts","user_id":19},{"extras":null,"description":"Frequent Poster","user_id":14},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11997,"title":"Create topic in the future","fancy_title":"Create topic in the future","slug":"create-topic-in-the-future","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-16T12:14:36.000-05:00","last_posted_at":"2014-01-16T12:14:36.000-05:00","bumped":false,"bumped_at":"2014-01-16T12:14:36.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":7,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"sil","category_id":2,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":1917}]},{"id":11996,"title":"It's really hard to navigate the Create Topic / Reply pane with the keyboard","fancy_title":"It’s really hard to navigate the Create Topic / Reply pane with the keyboard","slug":"its-really-hard-to-navigate-the-create-topic-reply-pane-with-the-keyboard","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2014-01-16T10:51:36.000-05:00","last_posted_at":"2014-01-16T11:11:10.000-05:00","bumped":true,"bumped_at":"2014-01-16T11:11:10.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":12,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":9,"posters":[{"extras":null,"description":"Original Poster","user_id":7197},{"extras":"latest","description":"Most Recent Poster","user_id":1995}]},{"id":11994,"title":"Cross domain rules, followed?","fancy_title":"Cross domain rules, followed?","slug":"cross-domain-rules-followed","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":"/plugins/emoji/images/smile.png","created_at":"2014-01-16T09:59:15.000-05:00","last_posted_at":"2014-01-16T09:59:15.000-05:00","bumped":true,"bumped_at":"2014-01-16T11:04:32.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":15,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"Abhishek_Gupta","category_id":1,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":8021}]},{"id":11995,"title":"Discourse as a CAS Server","fancy_title":"Discourse as a CAS Server","slug":"discourse-as-a-cas-server","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-16T10:15:30.000-05:00","last_posted_at":"2014-01-16T10:15:31.000-05:00","bumped":true,"bumped_at":"2014-01-16T10:15:31.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":12,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"PabloC","category_id":6,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":2291}]},{"id":11993,"title":"How to check the user level via ajax?","fancy_title":"How to check the user level via ajax?","slug":"how-to-check-the-user-level-via-ajax","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-16T08:13:09.000-05:00","last_posted_at":"2014-01-16T08:13:09.000-05:00","bumped":true,"bumped_at":"2014-01-16T09:20:59.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":13,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"Abhishek_Gupta","category_id":7,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":8021}]},{"id":9540,"title":"Docker images for Discourse","fancy_title":"Docker images for Discourse","slug":"docker-images-for-discourse","posts_count":35,"reply_count":28,"highest_post_number":36,"image_url":null,"created_at":"2013-09-02T00:07:02.000-04:00","last_posted_at":"2014-01-16T07:47:18.000-05:00","bumped":true,"bumped_at":"2014-01-16T07:47:18.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":1322,"like_count":23,"has_summary":false,"archetype":"regular","last_poster_username":"illspirit","category_id":8,"posters":[{"extras":null,"description":"Original Poster","user_id":791},{"extras":null,"description":"Most Posts","user_id":1580},{"extras":null,"description":"Frequent Poster","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":7270},{"extras":"latest","description":"Most Recent Poster","user_id":6695}]},{"id":11957,"title":"Daily Active Users, Monthly Active Users - Statistics Need","fancy_title":"Daily Active Users, Monthly Active Users - Statistics Need","slug":"daily-active-users-monthly-active-users-statistics-need","posts_count":8,"reply_count":4,"highest_post_number":8,"image_url":null,"created_at":"2014-01-14T13:40:56.000-05:00","last_posted_at":"2014-01-16T06:46:05.000-05:00","bumped":true,"bumped_at":"2014-01-16T06:46:05.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":97,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"jeans","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":6929},{"extras":null,"description":"Most Posts","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":4385}]},{"id":11973,"title":"Pressing Wrench Icon in the Categories section","fancy_title":"Pressing Wrench Icon in the Categories section","slug":"pressing-wrench-icon-in-the-categories-section","posts_count":6,"reply_count":3,"highest_post_number":6,"image_url":"/uploads/default/2907/d8d4e0accd5ee244.png","created_at":"2014-01-15T05:58:12.000-05:00","last_posted_at":"2014-01-16T05:15:52.000-05:00","bumped":true,"bumped_at":"2014-01-16T05:15:52.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":46,"like_count":2,"has_summary":false,"archetype":"regular","last_poster_username":"5an1ty","category_id":9,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":7073},{"extras":null,"description":"Most Posts","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":6626}]},{"id":11835,"title":"The Road to Discourse 1.0","fancy_title":"The Road to Discourse 1.0","slug":"the-road-to-discourse-1-0","posts_count":6,"reply_count":2,"highest_post_number":6,"image_url":null,"created_at":"2014-01-08T19:08:44.000-05:00","last_posted_at":"2014-01-16T04:49:16.000-05:00","bumped":true,"bumped_at":"2014-01-16T04:49:16.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":421,"like_count":33,"has_summary":false,"archetype":"regular","last_poster_username":"iontishina","category_id":13,"posters":[{"extras":null,"description":"Original Poster","user_id":32},{"extras":null,"description":"Most Posts","user_id":4457},{"extras":null,"description":"Frequent Poster","user_id":4263},{"extras":"latest","description":"Most Recent Poster","user_id":8134}]},{"id":11992,"title":"Specific customization for each category","fancy_title":"Specific customization for each category","slug":"specific-customization-for-each-category","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-16T04:04:58.000-05:00","last_posted_at":"2014-01-16T04:04:58.000-05:00","bumped":false,"bumped_at":"2014-01-16T04:04:58.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":18,"like_count":2,"has_summary":false,"archetype":"regular","last_poster_username":"nXqd","category_id":2,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":2072}]},{"id":9214,"title":"Please make category url shorter","fancy_title":"Please make category url shorter","slug":"please-make-category-url-shorter","posts_count":9,"reply_count":3,"highest_post_number":9,"image_url":null,"created_at":"2013-08-20T05:28:17.000-04:00","last_posted_at":"2014-01-16T04:02:46.000-05:00","bumped":true,"bumped_at":"2014-01-16T04:02:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":319,"like_count":13,"has_summary":false,"archetype":"regular","last_poster_username":"nXqd","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":4983},{"extras":null,"description":"Most Posts","user_id":3657},{"extras":null,"description":"Frequent Poster","user_id":2624},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":2072}]},{"id":11989,"title":"Where to change the email subject prefix","fancy_title":"Where to change the email subject prefix","slug":"where-to-change-the-email-subject-prefix","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":"/uploads/default/2919/adbfe0ff90353440.png","created_at":"2014-01-16T01:03:48.000-05:00","last_posted_at":"2014-01-16T03:20:09.000-05:00","bumped":true,"bumped_at":"2014-01-16T03:20:09.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":19,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":6,"posters":[{"extras":null,"description":"Original Poster","user_id":8085},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":10866,"title":"Header logo overflows the top header area","fancy_title":"Header logo overflows the top header area","slug":"header-logo-overflows-the-top-header-area","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2013-11-09T03:40:04.000-05:00","last_posted_at":"2014-01-16T02:27:52.000-05:00","bumped":true,"bumped_at":"2014-01-16T02:40:47.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":157,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"stellarhopper","category_id":6,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":6973},{"extras":null,"description":"Most Posts","user_id":32}]},{"id":11988,"title":"Could not locate Gemfile error","fancy_title":"Could not locate Gemfile error","slug":"could-not-locate-gemfile-error","posts_count":7,"reply_count":3,"highest_post_number":7,"image_url":null,"created_at":"2014-01-16T00:41:57.000-05:00","last_posted_at":"2014-01-16T01:20:46.000-05:00","bumped":true,"bumped_at":"2014-01-16T01:20:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":18,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":6,"posters":[{"extras":null,"description":"Original Poster","user_id":6973},{"extras":"latest","description":"Most Recent Poster","user_id":1}]},{"id":6266,"title":"What sort of replies trigger a notice?","fancy_title":"What sort of replies trigger a notice?","slug":"what-sort-of-replies-trigger-a-notice","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2013-04-30T17:46:39.000-04:00","last_posted_at":"2014-01-16T00:52:21.000-05:00","bumped":true,"bumped_at":"2014-01-16T00:57:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":115,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":17,"posters":[{"extras":null,"description":"Original Poster","user_id":4612},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11610,"title":"Private replies that only admins can see","fancy_title":"Private replies that only admins can see","slug":"private-replies-that-only-admins-can-see","posts_count":21,"reply_count":20,"highest_post_number":23,"image_url":null,"created_at":"2013-12-26T20:31:10.000-05:00","last_posted_at":"2014-01-16T00:18:19.000-05:00","bumped":true,"bumped_at":"2014-01-16T00:18:19.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":206,"like_count":9,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":8018},{"extras":null,"description":"Most Posts","user_id":4263},{"extras":null,"description":"Frequent Poster","user_id":6060},{"extras":null,"description":"Frequent Poster","user_id":6626},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11888,"title":"Uncategorized topics not allowed, still seeing tag places","fancy_title":"Uncategorized topics not allowed, still seeing tag places","slug":"uncategorized-topics-not-allowed-still-seeing-tag-places","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":null,"created_at":"2014-01-10T19:23:37.000-05:00","last_posted_at":"2014-01-15T22:41:25.000-05:00","bumped":true,"bumped_at":"2014-01-15T22:41:25.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":50,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"illspirit","category_id":1,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":6695},{"extras":null,"description":"Most Posts","user_id":32},{"extras":null,"description":"Frequent Poster","user_id":2}]},{"id":11985,"title":"Installation nearly installs on Centos 6.5 with Apache/Phusion","fancy_title":"Installation nearly installs on Centos 6.5 with Apache/Phusion","slug":"installation-nearly-installs-on-centos-6-5-with-apache-phusion","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-15T19:48:30.000-05:00","last_posted_at":"2014-01-15T19:48:30.000-05:00","bumped":false,"bumped_at":"2014-01-15T19:48:30.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":26,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"printec","category_id":6,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":8037}]},{"id":11981,"title":"Excluding categories from the top view?","fancy_title":"Excluding categories from the top view?","slug":"excluding-categories-from-the-top-view","posts_count":6,"reply_count":1,"highest_post_number":6,"image_url":"/uploads/default/_optimized/f01/22f/7ea01f77b9_690x355.png","created_at":"2014-01-15T15:01:37.000-05:00","last_posted_at":"2014-01-15T18:57:52.000-05:00","bumped":true,"bumped_at":"2014-01-15T18:57:47.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":43,"like_count":6,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":3415},{"extras":null,"description":"Most Posts","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":1995}]},{"id":9408,"title":"Different home page for regular vs. new user","fancy_title":"Different home page for regular vs. new user","slug":"different-home-page-for-regular-vs-new-user","posts_count":25,"reply_count":17,"highest_post_number":25,"image_url":null,"created_at":"2013-08-28T09:54:41.000-04:00","last_posted_at":"2014-01-15T18:33:16.000-05:00","bumped":true,"bumped_at":"2014-01-15T18:33:16.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":334,"like_count":21,"has_summary":false,"archetype":"regular","last_poster_username":"mcwumbly","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":6283},{"extras":null,"description":"Most Posts","user_id":32},{"extras":null,"description":"Frequent Poster","user_id":1995},{"extras":null,"description":"Frequent Poster","user_id":471},{"extras":"latest","description":"Most Recent Poster","user_id":4263}]},{"id":11896,"title":"Problem creating new account","fancy_title":"Problem creating new account","slug":"problem-creating-new-account","posts_count":11,"reply_count":2,"highest_post_number":11,"image_url":null,"created_at":"2014-01-11T09:07:20.000-05:00","last_posted_at":"2014-01-15T20:50:05.000-05:00","bumped":true,"bumped_at":"2014-01-15T15:23:32.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":87,"like_count":6,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":6,"posters":[{"extras":null,"description":"Original Poster","user_id":6548},{"extras":null,"description":"Most Posts","user_id":32},{"extras":null,"description":"Frequent Poster","user_id":2},{"extras":"latest","description":"Most Recent Poster","user_id":1}]},{"id":10511,"title":"External urls should open in new tab","fancy_title":"External urls should open in new tab","slug":"external-urls-should-open-in-new-tab","posts_count":7,"reply_count":3,"highest_post_number":7,"image_url":null,"created_at":"2013-10-20T14:54:27.000-04:00","last_posted_at":"2014-01-15T14:02:11.000-05:00","bumped":true,"bumped_at":"2014-01-15T14:01:55.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":242,"like_count":10,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":7286},{"extras":null,"description":"Most Posts","user_id":3169},{"extras":null,"description":"Frequent Poster","user_id":4263},{"extras":null,"description":"Frequent Poster","user_id":6626},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":1589,"title":"Keyboard shortcuts?","fancy_title":"Keyboard shortcuts?","slug":"keyboard-shortcuts","posts_count":19,"reply_count":10,"highest_post_number":20,"image_url":null,"created_at":"2013-02-06T14:05:01.000-05:00","last_posted_at":"2014-01-15T13:52:45.000-05:00","bumped":true,"bumped_at":"2014-01-15T13:52:45.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":754,"like_count":31,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":926},{"extras":null,"description":"Most Posts","user_id":2003},{"extras":null,"description":"Frequent Poster","user_id":369},{"extras":null,"description":"Frequent Poster","user_id":562},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11763,"title":"Google AdSense plugin is now available","fancy_title":"Google AdSense plugin is now available","slug":"google-adsense-plugin-is-now-available","posts_count":7,"reply_count":2,"highest_post_number":7,"image_url":"/uploads/default/_optimized/66d/cf0/d69e6709fe_496x500.PNG","created_at":"2014-01-05T14:28:58.000-05:00","last_posted_at":"2014-01-15T13:32:35.000-05:00","bumped":true,"bumped_at":"2014-01-15T13:32:35.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":213,"like_count":14,"has_summary":false,"archetype":"regular","last_poster_username":"michaeld","category_id":5,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":6548},{"extras":null,"description":"Most Posts","user_id":6653},{"extras":null,"description":"Frequent Poster","user_id":6677},{"extras":null,"description":"Frequent Poster","user_id":5048},{"extras":null,"description":"Frequent Poster","user_id":7333}]},{"id":9151,"title":"Apple touch icon doesn't show if there is no sub domain","fancy_title":"Apple touch icon doesn’t show if there is no sub domain","slug":"apple-touch-icon-doesnt-show-if-there-is-no-sub-domain","posts_count":7,"reply_count":4,"highest_post_number":7,"image_url":null,"created_at":"2013-08-16T18:16:53.000-04:00","last_posted_at":"2014-01-15T17:10:18.000-05:00","bumped":true,"bumped_at":"2014-01-15T13:19:22.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":188,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":3124},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":11977,"title":"Show subcategory topics in categories list summary","fancy_title":"Show subcategory topics in categories list summary","slug":"show-subcategory-topics-in-categories-list-summary","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":"/uploads/default/_optimized/084/4e4/8af88c0839_571x500.png","created_at":"2014-01-15T12:09:49.000-05:00","last_posted_at":"2014-01-15T12:50:04.000-05:00","bumped":true,"bumped_at":"2014-01-15T12:50:04.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":32,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":7604},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":10201,"title":"How To override an existing handlebars template from plugin","fancy_title":"How To override an existing handlebars template from plugin","slug":"how-to-override-an-existing-handlebars-template-from-plugin","posts_count":6,"reply_count":1,"highest_post_number":6,"image_url":null,"created_at":"2013-10-04T10:44:33.000-04:00","last_posted_at":"2014-01-15T12:35:01.000-05:00","bumped":true,"bumped_at":"2014-01-15T12:34:58.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":325,"like_count":6,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":3929},{"extras":null,"description":"Most Posts","user_id":3415},{"extras":null,"description":"Frequent Poster","user_id":6680},{"extras":null,"description":"Frequent Poster","user_id":2},{"extras":"latest","description":"Most Recent Poster","user_id":1995}]},{"id":531,"title":"Discourse and Wordpress Integration","fancy_title":"Discourse and Wordpress Integration","slug":"discourse-and-wordpress-integration","posts_count":76,"reply_count":64,"highest_post_number":78,"image_url":null,"created_at":"2013-02-05T18:56:37.000-05:00","last_posted_at":"2014-01-15T11:56:54.000-05:00","bumped":true,"bumped_at":"2014-01-15T11:56:54.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":3809,"like_count":84,"has_summary":true,"archetype":"regular","last_poster_username":"codinghorror","category_id":5,"posters":[{"extras":null,"description":"Original Poster","user_id":500},{"extras":null,"description":"Most Posts","user_id":8},{"extras":null,"description":"Frequent Poster","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":606},{"extras":"latest","description":"Most Recent Poster","user_id":32}]}]}}, -"/categories.json": {"featured_users":[{"id":8021,"username":"Abhishek_Gupta","avatar_template":"//www.gravatar.com/avatar/bdab7e61b3191e483492fd680f563fed.png?s={size}&r=pg&d=identicon"},{"id":32,"username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon"},{"id":6695,"username":"illspirit","avatar_template":"//www.gravatar.com/avatar/20c057f893dc884e455f8c6798bda75b.png?s={size}&r=pg&d=identicon"},{"id":2,"username":"neil","avatar_template":"//www.gravatar.com/avatar/42776c4982dff1fa45ee8248532f8ad0.png?s={size}&r=pg&d=identicon"},{"id":1995,"username":"zogstrip","avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon"},{"id":1917,"username":"sil","avatar_template":"//www.gravatar.com/avatar/72a9ebaed35f880abb3418fe96ae6604.png?s={size}&r=pg&d=identicon"},{"id":4385,"username":"jeans","avatar_template":"//www.gravatar.com/avatar/31ef0f1b48c6387a898ef685a21ad450.png?s={size}&r=pg&d=identicon"},{"id":2072,"username":"nXqd","avatar_template":"//localhost:3000/uploads/default/avatars/139/21a/f9b00ec8d8/{size}.jpg"},{"id":4263,"username":"mcwumbly","avatar_template":"//www.gravatar.com/avatar/e217128117fe24525c7af5ebc5e45745.png?s={size}&r=pg&d=identicon"},{"id":2291,"username":"PabloC","avatar_template":"//www.gravatar.com/avatar/82c793022ec1bce6ea7573bc27b2340b.png?s={size}&r=pg&d=identicon"},{"id":6973,"username":"stellarhopper","avatar_template":"//www.gravatar.com/avatar/b7c236cc7222b5646f94e05c7c8fe985.png?s={size}&r=pg&d=identicon"},{"id":1,"username":"sam","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon"},{"id":8085,"username":"watchmanmonitor","avatar_template":"//www.gravatar.com/avatar/035d12bad251759d8fbc9fb10574d1f6.png?s={size}&r=pg&d=identicon"},{"id":5428,"username":"abbat","avatar_template":"//www.gravatar.com/avatar/8fdf603233c6a4328b8c943e2fabcf62.png?s={size}&r=pg&d=identicon"},{"id":8208,"username":"maximaximums","avatar_template":"//www.gravatar.com/avatar/950c1598c90f360489a4fb112dd153f7.png?s={size}&r=pg&d=identicon"},{"id":7995,"username":"Hunter","avatar_template":"//www.gravatar.com/avatar/fc0bb205dfe163a1f87c20ffaaa1c7ef.png?s={size}&r=pg&d=identicon"},{"id":7197,"username":"peeja","avatar_template":"//www.gravatar.com/avatar/d069ac0170dc6c93bad77734258fadae.png?s={size}&r=pg&d=identicon"},{"id":7073,"username":"5an1ty","avatar_template":"//www.gravatar.com/avatar/2c346c47486696df101694f766c45527.png?s={size}&r=pg&d=identicon"},{"id":6626,"username":"riking","avatar_template":"//www.gravatar.com/avatar/5120fc4e345db0d1a964888272073819.png?s={size}&r=pg&d=identicon"},{"id":6548,"username":"michaeld","avatar_template":"//localhost:3000/uploads/default/avatars/886/ea8/e533d87fd9/{size}.png"},{"id":8202,"username":"Matthieu","avatar_template":"//www.gravatar.com/avatar/ef7c64d1a92babba8d87df3436ecef68.png?s={size}&r=pg&d=identicon"},{"id":6677,"username":"Tropnevad","avatar_template":"//www.gravatar.com/avatar/fcdf445ac4790ba630e59e3006156c39.png?s={size}&r=pg&d=identicon"},{"id":7333,"username":"Jong","avatar_template":"//www.gravatar.com/avatar/1ddb211471b2f128ecdad91d47b5cbd8.png?s={size}&r=pg&d=identicon"},{"id":6018,"username":"robypez","avatar_template":"//www.gravatar.com/avatar/4d6c2e252349806a88636568da02efda.png?s={size}&r=pg&d=identicon"},{"id":1580,"username":"ABillionSuns","avatar_template":"//www.gravatar.com/avatar/3b0a7729f7a3b5e5dfa6a6968670ae3a.png?s={size}&r=pg&d=identicon"},{"id":7030,"username":"naabster","avatar_template":"//www.gravatar.com/avatar/58288ab0e5a4eb13d0fc509be3d3efc5.png?s={size}&r=pg&d=identicon"},{"id":8163,"username":"znation","avatar_template":"//www.gravatar.com/avatar/9cfd2536afac32d209335b092094c12c.png?s={size}&r=pg&d=identicon"},{"id":19,"username":"eviltrout","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon"},{"id":7796,"username":"almereyda","avatar_template":"//www.gravatar.com/avatar/62c40187f3eab76982681bfdce05baa9.png?s={size}&r=pg&d=identicon"},{"id":8024,"username":"stefanobernardi","avatar_template":"//www.gravatar.com/avatar/3a3455a5cc150d268e940d6a9c838fed.png?s={size}&r=pg&d=identicon"},{"id":5174,"username":"MaSe","avatar_template":"//www.gravatar.com/avatar/3e8ede783ef16c8234c03473a5b8780f.png?s={size}&r=pg&d=identicon"},{"id":4534,"username":"Julien","avatar_template":"//www.gravatar.com/avatar/9bc1f80f0ada847e6a306a36f1e62d0a.png?s={size}&r=pg&d=identicon"},{"id":2316,"username":"pakl","avatar_template":"//www.gravatar.com/avatar/42910619ef3d550e37f7150caa0d94ff.png?s={size}&r=pg&d=identicon"},{"id":4457,"username":"Lee_Ars","avatar_template":"//localhost:3000/uploads/default/avatars/95a/06d/c337428568/{size}.png"},{"id":8134,"username":"iontishina","avatar_template":"//www.gravatar.com/avatar/fd21735919ef17cdb2a38416928a7d5c.png?s={size}&r=pg&d=identicon"},{"id":8047,"username":"zooko","avatar_template":"//www.gravatar.com/avatar/8ebdb2638dbd7849787b9edb6e3f3509.png?s={size}&r=pg&d=identicon"},{"id":7483,"username":"jhogendorn","avatar_template":"//www.gravatar.com/avatar/c3b71275a0f542e5aac645bc421fa8c6.png?s={size}&r=pg&d=identicon"},{"id":5548,"username":"pdbradley","avatar_template":"//www.gravatar.com/avatar/696bf107459919d14d2d61af7c5e03d2.png?s={size}&r=pg&d=identicon"},{"id":4755,"username":"andanthor","avatar_template":"//www.gravatar.com/avatar/9cf8d6ee2ec2388f4d3431e73c2990c1.png?s={size}&r=pg&d=identicon"},{"id":7984,"username":"sophearak","avatar_template":"//www.gravatar.com/avatar/98ff4e4caf030d0b7c3c076d7e719032.png?s={size}&r=pg&d=identicon"},{"id":5351,"username":"erlend_sh","avatar_template":"//www.gravatar.com/avatar/69fda0df8b4878fb6a18deffa972d26a.png?s={size}&r=pg&d=identicon"}],"category_list":{"can_create_category":false,"can_create_topic":false,"draft":null,"draft_key":"new_topic","draft_sequence":null,"categories":[{"id":1,"name":"bug","color":"e9dd00","text_color":"000000","slug":"bug","topic_count":660,"description":"Bug reports on Discourse. Do be sure to search prior to submitting bugs. Include repro steps, and only describe one bug per topic please.","topic_url":"/t/category-definition-for-bug/2","read_restricted":false,"permission":null,"post_count":4318,"topics_day":0,"topics_week":18,"topics_month":54,"topics_year":658,"posts_day":0,"posts_week":330,"posts_month":574,"posts_year":4319,"description_excerpt":"Bug reports on Discourse. Do be sure to search prior to submitting bugs. Include repro steps, and only describe one bug per topic please.","featured_user_ids":[8021,32,6695,2,1995],"topics":[{"id":11994,"title":"Cross domain rules, followed?","fancy_title":"Cross domain rules, followed?","slug":"cross-domain-rules-followed","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":"/plugins/emoji/images/smile.png","created_at":"2014-01-16T09:59:15.000-05:00","last_posted_at":"2014-01-16T09:59:15.000-05:00","bumped":true,"bumped_at":"2014-01-16T11:04:32.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":8021,"username":"Abhishek_Gupta","avatar_template":"//www.gravatar.com/avatar/bdab7e61b3191e483492fd680f563fed.png?s={size}&r=pg&d=identicon"}},{"id":11888,"title":"Uncategorized topics not allowed, still seeing tag places","fancy_title":"Uncategorized topics not allowed, still seeing tag places","slug":"uncategorized-topics-not-allowed-still-seeing-tag-places","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":null,"created_at":"2014-01-10T19:23:37.000-05:00","last_posted_at":"2014-01-15T22:41:25.000-05:00","bumped":true,"bumped_at":"2014-01-15T22:41:25.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":6695,"username":"illspirit","avatar_template":"//www.gravatar.com/avatar/20c057f893dc884e455f8c6798bda75b.png?s={size}&r=pg&d=identicon"}},{"id":9151,"title":"Apple touch icon doesn't show if there is no sub domain","fancy_title":"Apple touch icon doesn’t show if there is no sub domain","slug":"apple-touch-icon-doesnt-show-if-there-is-no-sub-domain","posts_count":7,"reply_count":4,"highest_post_number":7,"image_url":null,"created_at":"2013-08-16T18:16:53.000-04:00","last_posted_at":"2014-01-15T17:10:18.000-05:00","bumped":true,"bumped_at":"2014-01-15T13:19:22.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon"}}]},{"id":2,"name":"feature","color":"0E76BD","text_color":"FFFFFF","slug":"feature","topic_count":727,"description":"Discussion about features or potential features of Discourse: how they work, why they work, etc.","topic_url":"/t/category-definition-for-feature/11","read_restricted":false,"permission":null,"post_count":6186,"topics_day":0,"topics_week":17,"topics_month":46,"topics_year":725,"posts_day":0,"posts_week":180,"posts_month":468,"posts_year":6187,"description_excerpt":"Discussion about features or potential features of Discourse: how they work, why they work, etc.","featured_user_ids":[1917,4385,2072,32,4263],"topics":[{"id":11997,"title":"Create topic in the future","fancy_title":"Create topic in the future","slug":"create-topic-in-the-future","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-16T12:14:36.000-05:00","last_posted_at":"2014-01-16T12:14:36.000-05:00","bumped":false,"bumped_at":"2014-01-16T12:14:36.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":1917,"username":"sil","avatar_template":"//www.gravatar.com/avatar/72a9ebaed35f880abb3418fe96ae6604.png?s={size}&r=pg&d=identicon"}},{"id":11957,"title":"Daily Active Users, Monthly Active Users - Statistics Need","fancy_title":"Daily Active Users, Monthly Active Users - Statistics Need","slug":"daily-active-users-monthly-active-users-statistics-need","posts_count":8,"reply_count":4,"highest_post_number":8,"image_url":null,"created_at":"2014-01-14T13:40:56.000-05:00","last_posted_at":"2014-01-16T06:46:05.000-05:00","bumped":true,"bumped_at":"2014-01-16T06:46:05.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":4385,"username":"jeans","avatar_template":"//www.gravatar.com/avatar/31ef0f1b48c6387a898ef685a21ad450.png?s={size}&r=pg&d=identicon"}},{"id":11992,"title":"Specific customization for each category","fancy_title":"Specific customization for each category","slug":"specific-customization-for-each-category","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-16T04:04:58.000-05:00","last_posted_at":"2014-01-16T04:04:58.000-05:00","bumped":false,"bumped_at":"2014-01-16T04:04:58.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":2072,"username":"nXqd","avatar_template":"//localhost:3000/uploads/default/avatars/139/21a/f9b00ec8d8/{size}.jpg"}}]},{"id":6,"name":"support","color":"b99","text_color":"FFFFFF","slug":"support","topic_count":782,"description":"Support on configuring, using, and installing Discourse. Not for software development related topics, but for admins and end users configuring and using Discourse.","topic_url":"/t/category-definition-for-support/389","read_restricted":false,"permission":null,"post_count":5396,"topics_day":0,"topics_week":16,"topics_month":67,"topics_year":779,"posts_day":0,"posts_week":122,"posts_month":481,"posts_year":5400,"description_excerpt":"Support on configuring, using, and installing Discourse. Not for software development related topics, but for admins and end users configuring and using Discourse.","featured_user_ids":[2291,32,6973,1,8085],"topics":[{"id":11995,"title":"Discourse as a CAS Server","fancy_title":"Discourse as a CAS Server","slug":"discourse-as-a-cas-server","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-16T10:15:30.000-05:00","last_posted_at":"2014-01-16T10:15:31.000-05:00","bumped":true,"bumped_at":"2014-01-16T10:15:31.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":2291,"username":"PabloC","avatar_template":"//www.gravatar.com/avatar/82c793022ec1bce6ea7573bc27b2340b.png?s={size}&r=pg&d=identicon"}},{"id":11989,"title":"Where to change the email subject prefix","fancy_title":"Where to change the email subject prefix","slug":"where-to-change-the-email-subject-prefix","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":"/uploads/default/2919/adbfe0ff90353440.png","created_at":"2014-01-16T01:03:48.000-05:00","last_posted_at":"2014-01-16T03:20:09.000-05:00","bumped":true,"bumped_at":"2014-01-16T03:20:09.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon"}},{"id":10866,"title":"Header logo overflows the top header area","fancy_title":"Header logo overflows the top header area","slug":"header-logo-overflows-the-top-header-area","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2013-11-09T03:40:04.000-05:00","last_posted_at":"2014-01-16T02:27:52.000-05:00","bumped":true,"bumped_at":"2014-01-16T02:40:47.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":6973,"username":"stellarhopper","avatar_template":"//www.gravatar.com/avatar/b7c236cc7222b5646f94e05c7c8fe985.png?s={size}&r=pg&d=identicon"}}]},{"id":7,"name":"dev","color":"000","text_color":"FFFFFF","slug":"dev","topic_count":284,"description":"This category is for topics related to hacking on Discourse: submitting pull requests, configuring development environments, coding conventions, and so forth.","topic_url":"/t/category-definition-for-dev/1026","read_restricted":false,"permission":null,"post_count":2352,"topics_day":0,"topics_week":3,"topics_month":19,"topics_year":284,"posts_day":0,"posts_week":37,"posts_month":150,"posts_year":2353,"description_excerpt":"This category is for topics related to hacking on Discourse: submitting pull requests, configuring development environments, coding conventions, and so forth.","featured_user_ids":[8021,1995,5428,8208,7995],"topics":[{"id":3823,"title":"So, you want to help out with Discourse","fancy_title":"So, you want to help out with Discourse","slug":"so-you-want-to-help-out-with-discourse","posts_count":22,"reply_count":28,"highest_post_number":56,"image_url":null,"created_at":"2013-02-23T00:46:11.000-05:00","last_posted_at":"2014-01-12T21:33:12.000-05:00","bumped":true,"bumped_at":"2014-01-12T21:33:12.000-05:00","unseen":false,"pinned":true,"excerpt":"People are wondering, how it is they can help out with Discourse. \n\nWe have seen some chattering both here and on Github. \n\nI wanted to create a topic @eviltrout , @codinghorror and myself can keep up to date with clear…","visible":true,"closed":false,"archived":false,"last_poster":{"id":7995,"username":"Hunter","avatar_template":"//www.gravatar.com/avatar/fc0bb205dfe163a1f87c20ffaaa1c7ef.png?s={size}&r=pg&d=identicon"}},{"id":11993,"title":"How to check the user level via ajax?","fancy_title":"How to check the user level via ajax?","slug":"how-to-check-the-user-level-via-ajax","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-16T08:13:09.000-05:00","last_posted_at":"2014-01-16T08:13:09.000-05:00","bumped":true,"bumped_at":"2014-01-16T09:20:59.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":8021,"username":"Abhishek_Gupta","avatar_template":"//www.gravatar.com/avatar/bdab7e61b3191e483492fd680f563fed.png?s={size}&r=pg&d=identicon"}},{"id":10201,"title":"How To override an existing handlebars template from plugin","fancy_title":"How To override an existing handlebars template from plugin","slug":"how-to-override-an-existing-handlebars-template-from-plugin","posts_count":6,"reply_count":1,"highest_post_number":6,"image_url":null,"created_at":"2013-10-04T10:44:33.000-04:00","last_posted_at":"2014-01-15T12:35:01.000-05:00","bumped":true,"bumped_at":"2014-01-15T12:34:58.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"last_poster":{"id":1995,"username":"zogstrip","avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon"}}]},{"id":9,"name":"ux","color":"5F497A","text_color":"FFFFFF","slug":"ux","topic_count":184,"description":"Discussion about the user interface of Discourse, how features are presented to the user in the client, including language and UI elements.","topic_url":"/t/category-definition-for-ux/2628","read_restricted":false,"permission":null,"post_count":1511,"topics_day":0,"topics_week":3,"topics_month":10,"topics_year":183,"posts_day":0,"posts_week":34,"posts_month":117,"posts_year":1511,"description_excerpt":"Discussion about the user interface of Discourse, how features are presented to the user in the client, including language and UI elements.","featured_user_ids":[1995,7197,7073,1,6626],"topics":[{"id":11996,"title":"It's really hard to navigate the Create Topic / Reply pane with the keyboard","fancy_title":"It’s really hard to navigate the Create Topic / Reply pane with the keyboard","slug":"its-really-hard-to-navigate-the-create-topic-reply-pane-with-the-keyboard","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2014-01-16T10:51:36.000-05:00","last_posted_at":"2014-01-16T11:11:10.000-05:00","bumped":true,"bumped_at":"2014-01-16T11:11:10.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":1995,"username":"zogstrip","avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon"}},{"id":11973,"title":"Pressing Wrench Icon in the Categories section","fancy_title":"Pressing Wrench Icon in the Categories section","slug":"pressing-wrench-icon-in-the-categories-section","posts_count":6,"reply_count":3,"highest_post_number":6,"image_url":"/uploads/default/2907/d8d4e0accd5ee244.png","created_at":"2014-01-15T05:58:12.000-05:00","last_posted_at":"2014-01-16T05:15:52.000-05:00","bumped":true,"bumped_at":"2014-01-16T05:15:52.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":7073,"username":"5an1ty","avatar_template":"//www.gravatar.com/avatar/2c346c47486696df101694f766c45527.png?s={size}&r=pg&d=identicon"}},{"id":5542,"title":"Title character requirements not very visible","fancy_title":"Title character requirements not very visible","slug":"title-character-requirements-not-very-visible","posts_count":24,"reply_count":11,"highest_post_number":24,"image_url":null,"created_at":"2013-04-02T20:09:59.000-04:00","last_posted_at":"2014-01-15T05:26:07.000-05:00","bumped":true,"bumped_at":"2014-01-15T05:26:04.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"last_poster":{"id":1995,"username":"zogstrip","avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon"}}]},{"id":5,"name":"extensibility","color":"FE8432","text_color":"FFFFFF","slug":"extensibility","topic_count":102,"description":"Topics about extending the functionality of Discourse with plugins, themes, add-ons, or other mechanisms for extensibility. ","topic_url":"/t/category-definition-for-extensibility/28","read_restricted":false,"permission":null,"post_count":964,"topics_day":0,"topics_week":2,"topics_month":18,"topics_year":102,"posts_day":0,"posts_week":17,"posts_month":76,"posts_year":964,"description_excerpt":"Topics about extending the functionality of Discourse with plugins, themes, add-ons, or other mechanisms for extensibility.","featured_user_ids":[6548,32,8202,6677,7333],"topics":[{"id":11763,"title":"Google AdSense plugin is now available","fancy_title":"Google AdSense plugin is now available","slug":"google-adsense-plugin-is-now-available","posts_count":7,"reply_count":2,"highest_post_number":7,"image_url":"/uploads/default/_optimized/66d/cf0/d69e6709fe_496x500.PNG","created_at":"2014-01-05T14:28:58.000-05:00","last_posted_at":"2014-01-15T13:32:35.000-05:00","bumped":true,"bumped_at":"2014-01-15T13:32:35.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":6548,"username":"michaeld","avatar_template":"//localhost:3000/uploads/default/avatars/886/ea8/e533d87fd9/{size}.png"}},{"id":531,"title":"Discourse and Wordpress Integration","fancy_title":"Discourse and Wordpress Integration","slug":"discourse-and-wordpress-integration","posts_count":76,"reply_count":64,"highest_post_number":78,"image_url":null,"created_at":"2013-02-05T18:56:37.000-05:00","last_posted_at":"2014-01-15T11:56:54.000-05:00","bumped":true,"bumped_at":"2014-01-15T11:56:54.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon"}},{"id":11965,"title":"In your opinion, what is the best wiki engine to be associated with discourse?","fancy_title":"In your opinion, what is the best wiki engine to be associated with discourse?","slug":"in-your-opinion-what-is-the-best-wiki-engine-to-be-associated-with-discourse","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-14T19:27:06.000-05:00","last_posted_at":"2014-01-14T19:27:06.000-05:00","bumped":false,"bumped_at":"2014-01-14T19:27:06.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":8202,"username":"Matthieu","avatar_template":"//www.gravatar.com/avatar/ef7c64d1a92babba8d87df3436ecef68.png?s={size}&r=pg&d=identicon"}}]},{"id":8,"name":"hosting","color":"74CCED","text_color":"FFFFFF","slug":"hosting","topic_count":69,"description":"Topics about hosting Discourse, either on your own servers, in the cloud, or with specific hosting services.","topic_url":"/t/category-definition-for-hosting/2626","read_restricted":false,"permission":null,"post_count":664,"topics_day":0,"topics_week":2,"topics_month":2,"topics_year":69,"posts_day":0,"posts_week":15,"posts_month":35,"posts_year":664,"description_excerpt":"Topics about hosting Discourse, either on your own servers, in the cloud, or with specific hosting services.","featured_user_ids":[6695,1,6018,1580,7030],"topics":[{"id":9540,"title":"Docker images for Discourse","fancy_title":"Docker images for Discourse","slug":"docker-images-for-discourse","posts_count":35,"reply_count":28,"highest_post_number":36,"image_url":null,"created_at":"2013-09-02T00:07:02.000-04:00","last_posted_at":"2014-01-16T07:47:18.000-05:00","bumped":true,"bumped_at":"2014-01-16T07:47:18.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":6695,"username":"illspirit","avatar_template":"//www.gravatar.com/avatar/20c057f893dc884e455f8c6798bda75b.png?s={size}&r=pg&d=identicon"}},{"id":11971,"title":"Installing Discourse on Ubuntu 12.04 with Parallels Plesk and Apache","fancy_title":"Installing Discourse on Ubuntu 12.04 with Parallels Plesk and Apache","slug":"installing-discourse-on-ubuntu-12-04-with-parallels-plesk-and-apache","posts_count":3,"reply_count":1,"highest_post_number":3,"image_url":null,"created_at":"2014-01-15T04:23:38.000-05:00","last_posted_at":"2014-01-15T04:47:20.000-05:00","bumped":true,"bumped_at":"2014-01-15T04:47:20.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":7030,"username":"naabster","avatar_template":"//www.gravatar.com/avatar/58288ab0e5a4eb13d0fc509be3d3efc5.png?s={size}&r=pg&d=identicon"}},{"id":10844,"title":"Discourse in a Docker container","fancy_title":"Discourse in a Docker container","slug":"discourse-in-a-docker-container","posts_count":12,"reply_count":8,"highest_post_number":12,"image_url":null,"created_at":"2013-11-07T19:12:22.000-05:00","last_posted_at":"2014-01-11T14:43:53.000-05:00","bumped":true,"bumped_at":"2014-01-11T14:43:53.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":1,"username":"sam","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon"}}]},{"id":17,"name":"uncategorized","color":"AB9364","text_color":"FFFFFF","slug":"uncategorized","topic_count":229,"description":"","topic_url":null,"read_restricted":false,"permission":null,"post_count":2138,"topics_day":0,"topics_week":0,"topics_month":9,"topics_year":229,"posts_day":1,"posts_week":11,"posts_month":183,"posts_year":2138,"description_excerpt":"","is_uncategorized":true,"featured_user_ids":[6973,32,1,1995,7073],"topics":[{"id":1,"title":"Welcome to meta.discourse.org","fancy_title":"Welcome to meta.discourse.org","slug":"welcome-to-meta-discourse-org","posts_count":5,"reply_count":5,"highest_post_number":23,"image_url":null,"created_at":"2013-01-31T23:52:28.000-05:00","last_posted_at":"2013-02-07T16:50:41.000-05:00","bumped":true,"bumped_at":"2013-02-07T11:57:34.000-05:00","unseen":false,"pinned":true,"excerpt":"Welcome to meta, the official site for discussing the next-gen open source Discourse forum software. You'll find topics on features, bugs, hosting, development, and general support here. \n\nDiscourse is early beta softwar…","visible":true,"closed":true,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon"}},{"id":11557,"title":"Error after upgrade to 0.9.7.9+","fancy_title":"Error after upgrade to 0.9.7.9+","slug":"error-after-upgrade-to-0-9-7-9","posts_count":83,"reply_count":58,"highest_post_number":85,"image_url":null,"created_at":"2013-12-22T17:12:05.000-05:00","last_posted_at":"2014-01-16T00:52:30.000-05:00","bumped":true,"bumped_at":"2014-01-16T00:52:30.000-05:00","unseen":false,"pinned":true,"excerpt":"Hi, \n\nI'm using webfaction postgresql specific private instance to run discourse (custom port already configured for discourse 0.9.7.6). \n\nThis is not my first update, but this time i have an error. Impossible to upgrade…","visible":true,"closed":false,"archived":false,"last_poster":{"id":6973,"username":"stellarhopper","avatar_template":"//www.gravatar.com/avatar/b7c236cc7222b5646f94e05c7c8fe985.png?s={size}&r=pg&d=identicon"}},{"id":6266,"title":"What sort of replies trigger a notice?","fancy_title":"What sort of replies trigger a notice?","slug":"what-sort-of-replies-trigger-a-notice","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2013-04-30T17:46:39.000-04:00","last_posted_at":"2014-01-16T00:52:21.000-05:00","bumped":true,"bumped_at":"2014-01-16T00:57:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon"}}]},{"id":11,"name":"login","color":"edb400","text_color":"FFFFFF","slug":"login","topic_count":27,"description":"Topics about logging in to Discourse, using any standard third party provider (Twitter, Facebook, Google), traditional username and password, or with a custom plugin.","topic_url":"/t/category-definition-for-login/2828","read_restricted":false,"permission":null,"post_count":200,"topics_day":0,"topics_week":1,"topics_month":1,"topics_year":27,"posts_day":0,"posts_week":10,"posts_month":27,"posts_year":200,"description_excerpt":"Topics about logging in to Discourse, using any standard third party provider (Twitter, Facebook, Google), traditional username and password, or with a custom plugin.","featured_user_ids":[8163,19,7796,32,8024],"topics":[{"id":11959,"title":"Get current user information via JSON","fancy_title":"Get current user information via JSON","slug":"get-current-user-information-via-json","posts_count":3,"reply_count":1,"highest_post_number":3,"image_url":null,"created_at":"2014-01-14T15:05:34.000-05:00","last_posted_at":"2014-01-14T16:43:28.000-05:00","bumped":true,"bumped_at":"2014-01-14T16:43:28.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":8163,"username":"znation","avatar_template":"//www.gravatar.com/avatar/9cfd2536afac32d209335b092094c12c.png?s={size}&r=pg&d=identicon"}},{"id":6242,"title":"Allow authentication via multiple services on one account","fancy_title":"Allow authentication via multiple services on one account","slug":"allow-authentication-via-multiple-services-on-one-account","posts_count":34,"reply_count":27,"highest_post_number":34,"image_url":null,"created_at":"2013-04-29T18:51:52.000-04:00","last_posted_at":"2014-01-14T00:25:42.000-05:00","bumped":true,"bumped_at":"2014-01-14T00:25:42.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":7796,"username":"almereyda","avatar_template":"//www.gravatar.com/avatar/62c40187f3eab76982681bfdce05baa9.png?s={size}&r=pg&d=identicon"}},{"id":4738,"title":"Login support for browser password managers","fancy_title":"Login support for browser password managers","slug":"login-support-for-browser-password-managers","posts_count":6,"reply_count":2,"highest_post_number":6,"image_url":null,"created_at":"2013-03-13T17:55:29.000-04:00","last_posted_at":"2014-01-13T14:21:34.000-05:00","bumped":true,"bumped_at":"2014-01-13T14:21:34.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon"}}]},{"id":3,"name":"meta","color":"aaa","text_color":"FFFFFF","slug":"meta","topic_count":79,"description":"Discussion about meta.discourse.org itself, the organization of this forum about Discourse, how it works, and how we can improve this site.","topic_url":"/t/category-definition-for-meta/24","read_restricted":false,"permission":null,"post_count":695,"topics_day":0,"topics_week":1,"topics_month":3,"topics_year":79,"posts_day":0,"posts_week":4,"posts_month":18,"posts_year":696,"description_excerpt":"Discussion about meta.discourse.org itself, the organization of this forum about Discourse, how it works, and how we can improve this site.","featured_user_ids":[19,8085,32,5174,4534],"topics":[{"id":5249,"title":"What is \"Meta\"?","fancy_title":"What is “Meta”?","slug":"what-is-meta","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2013-03-25T18:00:52.000-04:00","last_posted_at":"2013-03-25T18:00:56.000-04:00","bumped":false,"bumped_at":"2013-03-25T18:00:52.000-04:00","unseen":false,"pinned":true,"excerpt":"Meta means discussion of the discussion itself instead of the actual topic of the discussion. \n\nWhy do we need a meta category?\n\nMeta is where communities come together to decide who they are and what they are about. \n…","visible":true,"closed":false,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon"}},{"id":11943,"title":"How far to take user documentation?","fancy_title":"How far to take user documentation?","slug":"how-far-to-take-user-documentation","posts_count":4,"reply_count":2,"highest_post_number":4,"image_url":"/plugins/emoji/images/smile.png","created_at":"2014-01-13T19:21:26.000-05:00","last_posted_at":"2014-01-14T14:19:46.000-05:00","bumped":true,"bumped_at":"2014-01-14T14:19:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":19,"username":"eviltrout","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon"}},{"id":11822,"title":"Search engine traffic share and level to Discourse","fancy_title":"Search engine traffic share and level to Discourse","slug":"search-engine-traffic-share-and-level-to-discourse","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2014-01-08T01:54:56.000-05:00","last_posted_at":"2014-01-08T02:21:25.000-05:00","bumped":true,"bumped_at":"2014-01-08T02:21:25.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon"}}]},{"id":12,"name":"discourse hub","color":"b2c79f","text_color":"FFFFFF","slug":"discourse-hub","topic_count":4,"description":"Topics about current or future Discourse Hub functionality at discourse.org including nickname registration, global user pages, and the site directory.","topic_url":"/t/category-definition-for-discourse-hub/3038","read_restricted":false,"permission":null,"post_count":121,"topics_day":0,"topics_week":0,"topics_month":0,"topics_year":4,"posts_day":0,"posts_week":3,"posts_month":3,"posts_year":121,"description_excerpt":"Topics about current or future Discourse Hub functionality at discourse.org including nickname registration, global user pages, and the site directory.","featured_user_ids":[2,32,2316,6695,4457],"topics":[{"id":6547,"title":"Where to get discourse_org_access_key?","fancy_title":"Where to get discourse_org_access_key?","slug":"where-to-get-discourse-org-access-key","posts_count":13,"reply_count":4,"highest_post_number":13,"image_url":null,"created_at":"2013-05-10T22:06:08.000-04:00","last_posted_at":"2014-01-13T11:38:15.000-05:00","bumped":true,"bumped_at":"2014-01-13T11:38:15.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":2,"username":"neil","avatar_template":"//www.gravatar.com/avatar/42776c4982dff1fa45ee8248532f8ad0.png?s={size}&r=pg&d=identicon"}},{"id":2544,"title":"Discourse central hub questions","fancy_title":"Discourse central hub questions","slug":"discourse-central-hub-questions","posts_count":51,"reply_count":44,"highest_post_number":52,"image_url":null,"created_at":"2013-02-09T04:28:21.000-05:00","last_posted_at":"2013-09-19T13:36:49.000-04:00","bumped":true,"bumped_at":"2013-09-19T14:04:08.000-04:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":2128,"username":"ultimape","avatar_template":"//www.gravatar.com/avatar/6fe82efded2ee5e218e0452644a07e2e.png?s={size}&r=pg&d=identicon"}},{"id":424,"title":"What are the 'consequences' of changing your name?","fancy_title":"What are the ‘consequences’ of changing your name?","slug":"what-are-the-consequences-of-changing-your-name","posts_count":35,"reply_count":36,"highest_post_number":43,"image_url":null,"created_at":"2013-02-05T17:37:52.000-05:00","last_posted_at":"2013-09-19T13:55:11.000-04:00","bumped":true,"bumped_at":"2013-09-19T13:55:11.000-04:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":2128,"username":"ultimape","avatar_template":"//www.gravatar.com/avatar/6fe82efded2ee5e218e0452644a07e2e.png?s={size}&r=pg&d=identicon"}}]},{"id":13,"name":"blog","color":"ED207B","text_color":"FFFFFF","slug":"blog","topic_count":14,"description":"Discussion topics generated from the official Discourse Blog. These topics are linked from the bottom of each blog entry where the blog comments would normally be.","topic_url":"/t/category-definition-for-blog/5250","read_restricted":false,"permission":null,"post_count":206,"topics_day":0,"topics_week":0,"topics_month":1,"topics_year":14,"posts_day":0,"posts_week":2,"posts_month":11,"posts_year":206,"description_excerpt":"Discussion topics generated from the official Discourse Blog. These topics are linked from the bottom of each blog entry where the blog comments would normally be.","featured_user_ids":[8134,32,4457,4263,1995],"topics":[{"id":11835,"title":"The Road to Discourse 1.0","fancy_title":"The Road to Discourse 1.0","slug":"the-road-to-discourse-1-0","posts_count":6,"reply_count":2,"highest_post_number":6,"image_url":null,"created_at":"2014-01-08T19:08:44.000-05:00","last_posted_at":"2014-01-16T04:49:16.000-05:00","bumped":true,"bumped_at":"2014-01-16T04:49:16.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":8134,"username":"iontishina","avatar_template":"//www.gravatar.com/avatar/fd21735919ef17cdb2a38416928a7d5c.png?s={size}&r=pg&d=identicon"}},{"id":5751,"title":"Discourse as Your First Rails App","fancy_title":"Discourse as Your First Rails App","slug":"discourse-as-your-first-rails-app","posts_count":62,"reply_count":43,"highest_post_number":71,"image_url":null,"created_at":"2013-04-09T19:08:33.000-04:00","last_posted_at":"2013-12-19T18:27:37.000-05:00","bumped":true,"bumped_at":"2013-12-19T18:27:37.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":1995,"username":"zogstrip","avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon"}},{"id":5898,"title":"The Discourse Servers","fancy_title":"The Discourse Servers","slug":"the-discourse-servers","posts_count":42,"reply_count":32,"highest_post_number":42,"image_url":null,"created_at":"2013-04-15T15:19:09.000-04:00","last_posted_at":"2013-11-29T15:14:35.000-05:00","bumped":true,"bumped_at":"2013-11-29T15:14:35.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":6626,"username":"riking","avatar_template":"//www.gravatar.com/avatar/5120fc4e345db0d1a964888272073819.png?s={size}&r=pg&d=identicon"}}]},{"id":4,"name":"faq","color":"33b","text_color":"FFFFFF","slug":"faq","topic_count":49,"description":"Topics that come up very often when discussing Discourse will eventually be classified into this Frequently Asked Questions category. Should only be added to popular topics.","topic_url":"/t/category-definition-for-faq/25","read_restricted":false,"permission":null,"post_count":450,"topics_day":0,"topics_week":0,"topics_month":0,"topics_year":49,"posts_day":0,"posts_week":1,"posts_month":10,"posts_year":450,"description_excerpt":"Topics that come up very often when discussing Discourse will eventually be classified into this Frequently Asked Questions category. Should only be added to popular topics.","featured_user_ids":[32,8047,7483,2,6626],"topics":[{"id":5372,"title":"UX confusion (or me confusion) is it possible to edit old posts or only your most recent post in a topic?","fancy_title":"UX confusion (or me confusion) is it possible to edit old posts or only your most recent post in a topic?","slug":"ux-confusion-or-me-confusion-is-it-possible-to-edit-old-posts-or-only-your-most-recent-post-in-a-topic","posts_count":3,"reply_count":0,"highest_post_number":3,"image_url":null,"created_at":"2013-03-28T22:25:57.000-04:00","last_posted_at":"2014-01-13T13:44:39.000-05:00","bumped":true,"bumped_at":"2014-01-13T13:44:39.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon"}},{"id":9631,"title":"All the options to deploy Discourse with their relative pros and cons","fancy_title":"All the options to deploy Discourse with their relative pros and cons","slug":"all-the-options-to-deploy-discourse-with-their-relative-pros-and-cons","posts_count":14,"reply_count":7,"highest_post_number":15,"image_url":null,"created_at":"2013-09-06T03:55:09.000-04:00","last_posted_at":"2013-09-26T18:49:04.000-04:00","bumped":true,"bumped_at":"2013-12-30T12:32:59.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":3929,"username":"ScotterC","avatar_template":"//www.gravatar.com/avatar/8441ad0b5f1b724aa9932691007afecb.png?s={size}&r=pg&d=identicon"}},{"id":4325,"title":"How to delete a user?","fancy_title":"How to delete a user?","slug":"how-to-delete-a-user","posts_count":31,"reply_count":23,"highest_post_number":33,"image_url":null,"created_at":"2013-03-01T23:18:55.000-05:00","last_posted_at":"2013-12-20T21:26:06.000-05:00","bumped":true,"bumped_at":"2013-12-20T21:26:06.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon"}}]},{"id":14,"name":"marketplace","color":"8C6238","text_color":"FFFFFF","slug":"marketplace","topic_count":24,"description":"About commercial Discourse related stuff: jobs or paid gigs, plugins, themes, hosting, etc.","topic_url":"/t/category-definition-for-marketplace/5425","read_restricted":false,"permission":null,"post_count":106,"topics_day":0,"topics_week":1,"topics_month":3,"topics_year":24,"posts_day":0,"posts_week":1,"posts_month":7,"posts_year":106,"description_excerpt":"About commercial Discourse related stuff: jobs or paid gigs, plugins, themes, hosting, etc.","featured_user_ids":[6548,32,5548,2291,4755],"topics":[{"id":11866,"title":"DiscourseHosting is now accepting BTC payments","fancy_title":"DiscourseHosting is now accepting BTC payments","slug":"discoursehosting-is-now-accepting-btc-payments","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-10T10:17:28.000-05:00","last_posted_at":"2014-01-10T10:17:28.000-05:00","bumped":false,"bumped_at":"2014-01-10T10:17:28.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":6548,"username":"michaeld","avatar_template":"//localhost:3000/uploads/default/avatars/886/ea8/e533d87fd9/{size}.png"}},{"id":11571,"title":"Looking for a developer for Discourse Customization","fancy_title":"Looking for a developer for Discourse Customization","slug":"looking-for-a-developer-for-discourse-customization","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2013-12-23T20:54:04.000-05:00","last_posted_at":"2013-12-24T13:12:17.000-05:00","bumped":true,"bumped_at":"2013-12-30T16:36:17.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":2291,"username":"PabloC","avatar_template":"//www.gravatar.com/avatar/82c793022ec1bce6ea7573bc27b2340b.png?s={size}&r=pg&d=identicon"}},{"id":11594,"title":"Need someone to fix a topic in my discourse install that won't load for moderators. Will pay","fancy_title":"Need someone to fix a topic in my discourse install that won’t load for moderators. Will pay","slug":"need-someone-to-fix-a-topic-in-my-discourse-install-that-wont-load-for-moderators-will-pay","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2013-12-25T10:25:57.000-05:00","last_posted_at":"2013-12-26T17:01:41.000-05:00","bumped":true,"bumped_at":"2013-12-25T17:01:15.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon"}}]},{"id":10,"name":"howto","color":"76923C","text_color":"FFFFFF","slug":"howto","topic_count":58,"description":"Tutorial topics that describe how to set up, configure, or install Discourse using a specific platform or environment. Topics in this category may only be created by trust level 2 and up. ","topic_url":"/t/category-definition-for-howto/2629","read_restricted":false,"permission":null,"post_count":677,"topics_day":0,"topics_week":0,"topics_month":1,"topics_year":58,"posts_day":0,"posts_week":0,"posts_month":13,"posts_year":675,"description_excerpt":"Tutorial topics that describe how to set up, configure, or install Discourse using a specific platform or environment. Topics in this category may only be created by trust level 2 and up.","featured_user_ids":[7984,4457,1995,6018,5351],"topics":[{"id":7582,"title":"Twitter login with Passenger + Varnish - quick lessons learned","fancy_title":"Twitter login with Passenger + Varnish - quick lessons learned","slug":"twitter-login-with-passenger-varnish-quick-lessons-learned","posts_count":9,"reply_count":3,"highest_post_number":9,"image_url":"/plugins/emoji/images/smile.png","created_at":"2013-06-17T19:46:31.000-04:00","last_posted_at":"2013-12-31T21:03:59.000-05:00","bumped":true,"bumped_at":"2013-12-31T21:03:59.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":7984,"username":"sophearak","avatar_template":"//www.gravatar.com/avatar/98ff4e4caf030d0b7c3c076d7e719032.png?s={size}&r=pg&d=identicon"}},{"id":7229,"title":"How to set up image uploads to S3?","fancy_title":"How to set up image uploads to S3?","slug":"how-to-set-up-image-uploads-to-s3","posts_count":14,"reply_count":11,"highest_post_number":14,"image_url":"/uploads/meta_discourse/1019/782cbc7e309ce43f.png","created_at":"2013-06-06T15:37:43.000-04:00","last_posted_at":"2013-12-31T11:54:18.000-05:00","bumped":true,"bumped_at":"2013-12-31T11:54:18.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":1995,"username":"zogstrip","avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon"}},{"id":11628,"title":"My experience with a successful migration (hints for a guide)","fancy_title":"My experience with a successful migration (hints for a guide)","slug":"my-experience-with-a-successful-migration-hints-for-a-guide","posts_count":3,"reply_count":1,"highest_post_number":3,"image_url":null,"created_at":"2013-12-28T09:23:45.000-05:00","last_posted_at":"2013-12-28T10:38:48.000-05:00","bumped":true,"bumped_at":"2013-12-28T10:38:48.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":6018,"username":"robypez","avatar_template":"//www.gravatar.com/avatar/4d6c2e252349806a88636568da02efda.png?s={size}&r=pg&d=identicon"}}]}]}}, -"/c/bug/l/latest.json": {"users":[{"id":1,"username":"sam","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon"},{"id":32,"username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon"},{"id":8021,"username":"Abhishek_Gupta","avatar_template":"//www.gravatar.com/avatar/bdab7e61b3191e483492fd680f563fed.png?s={size}&r=pg&d=identicon"},{"id":6695,"username":"illspirit","avatar_template":"//www.gravatar.com/avatar/20c057f893dc884e455f8c6798bda75b.png?s={size}&r=pg&d=identicon"},{"id":2,"username":"neil","avatar_template":"//www.gravatar.com/avatar/42776c4982dff1fa45ee8248532f8ad0.png?s={size}&r=pg&d=identicon"},{"id":3124,"username":"sipp11","avatar_template":"//www.gravatar.com/avatar/0598cfd42f00fa82223eff562a410ad5.png?s={size}&r=pg&d=identicon"},{"id":7513,"username":"digit","avatar_template":"//localhost:3000/uploads/default/avatars/067/555/7ff0bfdadf/{size}.jpg"},{"id":19,"username":"eviltrout","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon"},{"id":3,"username":"supermathie","avatar_template":"//www.gravatar.com/avatar/44ae1b2d44d48aed3d432129a5703942.png?s={size}&r=pg&d=identicon"},{"id":7073,"username":"5an1ty","avatar_template":"//www.gravatar.com/avatar/2c346c47486696df101694f766c45527.png?s={size}&r=pg&d=identicon"},{"id":4996,"username":"wmertens","avatar_template":"//www.gravatar.com/avatar/a64ed062eb5e2c3407122fcf16c5de6b.png?s={size}&r=pg&d=identicon"},{"id":6377,"username":"zh99998","avatar_template":"//www.gravatar.com/avatar/09fb7a14e5b9fbb9cd82ffaa1df37634.png?s={size}&r=pg&d=identicon"},{"id":1496,"username":"cfstras","avatar_template":"//www.gravatar.com/avatar/18c103ae1020a5a9ceefe80ae83af5d5.png?s={size}&r=pg&d=identicon"},{"id":7995,"username":"Hunter","avatar_template":"//www.gravatar.com/avatar/fc0bb205dfe163a1f87c20ffaaa1c7ef.png?s={size}&r=pg&d=identicon"},{"id":6626,"username":"riking","avatar_template":"//www.gravatar.com/avatar/5120fc4e345db0d1a964888272073819.png?s={size}&r=pg&d=identicon"},{"id":1995,"username":"zogstrip","avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon"},{"id":5048,"username":"SneakySly","avatar_template":"//www.gravatar.com/avatar/c062c74a11a5281e22a7f90fd080f3f1.png?s={size}&r=pg&d=identicon"},{"id":7731,"username":"YOU","avatar_template":"//www.gravatar.com/avatar/aedbd784f8a5013f527ce103aa1d3cc1.png?s={size}&r=pg&d=identicon"},{"id":7985,"username":"onlinedev","avatar_template":"//www.gravatar.com/avatar/c03a2d32265270e105d7ffeb2e15f076.png?s={size}&r=pg&d=identicon"},{"id":3415,"username":"radq","avatar_template":"//www.gravatar.com/avatar/7739a4187adb56e033b41ce0f9ccad32.png?s={size}&r=pg&d=identicon"},{"id":5351,"username":"erlend_sh","avatar_template":"//www.gravatar.com/avatar/69fda0df8b4878fb6a18deffa972d26a.png?s={size}&r=pg&d=identicon"},{"id":471,"username":"BhaelOchon","avatar_template":"//www.gravatar.com/avatar/413ef976f0d2ca993005c9aee4769254.png?s={size}&r=pg&d=identicon"},{"id":7,"username":"pekka","avatar_template":"//www.gravatar.com/avatar/100a6c42a31a56e882475725d65537f8.png?s={size}&r=pg&d=identicon"},{"id":4780,"username":"HugoAlmeida","avatar_template":"//www.gravatar.com/avatar/23d214ec75c6aa32787b6df919dc9a8e.png?s={size}&r=pg&d=identicon"},{"id":5053,"username":"Blue","avatar_template":"//www.gravatar.com/avatar/cbf6439b21bec74345556ba7538baa8d.png?s={size}&r=pg&d=identicon"},{"id":212,"username":"alxndr","avatar_template":"//www.gravatar.com/avatar/51c9cfe3d5ebd64a79983aa3117f4aed.png?s={size}&r=pg&d=identicon"},{"id":6118,"username":"lukelarris","avatar_template":"//www.gravatar.com/avatar/052a2426faa68b75429cd86431e7d87f.png?s={size}&r=pg&d=identicon"},{"id":7076,"username":"philnelson","avatar_template":"//www.gravatar.com/avatar/37b3083631ceae4ce759487551587a5b.png?s={size}&r=pg&d=identicon"},{"id":4851,"username":"jab","avatar_template":"//www.gravatar.com/avatar/14f382feb5f0dd3d3700edf8d6156aa9.png?s={size}&r=pg&d=identicon"},{"id":4457,"username":"Lee_Ars","avatar_template":"//localhost:3000/uploads/default/avatars/95a/06d/c337428568/{size}.png"},{"id":6280,"username":"mx2000","avatar_template":"//www.gravatar.com/avatar/4ce9219d5926aa3fb685aef5a4da797d.png?s={size}&r=pg&d=identicon"},{"id":3681,"username":"Ajarn","avatar_template":"//www.gravatar.com/avatar/bdfe9d9defc060d689ccd31c07e1bc19.png?s={size}&r=pg&d=identicon"},{"id":1621,"username":"bnb","avatar_template":"//www.gravatar.com/avatar/1e54a178bf671227ea6142e93bf33b39.png?s={size}&r=pg&d=identicon"},{"id":6266,"username":"bragi","avatar_template":"//www.gravatar.com/avatar/690c8d4a36c18855f22ba087b555bc08.png?s={size}&r=pg&d=identicon"},{"id":5335,"username":"masda70","avatar_template":"//www.gravatar.com/avatar/4ffceb3e2866ae3b4df7aab2e812c0ea.png?s={size}&r=pg&d=identicon"},{"id":6314,"username":"rafaelfranca","avatar_template":"//www.gravatar.com/avatar/0525b332aafb83307b32d9747a93de03.png?s={size}&r=pg&d=identicon"}],"topic_list":{"can_create_topic":false,"more_topics_url":"/latest.json?category=1&page=1","draft":null,"draft_key":"new_topic","draft_sequence":null,"topics":[{"id":2,"title":"Category definition for bug","fancy_title":"Category definition for bug","slug":"category-definition-for-bug","posts_count":2,"reply_count":0,"highest_post_number":3,"image_url":null,"created_at":"2013-01-31T23:56:34.000-05:00","last_posted_at":"2013-03-07T22:42:27.000-05:00","bumped":true,"bumped_at":"2013-02-26T18:52:56.000-05:00","unseen":false,"pinned":true,"excerpt":"Bug reports on Discourse. Do be sure to search prior to submitting bugs. Include repro steps, and only describe one bug per topic please.","visible":true,"closed":false,"archived":false,"views":469,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":1},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11994,"title":"Cross domain rules, followed?","fancy_title":"Cross domain rules, followed?","slug":"cross-domain-rules-followed","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":"/plugins/emoji/images/smile.png","created_at":"2014-01-16T09:59:15.000-05:00","last_posted_at":"2014-01-16T09:59:15.000-05:00","bumped":true,"bumped_at":"2014-01-16T11:04:32.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":15,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"Abhishek_Gupta","category_id":1,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":8021}]},{"id":11888,"title":"Uncategorized topics not allowed, still seeing tag places","fancy_title":"Uncategorized topics not allowed, still seeing tag places","slug":"uncategorized-topics-not-allowed-still-seeing-tag-places","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":null,"created_at":"2014-01-10T19:23:37.000-05:00","last_posted_at":"2014-01-15T22:41:25.000-05:00","bumped":true,"bumped_at":"2014-01-15T22:41:25.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":50,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"illspirit","category_id":1,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":6695},{"extras":null,"description":"Most Posts","user_id":32},{"extras":null,"description":"Frequent Poster","user_id":2}]},{"id":9151,"title":"Apple touch icon doesn't show if there is no sub domain","fancy_title":"Apple touch icon doesn’t show if there is no sub domain","slug":"apple-touch-icon-doesnt-show-if-there-is-no-sub-domain","posts_count":7,"reply_count":4,"highest_post_number":7,"image_url":null,"created_at":"2013-08-16T18:16:53.000-04:00","last_posted_at":"2014-01-15T17:10:18.000-05:00","bumped":true,"bumped_at":"2014-01-15T13:19:22.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":188,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":3124},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":10911,"title":"/users/activate-account pulling blank logo instead of defaulting to h2","fancy_title":"/users/activate-account pulling blank logo instead of defaulting to h2","slug":"users-activate-account-pulling-blank-logo-instead-of-defaulting-to-h2","posts_count":3,"reply_count":1,"highest_post_number":3,"image_url":null,"created_at":"2013-11-12T14:49:04.000-05:00","last_posted_at":"2014-01-15T10:21:37.000-05:00","bumped":true,"bumped_at":"2014-01-15T10:21:37.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":121,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"eviltrout","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":7513},{"extras":"latest","description":"Most Recent Poster","user_id":19}]},{"id":11937,"title":"Smiley parser is busted","fancy_title":"Smiley parser is busted","slug":"smiley-parser-is-busted","posts_count":4,"reply_count":4,"highest_post_number":7,"image_url":"/plugins/emoji/images/smile.png","created_at":"2014-01-13T15:42:00.000-05:00","last_posted_at":"2014-01-15T05:51:16.000-05:00","bumped":true,"bumped_at":"2014-01-15T05:51:16.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":66,"like_count":2,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":3},{"extras":null,"description":"Most Posts","user_id":7073},{"extras":"latest","description":"Most Recent Poster","user_id":1}]},{"id":6625,"title":"Error 500 on PUT of site config","fancy_title":"Error 500 on PUT of site config","slug":"error-500-on-put-of-site-config","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2013-05-14T18:13:56.000-04:00","last_posted_at":"2014-01-16T04:55:50.000-05:00","bumped":true,"bumped_at":"2014-01-15T04:43:23.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":132,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":4996},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":11225,"title":"Forum acts weirdly after client side updates","fancy_title":"Forum acts weirdly after client side updates","slug":"forum-acts-weirdly-after-client-side-updates","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":null,"created_at":"2013-12-02T18:32:10.000-05:00","last_posted_at":"2014-01-15T04:04:55.000-05:00","bumped":true,"bumped_at":"2014-01-15T02:55:18.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":117,"like_count":7,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":1},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":11903,"title":"Error after update to 0.9.8.1","fancy_title":"Error after update to 0.9.8.1","slug":"error-after-update-to-0-9-8-1","posts_count":14,"reply_count":6,"highest_post_number":17,"image_url":null,"created_at":"2014-01-12T06:55:45.000-05:00","last_posted_at":"2014-01-15T01:48:58.000-05:00","bumped":true,"bumped_at":"2014-01-15T01:48:58.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":121,"like_count":6,"has_summary":false,"archetype":"regular","last_poster_username":"zh99998","category_id":1,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":6377},{"extras":null,"description":"Most Posts","user_id":1496},{"extras":null,"description":"Frequent Poster","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":19}]},{"id":11969,"title":"Qunit error and possibly related ember.js problem","fancy_title":"Qunit error and possibly related ember.js problem","slug":"qunit-error-and-possibly-related-ember-js-problem","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-14T22:51:32.000-05:00","last_posted_at":"2014-01-14T22:51:32.000-05:00","bumped":false,"bumped_at":"2014-01-14T22:51:32.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":32,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"Hunter","category_id":1,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":7995}]},{"id":11945,"title":"Stuff disappears on the groups page","fancy_title":"Stuff disappears on the groups page","slug":"stuff-disappears-on-the-groups-page","posts_count":7,"reply_count":2,"highest_post_number":7,"image_url":null,"created_at":"2014-01-13T23:03:53.000-05:00","last_posted_at":"2014-01-15T01:26:07.000-05:00","bumped":true,"bumped_at":"2014-01-14T21:09:01.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":54,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":6695},{"extras":null,"description":"Most Posts","user_id":6626},{"extras":"latest","description":"Most Recent Poster, Frequent Poster","user_id":1995}]},{"id":11520,"title":"Discourse WordPress Plugin: Emoji's do not properly display","fancy_title":"Discourse WordPress Plugin: Emoji’s do not properly display","slug":"discourse-wordpress-plugin-emojis-do-not-properly-display","posts_count":9,"reply_count":4,"highest_post_number":9,"image_url":"/uploads/default/_optimized/638/4db/eff43a45b8_690x420.png","created_at":"2013-12-19T23:32:03.000-05:00","last_posted_at":"2014-01-15T04:32:19.000-05:00","bumped":true,"bumped_at":"2014-01-14T17:53:34.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":168,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":5048},{"extras":null,"description":"Frequent Poster","user_id":7731},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":11597,"title":"All categories drop down does not close after clicking on first menu \"all categories\"","fancy_title":"All categories drop down does not close after clicking on first menu “all categories”","slug":"all-categories-drop-down-does-not-close-after-clicking-on-first-menu-all-categories","posts_count":5,"reply_count":2,"highest_post_number":5,"image_url":"/uploads/default/2495/f9efe463ae67632d.png","created_at":"2013-12-25T15:09:27.000-05:00","last_posted_at":"2014-01-14T17:46:41.000-05:00","bumped":true,"bumped_at":"2014-01-14T17:46:41.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":73,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"radq","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":7985},{"extras":null,"description":"Most Posts","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":3415}]},{"id":11962,"title":"Editor When Clicking on Wrench Issue","fancy_title":"Editor When Clicking on Wrench Issue","slug":"editor-when-clicking-on-wrench-issue","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":"/uploads/default/_optimized/ca4/f70/ac7278b8f6_690x176.png","created_at":"2014-01-14T17:23:20.000-05:00","last_posted_at":"2014-01-14T17:24:02.000-05:00","bumped":true,"bumped_at":"2014-01-14T17:24:02.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":30,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":7073},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11831,"title":"Broken links, possibly related to HTTPS","fancy_title":"Broken links, possibly related to HTTPS","slug":"broken-links-possibly-related-to-https","posts_count":17,"reply_count":13,"highest_post_number":18,"image_url":null,"created_at":"2014-01-08T17:40:45.000-05:00","last_posted_at":"2014-01-14T16:03:07.000-05:00","bumped":true,"bumped_at":"2014-01-14T16:03:07.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":102,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"eviltrout","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":5351},{"extras":null,"description":"Most Posts","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":471},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":19}]},{"id":11916,"title":"Unable to save user preferences","fancy_title":"Unable to save user preferences","slug":"unable-to-save-user-preferences","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2014-01-13T02:29:26.000-05:00","last_posted_at":"2014-01-14T14:39:32.000-05:00","bumped":true,"bumped_at":"2014-01-14T14:39:29.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":34,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":6626},{"extras":"latest","description":"Most Recent Poster","user_id":1995}]},{"id":10425,"title":"Editing category permissions: select value doesn't change","fancy_title":"Editing category permissions: select value doesn’t change","slug":"editing-category-permissions-select-value-doesnt-change","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":"/uploads/meta_discourse/1956/d55fba29dbd7e1fe.png","created_at":"2013-10-17T18:20:20.000-04:00","last_posted_at":"2013-10-17T18:20:21.000-04:00","bumped":true,"bumped_at":"2014-01-14T13:35:37.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":92,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"pekka","category_id":1,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":7}]},{"id":6557,"title":"Middle clicking a link twice does not work as expected","fancy_title":"Middle clicking a link twice does not work as expected","slug":"middle-clicking-a-link-twice-does-not-work-as-expected","posts_count":10,"reply_count":7,"highest_post_number":10,"image_url":null,"created_at":"2013-05-11T13:56:02.000-04:00","last_posted_at":"2014-01-14T13:13:04.000-05:00","bumped":true,"bumped_at":"2014-01-14T13:13:04.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":401,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"neil","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":4780},{"extras":null,"description":"Most Posts","user_id":5053},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":2}]},{"id":11944,"title":"Regression: Cannot sort topic list","fancy_title":"Regression: Cannot sort topic list","slug":"regression-cannot-sort-topic-list","posts_count":5,"reply_count":0,"highest_post_number":5,"image_url":null,"created_at":"2014-01-13T20:14:06.000-05:00","last_posted_at":"2014-01-14T19:31:28.000-05:00","bumped":true,"bumped_at":"2014-01-14T07:31:19.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":true,"views":37,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":6626},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":1995}]},{"id":10462,"title":"Rebake error when posts contain deleted YouTube video","fancy_title":"Rebake error when posts contain deleted YouTube video","slug":"rebake-error-when-posts-contain-deleted-youtube-video","posts_count":7,"reply_count":1,"highest_post_number":7,"image_url":null,"created_at":"2013-10-19T00:01:21.000-04:00","last_posted_at":"2014-01-14T02:24:19.000-05:00","bumped":true,"bumped_at":"2014-01-14T02:24:12.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":178,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":6695},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11932,"title":"Use of blockquote tag causes text outside a paragraph","fancy_title":"Use of blockquote tag causes text outside a paragraph","slug":"use-of-blockquote-tag-causes-text-outside-a-paragraph","posts_count":4,"reply_count":2,"highest_post_number":4,"image_url":null,"created_at":"2014-01-13T13:38:15.000-05:00","last_posted_at":"2014-01-13T19:30:37.000-05:00","bumped":true,"bumped_at":"2014-01-14T02:22:58.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":54,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":6626},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":10357,"title":"Displaced Wrench Icon Chrome","fancy_title":"Displaced Wrench Icon Chrome","slug":"displaced-wrench-icon-chrome","posts_count":12,"reply_count":4,"highest_post_number":12,"image_url":"/uploads/default/_optimized/9f3/f35/c5379beffe_690x300.jpg","created_at":"2013-10-14T05:48:21.000-04:00","last_posted_at":"2014-01-14T03:21:32.000-05:00","bumped":true,"bumped_at":"2014-01-13T19:03:33.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":206,"like_count":10,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":7073},{"extras":null,"description":"Frequent Poster","user_id":212},{"extras":null,"description":"Frequent Poster","user_id":6118},{"extras":null,"description":"Frequent Poster","user_id":1},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":10114,"title":"Invitation expiry workflow is wonky","fancy_title":"Invitation expiry workflow is wonky","slug":"invitation-expiry-workflow-is-wonky","posts_count":14,"reply_count":7,"highest_post_number":14,"image_url":null,"created_at":"2013-09-30T00:59:36.000-04:00","last_posted_at":"2014-01-13T18:51:26.000-05:00","bumped":true,"bumped_at":"2014-01-13T18:51:26.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":176,"like_count":2,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":1},{"extras":null,"description":"Most Posts","user_id":7076},{"extras":null,"description":"Frequent Poster","user_id":2},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":6330,"title":"Reply not disabled if topic closed while viewing","fancy_title":"Reply not disabled if topic closed while viewing","slug":"reply-not-disabled-if-topic-closed-while-viewing","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":"https://www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s=40&r=pg&d=identicon","created_at":"2013-05-02T06:02:06.000-04:00","last_posted_at":"2014-01-13T11:54:22.000-05:00","bumped":true,"bumped_at":"2014-01-13T11:54:22.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":164,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":4851},{"extras":null,"description":"Most Posts","user_id":2},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":8367,"title":"Very fast scrolling fails to mark all posts read in a thread","fancy_title":"Very fast scrolling fails to mark all posts read in a thread","slug":"very-fast-scrolling-fails-to-mark-all-posts-read-in-a-thread","posts_count":11,"reply_count":7,"highest_post_number":13,"image_url":null,"created_at":"2013-07-14T12:37:02.000-04:00","last_posted_at":"2014-01-13T11:16:56.000-05:00","bumped":true,"bumped_at":"2014-01-13T11:16:33.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":288,"like_count":5,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":4457},{"extras":null,"description":"Most Posts","user_id":6280},{"extras":null,"description":"Frequent Poster","user_id":3681},{"extras":null,"description":"Frequent Poster","user_id":1621},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":8815,"title":"Cache headers confuse proxies","fancy_title":"Cache headers confuse proxies","slug":"cache-headers-confuse-proxies","posts_count":9,"reply_count":3,"highest_post_number":9,"image_url":null,"created_at":"2013-08-02T05:45:26.000-04:00","last_posted_at":"2014-01-13T11:12:09.000-05:00","bumped":true,"bumped_at":"2014-01-13T10:41:44.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":true,"views":314,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":6266},{"extras":null,"description":"Most Posts","user_id":19},{"extras":null,"description":"Frequent Poster","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":4457},{"extras":"latest","description":"Most Recent Poster, Frequent Poster","user_id":32}]},{"id":11371,"title":"Search not working for Staff users","fancy_title":"Search not working for Staff users","slug":"search-not-working-for-staff-users","posts_count":15,"reply_count":10,"highest_post_number":15,"image_url":null,"created_at":"2013-12-11T13:22:56.000-05:00","last_posted_at":"2014-01-13T01:41:50.000-05:00","bumped":true,"bumped_at":"2014-01-13T01:41:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":true,"views":217,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":5335},{"extras":null,"description":"Most Posts","user_id":19},{"extras":null,"description":"Frequent Poster","user_id":6314},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":1}]},{"id":9908,"title":"Draft bar overrides pagination widget","fancy_title":"Draft bar overrides pagination widget","slug":"draft-bar-overrides-pagination-widget","posts_count":4,"reply_count":0,"highest_post_number":4,"image_url":null,"created_at":"2013-09-19T17:19:52.000-04:00","last_posted_at":"2014-01-13T01:26:01.000-05:00","bumped":true,"bumped_at":"2014-01-13T01:25:12.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":true,"views":108,"like_count":2,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":5351},{"extras":null,"description":"Most Posts","user_id":471},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":6134,"title":"Unread topic is stuck as unread after insertion of staff message","fancy_title":"Unread topic is stuck as unread after insertion of staff message","slug":"unread-topic-is-stuck-as-unread-after-insertion-of-staff-message","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":"https://www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s=40&r=pg&d=identicon","created_at":"2013-04-24T13:37:32.000-04:00","last_posted_at":"2014-01-13T01:22:49.000-05:00","bumped":true,"bumped_at":"2014-01-13T01:22:42.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":169,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":3681},{"extras":null,"description":"Most Posts","user_id":5351},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11914,"title":"Google analytics is not registering page views","fancy_title":"Google analytics is not registering page views","slug":"google-analytics-is-not-registering-page-views","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-13T00:32:45.000-05:00","last_posted_at":"2014-01-13T00:32:46.000-05:00","bumped":true,"bumped_at":"2014-01-13T00:32:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":37,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":1,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":1}]}]}}, +"/latest.json": {"users":[{"id":7204,"username":"reyman64","avatar_template":"/images/avatar.png"},{"id":1,"username":"sam","avatar_template":"/images/avatar.png"},{"id":5481,"username":"f0rkz","avatar_template":"/images/avatar.png"},{"id":6473,"username":"jkf","avatar_template":"/images/avatar.png"},{"id":6973,"username":"stellarhopper","avatar_template":"/images/avatar.png"},{"id":19,"username":"eviltrout","avatar_template":"/images/avatar.png"},{"id":14,"username":"clay","avatar_template":"/images/avatar.png"},{"id":32,"username":"codinghorror","avatar_template":"/images/avatar.png"},{"id":1917,"username":"sil","avatar_template":"/images/avatar.png"},{"id":7197,"username":"peeja","avatar_template":"/images/avatar.png"},{"id":1995,"username":"zogstrip","avatar_template":"/images/avatar.png"},{"id":8021,"username":"Abhishek_Gupta","avatar_template":"/images/avatar.png"},{"id":2291,"username":"PabloC","avatar_template":"/images/avatar.png"},{"id":791,"username":"srid","avatar_template":"/images/avatar.png"},{"id":1580,"username":"ABillionSuns","avatar_template":"/images/avatar.png"},{"id":7270,"username":"mhurwi","avatar_template":"/images/avatar.png"},{"id":6695,"username":"illspirit","avatar_template":"/images/avatar.png"},{"id":6929,"username":"BCHK","avatar_template":"/images/avatar.png"},{"id":4385,"username":"jeans","avatar_template":"/images/avatar.png"},{"id":7073,"username":"5an1ty","avatar_template":"/images/avatar.png"},{"id":6626,"username":"riking","avatar_template":"/images/avatar.png"},{"id":4457,"username":"Lee_Ars","avatar_template":"/images/avatar.png"},{"id":4263,"username":"mcwumbly","avatar_template":"/images/avatar.png"},{"id":8134,"username":"iontishina","avatar_template":"/images/avatar.png"},{"id":2072,"username":"nXqd","avatar_template":"/images/avatar.png"},{"id":4983,"username":"hey_julien","avatar_template":"/images/avatar.png"},{"id":3657,"username":"steelmaiden","avatar_template":"/images/avatar.png"},{"id":2624,"username":"BowlingX","avatar_template":"/images/avatar.png"},{"id":8085,"username":"watchmanmonitor","avatar_template":"/images/avatar.png"},{"id":4612,"username":"Iszi","avatar_template":"/images/avatar.png"},{"id":8018,"username":"shivermetimbers","avatar_template":"/images/avatar.png"},{"id":6060,"username":"lightyear","avatar_template":"/images/avatar.png"},{"id":2,"username":"neil","avatar_template":"/images/avatar.png"},{"id":8037,"username":"printec","avatar_template":"/images/avatar.png"},{"id":3415,"username":"radq","avatar_template":"/images/avatar.png"},{"id":6283,"username":"hrishikesh","avatar_template":"/images/avatar.png"},{"id":471,"username":"BhaelOchon","avatar_template":"/images/avatar.png"},{"id":6548,"username":"michaeld","avatar_template":"/images/avatar.png"},{"id":7286,"username":"mrotsnahoj","avatar_template":"/images/avatar.png"},{"id":3169,"username":"dgw","avatar_template":"/images/avatar.png"},{"id":926,"username":"martinnormark","avatar_template":"/images/avatar.png"},{"id":2003,"username":"taylor","avatar_template":"/images/avatar.png"},{"id":369,"username":"CvX","avatar_template":"/images/avatar.png"},{"id":562,"username":"nightpool","avatar_template":"/images/avatar.png"},{"id":6653,"username":"amitfrid","avatar_template":"/images/avatar.png"},{"id":6677,"username":"Tropnevad","avatar_template":"/images/avatar.png"},{"id":5048,"username":"SneakySly","avatar_template":"/images/avatar.png"},{"id":7333,"username":"Jong","avatar_template":"/images/avatar.png"},{"id":3124,"username":"sipp11","avatar_template":"/images/avatar.png"},{"id":7604,"username":"citkane","avatar_template":"/images/avatar.png"},{"id":3929,"username":"ScotterC","avatar_template":"/images/avatar.png"},{"id":6680,"username":"cdman","avatar_template":"/images/avatar.png"},{"id":500,"username":"aeid","avatar_template":"/images/avatar.png"},{"id":8,"username":"geek","avatar_template":"/images/avatar.png"},{"id":606,"username":"Cafeine","avatar_template":"/images/avatar.png"}],"topic_list":{"can_create_topic":false,"more_topics_url":"/latest.json?page=1","draft":null,"draft_key":"new_topic","draft_sequence":null,"topics":[{"id":11557,"title":"Error after upgrade to 0.9.7.9+","fancy_title":"Error after upgrade to 0.9.7.9+","slug":"error-after-upgrade-to-0-9-7-9","posts_count":83,"reply_count":58,"highest_post_number":85,"image_url":null,"created_at":"2013-12-22T17:12:05.000-05:00","last_posted_at":"2014-01-16T00:52:30.000-05:00","bumped":true,"bumped_at":"2014-01-16T00:52:30.000-05:00","unseen":false,"pinned":true,"excerpt":"Hi, \n\nI'm using webfaction postgresql specific private instance to run discourse (custom port already configured for discourse 0.9.7.6). \n\nThis is not my first update, but this time i have an error. Impossible to upgrade…","visible":true,"closed":false,"archived":false,"views":1230,"like_count":40,"has_summary":true,"archetype":"regular","last_poster_username":"stellarhopper","category_id":17,"posters":[{"extras":null,"description":"Original Poster","user_id":7204},{"extras":null,"description":"Most Posts","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":5481},{"extras":null,"description":"Frequent Poster","user_id":6473},{"extras":"latest","description":"Most Recent Poster","user_id":6973}]},{"id":1,"title":"Welcome to meta.discourse.org","fancy_title":"Welcome to meta.discourse.org","slug":"welcome-to-meta-discourse-org","posts_count":5,"reply_count":5,"highest_post_number":23,"image_url":null,"created_at":"2013-01-31T23:52:28.000-05:00","last_posted_at":"2013-02-07T16:50:41.000-05:00","bumped":true,"bumped_at":"2013-02-07T11:57:34.000-05:00","unseen":false,"pinned":true,"excerpt":"Welcome to meta, the official site for discussing the next-gen open source Discourse forum software. You'll find topics on features, bugs, hosting, development, and general support here. \n\nDiscourse is early beta softwar…","visible":true,"closed":true,"archived":false,"views":13792,"like_count":108,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":17,"posters":[{"extras":null,"description":"Original Poster","user_id":1},{"extras":null,"description":"Most Posts","user_id":19},{"extras":null,"description":"Frequent Poster","user_id":14},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11997,"title":"Create topic in the future","fancy_title":"Create topic in the future","slug":"create-topic-in-the-future","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-16T12:14:36.000-05:00","last_posted_at":"2014-01-16T12:14:36.000-05:00","bumped":false,"bumped_at":"2014-01-16T12:14:36.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":7,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"sil","category_id":2,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":1917}]},{"id":11996,"title":"It's really hard to navigate the Create Topic / Reply pane with the keyboard","fancy_title":"It’s really hard to navigate the Create Topic / Reply pane with the keyboard","slug":"its-really-hard-to-navigate-the-create-topic-reply-pane-with-the-keyboard","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2014-01-16T10:51:36.000-05:00","last_posted_at":"2014-01-16T11:11:10.000-05:00","bumped":true,"bumped_at":"2014-01-16T11:11:10.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":12,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":9,"posters":[{"extras":null,"description":"Original Poster","user_id":7197},{"extras":"latest","description":"Most Recent Poster","user_id":1995}]},{"id":11994,"title":"Cross domain rules, followed?","fancy_title":"Cross domain rules, followed?","slug":"cross-domain-rules-followed","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":"/plugins/emoji/images/smile.png","created_at":"2014-01-16T09:59:15.000-05:00","last_posted_at":"2014-01-16T09:59:15.000-05:00","bumped":true,"bumped_at":"2014-01-16T11:04:32.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":15,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"Abhishek_Gupta","category_id":1,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":8021}]},{"id":11995,"title":"Discourse as a CAS Server","fancy_title":"Discourse as a CAS Server","slug":"discourse-as-a-cas-server","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-16T10:15:30.000-05:00","last_posted_at":"2014-01-16T10:15:31.000-05:00","bumped":true,"bumped_at":"2014-01-16T10:15:31.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":12,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"PabloC","category_id":6,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":2291}]},{"id":11993,"title":"How to check the user level via ajax?","fancy_title":"How to check the user level via ajax?","slug":"how-to-check-the-user-level-via-ajax","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-16T08:13:09.000-05:00","last_posted_at":"2014-01-16T08:13:09.000-05:00","bumped":true,"bumped_at":"2014-01-16T09:20:59.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":13,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"Abhishek_Gupta","category_id":7,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":8021}]},{"id":9540,"title":"Docker images for Discourse","fancy_title":"Docker images for Discourse","slug":"docker-images-for-discourse","posts_count":35,"reply_count":28,"highest_post_number":36,"image_url":null,"created_at":"2013-09-02T00:07:02.000-04:00","last_posted_at":"2014-01-16T07:47:18.000-05:00","bumped":true,"bumped_at":"2014-01-16T07:47:18.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":1322,"like_count":23,"has_summary":false,"archetype":"regular","last_poster_username":"illspirit","category_id":8,"posters":[{"extras":null,"description":"Original Poster","user_id":791},{"extras":null,"description":"Most Posts","user_id":1580},{"extras":null,"description":"Frequent Poster","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":7270},{"extras":"latest","description":"Most Recent Poster","user_id":6695}]},{"id":11957,"title":"Daily Active Users, Monthly Active Users - Statistics Need","fancy_title":"Daily Active Users, Monthly Active Users - Statistics Need","slug":"daily-active-users-monthly-active-users-statistics-need","posts_count":8,"reply_count":4,"highest_post_number":8,"image_url":null,"created_at":"2014-01-14T13:40:56.000-05:00","last_posted_at":"2014-01-16T06:46:05.000-05:00","bumped":true,"bumped_at":"2014-01-16T06:46:05.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":97,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"jeans","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":6929},{"extras":null,"description":"Most Posts","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":4385}]},{"id":11973,"title":"Pressing Wrench Icon in the Categories section","fancy_title":"Pressing Wrench Icon in the Categories section","slug":"pressing-wrench-icon-in-the-categories-section","posts_count":6,"reply_count":3,"highest_post_number":6,"image_url":"/uploads/default/2907/d8d4e0accd5ee244.png","created_at":"2014-01-15T05:58:12.000-05:00","last_posted_at":"2014-01-16T05:15:52.000-05:00","bumped":true,"bumped_at":"2014-01-16T05:15:52.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":46,"like_count":2,"has_summary":false,"archetype":"regular","last_poster_username":"5an1ty","category_id":9,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":7073},{"extras":null,"description":"Most Posts","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":6626}]},{"id":11835,"title":"The Road to Discourse 1.0","fancy_title":"The Road to Discourse 1.0","slug":"the-road-to-discourse-1-0","posts_count":6,"reply_count":2,"highest_post_number":6,"image_url":null,"created_at":"2014-01-08T19:08:44.000-05:00","last_posted_at":"2014-01-16T04:49:16.000-05:00","bumped":true,"bumped_at":"2014-01-16T04:49:16.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":421,"like_count":33,"has_summary":false,"archetype":"regular","last_poster_username":"iontishina","category_id":13,"posters":[{"extras":null,"description":"Original Poster","user_id":32},{"extras":null,"description":"Most Posts","user_id":4457},{"extras":null,"description":"Frequent Poster","user_id":4263},{"extras":"latest","description":"Most Recent Poster","user_id":8134}]},{"id":11992,"title":"Specific customization for each category","fancy_title":"Specific customization for each category","slug":"specific-customization-for-each-category","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-16T04:04:58.000-05:00","last_posted_at":"2014-01-16T04:04:58.000-05:00","bumped":false,"bumped_at":"2014-01-16T04:04:58.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":18,"like_count":2,"has_summary":false,"archetype":"regular","last_poster_username":"nXqd","category_id":2,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":2072}]},{"id":9214,"title":"Please make category url shorter","fancy_title":"Please make category url shorter","slug":"please-make-category-url-shorter","posts_count":9,"reply_count":3,"highest_post_number":9,"image_url":null,"created_at":"2013-08-20T05:28:17.000-04:00","last_posted_at":"2014-01-16T04:02:46.000-05:00","bumped":true,"bumped_at":"2014-01-16T04:02:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":319,"like_count":13,"has_summary":false,"archetype":"regular","last_poster_username":"nXqd","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":4983},{"extras":null,"description":"Most Posts","user_id":3657},{"extras":null,"description":"Frequent Poster","user_id":2624},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":2072}]},{"id":11989,"title":"Where to change the email subject prefix","fancy_title":"Where to change the email subject prefix","slug":"where-to-change-the-email-subject-prefix","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":"/uploads/default/2919/adbfe0ff90353440.png","created_at":"2014-01-16T01:03:48.000-05:00","last_posted_at":"2014-01-16T03:20:09.000-05:00","bumped":true,"bumped_at":"2014-01-16T03:20:09.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":19,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":6,"posters":[{"extras":null,"description":"Original Poster","user_id":8085},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":10866,"title":"Header logo overflows the top header area","fancy_title":"Header logo overflows the top header area","slug":"header-logo-overflows-the-top-header-area","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2013-11-09T03:40:04.000-05:00","last_posted_at":"2014-01-16T02:27:52.000-05:00","bumped":true,"bumped_at":"2014-01-16T02:40:47.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":157,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"stellarhopper","category_id":6,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":6973},{"extras":null,"description":"Most Posts","user_id":32}]},{"id":11988,"title":"Could not locate Gemfile error","fancy_title":"Could not locate Gemfile error","slug":"could-not-locate-gemfile-error","posts_count":7,"reply_count":3,"highest_post_number":7,"image_url":null,"created_at":"2014-01-16T00:41:57.000-05:00","last_posted_at":"2014-01-16T01:20:46.000-05:00","bumped":true,"bumped_at":"2014-01-16T01:20:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":18,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":6,"posters":[{"extras":null,"description":"Original Poster","user_id":6973},{"extras":"latest","description":"Most Recent Poster","user_id":1}]},{"id":6266,"title":"What sort of replies trigger a notice?","fancy_title":"What sort of replies trigger a notice?","slug":"what-sort-of-replies-trigger-a-notice","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2013-04-30T17:46:39.000-04:00","last_posted_at":"2014-01-16T00:52:21.000-05:00","bumped":true,"bumped_at":"2014-01-16T00:57:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":115,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":17,"posters":[{"extras":null,"description":"Original Poster","user_id":4612},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11610,"title":"Private replies that only admins can see","fancy_title":"Private replies that only admins can see","slug":"private-replies-that-only-admins-can-see","posts_count":21,"reply_count":20,"highest_post_number":23,"image_url":null,"created_at":"2013-12-26T20:31:10.000-05:00","last_posted_at":"2014-01-16T00:18:19.000-05:00","bumped":true,"bumped_at":"2014-01-16T00:18:19.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":206,"like_count":9,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":8018},{"extras":null,"description":"Most Posts","user_id":4263},{"extras":null,"description":"Frequent Poster","user_id":6060},{"extras":null,"description":"Frequent Poster","user_id":6626},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11888,"title":"Uncategorized topics not allowed, still seeing tag places","fancy_title":"Uncategorized topics not allowed, still seeing tag places","slug":"uncategorized-topics-not-allowed-still-seeing-tag-places","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":null,"created_at":"2014-01-10T19:23:37.000-05:00","last_posted_at":"2014-01-15T22:41:25.000-05:00","bumped":true,"bumped_at":"2014-01-15T22:41:25.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":50,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"illspirit","category_id":1,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":6695},{"extras":null,"description":"Most Posts","user_id":32},{"extras":null,"description":"Frequent Poster","user_id":2}]},{"id":11985,"title":"Installation nearly installs on Centos 6.5 with Apache/Phusion","fancy_title":"Installation nearly installs on Centos 6.5 with Apache/Phusion","slug":"installation-nearly-installs-on-centos-6-5-with-apache-phusion","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-15T19:48:30.000-05:00","last_posted_at":"2014-01-15T19:48:30.000-05:00","bumped":false,"bumped_at":"2014-01-15T19:48:30.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":26,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"printec","category_id":6,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":8037}]},{"id":11981,"title":"Excluding categories from the top view?","fancy_title":"Excluding categories from the top view?","slug":"excluding-categories-from-the-top-view","posts_count":6,"reply_count":1,"highest_post_number":6,"image_url":"/uploads/default/_optimized/f01/22f/7ea01f77b9_690x355.png","created_at":"2014-01-15T15:01:37.000-05:00","last_posted_at":"2014-01-15T18:57:52.000-05:00","bumped":true,"bumped_at":"2014-01-15T18:57:47.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":43,"like_count":6,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":3415},{"extras":null,"description":"Most Posts","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":1995}]},{"id":9408,"title":"Different home page for regular vs. new user","fancy_title":"Different home page for regular vs. new user","slug":"different-home-page-for-regular-vs-new-user","posts_count":25,"reply_count":17,"highest_post_number":25,"image_url":null,"created_at":"2013-08-28T09:54:41.000-04:00","last_posted_at":"2014-01-15T18:33:16.000-05:00","bumped":true,"bumped_at":"2014-01-15T18:33:16.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":334,"like_count":21,"has_summary":false,"archetype":"regular","last_poster_username":"mcwumbly","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":6283},{"extras":null,"description":"Most Posts","user_id":32},{"extras":null,"description":"Frequent Poster","user_id":1995},{"extras":null,"description":"Frequent Poster","user_id":471},{"extras":"latest","description":"Most Recent Poster","user_id":4263}]},{"id":11896,"title":"Problem creating new account","fancy_title":"Problem creating new account","slug":"problem-creating-new-account","posts_count":11,"reply_count":2,"highest_post_number":11,"image_url":null,"created_at":"2014-01-11T09:07:20.000-05:00","last_posted_at":"2014-01-15T20:50:05.000-05:00","bumped":true,"bumped_at":"2014-01-15T15:23:32.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":87,"like_count":6,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":6,"posters":[{"extras":null,"description":"Original Poster","user_id":6548},{"extras":null,"description":"Most Posts","user_id":32},{"extras":null,"description":"Frequent Poster","user_id":2},{"extras":"latest","description":"Most Recent Poster","user_id":1}]},{"id":10511,"title":"External urls should open in new tab","fancy_title":"External urls should open in new tab","slug":"external-urls-should-open-in-new-tab","posts_count":7,"reply_count":3,"highest_post_number":7,"image_url":null,"created_at":"2013-10-20T14:54:27.000-04:00","last_posted_at":"2014-01-15T14:02:11.000-05:00","bumped":true,"bumped_at":"2014-01-15T14:01:55.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":242,"like_count":10,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":7286},{"extras":null,"description":"Most Posts","user_id":3169},{"extras":null,"description":"Frequent Poster","user_id":4263},{"extras":null,"description":"Frequent Poster","user_id":6626},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":1589,"title":"Keyboard shortcuts?","fancy_title":"Keyboard shortcuts?","slug":"keyboard-shortcuts","posts_count":19,"reply_count":10,"highest_post_number":20,"image_url":null,"created_at":"2013-02-06T14:05:01.000-05:00","last_posted_at":"2014-01-15T13:52:45.000-05:00","bumped":true,"bumped_at":"2014-01-15T13:52:45.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":754,"like_count":31,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":926},{"extras":null,"description":"Most Posts","user_id":2003},{"extras":null,"description":"Frequent Poster","user_id":369},{"extras":null,"description":"Frequent Poster","user_id":562},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11763,"title":"Google AdSense plugin is now available","fancy_title":"Google AdSense plugin is now available","slug":"google-adsense-plugin-is-now-available","posts_count":7,"reply_count":2,"highest_post_number":7,"image_url":"/uploads/default/_optimized/66d/cf0/d69e6709fe_496x500.PNG","created_at":"2014-01-05T14:28:58.000-05:00","last_posted_at":"2014-01-15T13:32:35.000-05:00","bumped":true,"bumped_at":"2014-01-15T13:32:35.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":213,"like_count":14,"has_summary":false,"archetype":"regular","last_poster_username":"michaeld","category_id":5,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":6548},{"extras":null,"description":"Most Posts","user_id":6653},{"extras":null,"description":"Frequent Poster","user_id":6677},{"extras":null,"description":"Frequent Poster","user_id":5048},{"extras":null,"description":"Frequent Poster","user_id":7333}]},{"id":9151,"title":"Apple touch icon doesn't show if there is no sub domain","fancy_title":"Apple touch icon doesn’t show if there is no sub domain","slug":"apple-touch-icon-doesnt-show-if-there-is-no-sub-domain","posts_count":7,"reply_count":4,"highest_post_number":7,"image_url":null,"created_at":"2013-08-16T18:16:53.000-04:00","last_posted_at":"2014-01-15T17:10:18.000-05:00","bumped":true,"bumped_at":"2014-01-15T13:19:22.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":188,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":3124},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":11977,"title":"Show subcategory topics in categories list summary","fancy_title":"Show subcategory topics in categories list summary","slug":"show-subcategory-topics-in-categories-list-summary","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":"/uploads/default/_optimized/084/4e4/8af88c0839_571x500.png","created_at":"2014-01-15T12:09:49.000-05:00","last_posted_at":"2014-01-15T12:50:04.000-05:00","bumped":true,"bumped_at":"2014-01-15T12:50:04.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":32,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":7604},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":10201,"title":"How To override an existing handlebars template from plugin","fancy_title":"How To override an existing handlebars template from plugin","slug":"how-to-override-an-existing-handlebars-template-from-plugin","posts_count":6,"reply_count":1,"highest_post_number":6,"image_url":null,"created_at":"2013-10-04T10:44:33.000-04:00","last_posted_at":"2014-01-15T12:35:01.000-05:00","bumped":true,"bumped_at":"2014-01-15T12:34:58.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":325,"like_count":6,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":3929},{"extras":null,"description":"Most Posts","user_id":3415},{"extras":null,"description":"Frequent Poster","user_id":6680},{"extras":null,"description":"Frequent Poster","user_id":2},{"extras":"latest","description":"Most Recent Poster","user_id":1995}]},{"id":531,"title":"Discourse and Wordpress Integration","fancy_title":"Discourse and Wordpress Integration","slug":"discourse-and-wordpress-integration","posts_count":76,"reply_count":64,"highest_post_number":78,"image_url":null,"created_at":"2013-02-05T18:56:37.000-05:00","last_posted_at":"2014-01-15T11:56:54.000-05:00","bumped":true,"bumped_at":"2014-01-15T11:56:54.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":3809,"like_count":84,"has_summary":true,"archetype":"regular","last_poster_username":"codinghorror","category_id":5,"posters":[{"extras":null,"description":"Original Poster","user_id":500},{"extras":null,"description":"Most Posts","user_id":8},{"extras":null,"description":"Frequent Poster","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":606},{"extras":"latest","description":"Most Recent Poster","user_id":32}]}]}}, +"/categories.json": {"featured_users":[{"id":8021,"username":"Abhishek_Gupta","avatar_template":"/images/avatar.png"},{"id":32,"username":"codinghorror","avatar_template":"/images/avatar.png"},{"id":6695,"username":"illspirit","avatar_template":"/images/avatar.png"},{"id":2,"username":"neil","avatar_template":"/images/avatar.png"},{"id":1995,"username":"zogstrip","avatar_template":"/images/avatar.png"},{"id":1917,"username":"sil","avatar_template":"/images/avatar.png"},{"id":4385,"username":"jeans","avatar_template":"/images/avatar.png"},{"id":2072,"username":"nXqd","avatar_template":"/images/avatar.png"},{"id":4263,"username":"mcwumbly","avatar_template":"/images/avatar.png"},{"id":2291,"username":"PabloC","avatar_template":"/images/avatar.png"},{"id":6973,"username":"stellarhopper","avatar_template":"/images/avatar.png"},{"id":1,"username":"sam","avatar_template":"/images/avatar.png"},{"id":8085,"username":"watchmanmonitor","avatar_template":"/images/avatar.png"},{"id":5428,"username":"abbat","avatar_template":"/images/avatar.png"},{"id":8208,"username":"maximaximums","avatar_template":"/images/avatar.png"},{"id":7995,"username":"Hunter","avatar_template":"/images/avatar.png"},{"id":7197,"username":"peeja","avatar_template":"/images/avatar.png"},{"id":7073,"username":"5an1ty","avatar_template":"/images/avatar.png"},{"id":6626,"username":"riking","avatar_template":"/images/avatar.png"},{"id":6548,"username":"michaeld","avatar_template":"/images/avatar.png"},{"id":8202,"username":"Matthieu","avatar_template":"/images/avatar.png"},{"id":6677,"username":"Tropnevad","avatar_template":"/images/avatar.png"},{"id":7333,"username":"Jong","avatar_template":"/images/avatar.png"},{"id":6018,"username":"robypez","avatar_template":"/images/avatar.png"},{"id":1580,"username":"ABillionSuns","avatar_template":"/images/avatar.png"},{"id":7030,"username":"naabster","avatar_template":"/images/avatar.png"},{"id":8163,"username":"znation","avatar_template":"/images/avatar.png"},{"id":19,"username":"eviltrout","avatar_template":"/images/avatar.png"},{"id":7796,"username":"almereyda","avatar_template":"/images/avatar.png"},{"id":8024,"username":"stefanobernardi","avatar_template":"/images/avatar.png"},{"id":5174,"username":"MaSe","avatar_template":"/images/avatar.png"},{"id":4534,"username":"Julien","avatar_template":"/images/avatar.png"},{"id":2316,"username":"pakl","avatar_template":"/images/avatar.png"},{"id":4457,"username":"Lee_Ars","avatar_template":"/images/avatar.png"},{"id":8134,"username":"iontishina","avatar_template":"/images/avatar.png"},{"id":8047,"username":"zooko","avatar_template":"/images/avatar.png"},{"id":7483,"username":"jhogendorn","avatar_template":"/images/avatar.png"},{"id":5548,"username":"pdbradley","avatar_template":"/images/avatar.png"},{"id":4755,"username":"andanthor","avatar_template":"/images/avatar.png"},{"id":7984,"username":"sophearak","avatar_template":"/images/avatar.png"},{"id":5351,"username":"erlend_sh","avatar_template":"/images/avatar.png"}],"category_list":{"can_create_category":false,"can_create_topic":false,"draft":null,"draft_key":"new_topic","draft_sequence":null,"categories":[{"id":1,"name":"bug","color":"e9dd00","text_color":"000000","slug":"bug","topic_count":660,"description":"Bug reports on Discourse. Do be sure to search prior to submitting bugs. Include repro steps, and only describe one bug per topic please.","topic_url":"/t/category-definition-for-bug/2","read_restricted":false,"permission":null,"post_count":4318,"topics_day":0,"topics_week":18,"topics_month":54,"topics_year":658,"posts_day":0,"posts_week":330,"posts_month":574,"posts_year":4319,"description_excerpt":"Bug reports on Discourse. Do be sure to search prior to submitting bugs. Include repro steps, and only describe one bug per topic please.","featured_user_ids":[8021,32,6695,2,1995],"topics":[{"id":11994,"title":"Cross domain rules, followed?","fancy_title":"Cross domain rules, followed?","slug":"cross-domain-rules-followed","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":"/plugins/emoji/images/smile.png","created_at":"2014-01-16T09:59:15.000-05:00","last_posted_at":"2014-01-16T09:59:15.000-05:00","bumped":true,"bumped_at":"2014-01-16T11:04:32.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":8021,"username":"Abhishek_Gupta","avatar_template":"/images/avatar.png"}},{"id":11888,"title":"Uncategorized topics not allowed, still seeing tag places","fancy_title":"Uncategorized topics not allowed, still seeing tag places","slug":"uncategorized-topics-not-allowed-still-seeing-tag-places","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":null,"created_at":"2014-01-10T19:23:37.000-05:00","last_posted_at":"2014-01-15T22:41:25.000-05:00","bumped":true,"bumped_at":"2014-01-15T22:41:25.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":6695,"username":"illspirit","avatar_template":"/images/avatar.png"}},{"id":9151,"title":"Apple touch icon doesn't show if there is no sub domain","fancy_title":"Apple touch icon doesn’t show if there is no sub domain","slug":"apple-touch-icon-doesnt-show-if-there-is-no-sub-domain","posts_count":7,"reply_count":4,"highest_post_number":7,"image_url":null,"created_at":"2013-08-16T18:16:53.000-04:00","last_posted_at":"2014-01-15T17:10:18.000-05:00","bumped":true,"bumped_at":"2014-01-15T13:19:22.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"/images/avatar.png"}}]},{"id":2,"name":"feature","color":"0E76BD","text_color":"FFFFFF","slug":"feature","topic_count":727,"description":"Discussion about features or potential features of Discourse: how they work, why they work, etc.","topic_url":"/t/category-definition-for-feature/11","read_restricted":false,"permission":null,"post_count":6186,"topics_day":0,"topics_week":17,"topics_month":46,"topics_year":725,"posts_day":0,"posts_week":180,"posts_month":468,"posts_year":6187,"description_excerpt":"Discussion about features or potential features of Discourse: how they work, why they work, etc.","featured_user_ids":[1917,4385,2072,32,4263],"topics":[{"id":11997,"title":"Create topic in the future","fancy_title":"Create topic in the future","slug":"create-topic-in-the-future","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-16T12:14:36.000-05:00","last_posted_at":"2014-01-16T12:14:36.000-05:00","bumped":false,"bumped_at":"2014-01-16T12:14:36.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":1917,"username":"sil","avatar_template":"/images/avatar.png"}},{"id":11957,"title":"Daily Active Users, Monthly Active Users - Statistics Need","fancy_title":"Daily Active Users, Monthly Active Users - Statistics Need","slug":"daily-active-users-monthly-active-users-statistics-need","posts_count":8,"reply_count":4,"highest_post_number":8,"image_url":null,"created_at":"2014-01-14T13:40:56.000-05:00","last_posted_at":"2014-01-16T06:46:05.000-05:00","bumped":true,"bumped_at":"2014-01-16T06:46:05.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":4385,"username":"jeans","avatar_template":"/images/avatar.png"}},{"id":11992,"title":"Specific customization for each category","fancy_title":"Specific customization for each category","slug":"specific-customization-for-each-category","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-16T04:04:58.000-05:00","last_posted_at":"2014-01-16T04:04:58.000-05:00","bumped":false,"bumped_at":"2014-01-16T04:04:58.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":2072,"username":"nXqd","avatar_template":"/images/avatar.png"}}]},{"id":6,"name":"support","color":"b99","text_color":"FFFFFF","slug":"support","topic_count":782,"description":"Support on configuring, using, and installing Discourse. Not for software development related topics, but for admins and end users configuring and using Discourse.","topic_url":"/t/category-definition-for-support/389","read_restricted":false,"permission":null,"post_count":5396,"topics_day":0,"topics_week":16,"topics_month":67,"topics_year":779,"posts_day":0,"posts_week":122,"posts_month":481,"posts_year":5400,"description_excerpt":"Support on configuring, using, and installing Discourse. Not for software development related topics, but for admins and end users configuring and using Discourse.","featured_user_ids":[2291,32,6973,1,8085],"topics":[{"id":11995,"title":"Discourse as a CAS Server","fancy_title":"Discourse as a CAS Server","slug":"discourse-as-a-cas-server","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-16T10:15:30.000-05:00","last_posted_at":"2014-01-16T10:15:31.000-05:00","bumped":true,"bumped_at":"2014-01-16T10:15:31.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":2291,"username":"PabloC","avatar_template":"/images/avatar.png"}},{"id":11989,"title":"Where to change the email subject prefix","fancy_title":"Where to change the email subject prefix","slug":"where-to-change-the-email-subject-prefix","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":"/uploads/default/2919/adbfe0ff90353440.png","created_at":"2014-01-16T01:03:48.000-05:00","last_posted_at":"2014-01-16T03:20:09.000-05:00","bumped":true,"bumped_at":"2014-01-16T03:20:09.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"/images/avatar.png"}},{"id":10866,"title":"Header logo overflows the top header area","fancy_title":"Header logo overflows the top header area","slug":"header-logo-overflows-the-top-header-area","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2013-11-09T03:40:04.000-05:00","last_posted_at":"2014-01-16T02:27:52.000-05:00","bumped":true,"bumped_at":"2014-01-16T02:40:47.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":6973,"username":"stellarhopper","avatar_template":"/images/avatar.png"}}]},{"id":7,"name":"dev","color":"000","text_color":"FFFFFF","slug":"dev","topic_count":284,"description":"This category is for topics related to hacking on Discourse: submitting pull requests, configuring development environments, coding conventions, and so forth.","topic_url":"/t/category-definition-for-dev/1026","read_restricted":false,"permission":null,"post_count":2352,"topics_day":0,"topics_week":3,"topics_month":19,"topics_year":284,"posts_day":0,"posts_week":37,"posts_month":150,"posts_year":2353,"description_excerpt":"This category is for topics related to hacking on Discourse: submitting pull requests, configuring development environments, coding conventions, and so forth.","featured_user_ids":[8021,1995,5428,8208,7995],"topics":[{"id":3823,"title":"So, you want to help out with Discourse","fancy_title":"So, you want to help out with Discourse","slug":"so-you-want-to-help-out-with-discourse","posts_count":22,"reply_count":28,"highest_post_number":56,"image_url":null,"created_at":"2013-02-23T00:46:11.000-05:00","last_posted_at":"2014-01-12T21:33:12.000-05:00","bumped":true,"bumped_at":"2014-01-12T21:33:12.000-05:00","unseen":false,"pinned":true,"excerpt":"People are wondering, how it is they can help out with Discourse. \n\nWe have seen some chattering both here and on Github. \n\nI wanted to create a topic @eviltrout , @codinghorror and myself can keep up to date with clear…","visible":true,"closed":false,"archived":false,"last_poster":{"id":7995,"username":"Hunter","avatar_template":"/images/avatar.png"}},{"id":11993,"title":"How to check the user level via ajax?","fancy_title":"How to check the user level via ajax?","slug":"how-to-check-the-user-level-via-ajax","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-16T08:13:09.000-05:00","last_posted_at":"2014-01-16T08:13:09.000-05:00","bumped":true,"bumped_at":"2014-01-16T09:20:59.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":8021,"username":"Abhishek_Gupta","avatar_template":"/images/avatar.png"}},{"id":10201,"title":"How To override an existing handlebars template from plugin","fancy_title":"How To override an existing handlebars template from plugin","slug":"how-to-override-an-existing-handlebars-template-from-plugin","posts_count":6,"reply_count":1,"highest_post_number":6,"image_url":null,"created_at":"2013-10-04T10:44:33.000-04:00","last_posted_at":"2014-01-15T12:35:01.000-05:00","bumped":true,"bumped_at":"2014-01-15T12:34:58.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"last_poster":{"id":1995,"username":"zogstrip","avatar_template":"/images/avatar.png"}}]},{"id":9,"name":"ux","color":"5F497A","text_color":"FFFFFF","slug":"ux","topic_count":184,"description":"Discussion about the user interface of Discourse, how features are presented to the user in the client, including language and UI elements.","topic_url":"/t/category-definition-for-ux/2628","read_restricted":false,"permission":null,"post_count":1511,"topics_day":0,"topics_week":3,"topics_month":10,"topics_year":183,"posts_day":0,"posts_week":34,"posts_month":117,"posts_year":1511,"description_excerpt":"Discussion about the user interface of Discourse, how features are presented to the user in the client, including language and UI elements.","featured_user_ids":[1995,7197,7073,1,6626],"topics":[{"id":11996,"title":"It's really hard to navigate the Create Topic / Reply pane with the keyboard","fancy_title":"It’s really hard to navigate the Create Topic / Reply pane with the keyboard","slug":"its-really-hard-to-navigate-the-create-topic-reply-pane-with-the-keyboard","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2014-01-16T10:51:36.000-05:00","last_posted_at":"2014-01-16T11:11:10.000-05:00","bumped":true,"bumped_at":"2014-01-16T11:11:10.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":1995,"username":"zogstrip","avatar_template":"/images/avatar.png"}},{"id":11973,"title":"Pressing Wrench Icon in the Categories section","fancy_title":"Pressing Wrench Icon in the Categories section","slug":"pressing-wrench-icon-in-the-categories-section","posts_count":6,"reply_count":3,"highest_post_number":6,"image_url":"/uploads/default/2907/d8d4e0accd5ee244.png","created_at":"2014-01-15T05:58:12.000-05:00","last_posted_at":"2014-01-16T05:15:52.000-05:00","bumped":true,"bumped_at":"2014-01-16T05:15:52.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":7073,"username":"5an1ty","avatar_template":"/images/avatar.png"}},{"id":5542,"title":"Title character requirements not very visible","fancy_title":"Title character requirements not very visible","slug":"title-character-requirements-not-very-visible","posts_count":24,"reply_count":11,"highest_post_number":24,"image_url":null,"created_at":"2013-04-02T20:09:59.000-04:00","last_posted_at":"2014-01-15T05:26:07.000-05:00","bumped":true,"bumped_at":"2014-01-15T05:26:04.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"last_poster":{"id":1995,"username":"zogstrip","avatar_template":"/images/avatar.png"}}]},{"id":5,"name":"extensibility","color":"FE8432","text_color":"FFFFFF","slug":"extensibility","topic_count":102,"description":"Topics about extending the functionality of Discourse with plugins, themes, add-ons, or other mechanisms for extensibility. ","topic_url":"/t/category-definition-for-extensibility/28","read_restricted":false,"permission":null,"post_count":964,"topics_day":0,"topics_week":2,"topics_month":18,"topics_year":102,"posts_day":0,"posts_week":17,"posts_month":76,"posts_year":964,"description_excerpt":"Topics about extending the functionality of Discourse with plugins, themes, add-ons, or other mechanisms for extensibility.","featured_user_ids":[6548,32,8202,6677,7333],"topics":[{"id":11763,"title":"Google AdSense plugin is now available","fancy_title":"Google AdSense plugin is now available","slug":"google-adsense-plugin-is-now-available","posts_count":7,"reply_count":2,"highest_post_number":7,"image_url":"/uploads/default/_optimized/66d/cf0/d69e6709fe_496x500.PNG","created_at":"2014-01-05T14:28:58.000-05:00","last_posted_at":"2014-01-15T13:32:35.000-05:00","bumped":true,"bumped_at":"2014-01-15T13:32:35.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":6548,"username":"michaeld","avatar_template":"/images/avatar.png"}},{"id":531,"title":"Discourse and Wordpress Integration","fancy_title":"Discourse and Wordpress Integration","slug":"discourse-and-wordpress-integration","posts_count":76,"reply_count":64,"highest_post_number":78,"image_url":null,"created_at":"2013-02-05T18:56:37.000-05:00","last_posted_at":"2014-01-15T11:56:54.000-05:00","bumped":true,"bumped_at":"2014-01-15T11:56:54.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"/images/avatar.png"}},{"id":11965,"title":"In your opinion, what is the best wiki engine to be associated with discourse?","fancy_title":"In your opinion, what is the best wiki engine to be associated with discourse?","slug":"in-your-opinion-what-is-the-best-wiki-engine-to-be-associated-with-discourse","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-14T19:27:06.000-05:00","last_posted_at":"2014-01-14T19:27:06.000-05:00","bumped":false,"bumped_at":"2014-01-14T19:27:06.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":8202,"username":"Matthieu","avatar_template":"/images/avatar.png"}}]},{"id":8,"name":"hosting","color":"74CCED","text_color":"FFFFFF","slug":"hosting","topic_count":69,"description":"Topics about hosting Discourse, either on your own servers, in the cloud, or with specific hosting services.","topic_url":"/t/category-definition-for-hosting/2626","read_restricted":false,"permission":null,"post_count":664,"topics_day":0,"topics_week":2,"topics_month":2,"topics_year":69,"posts_day":0,"posts_week":15,"posts_month":35,"posts_year":664,"description_excerpt":"Topics about hosting Discourse, either on your own servers, in the cloud, or with specific hosting services.","featured_user_ids":[6695,1,6018,1580,7030],"topics":[{"id":9540,"title":"Docker images for Discourse","fancy_title":"Docker images for Discourse","slug":"docker-images-for-discourse","posts_count":35,"reply_count":28,"highest_post_number":36,"image_url":null,"created_at":"2013-09-02T00:07:02.000-04:00","last_posted_at":"2014-01-16T07:47:18.000-05:00","bumped":true,"bumped_at":"2014-01-16T07:47:18.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":6695,"username":"illspirit","avatar_template":"/images/avatar.png"}},{"id":11971,"title":"Installing Discourse on Ubuntu 12.04 with Parallels Plesk and Apache","fancy_title":"Installing Discourse on Ubuntu 12.04 with Parallels Plesk and Apache","slug":"installing-discourse-on-ubuntu-12-04-with-parallels-plesk-and-apache","posts_count":3,"reply_count":1,"highest_post_number":3,"image_url":null,"created_at":"2014-01-15T04:23:38.000-05:00","last_posted_at":"2014-01-15T04:47:20.000-05:00","bumped":true,"bumped_at":"2014-01-15T04:47:20.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":7030,"username":"naabster","avatar_template":"/images/avatar.png"}},{"id":10844,"title":"Discourse in a Docker container","fancy_title":"Discourse in a Docker container","slug":"discourse-in-a-docker-container","posts_count":12,"reply_count":8,"highest_post_number":12,"image_url":null,"created_at":"2013-11-07T19:12:22.000-05:00","last_posted_at":"2014-01-11T14:43:53.000-05:00","bumped":true,"bumped_at":"2014-01-11T14:43:53.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":1,"username":"sam","avatar_template":"/images/avatar.png"}}]},{"id":17,"name":"uncategorized","color":"AB9364","text_color":"FFFFFF","slug":"uncategorized","topic_count":229,"description":"","topic_url":null,"read_restricted":false,"permission":null,"post_count":2138,"topics_day":0,"topics_week":0,"topics_month":9,"topics_year":229,"posts_day":1,"posts_week":11,"posts_month":183,"posts_year":2138,"description_excerpt":"","is_uncategorized":true,"featured_user_ids":[6973,32,1,1995,7073],"topics":[{"id":1,"title":"Welcome to meta.discourse.org","fancy_title":"Welcome to meta.discourse.org","slug":"welcome-to-meta-discourse-org","posts_count":5,"reply_count":5,"highest_post_number":23,"image_url":null,"created_at":"2013-01-31T23:52:28.000-05:00","last_posted_at":"2013-02-07T16:50:41.000-05:00","bumped":true,"bumped_at":"2013-02-07T11:57:34.000-05:00","unseen":false,"pinned":true,"excerpt":"Welcome to meta, the official site for discussing the next-gen open source Discourse forum software. You'll find topics on features, bugs, hosting, development, and general support here. \n\nDiscourse is early beta softwar…","visible":true,"closed":true,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"/images/avatar.png"}},{"id":11557,"title":"Error after upgrade to 0.9.7.9+","fancy_title":"Error after upgrade to 0.9.7.9+","slug":"error-after-upgrade-to-0-9-7-9","posts_count":83,"reply_count":58,"highest_post_number":85,"image_url":null,"created_at":"2013-12-22T17:12:05.000-05:00","last_posted_at":"2014-01-16T00:52:30.000-05:00","bumped":true,"bumped_at":"2014-01-16T00:52:30.000-05:00","unseen":false,"pinned":true,"excerpt":"Hi, \n\nI'm using webfaction postgresql specific private instance to run discourse (custom port already configured for discourse 0.9.7.6). \n\nThis is not my first update, but this time i have an error. Impossible to upgrade…","visible":true,"closed":false,"archived":false,"last_poster":{"id":6973,"username":"stellarhopper","avatar_template":"/images/avatar.png"}},{"id":6266,"title":"What sort of replies trigger a notice?","fancy_title":"What sort of replies trigger a notice?","slug":"what-sort-of-replies-trigger-a-notice","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2013-04-30T17:46:39.000-04:00","last_posted_at":"2014-01-16T00:52:21.000-05:00","bumped":true,"bumped_at":"2014-01-16T00:57:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"/images/avatar.png"}}]},{"id":11,"name":"login","color":"edb400","text_color":"FFFFFF","slug":"login","topic_count":27,"description":"Topics about logging in to Discourse, using any standard third party provider (Twitter, Facebook, Google), traditional username and password, or with a custom plugin.","topic_url":"/t/category-definition-for-login/2828","read_restricted":false,"permission":null,"post_count":200,"topics_day":0,"topics_week":1,"topics_month":1,"topics_year":27,"posts_day":0,"posts_week":10,"posts_month":27,"posts_year":200,"description_excerpt":"Topics about logging in to Discourse, using any standard third party provider (Twitter, Facebook, Google), traditional username and password, or with a custom plugin.","featured_user_ids":[8163,19,7796,32,8024],"topics":[{"id":11959,"title":"Get current user information via JSON","fancy_title":"Get current user information via JSON","slug":"get-current-user-information-via-json","posts_count":3,"reply_count":1,"highest_post_number":3,"image_url":null,"created_at":"2014-01-14T15:05:34.000-05:00","last_posted_at":"2014-01-14T16:43:28.000-05:00","bumped":true,"bumped_at":"2014-01-14T16:43:28.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":8163,"username":"znation","avatar_template":"/images/avatar.png"}},{"id":6242,"title":"Allow authentication via multiple services on one account","fancy_title":"Allow authentication via multiple services on one account","slug":"allow-authentication-via-multiple-services-on-one-account","posts_count":34,"reply_count":27,"highest_post_number":34,"image_url":null,"created_at":"2013-04-29T18:51:52.000-04:00","last_posted_at":"2014-01-14T00:25:42.000-05:00","bumped":true,"bumped_at":"2014-01-14T00:25:42.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":7796,"username":"almereyda","avatar_template":"/images/avatar.png"}},{"id":4738,"title":"Login support for browser password managers","fancy_title":"Login support for browser password managers","slug":"login-support-for-browser-password-managers","posts_count":6,"reply_count":2,"highest_post_number":6,"image_url":null,"created_at":"2013-03-13T17:55:29.000-04:00","last_posted_at":"2014-01-13T14:21:34.000-05:00","bumped":true,"bumped_at":"2014-01-13T14:21:34.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"/images/avatar.png"}}]},{"id":3,"name":"meta","color":"aaa","text_color":"FFFFFF","slug":"meta","topic_count":79,"description":"Discussion about meta.discourse.org itself, the organization of this forum about Discourse, how it works, and how we can improve this site.","topic_url":"/t/category-definition-for-meta/24","read_restricted":false,"permission":null,"post_count":695,"topics_day":0,"topics_week":1,"topics_month":3,"topics_year":79,"posts_day":0,"posts_week":4,"posts_month":18,"posts_year":696,"description_excerpt":"Discussion about meta.discourse.org itself, the organization of this forum about Discourse, how it works, and how we can improve this site.","featured_user_ids":[19,8085,32,5174,4534],"topics":[{"id":5249,"title":"What is \"Meta\"?","fancy_title":"What is “Meta”?","slug":"what-is-meta","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2013-03-25T18:00:52.000-04:00","last_posted_at":"2013-03-25T18:00:56.000-04:00","bumped":false,"bumped_at":"2013-03-25T18:00:52.000-04:00","unseen":false,"pinned":true,"excerpt":"Meta means discussion of the discussion itself instead of the actual topic of the discussion. \n\nWhy do we need a meta category?\n\nMeta is where communities come together to decide who they are and what they are about. \n…","visible":true,"closed":false,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"/images/avatar.png"}},{"id":11943,"title":"How far to take user documentation?","fancy_title":"How far to take user documentation?","slug":"how-far-to-take-user-documentation","posts_count":4,"reply_count":2,"highest_post_number":4,"image_url":"/plugins/emoji/images/smile.png","created_at":"2014-01-13T19:21:26.000-05:00","last_posted_at":"2014-01-14T14:19:46.000-05:00","bumped":true,"bumped_at":"2014-01-14T14:19:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":19,"username":"eviltrout","avatar_template":"/images/avatar.png"}},{"id":11822,"title":"Search engine traffic share and level to Discourse","fancy_title":"Search engine traffic share and level to Discourse","slug":"search-engine-traffic-share-and-level-to-discourse","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2014-01-08T01:54:56.000-05:00","last_posted_at":"2014-01-08T02:21:25.000-05:00","bumped":true,"bumped_at":"2014-01-08T02:21:25.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"/images/avatar.png"}}]},{"id":12,"name":"discourse hub","color":"b2c79f","text_color":"FFFFFF","slug":"discourse-hub","topic_count":4,"description":"Topics about current or future Discourse Hub functionality at discourse.org including nickname registration, global user pages, and the site directory.","topic_url":"/t/category-definition-for-discourse-hub/3038","read_restricted":false,"permission":null,"post_count":121,"topics_day":0,"topics_week":0,"topics_month":0,"topics_year":4,"posts_day":0,"posts_week":3,"posts_month":3,"posts_year":121,"description_excerpt":"Topics about current or future Discourse Hub functionality at discourse.org including nickname registration, global user pages, and the site directory.","featured_user_ids":[2,32,2316,6695,4457],"topics":[{"id":6547,"title":"Where to get discourse_org_access_key?","fancy_title":"Where to get discourse_org_access_key?","slug":"where-to-get-discourse-org-access-key","posts_count":13,"reply_count":4,"highest_post_number":13,"image_url":null,"created_at":"2013-05-10T22:06:08.000-04:00","last_posted_at":"2014-01-13T11:38:15.000-05:00","bumped":true,"bumped_at":"2014-01-13T11:38:15.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":2,"username":"neil","avatar_template":"/images/avatar.png"}},{"id":2544,"title":"Discourse central hub questions","fancy_title":"Discourse central hub questions","slug":"discourse-central-hub-questions","posts_count":51,"reply_count":44,"highest_post_number":52,"image_url":null,"created_at":"2013-02-09T04:28:21.000-05:00","last_posted_at":"2013-09-19T13:36:49.000-04:00","bumped":true,"bumped_at":"2013-09-19T14:04:08.000-04:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":2128,"username":"ultimape","avatar_template":"/images/avatar.png"}},{"id":424,"title":"What are the 'consequences' of changing your name?","fancy_title":"What are the ‘consequences’ of changing your name?","slug":"what-are-the-consequences-of-changing-your-name","posts_count":35,"reply_count":36,"highest_post_number":43,"image_url":null,"created_at":"2013-02-05T17:37:52.000-05:00","last_posted_at":"2013-09-19T13:55:11.000-04:00","bumped":true,"bumped_at":"2013-09-19T13:55:11.000-04:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":2128,"username":"ultimape","avatar_template":"/images/avatar.png"}}]},{"id":13,"name":"blog","color":"ED207B","text_color":"FFFFFF","slug":"blog","topic_count":14,"description":"Discussion topics generated from the official Discourse Blog. These topics are linked from the bottom of each blog entry where the blog comments would normally be.","topic_url":"/t/category-definition-for-blog/5250","read_restricted":false,"permission":null,"post_count":206,"topics_day":0,"topics_week":0,"topics_month":1,"topics_year":14,"posts_day":0,"posts_week":2,"posts_month":11,"posts_year":206,"description_excerpt":"Discussion topics generated from the official Discourse Blog. These topics are linked from the bottom of each blog entry where the blog comments would normally be.","featured_user_ids":[8134,32,4457,4263,1995],"topics":[{"id":11835,"title":"The Road to Discourse 1.0","fancy_title":"The Road to Discourse 1.0","slug":"the-road-to-discourse-1-0","posts_count":6,"reply_count":2,"highest_post_number":6,"image_url":null,"created_at":"2014-01-08T19:08:44.000-05:00","last_posted_at":"2014-01-16T04:49:16.000-05:00","bumped":true,"bumped_at":"2014-01-16T04:49:16.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":8134,"username":"iontishina","avatar_template":"/images/avatar.png"}},{"id":5751,"title":"Discourse as Your First Rails App","fancy_title":"Discourse as Your First Rails App","slug":"discourse-as-your-first-rails-app","posts_count":62,"reply_count":43,"highest_post_number":71,"image_url":null,"created_at":"2013-04-09T19:08:33.000-04:00","last_posted_at":"2013-12-19T18:27:37.000-05:00","bumped":true,"bumped_at":"2013-12-19T18:27:37.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":1995,"username":"zogstrip","avatar_template":"/images/avatar.png"}},{"id":5898,"title":"The Discourse Servers","fancy_title":"The Discourse Servers","slug":"the-discourse-servers","posts_count":42,"reply_count":32,"highest_post_number":42,"image_url":null,"created_at":"2013-04-15T15:19:09.000-04:00","last_posted_at":"2013-11-29T15:14:35.000-05:00","bumped":true,"bumped_at":"2013-11-29T15:14:35.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":6626,"username":"riking","avatar_template":"/images/avatar.png"}}]},{"id":4,"name":"faq","color":"33b","text_color":"FFFFFF","slug":"faq","topic_count":49,"description":"Topics that come up very often when discussing Discourse will eventually be classified into this Frequently Asked Questions category. Should only be added to popular topics.","topic_url":"/t/category-definition-for-faq/25","read_restricted":false,"permission":null,"post_count":450,"topics_day":0,"topics_week":0,"topics_month":0,"topics_year":49,"posts_day":0,"posts_week":1,"posts_month":10,"posts_year":450,"description_excerpt":"Topics that come up very often when discussing Discourse will eventually be classified into this Frequently Asked Questions category. Should only be added to popular topics.","featured_user_ids":[32,8047,7483,2,6626],"topics":[{"id":5372,"title":"UX confusion (or me confusion) is it possible to edit old posts or only your most recent post in a topic?","fancy_title":"UX confusion (or me confusion) is it possible to edit old posts or only your most recent post in a topic?","slug":"ux-confusion-or-me-confusion-is-it-possible-to-edit-old-posts-or-only-your-most-recent-post-in-a-topic","posts_count":3,"reply_count":0,"highest_post_number":3,"image_url":null,"created_at":"2013-03-28T22:25:57.000-04:00","last_posted_at":"2014-01-13T13:44:39.000-05:00","bumped":true,"bumped_at":"2014-01-13T13:44:39.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"/images/avatar.png"}},{"id":9631,"title":"All the options to deploy Discourse with their relative pros and cons","fancy_title":"All the options to deploy Discourse with their relative pros and cons","slug":"all-the-options-to-deploy-discourse-with-their-relative-pros-and-cons","posts_count":14,"reply_count":7,"highest_post_number":15,"image_url":null,"created_at":"2013-09-06T03:55:09.000-04:00","last_posted_at":"2013-09-26T18:49:04.000-04:00","bumped":true,"bumped_at":"2013-12-30T12:32:59.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":3929,"username":"ScotterC","avatar_template":"/images/avatar.png"}},{"id":4325,"title":"How to delete a user?","fancy_title":"How to delete a user?","slug":"how-to-delete-a-user","posts_count":31,"reply_count":23,"highest_post_number":33,"image_url":null,"created_at":"2013-03-01T23:18:55.000-05:00","last_posted_at":"2013-12-20T21:26:06.000-05:00","bumped":true,"bumped_at":"2013-12-20T21:26:06.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"/images/avatar.png"}}]},{"id":14,"name":"marketplace","color":"8C6238","text_color":"FFFFFF","slug":"marketplace","topic_count":24,"description":"About commercial Discourse related stuff: jobs or paid gigs, plugins, themes, hosting, etc.","topic_url":"/t/category-definition-for-marketplace/5425","read_restricted":false,"permission":null,"post_count":106,"topics_day":0,"topics_week":1,"topics_month":3,"topics_year":24,"posts_day":0,"posts_week":1,"posts_month":7,"posts_year":106,"description_excerpt":"About commercial Discourse related stuff: jobs or paid gigs, plugins, themes, hosting, etc.","featured_user_ids":[6548,32,5548,2291,4755],"topics":[{"id":11866,"title":"DiscourseHosting is now accepting BTC payments","fancy_title":"DiscourseHosting is now accepting BTC payments","slug":"discoursehosting-is-now-accepting-btc-payments","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-10T10:17:28.000-05:00","last_posted_at":"2014-01-10T10:17:28.000-05:00","bumped":false,"bumped_at":"2014-01-10T10:17:28.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":6548,"username":"michaeld","avatar_template":"/images/avatar.png"}},{"id":11571,"title":"Looking for a developer for Discourse Customization","fancy_title":"Looking for a developer for Discourse Customization","slug":"looking-for-a-developer-for-discourse-customization","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2013-12-23T20:54:04.000-05:00","last_posted_at":"2013-12-24T13:12:17.000-05:00","bumped":true,"bumped_at":"2013-12-30T16:36:17.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":2291,"username":"PabloC","avatar_template":"/images/avatar.png"}},{"id":11594,"title":"Need someone to fix a topic in my discourse install that won't load for moderators. Will pay","fancy_title":"Need someone to fix a topic in my discourse install that won’t load for moderators. Will pay","slug":"need-someone-to-fix-a-topic-in-my-discourse-install-that-wont-load-for-moderators-will-pay","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2013-12-25T10:25:57.000-05:00","last_posted_at":"2013-12-26T17:01:41.000-05:00","bumped":true,"bumped_at":"2013-12-25T17:01:15.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"last_poster":{"id":32,"username":"codinghorror","avatar_template":"/images/avatar.png"}}]},{"id":10,"name":"howto","color":"76923C","text_color":"FFFFFF","slug":"howto","topic_count":58,"description":"Tutorial topics that describe how to set up, configure, or install Discourse using a specific platform or environment. Topics in this category may only be created by trust level 2 and up. ","topic_url":"/t/category-definition-for-howto/2629","read_restricted":false,"permission":null,"post_count":677,"topics_day":0,"topics_week":0,"topics_month":1,"topics_year":58,"posts_day":0,"posts_week":0,"posts_month":13,"posts_year":675,"description_excerpt":"Tutorial topics that describe how to set up, configure, or install Discourse using a specific platform or environment. Topics in this category may only be created by trust level 2 and up.","featured_user_ids":[7984,4457,1995,6018,5351],"topics":[{"id":7582,"title":"Twitter login with Passenger + Varnish - quick lessons learned","fancy_title":"Twitter login with Passenger + Varnish - quick lessons learned","slug":"twitter-login-with-passenger-varnish-quick-lessons-learned","posts_count":9,"reply_count":3,"highest_post_number":9,"image_url":"/plugins/emoji/images/smile.png","created_at":"2013-06-17T19:46:31.000-04:00","last_posted_at":"2013-12-31T21:03:59.000-05:00","bumped":true,"bumped_at":"2013-12-31T21:03:59.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":7984,"username":"sophearak","avatar_template":"/images/avatar.png"}},{"id":7229,"title":"How to set up image uploads to S3?","fancy_title":"How to set up image uploads to S3?","slug":"how-to-set-up-image-uploads-to-s3","posts_count":14,"reply_count":11,"highest_post_number":14,"image_url":"/uploads/meta_discourse/1019/782cbc7e309ce43f.png","created_at":"2013-06-06T15:37:43.000-04:00","last_posted_at":"2013-12-31T11:54:18.000-05:00","bumped":true,"bumped_at":"2013-12-31T11:54:18.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":1995,"username":"zogstrip","avatar_template":"/images/avatar.png"}},{"id":11628,"title":"My experience with a successful migration (hints for a guide)","fancy_title":"My experience with a successful migration (hints for a guide)","slug":"my-experience-with-a-successful-migration-hints-for-a-guide","posts_count":3,"reply_count":1,"highest_post_number":3,"image_url":null,"created_at":"2013-12-28T09:23:45.000-05:00","last_posted_at":"2013-12-28T10:38:48.000-05:00","bumped":true,"bumped_at":"2013-12-28T10:38:48.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"last_poster":{"id":6018,"username":"robypez","avatar_template":"/images/avatar.png"}}]}]}}, +"/c/bug/l/latest.json": {"users":[{"id":1,"username":"sam","avatar_template":"/images/avatar.png"},{"id":32,"username":"codinghorror","avatar_template":"/images/avatar.png"},{"id":8021,"username":"Abhishek_Gupta","avatar_template":"/images/avatar.png"},{"id":6695,"username":"illspirit","avatar_template":"/images/avatar.png"},{"id":2,"username":"neil","avatar_template":"/images/avatar.png"},{"id":3124,"username":"sipp11","avatar_template":"/images/avatar.png"},{"id":7513,"username":"digit","avatar_template":"/images/avatar.png"},{"id":19,"username":"eviltrout","avatar_template":"/images/avatar.png"},{"id":3,"username":"supermathie","avatar_template":"/images/avatar.png"},{"id":7073,"username":"5an1ty","avatar_template":"/images/avatar.png"},{"id":4996,"username":"wmertens","avatar_template":"/images/avatar.png"},{"id":6377,"username":"zh99998","avatar_template":"/images/avatar.png"},{"id":1496,"username":"cfstras","avatar_template":"/images/avatar.png"},{"id":7995,"username":"Hunter","avatar_template":"/images/avatar.png"},{"id":6626,"username":"riking","avatar_template":"/images/avatar.png"},{"id":1995,"username":"zogstrip","avatar_template":"/images/avatar.png"},{"id":5048,"username":"SneakySly","avatar_template":"/images/avatar.png"},{"id":7731,"username":"YOU","avatar_template":"/images/avatar.png"},{"id":7985,"username":"onlinedev","avatar_template":"/images/avatar.png"},{"id":3415,"username":"radq","avatar_template":"/images/avatar.png"},{"id":5351,"username":"erlend_sh","avatar_template":"/images/avatar.png"},{"id":471,"username":"BhaelOchon","avatar_template":"/images/avatar.png"},{"id":7,"username":"pekka","avatar_template":"/images/avatar.png"},{"id":4780,"username":"HugoAlmeida","avatar_template":"/images/avatar.png"},{"id":5053,"username":"Blue","avatar_template":"/images/avatar.png"},{"id":212,"username":"alxndr","avatar_template":"/images/avatar.png"},{"id":6118,"username":"lukelarris","avatar_template":"/images/avatar.png"},{"id":7076,"username":"philnelson","avatar_template":"/images/avatar.png"},{"id":4851,"username":"jab","avatar_template":"/images/avatar.png"},{"id":4457,"username":"Lee_Ars","avatar_template":"/images/avatar.png"},{"id":6280,"username":"mx2000","avatar_template":"/images/avatar.png"},{"id":3681,"username":"Ajarn","avatar_template":"/images/avatar.png"},{"id":1621,"username":"bnb","avatar_template":"/images/avatar.png"},{"id":6266,"username":"bragi","avatar_template":"/images/avatar.png"},{"id":5335,"username":"masda70","avatar_template":"/images/avatar.png"},{"id":6314,"username":"rafaelfranca","avatar_template":"/images/avatar.png"}],"topic_list":{"can_create_topic":false,"more_topics_url":"/latest.json?category=1&page=1","draft":null,"draft_key":"new_topic","draft_sequence":null,"topics":[{"id":2,"title":"Category definition for bug","fancy_title":"Category definition for bug","slug":"category-definition-for-bug","posts_count":2,"reply_count":0,"highest_post_number":3,"image_url":null,"created_at":"2013-01-31T23:56:34.000-05:00","last_posted_at":"2013-03-07T22:42:27.000-05:00","bumped":true,"bumped_at":"2013-02-26T18:52:56.000-05:00","unseen":false,"pinned":true,"excerpt":"Bug reports on Discourse. Do be sure to search prior to submitting bugs. Include repro steps, and only describe one bug per topic please.","visible":true,"closed":false,"archived":false,"views":469,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":1},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11994,"title":"Cross domain rules, followed?","fancy_title":"Cross domain rules, followed?","slug":"cross-domain-rules-followed","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":"/plugins/emoji/images/smile.png","created_at":"2014-01-16T09:59:15.000-05:00","last_posted_at":"2014-01-16T09:59:15.000-05:00","bumped":true,"bumped_at":"2014-01-16T11:04:32.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":15,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"Abhishek_Gupta","category_id":1,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":8021}]},{"id":11888,"title":"Uncategorized topics not allowed, still seeing tag places","fancy_title":"Uncategorized topics not allowed, still seeing tag places","slug":"uncategorized-topics-not-allowed-still-seeing-tag-places","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":null,"created_at":"2014-01-10T19:23:37.000-05:00","last_posted_at":"2014-01-15T22:41:25.000-05:00","bumped":true,"bumped_at":"2014-01-15T22:41:25.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":50,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"illspirit","category_id":1,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":6695},{"extras":null,"description":"Most Posts","user_id":32},{"extras":null,"description":"Frequent Poster","user_id":2}]},{"id":9151,"title":"Apple touch icon doesn't show if there is no sub domain","fancy_title":"Apple touch icon doesn’t show if there is no sub domain","slug":"apple-touch-icon-doesnt-show-if-there-is-no-sub-domain","posts_count":7,"reply_count":4,"highest_post_number":7,"image_url":null,"created_at":"2013-08-16T18:16:53.000-04:00","last_posted_at":"2014-01-15T17:10:18.000-05:00","bumped":true,"bumped_at":"2014-01-15T13:19:22.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":188,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":3124},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":10911,"title":"/users/activate-account pulling blank logo instead of defaulting to h2","fancy_title":"/users/activate-account pulling blank logo instead of defaulting to h2","slug":"users-activate-account-pulling-blank-logo-instead-of-defaulting-to-h2","posts_count":3,"reply_count":1,"highest_post_number":3,"image_url":null,"created_at":"2013-11-12T14:49:04.000-05:00","last_posted_at":"2014-01-15T10:21:37.000-05:00","bumped":true,"bumped_at":"2014-01-15T10:21:37.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":121,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"eviltrout","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":7513},{"extras":"latest","description":"Most Recent Poster","user_id":19}]},{"id":11937,"title":"Smiley parser is busted","fancy_title":"Smiley parser is busted","slug":"smiley-parser-is-busted","posts_count":4,"reply_count":4,"highest_post_number":7,"image_url":"/plugins/emoji/images/smile.png","created_at":"2014-01-13T15:42:00.000-05:00","last_posted_at":"2014-01-15T05:51:16.000-05:00","bumped":true,"bumped_at":"2014-01-15T05:51:16.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":66,"like_count":2,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":3},{"extras":null,"description":"Most Posts","user_id":7073},{"extras":"latest","description":"Most Recent Poster","user_id":1}]},{"id":6625,"title":"Error 500 on PUT of site config","fancy_title":"Error 500 on PUT of site config","slug":"error-500-on-put-of-site-config","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2013-05-14T18:13:56.000-04:00","last_posted_at":"2014-01-16T04:55:50.000-05:00","bumped":true,"bumped_at":"2014-01-15T04:43:23.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":132,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":4996},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":11225,"title":"Forum acts weirdly after client side updates","fancy_title":"Forum acts weirdly after client side updates","slug":"forum-acts-weirdly-after-client-side-updates","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":null,"created_at":"2013-12-02T18:32:10.000-05:00","last_posted_at":"2014-01-15T04:04:55.000-05:00","bumped":true,"bumped_at":"2014-01-15T02:55:18.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":117,"like_count":7,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":1},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":11903,"title":"Error after update to 0.9.8.1","fancy_title":"Error after update to 0.9.8.1","slug":"error-after-update-to-0-9-8-1","posts_count":14,"reply_count":6,"highest_post_number":17,"image_url":null,"created_at":"2014-01-12T06:55:45.000-05:00","last_posted_at":"2014-01-15T01:48:58.000-05:00","bumped":true,"bumped_at":"2014-01-15T01:48:58.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":121,"like_count":6,"has_summary":false,"archetype":"regular","last_poster_username":"zh99998","category_id":1,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":6377},{"extras":null,"description":"Most Posts","user_id":1496},{"extras":null,"description":"Frequent Poster","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":19}]},{"id":11969,"title":"Qunit error and possibly related ember.js problem","fancy_title":"Qunit error and possibly related ember.js problem","slug":"qunit-error-and-possibly-related-ember-js-problem","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-14T22:51:32.000-05:00","last_posted_at":"2014-01-14T22:51:32.000-05:00","bumped":false,"bumped_at":"2014-01-14T22:51:32.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":32,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"Hunter","category_id":1,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":7995}]},{"id":11945,"title":"Stuff disappears on the groups page","fancy_title":"Stuff disappears on the groups page","slug":"stuff-disappears-on-the-groups-page","posts_count":7,"reply_count":2,"highest_post_number":7,"image_url":null,"created_at":"2014-01-13T23:03:53.000-05:00","last_posted_at":"2014-01-15T01:26:07.000-05:00","bumped":true,"bumped_at":"2014-01-14T21:09:01.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":54,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":6695},{"extras":null,"description":"Most Posts","user_id":6626},{"extras":"latest","description":"Most Recent Poster, Frequent Poster","user_id":1995}]},{"id":11520,"title":"Discourse WordPress Plugin: Emoji's do not properly display","fancy_title":"Discourse WordPress Plugin: Emoji’s do not properly display","slug":"discourse-wordpress-plugin-emojis-do-not-properly-display","posts_count":9,"reply_count":4,"highest_post_number":9,"image_url":"/uploads/default/_optimized/638/4db/eff43a45b8_690x420.png","created_at":"2013-12-19T23:32:03.000-05:00","last_posted_at":"2014-01-15T04:32:19.000-05:00","bumped":true,"bumped_at":"2014-01-14T17:53:34.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":168,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":5048},{"extras":null,"description":"Frequent Poster","user_id":7731},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":11597,"title":"All categories drop down does not close after clicking on first menu \"all categories\"","fancy_title":"All categories drop down does not close after clicking on first menu “all categories”","slug":"all-categories-drop-down-does-not-close-after-clicking-on-first-menu-all-categories","posts_count":5,"reply_count":2,"highest_post_number":5,"image_url":"/uploads/default/2495/f9efe463ae67632d.png","created_at":"2013-12-25T15:09:27.000-05:00","last_posted_at":"2014-01-14T17:46:41.000-05:00","bumped":true,"bumped_at":"2014-01-14T17:46:41.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":73,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"radq","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":7985},{"extras":null,"description":"Most Posts","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":3415}]},{"id":11962,"title":"Editor When Clicking on Wrench Issue","fancy_title":"Editor When Clicking on Wrench Issue","slug":"editor-when-clicking-on-wrench-issue","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":"/uploads/default/_optimized/ca4/f70/ac7278b8f6_690x176.png","created_at":"2014-01-14T17:23:20.000-05:00","last_posted_at":"2014-01-14T17:24:02.000-05:00","bumped":true,"bumped_at":"2014-01-14T17:24:02.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":30,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":7073},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11831,"title":"Broken links, possibly related to HTTPS","fancy_title":"Broken links, possibly related to HTTPS","slug":"broken-links-possibly-related-to-https","posts_count":17,"reply_count":13,"highest_post_number":18,"image_url":null,"created_at":"2014-01-08T17:40:45.000-05:00","last_posted_at":"2014-01-14T16:03:07.000-05:00","bumped":true,"bumped_at":"2014-01-14T16:03:07.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":102,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"eviltrout","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":5351},{"extras":null,"description":"Most Posts","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":471},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":19}]},{"id":11916,"title":"Unable to save user preferences","fancy_title":"Unable to save user preferences","slug":"unable-to-save-user-preferences","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2014-01-13T02:29:26.000-05:00","last_posted_at":"2014-01-14T14:39:32.000-05:00","bumped":true,"bumped_at":"2014-01-14T14:39:29.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":34,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":6626},{"extras":"latest","description":"Most Recent Poster","user_id":1995}]},{"id":10425,"title":"Editing category permissions: select value doesn't change","fancy_title":"Editing category permissions: select value doesn’t change","slug":"editing-category-permissions-select-value-doesnt-change","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":"/uploads/meta_discourse/1956/d55fba29dbd7e1fe.png","created_at":"2013-10-17T18:20:20.000-04:00","last_posted_at":"2013-10-17T18:20:21.000-04:00","bumped":true,"bumped_at":"2014-01-14T13:35:37.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":92,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"pekka","category_id":1,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":7}]},{"id":6557,"title":"Middle clicking a link twice does not work as expected","fancy_title":"Middle clicking a link twice does not work as expected","slug":"middle-clicking-a-link-twice-does-not-work-as-expected","posts_count":10,"reply_count":7,"highest_post_number":10,"image_url":null,"created_at":"2013-05-11T13:56:02.000-04:00","last_posted_at":"2014-01-14T13:13:04.000-05:00","bumped":true,"bumped_at":"2014-01-14T13:13:04.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":401,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"neil","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":4780},{"extras":null,"description":"Most Posts","user_id":5053},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":2}]},{"id":11944,"title":"Regression: Cannot sort topic list","fancy_title":"Regression: Cannot sort topic list","slug":"regression-cannot-sort-topic-list","posts_count":5,"reply_count":0,"highest_post_number":5,"image_url":null,"created_at":"2014-01-13T20:14:06.000-05:00","last_posted_at":"2014-01-14T19:31:28.000-05:00","bumped":true,"bumped_at":"2014-01-14T07:31:19.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":true,"views":37,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":6626},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":1995}]},{"id":10462,"title":"Rebake error when posts contain deleted YouTube video","fancy_title":"Rebake error when posts contain deleted YouTube video","slug":"rebake-error-when-posts-contain-deleted-youtube-video","posts_count":7,"reply_count":1,"highest_post_number":7,"image_url":null,"created_at":"2013-10-19T00:01:21.000-04:00","last_posted_at":"2014-01-14T02:24:19.000-05:00","bumped":true,"bumped_at":"2014-01-14T02:24:12.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":178,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":6695},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11932,"title":"Use of blockquote tag causes text outside a paragraph","fancy_title":"Use of blockquote tag causes text outside a paragraph","slug":"use-of-blockquote-tag-causes-text-outside-a-paragraph","posts_count":4,"reply_count":2,"highest_post_number":4,"image_url":null,"created_at":"2014-01-13T13:38:15.000-05:00","last_posted_at":"2014-01-13T19:30:37.000-05:00","bumped":true,"bumped_at":"2014-01-14T02:22:58.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":54,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":6626},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":10357,"title":"Displaced Wrench Icon Chrome","fancy_title":"Displaced Wrench Icon Chrome","slug":"displaced-wrench-icon-chrome","posts_count":12,"reply_count":4,"highest_post_number":12,"image_url":"/uploads/default/_optimized/9f3/f35/c5379beffe_690x300.jpg","created_at":"2013-10-14T05:48:21.000-04:00","last_posted_at":"2014-01-14T03:21:32.000-05:00","bumped":true,"bumped_at":"2014-01-13T19:03:33.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":206,"like_count":10,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":7073},{"extras":null,"description":"Frequent Poster","user_id":212},{"extras":null,"description":"Frequent Poster","user_id":6118},{"extras":null,"description":"Frequent Poster","user_id":1},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":10114,"title":"Invitation expiry workflow is wonky","fancy_title":"Invitation expiry workflow is wonky","slug":"invitation-expiry-workflow-is-wonky","posts_count":14,"reply_count":7,"highest_post_number":14,"image_url":null,"created_at":"2013-09-30T00:59:36.000-04:00","last_posted_at":"2014-01-13T18:51:26.000-05:00","bumped":true,"bumped_at":"2014-01-13T18:51:26.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":176,"like_count":2,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":1},{"extras":null,"description":"Most Posts","user_id":7076},{"extras":null,"description":"Frequent Poster","user_id":2},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":6330,"title":"Reply not disabled if topic closed while viewing","fancy_title":"Reply not disabled if topic closed while viewing","slug":"reply-not-disabled-if-topic-closed-while-viewing","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":"https://www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s=40&r=pg&d=identicon","created_at":"2013-05-02T06:02:06.000-04:00","last_posted_at":"2014-01-13T11:54:22.000-05:00","bumped":true,"bumped_at":"2014-01-13T11:54:22.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":164,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":4851},{"extras":null,"description":"Most Posts","user_id":2},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":8367,"title":"Very fast scrolling fails to mark all posts read in a thread","fancy_title":"Very fast scrolling fails to mark all posts read in a thread","slug":"very-fast-scrolling-fails-to-mark-all-posts-read-in-a-thread","posts_count":11,"reply_count":7,"highest_post_number":13,"image_url":null,"created_at":"2013-07-14T12:37:02.000-04:00","last_posted_at":"2014-01-13T11:16:56.000-05:00","bumped":true,"bumped_at":"2014-01-13T11:16:33.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":288,"like_count":5,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":4457},{"extras":null,"description":"Most Posts","user_id":6280},{"extras":null,"description":"Frequent Poster","user_id":3681},{"extras":null,"description":"Frequent Poster","user_id":1621},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":8815,"title":"Cache headers confuse proxies","fancy_title":"Cache headers confuse proxies","slug":"cache-headers-confuse-proxies","posts_count":9,"reply_count":3,"highest_post_number":9,"image_url":null,"created_at":"2013-08-02T05:45:26.000-04:00","last_posted_at":"2014-01-13T11:12:09.000-05:00","bumped":true,"bumped_at":"2014-01-13T10:41:44.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":true,"views":314,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":6266},{"extras":null,"description":"Most Posts","user_id":19},{"extras":null,"description":"Frequent Poster","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":4457},{"extras":"latest","description":"Most Recent Poster, Frequent Poster","user_id":32}]},{"id":11371,"title":"Search not working for Staff users","fancy_title":"Search not working for Staff users","slug":"search-not-working-for-staff-users","posts_count":15,"reply_count":10,"highest_post_number":15,"image_url":null,"created_at":"2013-12-11T13:22:56.000-05:00","last_posted_at":"2014-01-13T01:41:50.000-05:00","bumped":true,"bumped_at":"2014-01-13T01:41:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":true,"views":217,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":5335},{"extras":null,"description":"Most Posts","user_id":19},{"extras":null,"description":"Frequent Poster","user_id":6314},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":1}]},{"id":9908,"title":"Draft bar overrides pagination widget","fancy_title":"Draft bar overrides pagination widget","slug":"draft-bar-overrides-pagination-widget","posts_count":4,"reply_count":0,"highest_post_number":4,"image_url":null,"created_at":"2013-09-19T17:19:52.000-04:00","last_posted_at":"2014-01-13T01:26:01.000-05:00","bumped":true,"bumped_at":"2014-01-13T01:25:12.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":true,"views":108,"like_count":2,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":5351},{"extras":null,"description":"Most Posts","user_id":471},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":6134,"title":"Unread topic is stuck as unread after insertion of staff message","fancy_title":"Unread topic is stuck as unread after insertion of staff message","slug":"unread-topic-is-stuck-as-unread-after-insertion-of-staff-message","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":"https://www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s=40&r=pg&d=identicon","created_at":"2013-04-24T13:37:32.000-04:00","last_posted_at":"2014-01-13T01:22:49.000-05:00","bumped":true,"bumped_at":"2014-01-13T01:22:42.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":169,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":1,"posters":[{"extras":null,"description":"Original Poster","user_id":3681},{"extras":null,"description":"Most Posts","user_id":5351},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11914,"title":"Google analytics is not registering page views","fancy_title":"Google analytics is not registering page views","slug":"google-analytics-is-not-registering-page-views","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-13T00:32:45.000-05:00","last_posted_at":"2014-01-13T00:32:46.000-05:00","bumped":true,"bumped_at":"2014-01-13T00:32:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":37,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":1,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":1}]}]}}, +"/c/feature/l/latest.json": {"users":[{"id":1,"username":"sam","avatar_template":"/images/avatar.png"},{"id":32,"username":"codinghorror","avatar_template":"/images/avatar.png"},{"id":8021,"username":"Abhishek_Gupta","avatar_template":"/images/avatar.png"},{"id":6695,"username":"illspirit","avatar_template":"/images/avatar.png"},{"id":2,"username":"neil","avatar_template":"/images/avatar.png"},{"id":3124,"username":"sipp11","avatar_template":"/images/avatar.png"},{"id":7513,"username":"digit","avatar_template":"/images/avatar.png"},{"id":19,"username":"eviltrout","avatar_template":"/images/avatar.png"},{"id":3,"username":"supermathie","avatar_template":"/images/avatar.png"},{"id":7073,"username":"5an1ty","avatar_template":"/images/avatar.png"},{"id":4996,"username":"wmertens","avatar_template":"/images/avatar.png"},{"id":6377,"username":"zh99998","avatar_template":"/images/avatar.png"},{"id":1496,"username":"cfstras","avatar_template":"/images/avatar.png"},{"id":7995,"username":"Hunter","avatar_template":"/images/avatar.png"},{"id":6626,"username":"riking","avatar_template":"/images/avatar.png"},{"id":1995,"username":"zogstrip","avatar_template":"/images/avatar.png"},{"id":5048,"username":"SneakySly","avatar_template":"/images/avatar.png"},{"id":7731,"username":"YOU","avatar_template":"/images/avatar.png"},{"id":7985,"username":"onlinedev","avatar_template":"/images/avatar.png"},{"id":3415,"username":"radq","avatar_template":"/images/avatar.png"},{"id":5351,"username":"erlend_sh","avatar_template":"/images/avatar.png"},{"id":471,"username":"BhaelOchon","avatar_template":"/images/avatar.png"},{"id":7,"username":"pekka","avatar_template":"/images/avatar.png"},{"id":4780,"username":"HugoAlmeida","avatar_template":"/images/avatar.png"},{"id":5053,"username":"Blue","avatar_template":"/images/avatar.png"},{"id":212,"username":"alxndr","avatar_template":"/images/avatar.png"},{"id":6118,"username":"lukelarris","avatar_template":"/images/avatar.png"},{"id":7076,"username":"philnelson","avatar_template":"/images/avatar.png"},{"id":4851,"username":"jab","avatar_template":"/images/avatar.png"},{"id":4457,"username":"Lee_Ars","avatar_template":"/images/avatar.png"},{"id":6280,"username":"mx2000","avatar_template":"/images/avatar.png"},{"id":3681,"username":"Ajarn","avatar_template":"/images/avatar.png"},{"id":1621,"username":"bnb","avatar_template":"/images/avatar.png"},{"id":6266,"username":"bragi","avatar_template":"/images/avatar.png"},{"id":5335,"username":"masda70","avatar_template":"/images/avatar.png"},{"id":6314,"username":"rafaelfranca","avatar_template":"/images/avatar.png"}],"topic_list":{"can_create_topic":false,"more_topics_url":"/latest.json?category=2&page=1","draft":null,"draft_key":"new_topic","draft_sequence":null,"topics":[{"id":2,"title":"Category definition for feature","fancy_title":"Category definition for feature","slug":"category-definition-for-feature","posts_count":2,"reply_count":0,"highest_post_number":3,"image_url":null,"created_at":"2013-01-31T23:56:34.000-05:00","last_posted_at":"2013-03-07T22:42:27.000-05:00","bumped":true,"bumped_at":"2013-02-26T18:52:56.000-05:00","unseen":false,"pinned":true,"excerpt":"Features on Discourse.","visible":true,"closed":false,"archived":false,"views":469,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":1},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11994,"title":"Cross domain rules, followed?","fancy_title":"Cross domain rules, followed?","slug":"cross-domain-rules-followed","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":"/plugins/emoji/images/smile.png","created_at":"2014-01-16T09:59:15.000-05:00","last_posted_at":"2014-01-16T09:59:15.000-05:00","bumped":true,"bumped_at":"2014-01-16T11:04:32.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":15,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"Abhishek_Gupta","category_id":2,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":8021}]},{"id":11888,"title":"Uncategorized topics not allowed, still seeing tag places","fancy_title":"Uncategorized topics not allowed, still seeing tag places","slug":"uncategorized-topics-not-allowed-still-seeing-tag-places","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":null,"created_at":"2014-01-10T19:23:37.000-05:00","last_posted_at":"2014-01-15T22:41:25.000-05:00","bumped":true,"bumped_at":"2014-01-15T22:41:25.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":50,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"illspirit","category_id":2,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":6695},{"extras":null,"description":"Most Posts","user_id":32},{"extras":null,"description":"Frequent Poster","user_id":2}]},{"id":9151,"title":"Apple touch icon doesn't show if there is no sub domain","fancy_title":"Apple touch icon doesn’t show if there is no sub domain","slug":"apple-touch-icon-doesnt-show-if-there-is-no-sub-domain","posts_count":7,"reply_count":4,"highest_post_number":7,"image_url":null,"created_at":"2013-08-16T18:16:53.000-04:00","last_posted_at":"2014-01-15T17:10:18.000-05:00","bumped":true,"bumped_at":"2014-01-15T13:19:22.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":188,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":3124},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":10911,"title":"/users/activate-account pulling blank logo instead of defaulting to h2","fancy_title":"/users/activate-account pulling blank logo instead of defaulting to h2","slug":"users-activate-account-pulling-blank-logo-instead-of-defaulting-to-h2","posts_count":3,"reply_count":1,"highest_post_number":3,"image_url":null,"created_at":"2013-11-12T14:49:04.000-05:00","last_posted_at":"2014-01-15T10:21:37.000-05:00","bumped":true,"bumped_at":"2014-01-15T10:21:37.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":121,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"eviltrout","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":7513},{"extras":"latest","description":"Most Recent Poster","user_id":19}]},{"id":11937,"title":"Smiley parser is busted","fancy_title":"Smiley parser is busted","slug":"smiley-parser-is-busted","posts_count":4,"reply_count":4,"highest_post_number":7,"image_url":"/plugins/emoji/images/smile.png","created_at":"2014-01-13T15:42:00.000-05:00","last_posted_at":"2014-01-15T05:51:16.000-05:00","bumped":true,"bumped_at":"2014-01-15T05:51:16.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":66,"like_count":2,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":3},{"extras":null,"description":"Most Posts","user_id":7073},{"extras":"latest","description":"Most Recent Poster","user_id":1}]},{"id":6625,"title":"Error 500 on PUT of site config","fancy_title":"Error 500 on PUT of site config","slug":"error-500-on-put-of-site-config","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2013-05-14T18:13:56.000-04:00","last_posted_at":"2014-01-16T04:55:50.000-05:00","bumped":true,"bumped_at":"2014-01-15T04:43:23.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":132,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":4996},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":11225,"title":"Forum acts weirdly after client side updates","fancy_title":"Forum acts weirdly after client side updates","slug":"forum-acts-weirdly-after-client-side-updates","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":null,"created_at":"2013-12-02T18:32:10.000-05:00","last_posted_at":"2014-01-15T04:04:55.000-05:00","bumped":true,"bumped_at":"2014-01-15T02:55:18.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":117,"like_count":7,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":1},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":11903,"title":"Error after update to 0.9.8.1","fancy_title":"Error after update to 0.9.8.1","slug":"error-after-update-to-0-9-8-1","posts_count":14,"reply_count":6,"highest_post_number":17,"image_url":null,"created_at":"2014-01-12T06:55:45.000-05:00","last_posted_at":"2014-01-15T01:48:58.000-05:00","bumped":true,"bumped_at":"2014-01-15T01:48:58.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":121,"like_count":6,"has_summary":false,"archetype":"regular","last_poster_username":"zh99998","category_id":2,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":6377},{"extras":null,"description":"Most Posts","user_id":1496},{"extras":null,"description":"Frequent Poster","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":19}]},{"id":11969,"title":"Qunit error and possibly related ember.js problem","fancy_title":"Qunit error and possibly related ember.js problem","slug":"qunit-error-and-possibly-related-ember-js-problem","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-14T22:51:32.000-05:00","last_posted_at":"2014-01-14T22:51:32.000-05:00","bumped":false,"bumped_at":"2014-01-14T22:51:32.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":32,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"Hunter","category_id":2,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":7995}]},{"id":11945,"title":"Stuff disappears on the groups page","fancy_title":"Stuff disappears on the groups page","slug":"stuff-disappears-on-the-groups-page","posts_count":7,"reply_count":2,"highest_post_number":7,"image_url":null,"created_at":"2014-01-13T23:03:53.000-05:00","last_posted_at":"2014-01-15T01:26:07.000-05:00","bumped":true,"bumped_at":"2014-01-14T21:09:01.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":54,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":6695},{"extras":null,"description":"Most Posts","user_id":6626},{"extras":"latest","description":"Most Recent Poster, Frequent Poster","user_id":1995}]},{"id":11520,"title":"Discourse WordPress Plugin: Emoji's do not properly display","fancy_title":"Discourse WordPress Plugin: Emoji’s do not properly display","slug":"discourse-wordpress-plugin-emojis-do-not-properly-display","posts_count":9,"reply_count":4,"highest_post_number":9,"image_url":"/uploads/default/_optimized/638/4db/eff43a45b8_690x420.png","created_at":"2013-12-19T23:32:03.000-05:00","last_posted_at":"2014-01-15T04:32:19.000-05:00","bumped":true,"bumped_at":"2014-01-14T17:53:34.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":168,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":5048},{"extras":null,"description":"Frequent Poster","user_id":7731},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":11597,"title":"All categories drop down does not close after clicking on first menu \"all categories\"","fancy_title":"All categories drop down does not close after clicking on first menu “all categories”","slug":"all-categories-drop-down-does-not-close-after-clicking-on-first-menu-all-categories","posts_count":5,"reply_count":2,"highest_post_number":5,"image_url":"/uploads/default/2495/f9efe463ae67632d.png","created_at":"2013-12-25T15:09:27.000-05:00","last_posted_at":"2014-01-14T17:46:41.000-05:00","bumped":true,"bumped_at":"2014-01-14T17:46:41.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":73,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"radq","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":7985},{"extras":null,"description":"Most Posts","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":3415}]},{"id":11962,"title":"Editor When Clicking on Wrench Issue","fancy_title":"Editor When Clicking on Wrench Issue","slug":"editor-when-clicking-on-wrench-issue","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":"/uploads/default/_optimized/ca4/f70/ac7278b8f6_690x176.png","created_at":"2014-01-14T17:23:20.000-05:00","last_posted_at":"2014-01-14T17:24:02.000-05:00","bumped":true,"bumped_at":"2014-01-14T17:24:02.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":30,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":7073},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11831,"title":"Broken links, possibly related to HTTPS","fancy_title":"Broken links, possibly related to HTTPS","slug":"broken-links-possibly-related-to-https","posts_count":17,"reply_count":13,"highest_post_number":18,"image_url":null,"created_at":"2014-01-08T17:40:45.000-05:00","last_posted_at":"2014-01-14T16:03:07.000-05:00","bumped":true,"bumped_at":"2014-01-14T16:03:07.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":102,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"eviltrout","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":5351},{"extras":null,"description":"Most Posts","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":471},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":19}]},{"id":11916,"title":"Unable to save user preferences","fancy_title":"Unable to save user preferences","slug":"unable-to-save-user-preferences","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2014-01-13T02:29:26.000-05:00","last_posted_at":"2014-01-14T14:39:32.000-05:00","bumped":true,"bumped_at":"2014-01-14T14:39:29.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":34,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":6626},{"extras":"latest","description":"Most Recent Poster","user_id":1995}]},{"id":10425,"title":"Editing category permissions: select value doesn't change","fancy_title":"Editing category permissions: select value doesn’t change","slug":"editing-category-permissions-select-value-doesnt-change","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":"/uploads/meta_discourse/1956/d55fba29dbd7e1fe.png","created_at":"2013-10-17T18:20:20.000-04:00","last_posted_at":"2013-10-17T18:20:21.000-04:00","bumped":true,"bumped_at":"2014-01-14T13:35:37.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":92,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"pekka","category_id":2,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":7}]},{"id":6557,"title":"Middle clicking a link twice does not work as expected","fancy_title":"Middle clicking a link twice does not work as expected","slug":"middle-clicking-a-link-twice-does-not-work-as-expected","posts_count":10,"reply_count":7,"highest_post_number":10,"image_url":null,"created_at":"2013-05-11T13:56:02.000-04:00","last_posted_at":"2014-01-14T13:13:04.000-05:00","bumped":true,"bumped_at":"2014-01-14T13:13:04.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":401,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"neil","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":4780},{"extras":null,"description":"Most Posts","user_id":5053},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":2}]},{"id":11944,"title":"Regression: Cannot sort topic list","fancy_title":"Regression: Cannot sort topic list","slug":"regression-cannot-sort-topic-list","posts_count":5,"reply_count":0,"highest_post_number":5,"image_url":null,"created_at":"2014-01-13T20:14:06.000-05:00","last_posted_at":"2014-01-14T19:31:28.000-05:00","bumped":true,"bumped_at":"2014-01-14T07:31:19.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":true,"views":37,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":6626},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":1995}]},{"id":10462,"title":"Rebake error when posts contain deleted YouTube video","fancy_title":"Rebake error when posts contain deleted YouTube video","slug":"rebake-error-when-posts-contain-deleted-youtube-video","posts_count":7,"reply_count":1,"highest_post_number":7,"image_url":null,"created_at":"2013-10-19T00:01:21.000-04:00","last_posted_at":"2014-01-14T02:24:19.000-05:00","bumped":true,"bumped_at":"2014-01-14T02:24:12.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":178,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":6695},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11932,"title":"Use of blockquote tag causes text outside a paragraph","fancy_title":"Use of blockquote tag causes text outside a paragraph","slug":"use-of-blockquote-tag-causes-text-outside-a-paragraph","posts_count":4,"reply_count":2,"highest_post_number":4,"image_url":null,"created_at":"2014-01-13T13:38:15.000-05:00","last_posted_at":"2014-01-13T19:30:37.000-05:00","bumped":true,"bumped_at":"2014-01-14T02:22:58.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":54,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":6626},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":10357,"title":"Displaced Wrench Icon Chrome","fancy_title":"Displaced Wrench Icon Chrome","slug":"displaced-wrench-icon-chrome","posts_count":12,"reply_count":4,"highest_post_number":12,"image_url":"/uploads/default/_optimized/9f3/f35/c5379beffe_690x300.jpg","created_at":"2013-10-14T05:48:21.000-04:00","last_posted_at":"2014-01-14T03:21:32.000-05:00","bumped":true,"bumped_at":"2014-01-13T19:03:33.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":206,"like_count":10,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":7073},{"extras":null,"description":"Frequent Poster","user_id":212},{"extras":null,"description":"Frequent Poster","user_id":6118},{"extras":null,"description":"Frequent Poster","user_id":1},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":10114,"title":"Invitation expiry workflow is wonky","fancy_title":"Invitation expiry workflow is wonky","slug":"invitation-expiry-workflow-is-wonky","posts_count":14,"reply_count":7,"highest_post_number":14,"image_url":null,"created_at":"2013-09-30T00:59:36.000-04:00","last_posted_at":"2014-01-13T18:51:26.000-05:00","bumped":true,"bumped_at":"2014-01-13T18:51:26.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":176,"like_count":2,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":1},{"extras":null,"description":"Most Posts","user_id":7076},{"extras":null,"description":"Frequent Poster","user_id":2},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":6330,"title":"Reply not disabled if topic closed while viewing","fancy_title":"Reply not disabled if topic closed while viewing","slug":"reply-not-disabled-if-topic-closed-while-viewing","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":"https://www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s=40&r=pg&d=identicon","created_at":"2013-05-02T06:02:06.000-04:00","last_posted_at":"2014-01-13T11:54:22.000-05:00","bumped":true,"bumped_at":"2014-01-13T11:54:22.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":164,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":4851},{"extras":null,"description":"Most Posts","user_id":2},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":8367,"title":"Very fast scrolling fails to mark all posts read in a thread","fancy_title":"Very fast scrolling fails to mark all posts read in a thread","slug":"very-fast-scrolling-fails-to-mark-all-posts-read-in-a-thread","posts_count":11,"reply_count":7,"highest_post_number":13,"image_url":null,"created_at":"2013-07-14T12:37:02.000-04:00","last_posted_at":"2014-01-13T11:16:56.000-05:00","bumped":true,"bumped_at":"2014-01-13T11:16:33.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":288,"like_count":5,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":4457},{"extras":null,"description":"Most Posts","user_id":6280},{"extras":null,"description":"Frequent Poster","user_id":3681},{"extras":null,"description":"Frequent Poster","user_id":1621},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":8815,"title":"Cache headers confuse proxies","fancy_title":"Cache headers confuse proxies","slug":"cache-headers-confuse-proxies","posts_count":9,"reply_count":3,"highest_post_number":9,"image_url":null,"created_at":"2013-08-02T05:45:26.000-04:00","last_posted_at":"2014-01-13T11:12:09.000-05:00","bumped":true,"bumped_at":"2014-01-13T10:41:44.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":true,"views":314,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":6266},{"extras":null,"description":"Most Posts","user_id":19},{"extras":null,"description":"Frequent Poster","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":4457},{"extras":"latest","description":"Most Recent Poster, Frequent Poster","user_id":32}]},{"id":11371,"title":"Search not working for Staff users","fancy_title":"Search not working for Staff users","slug":"search-not-working-for-staff-users","posts_count":15,"reply_count":10,"highest_post_number":15,"image_url":null,"created_at":"2013-12-11T13:22:56.000-05:00","last_posted_at":"2014-01-13T01:41:50.000-05:00","bumped":true,"bumped_at":"2014-01-13T01:41:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":true,"views":217,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":5335},{"extras":null,"description":"Most Posts","user_id":19},{"extras":null,"description":"Frequent Poster","user_id":6314},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":1}]},{"id":9908,"title":"Draft bar overrides pagination widget","fancy_title":"Draft bar overrides pagination widget","slug":"draft-bar-overrides-pagination-widget","posts_count":4,"reply_count":0,"highest_post_number":4,"image_url":null,"created_at":"2013-09-19T17:19:52.000-04:00","last_posted_at":"2014-01-13T01:26:01.000-05:00","bumped":true,"bumped_at":"2014-01-13T01:25:12.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":true,"views":108,"like_count":2,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":5351},{"extras":null,"description":"Most Posts","user_id":471},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":6134,"title":"Unread topic is stuck as unread after insertion of staff message","fancy_title":"Unread topic is stuck as unread after insertion of staff message","slug":"unread-topic-is-stuck-as-unread-after-insertion-of-staff-message","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":"https://www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s=40&r=pg&d=identicon","created_at":"2013-04-24T13:37:32.000-04:00","last_posted_at":"2014-01-13T01:22:49.000-05:00","bumped":true,"bumped_at":"2014-01-13T01:22:42.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":169,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":3681},{"extras":null,"description":"Most Posts","user_id":5351},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11914,"title":"Google analytics is not registering page views","fancy_title":"Google analytics is not registering page views","slug":"google-analytics-is-not-registering-page-views","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-13T00:32:45.000-05:00","last_posted_at":"2014-01-13T00:32:46.000-05:00","bumped":true,"bumped_at":"2014-01-13T00:32:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":37,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":2,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":1}]}]}}, +"/c/dev/l/latest.json": {"users":[{"id":1,"username":"sam","avatar_template":"/images/avatar.png"},{"id":32,"username":"codinghorror","avatar_template":"/images/avatar.png"},{"id":8021,"username":"Abhishek_Gupta","avatar_template":"/images/avatar.png"},{"id":6695,"username":"illspirit","avatar_template":"/images/avatar.png"},{"id":2,"username":"neil","avatar_template":"/images/avatar.png"},{"id":3124,"username":"sipp11","avatar_template":"/images/avatar.png"},{"id":7513,"username":"digit","avatar_template":"/images/avatar.png"},{"id":19,"username":"eviltrout","avatar_template":"/images/avatar.png"},{"id":3,"username":"supermathie","avatar_template":"/images/avatar.png"},{"id":7073,"username":"5an1ty","avatar_template":"/images/avatar.png"},{"id":4996,"username":"wmertens","avatar_template":"/images/avatar.png"},{"id":6377,"username":"zh99998","avatar_template":"/images/avatar.png"},{"id":1496,"username":"cfstras","avatar_template":"/images/avatar.png"},{"id":7995,"username":"Hunter","avatar_template":"/images/avatar.png"},{"id":6626,"username":"riking","avatar_template":"/images/avatar.png"},{"id":1995,"username":"zogstrip","avatar_template":"/images/avatar.png"},{"id":5048,"username":"SneakySly","avatar_template":"/images/avatar.png"},{"id":7731,"username":"YOU","avatar_template":"/images/avatar.png"},{"id":7985,"username":"onlinedev","avatar_template":"/images/avatar.png"},{"id":3415,"username":"radq","avatar_template":"/images/avatar.png"},{"id":5351,"username":"erlend_sh","avatar_template":"/images/avatar.png"},{"id":471,"username":"BhaelOchon","avatar_template":"/images/avatar.png"},{"id":7,"username":"pekka","avatar_template":"/images/avatar.png"},{"id":4780,"username":"HugoAlmeida","avatar_template":"/images/avatar.png"},{"id":5053,"username":"Blue","avatar_template":"/images/avatar.png"},{"id":212,"username":"alxndr","avatar_template":"/images/avatar.png"},{"id":6118,"username":"lukelarris","avatar_template":"/images/avatar.png"},{"id":7076,"username":"philnelson","avatar_template":"/images/avatar.png"},{"id":4851,"username":"jab","avatar_template":"/images/avatar.png"},{"id":4457,"username":"Lee_Ars","avatar_template":"/images/avatar.png"},{"id":6280,"username":"mx2000","avatar_template":"/images/avatar.png"},{"id":3681,"username":"Ajarn","avatar_template":"/images/avatar.png"},{"id":1621,"username":"bnb","avatar_template":"/images/avatar.png"},{"id":6266,"username":"bragi","avatar_template":"/images/avatar.png"},{"id":5335,"username":"masda70","avatar_template":"/images/avatar.png"},{"id":6314,"username":"rafaelfranca","avatar_template":"/images/avatar.png"}],"topic_list":{"can_create_topic":false,"more_topics_url":"/latest.json?category=2&page=1","draft":null,"draft_key":"new_topic","draft_sequence":null,"topics":[{"id":2,"title":"Category definition for dev","fancy_title":"Category definition for dev","slug":"category-definition-for-dev","posts_count":2,"reply_count":0,"highest_post_number":3,"image_url":null,"created_at":"2013-01-31T23:56:34.000-05:00","last_posted_at":"2013-03-07T22:42:27.000-05:00","bumped":true,"bumped_at":"2013-02-26T18:52:56.000-05:00","unseen":false,"pinned":true,"excerpt":"Development of Discourse.","visible":true,"closed":false,"archived":false,"views":469,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":1},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11994,"title":"Cross domain rules, followed?","fancy_title":"Cross domain rules, followed?","slug":"cross-domain-rules-followed","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":"/plugins/emoji/images/smile.png","created_at":"2014-01-16T09:59:15.000-05:00","last_posted_at":"2014-01-16T09:59:15.000-05:00","bumped":true,"bumped_at":"2014-01-16T11:04:32.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":15,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"Abhishek_Gupta","category_id":7,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":8021}]},{"id":11888,"title":"Uncategorized topics not allowed, still seeing tag places","fancy_title":"Uncategorized topics not allowed, still seeing tag places","slug":"uncategorized-topics-not-allowed-still-seeing-tag-places","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":null,"created_at":"2014-01-10T19:23:37.000-05:00","last_posted_at":"2014-01-15T22:41:25.000-05:00","bumped":true,"bumped_at":"2014-01-15T22:41:25.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":50,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"illspirit","category_id":7,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":6695},{"extras":null,"description":"Most Posts","user_id":32},{"extras":null,"description":"Frequent Poster","user_id":2}]},{"id":9151,"title":"Apple touch icon doesn't show if there is no sub domain","fancy_title":"Apple touch icon doesn’t show if there is no sub domain","slug":"apple-touch-icon-doesnt-show-if-there-is-no-sub-domain","posts_count":7,"reply_count":4,"highest_post_number":7,"image_url":null,"created_at":"2013-08-16T18:16:53.000-04:00","last_posted_at":"2014-01-15T17:10:18.000-05:00","bumped":true,"bumped_at":"2014-01-15T13:19:22.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":188,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":3124},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":10911,"title":"/users/activate-account pulling blank logo instead of defaulting to h2","fancy_title":"/users/activate-account pulling blank logo instead of defaulting to h2","slug":"users-activate-account-pulling-blank-logo-instead-of-defaulting-to-h2","posts_count":3,"reply_count":1,"highest_post_number":3,"image_url":null,"created_at":"2013-11-12T14:49:04.000-05:00","last_posted_at":"2014-01-15T10:21:37.000-05:00","bumped":true,"bumped_at":"2014-01-15T10:21:37.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":121,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"eviltrout","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":7513},{"extras":"latest","description":"Most Recent Poster","user_id":19}]},{"id":11937,"title":"Smiley parser is busted","fancy_title":"Smiley parser is busted","slug":"smiley-parser-is-busted","posts_count":4,"reply_count":4,"highest_post_number":7,"image_url":"/plugins/emoji/images/smile.png","created_at":"2014-01-13T15:42:00.000-05:00","last_posted_at":"2014-01-15T05:51:16.000-05:00","bumped":true,"bumped_at":"2014-01-15T05:51:16.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":66,"like_count":2,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":3},{"extras":null,"description":"Most Posts","user_id":7073},{"extras":"latest","description":"Most Recent Poster","user_id":1}]},{"id":6625,"title":"Error 500 on PUT of site config","fancy_title":"Error 500 on PUT of site config","slug":"error-500-on-put-of-site-config","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2013-05-14T18:13:56.000-04:00","last_posted_at":"2014-01-16T04:55:50.000-05:00","bumped":true,"bumped_at":"2014-01-15T04:43:23.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":132,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":4996},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":11225,"title":"Forum acts weirdly after client side updates","fancy_title":"Forum acts weirdly after client side updates","slug":"forum-acts-weirdly-after-client-side-updates","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":null,"created_at":"2013-12-02T18:32:10.000-05:00","last_posted_at":"2014-01-15T04:04:55.000-05:00","bumped":true,"bumped_at":"2014-01-15T02:55:18.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":117,"like_count":7,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":1},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":11903,"title":"Error after update to 0.9.8.1","fancy_title":"Error after update to 0.9.8.1","slug":"error-after-update-to-0-9-8-1","posts_count":14,"reply_count":6,"highest_post_number":17,"image_url":null,"created_at":"2014-01-12T06:55:45.000-05:00","last_posted_at":"2014-01-15T01:48:58.000-05:00","bumped":true,"bumped_at":"2014-01-15T01:48:58.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":121,"like_count":6,"has_summary":false,"archetype":"regular","last_poster_username":"zh99998","category_id":7,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":6377},{"extras":null,"description":"Most Posts","user_id":1496},{"extras":null,"description":"Frequent Poster","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":19}]},{"id":11969,"title":"Qunit error and possibly related ember.js problem","fancy_title":"Qunit error and possibly related ember.js problem","slug":"qunit-error-and-possibly-related-ember-js-problem","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-14T22:51:32.000-05:00","last_posted_at":"2014-01-14T22:51:32.000-05:00","bumped":false,"bumped_at":"2014-01-14T22:51:32.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":32,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"Hunter","category_id":7,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":7995}]},{"id":11945,"title":"Stuff disappears on the groups page","fancy_title":"Stuff disappears on the groups page","slug":"stuff-disappears-on-the-groups-page","posts_count":7,"reply_count":2,"highest_post_number":7,"image_url":null,"created_at":"2014-01-13T23:03:53.000-05:00","last_posted_at":"2014-01-15T01:26:07.000-05:00","bumped":true,"bumped_at":"2014-01-14T21:09:01.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":54,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":6695},{"extras":null,"description":"Most Posts","user_id":6626},{"extras":"latest","description":"Most Recent Poster, Frequent Poster","user_id":1995}]},{"id":11520,"title":"Discourse WordPress Plugin: Emoji's do not properly display","fancy_title":"Discourse WordPress Plugin: Emoji’s do not properly display","slug":"discourse-wordpress-plugin-emojis-do-not-properly-display","posts_count":9,"reply_count":4,"highest_post_number":9,"image_url":"/uploads/default/_optimized/638/4db/eff43a45b8_690x420.png","created_at":"2013-12-19T23:32:03.000-05:00","last_posted_at":"2014-01-15T04:32:19.000-05:00","bumped":true,"bumped_at":"2014-01-14T17:53:34.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":168,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":5048},{"extras":null,"description":"Frequent Poster","user_id":7731},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":11597,"title":"All categories drop down does not close after clicking on first menu \"all categories\"","fancy_title":"All categories drop down does not close after clicking on first menu “all categories”","slug":"all-categories-drop-down-does-not-close-after-clicking-on-first-menu-all-categories","posts_count":5,"reply_count":2,"highest_post_number":5,"image_url":"/uploads/default/2495/f9efe463ae67632d.png","created_at":"2013-12-25T15:09:27.000-05:00","last_posted_at":"2014-01-14T17:46:41.000-05:00","bumped":true,"bumped_at":"2014-01-14T17:46:41.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":73,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"radq","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":7985},{"extras":null,"description":"Most Posts","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":3415}]},{"id":11962,"title":"Editor When Clicking on Wrench Issue","fancy_title":"Editor When Clicking on Wrench Issue","slug":"editor-when-clicking-on-wrench-issue","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":"/uploads/default/_optimized/ca4/f70/ac7278b8f6_690x176.png","created_at":"2014-01-14T17:23:20.000-05:00","last_posted_at":"2014-01-14T17:24:02.000-05:00","bumped":true,"bumped_at":"2014-01-14T17:24:02.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":30,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":7073},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11831,"title":"Broken links, possibly related to HTTPS","fancy_title":"Broken links, possibly related to HTTPS","slug":"broken-links-possibly-related-to-https","posts_count":17,"reply_count":13,"highest_post_number":18,"image_url":null,"created_at":"2014-01-08T17:40:45.000-05:00","last_posted_at":"2014-01-14T16:03:07.000-05:00","bumped":true,"bumped_at":"2014-01-14T16:03:07.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":102,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"eviltrout","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":5351},{"extras":null,"description":"Most Posts","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":471},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":19}]},{"id":11916,"title":"Unable to save user preferences","fancy_title":"Unable to save user preferences","slug":"unable-to-save-user-preferences","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2014-01-13T02:29:26.000-05:00","last_posted_at":"2014-01-14T14:39:32.000-05:00","bumped":true,"bumped_at":"2014-01-14T14:39:29.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":34,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":6626},{"extras":"latest","description":"Most Recent Poster","user_id":1995}]},{"id":10425,"title":"Editing category permissions: select value doesn't change","fancy_title":"Editing category permissions: select value doesn’t change","slug":"editing-category-permissions-select-value-doesnt-change","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":"/uploads/meta_discourse/1956/d55fba29dbd7e1fe.png","created_at":"2013-10-17T18:20:20.000-04:00","last_posted_at":"2013-10-17T18:20:21.000-04:00","bumped":true,"bumped_at":"2014-01-14T13:35:37.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":92,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"pekka","category_id":7,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":7}]},{"id":6557,"title":"Middle clicking a link twice does not work as expected","fancy_title":"Middle clicking a link twice does not work as expected","slug":"middle-clicking-a-link-twice-does-not-work-as-expected","posts_count":10,"reply_count":7,"highest_post_number":10,"image_url":null,"created_at":"2013-05-11T13:56:02.000-04:00","last_posted_at":"2014-01-14T13:13:04.000-05:00","bumped":true,"bumped_at":"2014-01-14T13:13:04.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":401,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"neil","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":4780},{"extras":null,"description":"Most Posts","user_id":5053},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":2}]},{"id":11944,"title":"Regression: Cannot sort topic list","fancy_title":"Regression: Cannot sort topic list","slug":"regression-cannot-sort-topic-list","posts_count":5,"reply_count":0,"highest_post_number":5,"image_url":null,"created_at":"2014-01-13T20:14:06.000-05:00","last_posted_at":"2014-01-14T19:31:28.000-05:00","bumped":true,"bumped_at":"2014-01-14T07:31:19.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":true,"views":37,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":6626},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":1995}]},{"id":10462,"title":"Rebake error when posts contain deleted YouTube video","fancy_title":"Rebake error when posts contain deleted YouTube video","slug":"rebake-error-when-posts-contain-deleted-youtube-video","posts_count":7,"reply_count":1,"highest_post_number":7,"image_url":null,"created_at":"2013-10-19T00:01:21.000-04:00","last_posted_at":"2014-01-14T02:24:19.000-05:00","bumped":true,"bumped_at":"2014-01-14T02:24:12.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":178,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":6695},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11932,"title":"Use of blockquote tag causes text outside a paragraph","fancy_title":"Use of blockquote tag causes text outside a paragraph","slug":"use-of-blockquote-tag-causes-text-outside-a-paragraph","posts_count":4,"reply_count":2,"highest_post_number":4,"image_url":null,"created_at":"2014-01-13T13:38:15.000-05:00","last_posted_at":"2014-01-13T19:30:37.000-05:00","bumped":true,"bumped_at":"2014-01-14T02:22:58.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":54,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":6626},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":10357,"title":"Displaced Wrench Icon Chrome","fancy_title":"Displaced Wrench Icon Chrome","slug":"displaced-wrench-icon-chrome","posts_count":12,"reply_count":4,"highest_post_number":12,"image_url":"/uploads/default/_optimized/9f3/f35/c5379beffe_690x300.jpg","created_at":"2013-10-14T05:48:21.000-04:00","last_posted_at":"2014-01-14T03:21:32.000-05:00","bumped":true,"bumped_at":"2014-01-13T19:03:33.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":206,"like_count":10,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":7073},{"extras":null,"description":"Frequent Poster","user_id":212},{"extras":null,"description":"Frequent Poster","user_id":6118},{"extras":null,"description":"Frequent Poster","user_id":1},{"extras":"latest","description":"Most Recent Poster, Most Posts","user_id":32}]},{"id":10114,"title":"Invitation expiry workflow is wonky","fancy_title":"Invitation expiry workflow is wonky","slug":"invitation-expiry-workflow-is-wonky","posts_count":14,"reply_count":7,"highest_post_number":14,"image_url":null,"created_at":"2013-09-30T00:59:36.000-04:00","last_posted_at":"2014-01-13T18:51:26.000-05:00","bumped":true,"bumped_at":"2014-01-13T18:51:26.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":176,"like_count":2,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":1},{"extras":null,"description":"Most Posts","user_id":7076},{"extras":null,"description":"Frequent Poster","user_id":2},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":6330,"title":"Reply not disabled if topic closed while viewing","fancy_title":"Reply not disabled if topic closed while viewing","slug":"reply-not-disabled-if-topic-closed-while-viewing","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":"https://www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s=40&r=pg&d=identicon","created_at":"2013-05-02T06:02:06.000-04:00","last_posted_at":"2014-01-13T11:54:22.000-05:00","bumped":true,"bumped_at":"2014-01-13T11:54:22.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":164,"like_count":1,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":4851},{"extras":null,"description":"Most Posts","user_id":2},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":8367,"title":"Very fast scrolling fails to mark all posts read in a thread","fancy_title":"Very fast scrolling fails to mark all posts read in a thread","slug":"very-fast-scrolling-fails-to-mark-all-posts-read-in-a-thread","posts_count":11,"reply_count":7,"highest_post_number":13,"image_url":null,"created_at":"2013-07-14T12:37:02.000-04:00","last_posted_at":"2014-01-13T11:16:56.000-05:00","bumped":true,"bumped_at":"2014-01-13T11:16:33.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":288,"like_count":5,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":4457},{"extras":null,"description":"Most Posts","user_id":6280},{"extras":null,"description":"Frequent Poster","user_id":3681},{"extras":null,"description":"Frequent Poster","user_id":1621},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":8815,"title":"Cache headers confuse proxies","fancy_title":"Cache headers confuse proxies","slug":"cache-headers-confuse-proxies","posts_count":9,"reply_count":3,"highest_post_number":9,"image_url":null,"created_at":"2013-08-02T05:45:26.000-04:00","last_posted_at":"2014-01-13T11:12:09.000-05:00","bumped":true,"bumped_at":"2014-01-13T10:41:44.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":true,"views":314,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":6266},{"extras":null,"description":"Most Posts","user_id":19},{"extras":null,"description":"Frequent Poster","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":4457},{"extras":"latest","description":"Most Recent Poster, Frequent Poster","user_id":32}]},{"id":11371,"title":"Search not working for Staff users","fancy_title":"Search not working for Staff users","slug":"search-not-working-for-staff-users","posts_count":15,"reply_count":10,"highest_post_number":15,"image_url":null,"created_at":"2013-12-11T13:22:56.000-05:00","last_posted_at":"2014-01-13T01:41:50.000-05:00","bumped":true,"bumped_at":"2014-01-13T01:41:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":true,"views":217,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":5335},{"extras":null,"description":"Most Posts","user_id":19},{"extras":null,"description":"Frequent Poster","user_id":6314},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":1}]},{"id":9908,"title":"Draft bar overrides pagination widget","fancy_title":"Draft bar overrides pagination widget","slug":"draft-bar-overrides-pagination-widget","posts_count":4,"reply_count":0,"highest_post_number":4,"image_url":null,"created_at":"2013-09-19T17:19:52.000-04:00","last_posted_at":"2014-01-13T01:26:01.000-05:00","bumped":true,"bumped_at":"2014-01-13T01:25:12.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":true,"views":108,"like_count":2,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":5351},{"extras":null,"description":"Most Posts","user_id":471},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":6134,"title":"Unread topic is stuck as unread after insertion of staff message","fancy_title":"Unread topic is stuck as unread after insertion of staff message","slug":"unread-topic-is-stuck-as-unread-after-insertion-of-staff-message","posts_count":5,"reply_count":1,"highest_post_number":5,"image_url":"https://www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s=40&r=pg&d=identicon","created_at":"2013-04-24T13:37:32.000-04:00","last_posted_at":"2014-01-13T01:22:49.000-05:00","bumped":true,"bumped_at":"2014-01-13T01:22:42.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":true,"archived":false,"views":169,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":3681},{"extras":null,"description":"Most Posts","user_id":5351},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":11914,"title":"Google analytics is not registering page views","fancy_title":"Google analytics is not registering page views","slug":"google-analytics-is-not-registering-page-views","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2014-01-13T00:32:45.000-05:00","last_posted_at":"2014-01-13T00:32:46.000-05:00","bumped":true,"bumped_at":"2014-01-13T00:32:46.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":37,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":7,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":1}]}]}}, "/categories_and_latest.json": {"category_list":{"can_create_category":false,"can_create_topic":false,"draft":null,"draft_key":"new_topic","draft_sequence":null,"categories":[{"id":1,"name":"Uncategorized","color":"AB9364","text_color":"FFFFFF","slug":"uncategorized","topic_count":1,"post_count":0,"position":0,"description":"Topics that don't need a category, or don't fit into any other existing category.","description_text":"","topic_url":null,"logo_url":null,"background_url":null,"read_restricted":false,"permission":null,"notification_level":null,"topic_template":null,"has_children":false,"topics_day":0,"topics_week":0,"topics_month":0,"topics_year":0,"topics_all_time":1,"description_excerpt":"Topics that don't need a category, or don't fit into any other existing category.","is_uncategorized":true},{"id":3,"name":"Site Feedback","color":"808281","text_color":"FFFFFF","slug":"site-feedback","topic_count":0,"post_count":0,"position":1,"description":"Discussion about this site, its organization, how it works, and how we can improve it.","description_text":"Discussion about this site, its organization, how it works, and how we can improve it.","topic_url":"/t/about-the-site-feedback-category/2","logo_url":null,"background_url":null,"read_restricted":false,"permission":null,"notification_level":null,"topic_template":null,"has_children":false,"topics_day":0,"topics_week":0,"topics_month":0,"topics_year":0,"topics_all_time":0,"description_excerpt":"Discussion about this site, its organization, how it works, and how we can improve it."}]},"topic_list":{"can_create_topic":false,"draft":null,"draft_key":"new_topic","draft_sequence":null,"per_page":30,"topics":[{"id":8,"title":"Welcome to Discourse","fancy_title":"Welcome to Discourse","slug":"welcome-to-discourse","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2016-08-29T20:38:19.359Z","last_posted_at":"2016-08-29T20:38:19.402Z","bumped":true,"bumped_at":"2016-08-29T20:38:19.402Z","unseen":false,"pinned":true,"unpinned":null,"excerpt":"The first paragraph of this pinned topic will be visible as a welcome message to all new visitors on your homepage. It's important! \n\nEdit this into a brief description of your community: \n\n\nWho is it for?\nWhat can they …","visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"views":0,"like_count":0,"has_summary":false,"archetype":"regular","last_poster_username":"system","category_id":1,"pinned_globally":true,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user_id":-1}]}]}} }; diff --git a/test/javascripts/fixtures/group-fixtures.js.es6 b/test/javascripts/fixtures/group-fixtures.js.es6 index 92ecb7c48b..a55d8303cb 100644 --- a/test/javascripts/fixtures/group-fixtures.js.es6 +++ b/test/javascripts/fixtures/group-fixtures.js.es6 @@ -440,7 +440,7 @@ export default { }, { "id":94544, - "cooked":"

            @techapj fixed this for 1.2.

            ", + "cooked":"

            @techapj fixed this for 1.2.

            ", "created_at":"2015-01-23T05:49:35.881Z", "title":"After sign-in, I'm not redirected to the conversation", "url":"/t/after-sign-in-im-not-redirected-to-the-conversation/17753/8", @@ -540,7 +540,7 @@ export default { }, { "id":94542, - "cooked":"

            Hmm that looks like a bug, @techapj can you have a look?

            ", + "cooked":"

            Hmm that looks like a bug, @techapj can you have a look?

            ", "created_at":"2015-01-23T05:43:55.602Z", "title":"RSS is not valid", "url":"/t/rss-is-not-valid/24338/2", @@ -590,7 +590,7 @@ export default { }, { "id":94522, - "cooked":"

            Oh I see. @zogstrip can you have a look?

            ", + "cooked":"

            Oh I see. @zogstrip can you have a look?

            ", "created_at":"2015-01-23T03:00:20.485Z", "title":"Pasted image upload size error", "url":"/t/pasted-image-upload-size-error/24320/4", @@ -640,7 +640,7 @@ export default { }, { "id":94521, - "cooked":"

            @techapj fixed this for 1.2.

            ", + "cooked":"

            @techapj fixed this for 1.2.

            ", "created_at":"2015-01-23T02:58:27.451Z", "title":"The end of Clown Vomit, or, simplified category styles", "url":"/t/the-end-of-clown-vomit-or-simplified-category-styles/24249/57", diff --git a/test/javascripts/fixtures/site-fixtures.js.es6 b/test/javascripts/fixtures/site-fixtures.js.es6 index 81ce4b8697..9745c86a5d 100644 --- a/test/javascripts/fixtures/site-fixtures.js.es6 +++ b/test/javascripts/fixtures/site-fixtures.js.es6 @@ -92,7 +92,9 @@ export default { "permission":1, "notification_level":null, "logo_url":null, - "background_url":null + "background_url":null, + "show_subcategory_list":false, + "default_view":"latest" }, { "id":10, @@ -108,7 +110,9 @@ export default { "permission":1, "notification_level":null, "logo_url":null, - "background_url":null + "background_url":null, + "show_subcategory_list":false, + "default_view":"latest" }, { "id":26, @@ -141,7 +145,10 @@ export default { "permission":1, "notification_level":null, "logo_url":null, - "background_url":null + "background_url":null, + "show_subcategory_list":true, + "default_view":"latest", + "subcategory_list_style":"boxes_with_featured_topics" }, { "id":6, @@ -157,7 +164,9 @@ export default { "permission":1, "notification_level":null, "logo_url":null, - "background_url":null + "background_url":null, + "show_subcategory_list":false, + "default_view":"latest" }, { "id":24, @@ -224,7 +233,9 @@ export default { "permission":1, "notification_level":null, "logo_url":null, - "background_url":null + "background_url":null, + "show_subcategory_list":false, + "default_view":"latest" }, { "id":14, @@ -240,7 +251,9 @@ export default { "permission":1, "notification_level":null, "logo_url":null, - "background_url":null + "background_url":null, + "show_subcategory_list":false, + "default_view":"latest" }, { "id":12, @@ -256,7 +269,9 @@ export default { "permission":1, "notification_level":null, "logo_url":null, - "background_url":null + "background_url":null, + "show_subcategory_list":false, + "default_view":"latest" }, { "id":13, @@ -272,7 +287,9 @@ export default { "permission":1, "notification_level":null, "logo_url":null, - "background_url":null + "background_url":null, + "show_subcategory_list":false, + "default_view":"latest" }, { "id":5, @@ -288,7 +305,9 @@ export default { "permission":1, "notification_level":null, "logo_url":null, - "background_url":null + "background_url":null, + "show_subcategory_list":false, + "default_view":"latest" }, { "id":11, @@ -304,7 +323,9 @@ export default { "permission":1, "notification_level":null, "logo_url":null, - "background_url":null + "background_url":null, + "show_subcategory_list":false, + "default_view":"latest" }, { "id":22, @@ -338,7 +359,9 @@ export default { "notification_level":null, "logo_url":null, "background_url":null, - "can_edit":true + "can_edit":true, + "show_subcategory_list":false, + "default_view":"latest" }, { "id":17, @@ -354,7 +377,9 @@ export default { "permission":1, "notification_level":null, "logo_url":"", - "background_url":"" + "background_url":"", + "show_subcategory_list":false, + "default_view":"latest" }, { "id":21, @@ -387,7 +412,9 @@ export default { "permission":1, "notification_level":null, "logo_url":null, - "background_url":null + "background_url":null, + "show_subcategory_list":false, + "default_view":"latest" }, { "id":9, @@ -403,7 +430,9 @@ export default { "permission":1, "notification_level":null, "logo_url":null, - "background_url":null + "background_url":null, + "show_subcategory_list":false, + "default_view":"latest" }, { "id":2, @@ -419,7 +448,10 @@ export default { "permission":1, "notification_level":null, "logo_url":null, - "background_url":null + "background_url":null, + "show_subcategory_list":true, + "default_view":"latest", + "subcategory_list_style":"boxes" } ], "post_action_types":[ @@ -427,6 +459,7 @@ export default { "name_key":"bookmark", "name":"Bookmark", "description":"Bookmark this post", + "short_description":"Bookmark this post", "long_form":"bookmarked this post", "is_flag":false, "icon":null, @@ -437,6 +470,7 @@ export default { "name_key":"like", "name":"Like", "description":"Like this post", + "short_description":"Like this post", "long_form":"liked this", "is_flag":false, "icon":"heart", @@ -447,6 +481,7 @@ export default { "name_key":"off_topic", "name":"Off-Topic", "description":"This post is radically off-topic in the current topic, and should probably be moved. If this is a topic, perhaps it does not belong here.", + "short_description":"Not relevant to the discussion", "long_form":"flagged this as off-topic", "is_flag":true, "icon":null, @@ -457,6 +492,7 @@ export default { "name_key":"inappropriate", "name":"Inappropriate", "description":"This post contains content that a reasonable person would consider offensive, abusive, or a violation of our community guidelines.", + "short_description":'A violation of our community guidelines', "long_form":"flagged this as inappropriate", "is_flag":true, "icon":null, @@ -467,6 +503,7 @@ export default { "name_key":"vote", "name":"Vote", "description":"Vote for this post", + "short_description":'Vote for this post', "long_form":"voted for this post", "is_flag":false, "icon":null, @@ -477,6 +514,7 @@ export default { "name_key":"spam", "name":"Spam", "description":"This post is an advertisement. It is not useful or relevant to the current topic, but promotional in nature.", + "short_description":'This is an advertisement', "long_form":"flagged this as spam", "is_flag":true, "icon":null, @@ -487,6 +525,7 @@ export default { "name_key":"notify_user", "name":"Notify {{username}}", "description":"This post contains something I want to talk to this person directly and privately about. Does not cast a flag.", + "short_description":'I want to talk to this person directly and privately about their post.', "long_form":"notified user", "is_flag":true, "icon":null, @@ -497,6 +536,7 @@ export default { "name_key":"notify_moderators", "name":"Notify moderators", "description":"This post requires general moderator attention based on the guidelines, TOS, or for another reason not listed above.", + "short_description":'Requires staff attention for another reason', "long_form":"notified moderators", "is_flag":true, "icon":null, diff --git a/test/javascripts/fixtures/topic.js.es6 b/test/javascripts/fixtures/topic.js.es6 index 7031b394df..5affc55f9e 100644 --- a/test/javascripts/fixtures/topic.js.es6 +++ b/test/javascripts/fixtures/topic.js.es6 @@ -1,4 +1,5 @@ /*jshint maxlen:10000000 */ -export default {"/t/280/1.json": {"post_stream":{"posts":[{"id":398,"name":"Uwe Keim","username":"uwe_keim","avatar_template":"/user_avatar/meta.discourse.org/uwe_keim/{size}/5697.png","uploaded_avatar_id":5697,"created_at":"2013-02-05T21:29:00.280Z","cooked":"

            Any plans to support localization of UI elements, so that I (for example) could set up a completely German speaking forum?

            ","post_number":1,"post_type":1,"updated_at":"2013-02-05T21:29:00.280Z","like_count":0,"reply_count":1,"reply_to_post_number":null,"quote_count":0,"avg_time":25,"incoming_link_count":314,"reads":475,"score":1702.25,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Uwe Keim","primary_group_name":null,"version":1,"can_edit":true,"can_delete":false,"can_recover":true,"link_counts":[{"url":"https://meta.discourse.org/t/language-mirrors/2378/2","internal":true,"reflection":true,"title":"Language mirrors","clicks":3},{"url":"https://meta.discourse.org/t/translation-workflow/6102","internal":true,"reflection":true,"title":"Translation workflow","clicks":2},{"url":"https://meta.discourse.org/t/solving-xda-developer-style-forums/4368/4","internal":true,"reflection":true,"title":"Solving XDA-Developer style forums","clicks":2},{"url":"https://meta.discourse.org/t/comrades-lets-join-our-efforts-on-ukrainian-and-russian-translations/4403/5","internal":true,"reflection":true,"title":"Comrades let's join our efforts on ukrainian and russian translations","clicks":1},{"url":"https://meta.discourse.org/t/bookmark-last-read-sometimes-doesn-t-go-to-the-end-of-a-topic/4825/9","internal":true,"reflection":true,"title":"Bookmark/last read sometimes doesn't go to the end of a topic","clicks":0},{"url":"https://meta.discourse.org/t/roadplan-for-discourse/2939/5","internal":true,"reflection":true,"title":"Roadplan for Discourse 2013","clicks":0}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":255,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":419,"name":"Tim Stone","username":"tms","avatar_template":"/user_avatar/meta.discourse.org/tms/{size}/40181.png","uploaded_avatar_id":40181,"created_at":"2013-02-05T21:32:47.649Z","cooked":"

            The application strings are externalized, so localization should be entirely possible with enough translation effort.

            ","post_number":2,"post_type":1,"updated_at":"2013-02-06T10:15:27.965Z","like_count":4,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":27,"incoming_link_count":16,"reads":460,"score":308.35,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Tim Stone","primary_group_name":null,"version":2,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://github.com/discourse/discourse/blob/master/config/locales/en.yml","internal":false,"reflection":false,"clicks":118}],"read":true,"user_title":"Great contributor","actions_summary":[{"id":2,"count":4,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":9,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":1060,"name":"Jeff Atwood","username":"codinghorror","avatar_template":"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png","uploaded_avatar_id":5297,"created_at":"2013-02-06T02:26:24.922Z","cooked":"

            Yep, all strings are going through a lookup table.*

            \n\n

            master/config/locales

            \n\n

            So you could replace that lookup table with the \"de\" one to get German.

            \n\n

            * we didn't get all the strings into the lookup table for launch, but the vast, vast majority of them are and the ones that are not, we will fix as we go!

            ","post_number":3,"post_type":1,"updated_at":"2014-02-24T05:23:39.211Z","like_count":4,"reply_count":3,"reply_to_post_number":null,"quote_count":0,"avg_time":33,"incoming_link_count":5,"reads":449,"score":191.45,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Jeff Atwood","primary_group_name":"discourse","version":4,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://github.com/discourse/discourse/blob/master/config/locales","internal":false,"reflection":false,"title":"discourse/config/locales at master · discourse/discourse · GitHub","clicks":62},{"url":"https://meta.discourse.org/t/github-onebox-rendering-issue/7616","internal":true,"reflection":true,"title":"GitHub OneBox Rendering Issue","clicks":0}],"read":true,"user_title":"co-founder","actions_summary":[{"id":2,"count":4,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":true,"admin":true,"staff":true,"user_id":32,"hidden":false,"hidden_reason_id":null,"trust_level":3,"deleted_at":null,"user_deleted":false,"edit_reason":"","can_view_edit_history":true,"wiki":false},{"id":3623,"name":"Shade","username":"shade","avatar_template":"/user_avatar/meta.discourse.org/shade/{size}/8306.png","uploaded_avatar_id":8306,"created_at":"2013-02-07T12:55:33.129Z","cooked":"

            Is it a coincidence that the strings file is 1337 lines long? \"smiley\"

            ","post_number":4,"post_type":1,"updated_at":"2013-02-07T12:55:33.129Z","like_count":7,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":20,"incoming_link_count":15,"reads":401,"score":291.2,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Shade","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://meta.discourse.org/t/hi-support-chinese/4393/6","internal":true,"reflection":true,"title":"Hi, support Chinese?","clicks":0}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":7,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":1808,"hidden":false,"hidden_reason_id":null,"trust_level":1,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3651,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/user_avatar/meta.discourse.org/pekka/{size}/5253.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T14:02:07.869Z","cooked":"

            \n\n

            The problem I see here is that this file is likely two grow and change massively over the next couple months, and tracking these changes in order to keep a localized file up to date is going to be a bitch.

            \n\n

            I wonder where there is a tool that can compare two yml structures and point out which nodes are missing? That would help keep track of new strings.

            \n\n

            Re keeping track of changed strings, @codinghorror I found this very interesting: http://stackoverflow.com/questions/4232922/why-do-people-use-plain-english-as-translation-placeholders if plain English placeholders were used, any change in strings would lead to a new node in the yml file, making keeping the translation up to date easier. Maybe worth thinking about in the future.

            ","post_number":5,"post_type":1,"updated_at":"2013-02-07T14:05:42.328Z","like_count":2,"reply_count":2,"reply_to_post_number":3,"quote_count":1,"avg_time":22,"incoming_link_count":10,"reads":386,"score":213.3,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"http://stackoverflow.com/questions/4232922/why-do-people-use-plain-english-as-translation-placeholders","internal":false,"reflection":false,"title":"internationalization - Why do people use plain english as translation placeholders? - Stack Overflow","clicks":63}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":2,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3654,"name":"Sam Saffron","username":"sam","avatar_template":"/user_avatar/meta.discourse.org/sam/{size}/5243.png","uploaded_avatar_id":5243,"created_at":"2013-02-07T14:05:39.910Z","cooked":"

            Yes, I really like the concept of fuzzy matching for localization, perhaps you can chase up alex sexton he was meaning to upload a localization tool for this kind of stuff.

            \n\n

            Also, I am a big fan of ICU message format, but it is not the \"Rails way (tm)\".

            ","post_number":6,"post_type":1,"updated_at":"2013-02-07T14:05:39.910Z","like_count":1,"reply_count":1,"reply_to_post_number":5,"quote_count":0,"avg_time":17,"incoming_link_count":4,"reads":329,"score":106.65,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Sam Saffron","primary_group_name":"discourse","version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://github.com/SlexAxton/messageformat.js","internal":false,"reflection":false,"title":"SlexAxton/messageformat.js · GitHub","clicks":46},{"url":"https://github.com/SlexAxton","internal":false,"reflection":false,"title":"SlexAxton (Alex Sexton) · GitHub","clicks":10}],"read":true,"user_title":"co-founder","reply_to_user":{"username":"pekka","avatar_template":"/user_avatar/meta.discourse.org/pekka/{size}/5253.png","uploaded_avatar_id":5253},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":true,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3655,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/user_avatar/meta.discourse.org/pekka/{size}/5253.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T14:08:17.493Z","cooked":"

            Looks interesting, I'll take a peek.

            \n\n

            As said on dev, the best tool I can see in terms of giving translators a proper interface and quality control would be something like GlotPress. It's based on the PO messages format (is that somehow related to ICU?) but looks pretty great.

            \n\n

            \n\n

            I'm not familiar with the term in this context, you mean keeping the English version in the code base (instead of a generic code like message_error_nametooshort ?)

            ","post_number":7,"post_type":1,"updated_at":"2013-02-07T14:12:02.965Z","like_count":1,"reply_count":1,"reply_to_post_number":6,"quote_count":1,"avg_time":16,"incoming_link_count":0,"reads":326,"score":86.0,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"http://translate.wordpress.org/projects/bbpress/dev","internal":false,"reflection":false,"title":"WordPress › Development < GlotPress","clicks":16}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3658,"name":"Sam Saffron","username":"sam","avatar_template":"/user_avatar/meta.discourse.org/sam/{size}/5243.png","uploaded_avatar_id":5243,"created_at":"2013-02-07T14:12:22.582Z","cooked":"

            ICU Message format is basically Gettext on steroids, Gettext has been around for so many years and actually works pretty well, being super prevalent in Linux.

            \n\n

            Trouble is you need a fuzzy matcher for translators if you are going to store stuff like mf.compile( 'This is a message.' ) in source, one letter change and all your translators need to validate it.

            ","post_number":8,"post_type":1,"updated_at":"2013-02-07T14:12:22.582Z","like_count":1,"reply_count":1,"reply_to_post_number":7,"quote_count":0,"avg_time":11,"incoming_link_count":2,"reads":296,"score":89.75,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Sam Saffron","primary_group_name":"discourse","version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://meta.discourse.org/t/what-i-love-about-wordpress-plugins/5697","internal":true,"reflection":true,"title":"What I love about WordPress plugins","clicks":0}],"read":true,"user_title":"co-founder","reply_to_user":{"username":"pekka","avatar_template":"/user_avatar/meta.discourse.org/pekka/{size}/5253.png","uploaded_avatar_id":5253},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":true,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3660,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/user_avatar/meta.discourse.org/pekka/{size}/5253.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T14:14:12.666Z","cooked":"

            \n\n

            Yeah, that's why I've always been a friend of message_error_nametooshort placeholders, until I asked the SO question linked above. The accepted answer makes a good argument against those placeholders: you want translations to break even on small changes in the English original because the translations will probably need to reflect the change, too. Maybe that's not the case right now as new stuff is being checked in pretty much every couple of hours, but in the long run, it'll be overwhelmingly true.

            ","post_number":9,"post_type":1,"updated_at":"2013-02-07T14:18:09.569Z","like_count":1,"reply_count":1,"reply_to_post_number":8,"quote_count":1,"avg_time":10,"incoming_link_count":0,"reads":293,"score":79.1,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3667,"name":"Tim Stone","username":"tms","avatar_template":"/user_avatar/meta.discourse.org/tms/{size}/40181.png","uploaded_avatar_id":40181,"created_at":"2013-02-07T14:25:16.859Z","cooked":"

            Hmm...You could theoretically also build something into the development process that would monitor changes to the English locale file and make a translator-friendly list of changes between versions.

            ","post_number":10,"post_type":1,"updated_at":"2013-02-07T14:25:16.859Z","like_count":1,"reply_count":1,"reply_to_post_number":9,"quote_count":0,"avg_time":7,"incoming_link_count":0,"reads":275,"score":75.35,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Tim Stone","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":"Great contributor","reply_to_user":{"username":"pekka","avatar_template":"/user_avatar/meta.discourse.org/pekka/{size}/5253.png","uploaded_avatar_id":5253},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":9,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3673,"name":"Sam Saffron","username":"sam","avatar_template":"/user_avatar/meta.discourse.org/sam/{size}/5243.png","uploaded_avatar_id":5243,"created_at":"2013-02-07T14:30:21.401Z","cooked":"

            Yeah, totally, also we could build tools for dev that make introducing string less annoying and make it possible to garbage collect old unused strings, I hate trudging through that file.

            ","post_number":11,"post_type":1,"updated_at":"2013-02-07T14:30:21.401Z","like_count":1,"reply_count":1,"reply_to_post_number":10,"quote_count":0,"avg_time":7,"incoming_link_count":1,"reads":273,"score":79.95,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Sam Saffron","primary_group_name":"discourse","version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":"co-founder","reply_to_user":{"username":"tms","avatar_template":"/user_avatar/meta.discourse.org/tms/{size}/40181.png","uploaded_avatar_id":40181},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":true,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3675,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/user_avatar/meta.discourse.org/pekka/{size}/5253.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T14:33:38.280Z","cooked":"

            \n\n

            As said, I'd look into whether WP's tools can't be reused for this with some tweaking. They seem to be able to scan a code base for new strings, and make them available automatically to translators.

            \n\n

            They're PHP based which isn't ideal, but it looks like they've done a crapload of work to take the hassle out of translations.

            ","post_number":12,"post_type":1,"updated_at":"2013-02-07T14:34:39.910Z","like_count":1,"reply_count":1,"reply_to_post_number":11,"quote_count":1,"avg_time":7,"incoming_link_count":2,"reads":273,"score":84.95,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3690,"name":"Valts","username":"Vilx","avatar_template":"/user_avatar/meta.discourse.org/vilx/{size}/7299.png","uploaded_avatar_id":7299,"created_at":"2013-02-07T15:05:35.867Z","cooked":"

            This site looks so nice with all the little tweaks like \"10 minutes ago\" instead of simply time, etc - I wonder if there will also be support for proper pluralization in other languages? That's a pretty hard task though, I don't think I've ever seen a website that has done that. But it would be awesome.

            ","post_number":13,"post_type":1,"updated_at":"2013-02-07T15:05:35.867Z","like_count":3,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":7,"incoming_link_count":11,"reads":290,"score":158.35,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Valts","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":3,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":1216,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3925,"name":"Eric Kidd","username":"emk","avatar_template":"/user_avatar/meta.discourse.org/emk/{size}/8400.png","uploaded_avatar_id":8400,"created_at":"2013-02-07T19:37:06.194Z","cooked":"

            \n\n

            I've had pretty decent luck using Localeapp to localize Rails applications:

            \n\n

            http://www.localeapp.com/

            \n\n

            The developer workflow took me about an hour to really get used to, and there were a few minor glitches. But the non-technical translators had very few problems. One limitation: It insists on rewriting all those yaml files full of strings.

            \n\n

            Anyway, it's worth a look, and it's free for open source, if I recall correctly. Certainly easier than doing a whole bunch of toolsmithing from scratch.

            ","post_number":14,"post_type":1,"updated_at":"2013-02-07T19:37:06.194Z","like_count":3,"reply_count":1,"reply_to_post_number":12,"quote_count":1,"avg_time":9,"incoming_link_count":0,"reads":283,"score":137.05,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Eric Kidd","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"http://www.localeapp.com/","internal":false,"reflection":false,"title":"Easy localization for Rails apps | Locale","clicks":69}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":3,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":1860,"hidden":false,"hidden_reason_id":null,"trust_level":1,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3938,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/user_avatar/meta.discourse.org/pekka/{size}/5253.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T19:52:13.748Z","cooked":"

            \n\n

            Ohhh. Looking sexy. droool

            ","post_number":15,"post_type":1,"updated_at":"2013-02-07T19:52:13.748Z","like_count":1,"reply_count":1,"reply_to_post_number":14,"quote_count":1,"avg_time":7,"incoming_link_count":0,"reads":260,"score":72.35,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3982,"name":"Eric Kidd","username":"emk","avatar_template":"/user_avatar/meta.discourse.org/emk/{size}/8400.png","uploaded_avatar_id":8400,"created_at":"2013-02-07T20:52:22.454Z","cooked":"

            \n\n

            Yeah, it's pretty. \"smile\" But there were still some rough edges as of a few months ago.

            \n\n

            Whether or not those rough edges are a deal-breaker will probably depends on whether or not localization is already a source of acute pain. If you're already hurting, Localeapp is a pretty useful tool, especially when it comes to enlisting non-technical translators.

            \n\n

            But it does require changing how you work with text, and adding one new tool to the mix. So for projects that just don't want to know about non-English languages, it's not yet seamless the way Unicode is these days.

            \n\n

            (Sweet forum software, by the way. I was just testing out Egyptian hieroglyphics on the test server, because they're well off the Basic Multilingual Plane, and tend to flush Unicode bugs. Everything worked flawlessly.)

            ","post_number":16,"post_type":1,"updated_at":"2013-02-07T20:52:22.454Z","like_count":1,"reply_count":1,"reply_to_post_number":15,"quote_count":1,"avg_time":7,"incoming_link_count":0,"reads":254,"score":71.15,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Eric Kidd","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":1860,"hidden":false,"hidden_reason_id":null,"trust_level":1,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3989,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/user_avatar/meta.discourse.org/pekka/{size}/5253.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T21:04:15.405Z","cooked":"

            \n\n

            Interesting, thanks for the insight. I don't think localization is seriously on their table right now, there's likely to be many other things on the table before it... but it will become an issue sooner or later.

            ","post_number":17,"post_type":1,"updated_at":"2013-02-07T21:04:15.405Z","like_count":1,"reply_count":2,"reply_to_post_number":16,"quote_count":1,"avg_time":7,"incoming_link_count":0,"reads":255,"score":76.35,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3996,"name":"Sam Saffron","username":"sam","avatar_template":"/user_avatar/meta.discourse.org/sam/{size}/5243.png","uploaded_avatar_id":5243,"created_at":"2013-02-07T21:12:06.575Z","cooked":"

            I had an idea ... what if in dev mode, you could right-click on a page and get access to all the translations on the page, make your edits and have it refreshed live.

            \n\n

            I think it would be awesome, very doable technically.

            ","post_number":18,"post_type":1,"updated_at":"2013-02-07T21:12:06.575Z","like_count":7,"reply_count":2,"reply_to_post_number":17,"quote_count":0,"avg_time":8,"incoming_link_count":0,"reads":264,"score":168.2,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Sam Saffron","primary_group_name":"discourse","version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":"co-founder","reply_to_user":{"username":"pekka","avatar_template":"/user_avatar/meta.discourse.org/pekka/{size}/5253.png","uploaded_avatar_id":5253},"actions_summary":[{"id":2,"count":7,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":true,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":4009,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/user_avatar/meta.discourse.org/pekka/{size}/5253.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T21:18:47.422Z","cooked":"

            That would be fricking cool. There'd still be some leftovers (like error messages that normally never show up, etc.) but you could corral those up on a specific page.

            \n\n

            It could have a dropdown giving you all the languages that you have a .yml for in the locale directory, and write the changes into the one selected. I'm sure people would love it.

            ","post_number":19,"post_type":1,"updated_at":"2013-02-07T21:22:10.692Z","like_count":1,"reply_count":0,"reply_to_post_number":18,"quote_count":0,"avg_time":8,"incoming_link_count":1,"reads":241,"score":68.6,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"reply_to_user":{"username":"sam","avatar_template":"/user_avatar/meta.discourse.org/sam/{size}/5243.png","uploaded_avatar_id":5243},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":4012,"name":"Marco Ceppi","username":"marcoceppi","avatar_template":"/user_avatar/meta.discourse.org/marcoceppi/{size}/6552.png","uploaded_avatar_id":6552,"created_at":"2013-02-07T21:22:46.376Z","cooked":"

            If you use gettext format you could leverage Launchpad translations and the community behind it.

            ","post_number":20,"post_type":1,"updated_at":"2013-02-07T21:22:46.376Z","like_count":1,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":9,"incoming_link_count":2,"reads":244,"score":74.25,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Marco Ceppi","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://translations.launchpad.net/","internal":false,"reflection":false,"title":"Launchpad Translations","clicks":13}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":761,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false}],"stream":[398,419,1060,3623,3651,3654,3655,3658,3660,3667,3673,3675,3690,3925,3938,3982,3989,3996,4009,4012],"gaps":{"before":{"20706":[20125]},"after":{}}},"id":280,"title":"Internationalization / localization","fancy_title":"Internationalization / localization","posts_count":103,"created_at":"2013-02-05T21:29:00.174Z","views":5211,"reply_count":67,"participant_count":40,"like_count":135,"last_posted_at":"2015-03-04T15:07:10.487Z","visible":true,"closed":false,"archived":false,"has_summary":true,"archetype":"regular","slug":"internationalization-localization","category_id":2,"word_count":6198,"deleted_at":null,"draft":null,"draft_key":"topic_280","draft_sequence":4,"posted":true,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":255,"username":"uwe_keim","uploaded_avatar_id":5697,"avatar_template":"/user_avatar/meta.discourse.org/uwe_keim/{size}/5697.png"},"last_poster":{"id":14091,"username":"Luciano_Fantuzzi","uploaded_avatar_id":39484,"avatar_template":"/user_avatar/meta.discourse.org/luciano_fantuzzi/{size}/39484.png"},"participants":[{"id":212,"username":"alxndr","uploaded_avatar_id":5619,"avatar_template":"/user_avatar/meta.discourse.org/alxndr/{size}/5619.png","post_count":11},{"id":1,"username":"sam","uploaded_avatar_id":5243,"avatar_template":"/user_avatar/meta.discourse.org/sam/{size}/5243.png","post_count":11},{"id":7,"username":"pekka","uploaded_avatar_id":5253,"avatar_template":"/user_avatar/meta.discourse.org/pekka/{size}/5253.png","post_count":8},{"id":461,"username":"kuba","uploaded_avatar_id":6049,"avatar_template":"/user_avatar/meta.discourse.org/kuba/{size}/6049.png","post_count":7},{"id":2995,"username":"tattoo","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/tattoo/{size}/3.png","post_count":6},{"id":2540,"username":"jgourdon","uploaded_avatar_id":9537,"avatar_template":"/user_avatar/meta.discourse.org/jgourdon/{size}/9537.png","post_count":5},{"id":1860,"username":"emk","uploaded_avatar_id":8400,"avatar_template":"/user_avatar/meta.discourse.org/emk/{size}/8400.png","post_count":4},{"id":1275,"username":"dacap","uploaded_avatar_id":7401,"avatar_template":"/user_avatar/meta.discourse.org/dacap/{size}/7401.png","post_count":4},{"id":19,"username":"eviltrout","uploaded_avatar_id":5275,"avatar_template":"/user_avatar/meta.discourse.org/eviltrout/{size}/5275.png","post_count":4},{"id":3704,"username":"mojzis","uploaded_avatar_id":31201,"avatar_template":"/user_avatar/meta.discourse.org/mojzis/{size}/31201.png","post_count":3},{"id":3190,"username":"gururea","uploaded_avatar_id":10663,"avatar_template":"/user_avatar/meta.discourse.org/gururea/{size}/10663.png","post_count":3},{"id":1895,"username":"maciek","uploaded_avatar_id":8463,"avatar_template":"/user_avatar/meta.discourse.org/maciek/{size}/8463.png","post_count":3},{"id":22,"username":"splattne","uploaded_avatar_id":5280,"avatar_template":"/user_avatar/meta.discourse.org/splattne/{size}/5280.png","post_count":2},{"id":1979,"username":"Superuser","uploaded_avatar_id":8604,"avatar_template":"/user_avatar/meta.discourse.org/superuser/{size}/8604.png","post_count":2},{"id":3818,"username":"Tudor","uploaded_avatar_id":11675,"avatar_template":"/user_avatar/meta.discourse.org/tudor/{size}/11675.png","post_count":2},{"id":32,"username":"codinghorror","uploaded_avatar_id":5297,"avatar_template":"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png","post_count":2},{"id":3620,"username":"potthast","uploaded_avatar_id":11363,"avatar_template":"/user_avatar/meta.discourse.org/potthast/{size}/11363.png","post_count":2},{"id":9,"username":"tms","uploaded_avatar_id":40181,"avatar_template":"/user_avatar/meta.discourse.org/tms/{size}/40181.png","post_count":2},{"id":14091,"username":"Luciano_Fantuzzi","uploaded_avatar_id":39484,"avatar_template":"/user_avatar/meta.discourse.org/luciano_fantuzzi/{size}/39484.png","post_count":1},{"id":255,"username":"uwe_keim","uploaded_avatar_id":5697,"avatar_template":"/user_avatar/meta.discourse.org/uwe_keim/{size}/5697.png","post_count":1},{"id":9006,"username":"berk","uploaded_avatar_id":19348,"avatar_template":"/user_avatar/meta.discourse.org/berk/{size}/19348.png","post_count":1},{"id":754,"username":"danneu","uploaded_avatar_id":6540,"avatar_template":"/user_avatar/meta.discourse.org/danneu/{size}/6540.png","post_count":1},{"id":761,"username":"marcoceppi","uploaded_avatar_id":6552,"avatar_template":"/user_avatar/meta.discourse.org/marcoceppi/{size}/6552.png","post_count":1},{"id":2753,"username":"mikl","uploaded_avatar_id":9918,"avatar_template":"/user_avatar/meta.discourse.org/mikl/{size}/9918.png","post_count":1}],"suggested_topics":[{"id":27331,"title":"Polls are still very buggy","fancy_title":"Polls are still very buggy","slug":"polls-are-still-very-buggy","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":"/uploads/default/_optimized/cd1/b8c/c162528887_690x401.png","created_at":"2015-04-08T09:51:00.357Z","last_posted_at":"2015-04-08T15:59:16.258Z","bumped":true,"bumped_at":"2015-04-08T16:05:09.842Z","unseen":false,"last_read_post_number":3,"unread":0,"new_posts":1,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":11,"views":55,"category_id":1},{"id":27343,"title":"Mobile theme doesn't show last activity time for topics on category page","fancy_title":"Mobile theme doesn’t show last activity time for topics on category page","slug":"mobile-theme-doesnt-show-last-activity-time-for-topics-on-category-page","posts_count":4,"reply_count":2,"highest_post_number":4,"image_url":"/uploads/default/_optimized/13e/25c/bd30b466be_281x500.png","created_at":"2015-04-08T14:20:51.177Z","last_posted_at":"2015-04-08T15:40:30.037Z","bumped":true,"bumped_at":"2015-04-08T15:40:30.037Z","unseen":false,"last_read_post_number":2,"unread":0,"new_posts":2,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":3,"views":23,"category_id":9},{"id":27346,"title":"Reply+{messagekey}@... optionaly in header \"from\" in addition to \"reply-to\"","fancy_title":"Reply+{messagekey}@… optionaly in header “from” in addition to “reply-to”","slug":"reply-messagekey-optionaly-in-header-from-in-addition-to-reply-to","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2015-04-08T16:05:13.103Z","last_posted_at":"2015-04-08T16:05:13.415Z","bumped":true,"bumped_at":"2015-04-08T16:05:13.415Z","unseen":true,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":0,"views":8,"category_id":2},{"id":19670,"title":"Parsing (Oneboxing) IMDB links","fancy_title":"Parsing (Oneboxing) IMDB links","slug":"parsing-oneboxing-imdb-links","posts_count":8,"reply_count":1,"highest_post_number":8,"image_url":null,"created_at":"2014-09-05T07:19:26.161Z","last_posted_at":"2015-04-07T09:21:21.570Z","bumped":true,"bumped_at":"2015-04-07T09:21:21.570Z","unseen":false,"last_read_post_number":8,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":4,"views":253,"category_id":2},{"id":7512,"title":"Support for Piwik Analytics as an alternative to Google Analytics","fancy_title":"Support for Piwik Analytics as an alternative to Google Analytics","slug":"support-for-piwik-analytics-as-an-alternative-to-google-analytics","posts_count":53,"reply_count":41,"highest_post_number":65,"image_url":"/plugins/emoji/images/smile.png","created_at":"2013-06-16T01:32:30.596Z","last_posted_at":"2015-02-22T13:46:26.845Z","bumped":true,"bumped_at":"2015-02-22T13:46:26.845Z","unseen":false,"last_read_post_number":65,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":62,"views":1877,"category_id":2},{"id":25480,"title":"CSS admin-contents reloaded","fancy_title":"CSS admin-contents reloaded","slug":"css-admin-contents-reloaded","posts_count":22,"reply_count":15,"highest_post_number":22,"image_url":null,"created_at":"2015-02-21T12:15:57.707Z","last_posted_at":"2015-03-02T23:24:18.899Z","bumped":true,"bumped_at":"2015-03-02T23:24:18.899Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":21,"views":185,"category_id":2},{"id":26576,"title":"Badge timestamp should be the time the badge was granted?","fancy_title":"Badge timestamp should be the time the badge was granted?","slug":"badge-timestamp-should-be-the-time-the-badge-was-granted","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2015-03-20T13:22:08.266Z","last_posted_at":"2015-03-21T00:33:52.243Z","bumped":true,"bumped_at":"2015-03-21T00:33:52.243Z","unseen":false,"last_read_post_number":1,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":1,"bookmarked":false,"liked":false,"archetype":"regular","like_count":9,"views":87,"category_id":2}],"links":[{"url":"https://github.com/discourse/discourse/blob/master/config/locales/en.yml","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":118,"user_id":9,"domain":"github.com"},{"url":"http://www.localeapp.com/","title":"Easy localization for Rails apps | Locale","fancy_title":null,"internal":false,"reflection":false,"clicks":69,"user_id":1860,"domain":"www.localeapp.com"},{"url":"http://stackoverflow.com/questions/4232922/why-do-people-use-plain-english-as-translation-placeholders","title":"internationalization - Why do people use plain english as translation placeholders? - Stack Overflow","fancy_title":null,"internal":false,"reflection":false,"clicks":63,"user_id":7,"domain":"stackoverflow.com"},{"url":"https://github.com/discourse/discourse/blob/master/config/locales","title":"discourse/config/locales at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":62,"user_id":32,"domain":"github.com"},{"url":"https://github.com/SlexAxton/messageformat.js","title":"SlexAxton/messageformat.js · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":46,"user_id":1,"domain":"github.com"},{"url":"http://www.localeapp.com/projects/1537/translations?utf8=%E2%9C%93&search=source_code","title":"langforums | Locale","fancy_title":null,"internal":false,"reflection":false,"clicks":25,"user_id":1860,"domain":"www.localeapp.com"},{"url":"https://translations.launchpad.net/","title":"Launchpad Translations","fancy_title":null,"internal":false,"reflection":false,"clicks":23,"user_id":761,"domain":"translations.launchpad.net"},{"url":"https://www.transifex.com/","title":"Transifex - Continuous Localization Platform","fancy_title":null,"internal":false,"reflection":false,"clicks":22,"user_id":1979,"domain":"www.transifex.com"},{"url":"https://github.com/berk/tr8n","title":"berk/tr8n · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":22,"user_id":1,"domain":"github.com"},{"url":"http://translate.wordpress.org/projects/bbpress/dev","title":"WordPress › Development < GlotPress","fancy_title":null,"internal":false,"reflection":false,"clicks":16,"user_id":7,"domain":"translate.wordpress.org"},{"url":"http://weblate.org","title":"Weblate - web-based translation","fancy_title":null,"internal":false,"reflection":false,"clicks":15,"user_id":2316,"domain":"weblate.org"},{"url":"https://github.com/discourse/discourse/tree/master/config/locales","title":"discourse/config/locales at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":14,"user_id":19,"domain":"github.com"},{"url":"https://github.com/discourse/discourse/pull/493","title":"Danish translation. by mikl · Pull Request #493 · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":12,"user_id":2753,"domain":"github.com"},{"url":"https://github.com/SlexAxton","title":"SlexAxton (Alex Sexton) · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":10,"user_id":1,"domain":"github.com"},{"url":"https://github.com/gururea/discourse/tree/master/config/locales","title":"discourse/config/locales at master · gururea/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":9,"user_id":3190,"domain":"github.com"},{"url":"https://github.com/discourse/discourse/blob/master/config/locales/client.en.yml#L691","title":"discourse/config/locales/client.en.yml at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":7,"user_id":461,"domain":"github.com"},{"url":"https://github.com/dacap/discourse/tree/spanish","title":"dacap/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":7,"user_id":1275,"domain":"github.com"},{"url":"https://github.com/discourse/discourse/blob/master/config/locales/client.nl.yml","title":"discourse/config/locales/client.nl.yml at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":7,"user_id":461,"domain":"github.com"},{"url":"https://github.com/discourse/discourse/commit/c5761eae8afe37e20cec0d0f9d14b85b6e585bda","title":"Support for Simplified Chinese thanks to tangramor · c5761ea · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":6,"user_id":212,"domain":"github.com"},{"url":"http://tr8n.github.com/","title":"tr8n","fancy_title":null,"internal":false,"reflection":false,"clicks":6,"user_id":212,"domain":"tr8n.github.com"},{"url":"http://www.getlocalization.com/","title":"Crowdsourced, Social and Collaborative App & Website Translation - Get Localization","fancy_title":null,"internal":false,"reflection":false,"clicks":6,"user_id":22,"domain":"www.getlocalization.com"},{"url":"http://blog.discourse.org/2013/04/discourse-as-your-first-rails-app/","title":"Discourse as Your First Rails App","fancy_title":null,"internal":false,"reflection":false,"clicks":5,"user_id":1995,"domain":"blog.discourse.org"},{"url":"https://github.com/alxndr/discourse/blob/i18n-chinese/config/locales/server.zh.yml","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":5,"user_id":212,"domain":"github.com"},{"url":"http://translate.sourceforge.net/wiki/virtaal/index","title":"Easy-to-use and powerful offline translation tool | Virtaal","fancy_title":null,"internal":false,"reflection":false,"clicks":4,"user_id":1979,"domain":"translate.sourceforge.net"},{"url":"https://poeditor.com/","title":"POEditor - online software localization tool","fancy_title":null,"internal":false,"reflection":false,"clicks":4,"user_id":1979,"domain":"poeditor.com"},{"url":"http://en.lichess.org/@/Hellball","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":3,"user_id":1979,"domain":"en.lichess.org"},{"url":"http://en.wikipedia.org/wiki/T%E2%80%93V_distinction","title":"T–V distinction - Wikipedia, the free encyclopedia","fancy_title":null,"internal":false,"reflection":false,"clicks":3,"user_id":3620,"domain":"en.wikipedia.org"},{"url":"http://www.slideshare.net/HeatherRivers/linguistic-potluck-crowdsourcing-localization-with-rails","title":"Linguistic Potluck: Crowdsourcing localization with Rails","fancy_title":null,"internal":false,"reflection":false,"clicks":3,"user_id":212,"domain":"www.slideshare.net"},{"url":"https://meta.discourse.org/t/language-mirrors/2378/2","title":"Language mirrors","fancy_title":null,"internal":true,"reflection":true,"clicks":3,"user_id":32,"domain":"meta.discourse.org"},{"url":"http://www.madanalogy.com/2012/06/rails-i18n-translations-in-yaml.html","title":"Mad Analogy: Rails i18n translations in Yaml: translation tool support","fancy_title":null,"internal":false,"reflection":false,"clicks":3,"user_id":3190,"domain":"www.madanalogy.com"},{"url":"https://github.com/tr8n","title":"Translation Exchange · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":3,"user_id":9006,"domain":"github.com"},{"url":"http://pootle.locamotion.org/","title":"Main | Pootle Demo","fancy_title":null,"internal":false,"reflection":false,"clicks":2,"user_id":3190,"domain":"pootle.locamotion.org"},{"url":"http://www.youtube.com/watch?v=MqqdzJ98q7s","title":"GoGaRuCo 2012 - Linguistic Potluck: Crowdsourcing Localization in Rails by Heather Rivers - YouTube","fancy_title":null,"internal":false,"reflection":false,"clicks":2,"user_id":212,"domain":"www.youtube.com"},{"url":"https://meta.discourse.org/t/translation-workflow/6102","title":"Translation workflow","fancy_title":null,"internal":true,"reflection":true,"clicks":2,"user_id":4702,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/solving-xda-developer-style-forums/4368/4","title":"Solving XDA-Developer style forums","fancy_title":null,"internal":true,"reflection":true,"clicks":2,"user_id":639,"domain":"meta.discourse.org"},{"url":"https://tr8nhub.com","title":"TranslationExchange","fancy_title":null,"internal":false,"reflection":false,"clicks":2,"user_id":9006,"domain":"tr8nhub.com"},{"url":"https://meta.discourse.org/t/roadplan-for-discourse/2939/3","title":"Roadplan for Discourse 2013","fancy_title":null,"internal":true,"reflection":true,"clicks":1,"user_id":2540,"domain":"meta.discourse.org"},{"url":"http://sugarjs.com/dates#date_locales","title":"Dates - Sugar","fancy_title":null,"internal":false,"reflection":false,"clicks":1,"user_id":461,"domain":"sugarjs.com"},{"url":"http://blog.discourse.org/2013/03/localizing-discourse/","title":"Localizing Discourse","fancy_title":null,"internal":false,"reflection":false,"clicks":1,"user_id":893,"domain":"blog.discourse.org"},{"url":"https://github.com/discourse/discourse/blob/master/app/assets/javascripts/locales/date_locales.js","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":1,"user_id":461,"domain":"github.com"},{"url":"http://transifex.com/projects/p/discourse-pt-br/","title":"Discourse-Translations-Project localization","fancy_title":null,"internal":false,"reflection":false,"clicks":1,"user_id":893,"domain":"transifex.com"},{"url":"https://github.com/discourse/discourse/issues/279","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":1,"user_id":893,"domain":"github.com"},{"url":"https://meta.discourse.org/t/comrades-lets-join-our-efforts-on-ukrainian-and-russian-translations/4403/5","title":"Comrades let's join our efforts on ukrainian and russian translations","fancy_title":null,"internal":true,"reflection":true,"clicks":1,"user_id":3417,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/translation-workflow/6102/6","title":"Translation workflow","fancy_title":null,"internal":true,"reflection":false,"clicks":0,"user_id":1995,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/bookmark-last-read-sometimes-doesn-t-go-to-the-end-of-a-topic/4825/9","title":"Bookmark/last read sometimes doesn't go to the end of a topic","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":3681,"domain":"meta.discourse.org"},{"url":"https://github.com/discourse/discourse/blob/master/config/locales/client.de.yml","title":"discourse/config/locales/client.de.yml at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":0,"user_id":2,"domain":"github.com"},{"url":"https://meta.discourse.org/t/what-i-love-about-wordpress-plugins/5697","title":"What I love about WordPress plugins","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":1,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/github-onebox-rendering-issue/7616","title":"GitHub OneBox Rendering Issue","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":5372,"domain":"meta.discourse.org"},{"url":"https://github.com/discourse/discourse/blob/master/config/locales/server.de.yml","title":"discourse/config/locales/server.de.yml at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":0,"user_id":2,"domain":"github.com"},{"url":"https://meta.discourse.org/t/roadplan-for-discourse/2939/5","title":"Roadplan for Discourse 2013","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":32,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/translation-tools-transifex-localeapp/7763","title":"Translation Tools: Transifex? Localeapp?","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":2,"domain":"meta.discourse.org"},{"url":"http://guides.rubyonrails.org/i18n.html#the-public-i18n-api","title":"Rails Internationalization (I18n) API — Ruby on Rails Guides","fancy_title":null,"internal":false,"reflection":false,"clicks":0,"user_id":1895,"domain":"guides.rubyonrails.org"},{"url":"https://meta.discourse.org/t/hi-support-chinese/4393/6","title":"Hi, support Chinese?","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":2014,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/translation-tools-transifex-localeapp/7763/41","title":"Translation Tools: Transifex? Localeapp?","fancy_title":null,"internal":true,"reflection":false,"clicks":0,"user_id":6626,"domain":"meta.discourse.org"}],"notification_level":2,"notifications_reason_id":4,"can_move_posts":true,"can_edit":true,"can_delete":true,"can_recover":true,"can_remove_allowed_users":true,"can_invite_to":true,"can_create_post":true,"can_reply_as_new_topic":true,"can_flag_topic":true},"highest_post_number":10,"last_read_post_number":10,"deleted_by":null,"has_deleted":true,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":true},{"id":7,"count":0,"hidden":false,"can_act":true},{"id":8,"count":0,"hidden":false,"can_act":true}],"chunk_size":20,"bookmarked":false,"tags":null}, -"/t/28830/1.json": {"post_stream":{"posts":[{"id":118591,"name":"spends too much time on WTDWTF","username":"RaceProUK","avatar_template":"/user_avatar/meta.discourse.org/raceprouk/{size}/40071.png","uploaded_avatar_id":40071,"created_at":"2015-05-14T20:18:17.954Z","cooked":"

            Normally, actions such as Liking are rate-limited, and when you hit the limit, you get a message telling you you've hit the limit. However, in 1.3.0beta9, it seems those popups are no longer appearing.

            \n\n

            Edit: Possibly linked to this issue?

            ","post_number":1,"post_type":1,"updated_at":"2015-05-14T20:21:42.825Z","like_count":6,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":14,"reads":24,"score":224.6,"yours":false,"topic_id":28830,"topic_slug":"1-3-0beta9-no-rate-limit-popups","display_username":"spends too much time on WTDWTF","primary_group_name":null,"version":1,"can_edit":false,"can_delete":false,"can_recover":false,"link_counts":[{"url":"https://meta.discourse.org/t/post-reply-on-different-topic-no-longer-works/28825","internal":true,"reflection":false,"title":"Post reply on different topic no longer works","clicks":6}],"read":true,"user_title":"Contributor","actions_summary":[{"id":2,"count":6,"hidden":false,"can_act":false},{"id":3,"count":0,"hidden":false,"can_act":false},{"id":4,"count":0,"hidden":false,"can_act":false},{"id":5,"count":0,"hidden":true,"can_act":false},{"id":6,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"moderator":false,"admin":false,"staff":false,"user_id":14169,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":"","can_view_edit_history":true,"wiki":false},{"id":118597,"name":"Sam","username":"Yuun","avatar_template":"/letter_avatar/yuun/{size}/3_90a587a04512ff220ac26ec1465844c5.png","uploaded_avatar_id":null,"created_at":"2015-05-14T20:35:03.793Z","cooked":"

            I'm seeing this issue as well. When you hit the rate limit, any further likes look like the forum is attempting and failing to apply them - the text saying 'you liked this' comes into place before quickly being removed.

            \n\n

            This makes it look (to the user) like the forum software is running into errors instead of said user hitting an intentional limit, which is a bit unfortunate.

            ","post_number":2,"post_type":1,"updated_at":"2015-05-14T20:35:03.793Z","like_count":0,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":6,"reads":22,"score":34.2,"yours":false,"topic_id":28830,"topic_slug":"1-3-0beta9-no-rate-limit-popups","display_username":"Sam","primary_group_name":null,"version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":0,"hidden":false,"can_act":false},{"id":3,"count":0,"hidden":false,"can_act":false},{"id":4,"count":0,"hidden":false,"can_act":false},{"id":5,"count":0,"hidden":true,"can_act":false},{"id":6,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"moderator":false,"admin":false,"staff":false,"user_id":14795,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":118601,"name":"Kane York","username":"riking","avatar_template":"/user_avatar/meta.discourse.org/riking/{size}/40212.png","uploaded_avatar_id":40212,"created_at":"2015-05-14T21:05:19.837Z","cooked":"

            I'm going to guess that the bootbox library got broken somehow?

            ","post_number":3,"post_type":1,"updated_at":"2015-05-14T21:05:19.837Z","like_count":0,"reply_count":1,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":14,"score":7.2,"yours":false,"topic_id":28830,"topic_slug":"1-3-0beta9-no-rate-limit-popups","display_username":"Kane York","primary_group_name":null,"version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":true,"user_title":"team summer intern 2014","actions_summary":[{"id":2,"count":0,"hidden":false,"can_act":false},{"id":3,"count":0,"hidden":false,"can_act":false},{"id":4,"count":0,"hidden":false,"can_act":false},{"id":5,"count":0,"hidden":true,"can_act":false},{"id":6,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"moderator":false,"admin":false,"staff":false,"user_id":6626,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":118606,"name":"Jeff Atwood","username":"codinghorror","avatar_template":"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png","uploaded_avatar_id":5297,"created_at":"2015-05-14T21:15:41.612Z","cooked":"

            Yeah maybe another Ember 1.10 regression for @eviltrout ?

            ","post_number":4,"post_type":1,"updated_at":"2015-05-14T21:15:41.612Z","like_count":0,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":6,"reads":12,"score":31.6,"yours":false,"topic_id":28830,"topic_slug":"1-3-0beta9-no-rate-limit-popups","display_username":"Jeff Atwood","primary_group_name":"discourse","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":true,"user_title":"co-founder","actions_summary":[{"id":2,"count":0,"hidden":false,"can_act":false},{"id":3,"count":0,"hidden":false,"can_act":false},{"id":4,"count":0,"hidden":false,"can_act":false},{"id":5,"count":0,"hidden":true,"can_act":false},{"id":6,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"moderator":true,"admin":true,"staff":true,"user_id":32,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":118612,"name":"TDWTF member","username":"Onyx","avatar_template":"/user_avatar/meta.discourse.org/onyx/{size}/33015.png","uploaded_avatar_id":33015,"created_at":"2015-05-14T21:23:09.562Z","cooked":"\n\n

            You mean the popup box library, guessing by the name? Still shows up when you want to cancel a post, so it's not all popups it seems.

            ","post_number":5,"post_type":1,"updated_at":"2015-05-14T21:23:09.562Z","like_count":1,"reply_count":0,"reply_to_post_number":3,"quote_count":1,"avg_time":null,"incoming_link_count":0,"reads":11,"score":16.0,"yours":false,"topic_id":28830,"topic_slug":"1-3-0beta9-no-rate-limit-popups","display_username":"TDWTF member","primary_group_name":null,"version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":false},{"id":3,"count":0,"hidden":false,"can_act":false},{"id":4,"count":0,"hidden":false,"can_act":false},{"id":5,"count":0,"hidden":true,"can_act":false},{"id":6,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"moderator":false,"admin":false,"staff":false,"user_id":10886,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false}],"stream":[118591,118597,118601,118606,118612]},"id":28830,"title":"1.3.0beta9: No rate-limit popups","fancy_title":"1.3.0beta9: No rate-limit popups","posts_count":5,"created_at":"2015-05-14T20:18:17.877Z","views":38,"reply_count":1,"participant_count":5,"like_count":7,"last_posted_at":"2015-05-14T21:23:09.562Z","visible":true,"closed":false,"archived":false,"has_summary":false,"archetype":"regular","slug":"1-3-0beta9-no-rate-limit-popups","category_id":1,"word_count":198,"deleted_at":null,"pending_posts_count":0,"draft":null,"draft_key":"topic_28830","draft_sequence":null,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":14169,"username":"RaceProUK","uploaded_avatar_id":40071,"avatar_template":"/user_avatar/meta.discourse.org/raceprouk/{size}/40071.png"},"last_poster":{"id":10886,"username":"Onyx","uploaded_avatar_id":33015,"avatar_template":"/user_avatar/meta.discourse.org/onyx/{size}/33015.png"},"participants":[{"id":14795,"username":"Yuun","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/yuun/{size}/3_90a587a04512ff220ac26ec1465844c5.png","post_count":1},{"id":10886,"username":"Onyx","uploaded_avatar_id":33015,"avatar_template":"/user_avatar/meta.discourse.org/onyx/{size}/33015.png","post_count":1},{"id":14169,"username":"RaceProUK","uploaded_avatar_id":40071,"avatar_template":"/user_avatar/meta.discourse.org/raceprouk/{size}/40071.png","post_count":1},{"id":6626,"username":"riking","uploaded_avatar_id":40212,"avatar_template":"/user_avatar/meta.discourse.org/riking/{size}/40212.png","post_count":1},{"id":32,"username":"codinghorror","uploaded_avatar_id":5297,"avatar_template":"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png","post_count":1}],"suggested_topics":[{"id":2890,"title":"Expanded quoted text not highlighting when text is formatted","fancy_title":"Expanded quoted text not highlighting when text is formatted","slug":"expanded-quoted-text-not-highlighting-when-text-is-formatted","posts_count":8,"reply_count":5,"highest_post_number":8,"image_url":null,"created_at":"2013-02-12T12:18:02.181Z","last_posted_at":"2013-02-14T15:59:40.014Z","bumped":true,"bumped_at":"2013-02-14T15:59:40.014Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":3,"views":361,"category_id":1},{"id":14213,"title":"Plugins not being parsed in correct javascript context when loaded for jobs","fancy_title":"Plugins not being parsed in correct javascript context when loaded for jobs","slug":"plugins-not-being-parsed-in-correct-javascript-context-when-loaded-for-jobs","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":"/plugins/emoji/images/frowning.png","created_at":"2014-03-27T23:57:00.974Z","last_posted_at":"2015-03-20T04:56:03.982Z","bumped":true,"bumped_at":"2015-03-20T04:56:03.982Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":0,"views":156,"category_id":1},{"id":22544,"title":"Like count on profile off by one","fancy_title":"Like count on profile off by one","slug":"like-count-on-profile-off-by-one","posts_count":7,"reply_count":2,"highest_post_number":7,"image_url":null,"created_at":"2014-11-26T08:15:39.802Z","last_posted_at":"2014-11-27T07:23:37.638Z","bumped":true,"bumped_at":"2014-11-27T07:23:37.638Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":18,"views":192,"category_id":1},{"id":27670,"title":"Using back still shows unread indicator on the topic","fancy_title":"Using back still shows unread indicator on the topic","slug":"using-back-still-shows-unread-indicator-on-the-topic","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2015-04-16T23:21:42.739Z","last_posted_at":"2015-04-17T02:43:08.447Z","bumped":true,"bumped_at":"2015-04-17T02:43:08.447Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":1,"views":85,"category_id":1},{"id":26628,"title":"Embed blacklist selector is broken","fancy_title":"Embed blacklist selector is broken","slug":"embed-blacklist-selector-is-broken","posts_count":11,"reply_count":7,"highest_post_number":11,"image_url":null,"created_at":"2015-03-22T11:21:14.825Z","last_posted_at":"2015-04-20T09:11:38.999Z","bumped":true,"bumped_at":"2015-04-20T09:11:38.999Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":1,"views":247,"category_id":1},{"id":18027,"title":"Minor: delete/undelete needs a rate limit","fancy_title":"Minor: delete/undelete needs a rate limit","slug":"minor-delete-undelete-needs-a-rate-limit","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2014-07-25T02:51:41.158Z","last_posted_at":"2014-07-25T04:01:15.343Z","bumped":true,"bumped_at":"2014-07-25T11:06:46.213Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":1,"views":165,"category_id":1},{"id":17396,"title":"Bad Reply Key when pulling Autoforwarded Emails to Discourse","fancy_title":"Bad Reply Key when pulling Autoforwarded Emails to Discourse","slug":"bad-reply-key-when-pulling-autoforwarded-emails-to-discourse","posts_count":20,"reply_count":15,"highest_post_number":20,"image_url":null,"created_at":"2014-07-09T18:34:57.114Z","last_posted_at":"2014-10-21T15:08:50.441Z","bumped":true,"bumped_at":"2014-10-21T15:08:50.441Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":7,"views":542,"category_id":1}],"links":[{"url":"https://meta.discourse.org/t/post-reply-on-different-topic-no-longer-works/28825","title":"Post reply on different topic no longer works","fancy_title":null,"internal":true,"reflection":false,"clicks":6,"user_id":14169,"domain":"meta.discourse.org"}],"notification_level":1,"can_flag_topic":false},"highest_post_number":5,"deleted_by":null,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"chunk_size":20,"bookmarked":null,"tags":null}, -"/t/9/1.json": {"post_stream":{"posts":[{"id":18,"username":"eviltrout","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","name":"Evil Trout","uploaded_avatar_id":9,"created_at":"2015-08-13T14:49:11.840Z","cooked":"

            This is the first post.

            ","post_number":1,"post_type":1,"updated_at":"2015-08-13T14:49:11.840Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":9,"topic_slug":"this-is-a-test-topic","display_username":"","primary_group_name":null,"version":1,"can_edit":true,"can_delete":false,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":19,"username":"eviltrout","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","name":"Evil Trout","uploaded_avatar_id":9,"created_at":"2015-08-13T14:49:18.231Z","cooked":"

            This is the second post.

            ","post_number":2,"post_type":1,"updated_at":"2015-08-13T14:49:18.231Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":9,"topic_slug":"this-is-a-test-topic","display_username":"","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":20,"username":"eviltrout","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","name":"Evil Trout","uploaded_avatar_id":9,"created_at":"2015-08-13T14:49:23.927Z","cooked":"

            This is the third post.

            ","post_number":3,"post_type":1,"updated_at":"2015-08-13T14:49:23.927Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":9,"topic_slug":"this-is-a-test-topic","display_username":"","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false}],"stream":[18,19,20]},"id":9,"title":"This is a test topic!","fancy_title":"This is a test topic!","posts_count":3,"created_at":"2015-08-13T14:49:11.720Z","views":1,"reply_count":0,"participant_count":1,"like_count":0,"last_posted_at":"2015-08-13T14:49:23.927Z","visible":true,"closed":false,"archived":false,"has_summary":false,"archetype":"regular","slug":"this-is-a-test-topic","category_id":1,"word_count":15,"deleted_at":null,"pending_posts_count":0,"user_id":1,"draft":null,"draft_key":"topic_9","draft_sequence":3,"posted":true,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"pinned_until":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":1,"username":"tgxworld","uploaded_avatar_id":9,"avatar_template":"/user_avatar/localhost/tgxworld/{size}/9_1.png"},"last_poster":{"id":1,"username":"tgxworld","uploaded_avatar_id":9,"avatar_template":"/user_avatar/localhost/tgxworld/{size}/9_1.png"},"participants":[{"id":1,"username":"tgxworld","uploaded_avatar_id":9,"avatar_template":"/user_avatar/localhost/tgxworld/{size}/9_1.png","post_count":3}],"suggested_topics":[{"id":8,"title":"This is a new and awesome topic!","fancy_title":"This is a new and awesome topic!","slug":"this-is-a-new-and-awesome-topic","posts_count":3,"reply_count":0,"highest_post_number":5,"image_url":null,"created_at":"2015-08-13T05:17:00.000Z","last_posted_at":"2015-08-13T10:14:34.799Z","bumped":true,"bumped_at":"2015-08-13T10:14:34.799Z","unseen":false,"last_read_post_number":5,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":2,"category_id":1},{"id":7,"title":"This is a test category!","fancy_title":"This is a test category!","slug":"this-is-a-test-category","posts_count":3,"reply_count":0,"highest_post_number":3,"image_url":null,"created_at":"2015-08-10T13:40:38.439Z","last_posted_at":"2015-08-13T01:59:44.928Z","bumped":true,"bumped_at":"2015-08-13T01:58:35.206Z","unseen":false,"last_read_post_number":3,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":2,"category_id":1}],"notification_level":3,"notifications_reason_id":1,"can_move_posts":true,"can_edit":true,"can_delete":true,"can_recover":true,"can_remove_allowed_users":true,"can_invite_to":true,"can_create_post":true,"can_reply_as_new_topic":true,"can_flag_topic":true},"highest_post_number":3,"last_read_post_number":3,"deleted_by":null,"has_deleted":false,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":true},{"id":7,"count":0,"hidden":false,"can_act":true},{"id":8,"count":0,"hidden":false,"can_act":true}],"chunk_size":20,"bookmarked":false} }; +export default {"/t/280/1.json": {"post_stream":{"posts":[{"id":398,"name":"Uwe Keim","username":"uwe_keim","avatar_template":"/images/avatar.png","uploaded_avatar_id":5697,"created_at":"2013-02-05T21:29:00.280Z","cooked":"

            Any plans to support localization of UI elements, so that I (for example) could set up a completely German speaking forum?

            ","post_number":1,"post_type":1,"updated_at":"2013-02-05T21:29:00.280Z","like_count":0,"reply_count":1,"reply_to_post_number":null,"quote_count":0,"avg_time":25,"incoming_link_count":314,"reads":475,"score":1702.25,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Uwe Keim","primary_group_name":null,"version":1,"can_edit":true,"can_delete":false,"can_recover":true,"link_counts":[{"url":"https://meta.discourse.org/t/language-mirrors/2378/2","internal":true,"reflection":true,"title":"Language mirrors","clicks":3},{"url":"https://meta.discourse.org/t/translation-workflow/6102","internal":true,"reflection":true,"title":"Translation workflow","clicks":2},{"url":"https://meta.discourse.org/t/solving-xda-developer-style-forums/4368/4","internal":true,"reflection":true,"title":"Solving XDA-Developer style forums","clicks":2},{"url":"https://meta.discourse.org/t/comrades-lets-join-our-efforts-on-ukrainian-and-russian-translations/4403/5","internal":true,"reflection":true,"title":"Comrades let's join our efforts on ukrainian and russian translations","clicks":1},{"url":"https://meta.discourse.org/t/bookmark-last-read-sometimes-doesn-t-go-to-the-end-of-a-topic/4825/9","internal":true,"reflection":true,"title":"Bookmark/last read sometimes doesn't go to the end of a topic","clicks":0},{"url":"https://meta.discourse.org/t/roadplan-for-discourse/2939/5","internal":true,"reflection":true,"title":"Roadplan for Discourse 2013","clicks":0}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":255,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":419,"name":"Tim Stone","username":"tms","avatar_template":"/images/avatar.png","uploaded_avatar_id":40181,"created_at":"2013-02-05T21:32:47.649Z","cooked":"

            The application strings are externalized, so localization should be entirely possible with enough translation effort.

            ","post_number":2,"post_type":1,"updated_at":"2013-02-06T10:15:27.965Z","like_count":4,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":27,"incoming_link_count":16,"reads":460,"score":308.35,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Tim Stone","primary_group_name":null,"version":2,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://github.com/discourse/discourse/blob/master/config/locales/en.yml","internal":false,"reflection":false,"clicks":118}],"read":true,"user_title":"Great contributor","actions_summary":[{"id":2,"count":4,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":9,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":1060,"name":"Jeff Atwood","username":"codinghorror","avatar_template":"/images/avatar.png","uploaded_avatar_id":5297,"created_at":"2013-02-06T02:26:24.922Z","cooked":"

            Yep, all strings are going through a lookup table.*

            \n\n

            master/config/locales

            \n\n

            So you could replace that lookup table with the \"de\" one to get German.

            \n\n

            * we didn't get all the strings into the lookup table for launch, but the vast, vast majority of them are and the ones that are not, we will fix as we go!

            ","post_number":3,"post_type":1,"updated_at":"2014-02-24T05:23:39.211Z","like_count":4,"reply_count":3,"reply_to_post_number":null,"quote_count":0,"avg_time":33,"incoming_link_count":5,"reads":449,"score":191.45,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Jeff Atwood","primary_group_name":"discourse","version":4,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://github.com/discourse/discourse/blob/master/config/locales","internal":false,"reflection":false,"title":"discourse/config/locales at master · discourse/discourse · GitHub","clicks":62},{"url":"https://meta.discourse.org/t/github-onebox-rendering-issue/7616","internal":true,"reflection":true,"title":"GitHub OneBox Rendering Issue","clicks":0}],"read":true,"user_title":"co-founder","actions_summary":[{"id":2,"count":4,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":true,"admin":true,"staff":true,"user_id":32,"hidden":false,"hidden_reason_id":null,"trust_level":3,"deleted_at":null,"user_deleted":false,"edit_reason":"","can_view_edit_history":true,"wiki":false},{"id":3623,"name":"Shade","username":"shade","avatar_template":"/images/avatar.png","uploaded_avatar_id":8306,"created_at":"2013-02-07T12:55:33.129Z","cooked":"

            Is it a coincidence that the strings file is 1337 lines long? \"smiley\"

            ","post_number":4,"post_type":1,"updated_at":"2013-02-07T12:55:33.129Z","like_count":7,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":20,"incoming_link_count":15,"reads":401,"score":291.2,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Shade","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://meta.discourse.org/t/hi-support-chinese/4393/6","internal":true,"reflection":true,"title":"Hi, support Chinese?","clicks":0}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":7,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":1808,"hidden":false,"hidden_reason_id":null,"trust_level":1,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3651,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T14:02:07.869Z","cooked":"

            \n\n

            The problem I see here is that this file is likely two grow and change massively over the next couple months, and tracking these changes in order to keep a localized file up to date is going to be a bitch.

            \n\n

            I wonder where there is a tool that can compare two yml structures and point out which nodes are missing? That would help keep track of new strings.

            \n\n

            Re keeping track of changed strings, @codinghorror I found this very interesting: http://stackoverflow.com/questions/4232922/why-do-people-use-plain-english-as-translation-placeholders if plain English placeholders were used, any change in strings would lead to a new node in the yml file, making keeping the translation up to date easier. Maybe worth thinking about in the future.

            ","post_number":5,"post_type":1,"updated_at":"2013-02-07T14:05:42.328Z","like_count":2,"reply_count":2,"reply_to_post_number":3,"quote_count":1,"avg_time":22,"incoming_link_count":10,"reads":386,"score":213.3,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"http://stackoverflow.com/questions/4232922/why-do-people-use-plain-english-as-translation-placeholders","internal":false,"reflection":false,"title":"internationalization - Why do people use plain english as translation placeholders? - Stack Overflow","clicks":63}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":2,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3654,"name":"Sam Saffron","username":"sam","avatar_template":"/images/avatar.png","uploaded_avatar_id":5243,"created_at":"2013-02-07T14:05:39.910Z","cooked":"

            Yes, I really like the concept of fuzzy matching for localization, perhaps you can chase up alex sexton he was meaning to upload a localization tool for this kind of stuff.

            \n\n

            Also, I am a big fan of ICU message format, but it is not the \"Rails way (tm)\".

            ","post_number":6,"post_type":1,"updated_at":"2013-02-07T14:05:39.910Z","like_count":1,"reply_count":1,"reply_to_post_number":5,"quote_count":0,"avg_time":17,"incoming_link_count":4,"reads":329,"score":106.65,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Sam Saffron","primary_group_name":"discourse","version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://github.com/SlexAxton/messageformat.js","internal":false,"reflection":false,"title":"SlexAxton/messageformat.js · GitHub","clicks":46},{"url":"https://github.com/SlexAxton","internal":false,"reflection":false,"title":"SlexAxton (Alex Sexton) · GitHub","clicks":10}],"read":true,"user_title":"co-founder","reply_to_user":{"username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":true,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3655,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T14:08:17.493Z","cooked":"

            Looks interesting, I'll take a peek.

            \n\n

            As said on dev, the best tool I can see in terms of giving translators a proper interface and quality control would be something like GlotPress. It's based on the PO messages format (is that somehow related to ICU?) but looks pretty great.

            \n\n

            \n\n

            I'm not familiar with the term in this context, you mean keeping the English version in the code base (instead of a generic code like message_error_nametooshort ?)

            ","post_number":7,"post_type":1,"updated_at":"2013-02-07T14:12:02.965Z","like_count":1,"reply_count":1,"reply_to_post_number":6,"quote_count":1,"avg_time":16,"incoming_link_count":0,"reads":326,"score":86.0,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"http://translate.wordpress.org/projects/bbpress/dev","internal":false,"reflection":false,"title":"WordPress › Development < GlotPress","clicks":16}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3658,"name":"Sam Saffron","username":"sam","avatar_template":"/images/avatar.png","uploaded_avatar_id":5243,"created_at":"2013-02-07T14:12:22.582Z","cooked":"

            ICU Message format is basically Gettext on steroids, Gettext has been around for so many years and actually works pretty well, being super prevalent in Linux.

            \n\n

            Trouble is you need a fuzzy matcher for translators if you are going to store stuff like mf.compile( 'This is a message.' ) in source, one letter change and all your translators need to validate it.

            ","post_number":8,"post_type":1,"updated_at":"2013-02-07T14:12:22.582Z","like_count":1,"reply_count":1,"reply_to_post_number":7,"quote_count":0,"avg_time":11,"incoming_link_count":2,"reads":296,"score":89.75,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Sam Saffron","primary_group_name":"discourse","version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://meta.discourse.org/t/what-i-love-about-wordpress-plugins/5697","internal":true,"reflection":true,"title":"What I love about WordPress plugins","clicks":0}],"read":true,"user_title":"co-founder","reply_to_user":{"username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":true,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3660,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T14:14:12.666Z","cooked":"

            \n\n

            Yeah, that's why I've always been a friend of message_error_nametooshort placeholders, until I asked the SO question linked above. The accepted answer makes a good argument against those placeholders: you want translations to break even on small changes in the English original because the translations will probably need to reflect the change, too. Maybe that's not the case right now as new stuff is being checked in pretty much every couple of hours, but in the long run, it'll be overwhelmingly true.

            ","post_number":9,"post_type":1,"updated_at":"2013-02-07T14:18:09.569Z","like_count":1,"reply_count":1,"reply_to_post_number":8,"quote_count":1,"avg_time":10,"incoming_link_count":0,"reads":293,"score":79.1,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3667,"name":"Tim Stone","username":"tms","avatar_template":"/images/avatar.png","uploaded_avatar_id":40181,"created_at":"2013-02-07T14:25:16.859Z","cooked":"

            Hmm...You could theoretically also build something into the development process that would monitor changes to the English locale file and make a translator-friendly list of changes between versions.

            ","post_number":10,"post_type":1,"updated_at":"2013-02-07T14:25:16.859Z","like_count":1,"reply_count":1,"reply_to_post_number":9,"quote_count":0,"avg_time":7,"incoming_link_count":0,"reads":275,"score":75.35,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Tim Stone","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":"Great contributor","reply_to_user":{"username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":9,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3673,"name":"Sam Saffron","username":"sam","avatar_template":"/images/avatar.png","uploaded_avatar_id":5243,"created_at":"2013-02-07T14:30:21.401Z","cooked":"

            Yeah, totally, also we could build tools for dev that make introducing string less annoying and make it possible to garbage collect old unused strings, I hate trudging through that file.

            ","post_number":11,"post_type":1,"updated_at":"2013-02-07T14:30:21.401Z","like_count":1,"reply_count":1,"reply_to_post_number":10,"quote_count":0,"avg_time":7,"incoming_link_count":1,"reads":273,"score":79.95,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Sam Saffron","primary_group_name":"discourse","version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":"co-founder","reply_to_user":{"username":"tms","avatar_template":"/images/avatar.png","uploaded_avatar_id":40181},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":true,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3675,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T14:33:38.280Z","cooked":"

            \n\n

            As said, I'd look into whether WP's tools can't be reused for this with some tweaking. They seem to be able to scan a code base for new strings, and make them available automatically to translators.

            \n\n

            They're PHP based which isn't ideal, but it looks like they've done a crapload of work to take the hassle out of translations.

            ","post_number":12,"post_type":1,"updated_at":"2013-02-07T14:34:39.910Z","like_count":1,"reply_count":1,"reply_to_post_number":11,"quote_count":1,"avg_time":7,"incoming_link_count":2,"reads":273,"score":84.95,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3690,"name":"Valts","username":"Vilx","avatar_template":"/images/avatar.png","uploaded_avatar_id":7299,"created_at":"2013-02-07T15:05:35.867Z","cooked":"

            This site looks so nice with all the little tweaks like \"10 minutes ago\" instead of simply time, etc - I wonder if there will also be support for proper pluralization in other languages? That's a pretty hard task though, I don't think I've ever seen a website that has done that. But it would be awesome.

            ","post_number":13,"post_type":1,"updated_at":"2013-02-07T15:05:35.867Z","like_count":3,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":7,"incoming_link_count":11,"reads":290,"score":158.35,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Valts","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":3,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":1216,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3925,"name":"Eric Kidd","username":"emk","avatar_template":"/images/avatar.png","uploaded_avatar_id":8400,"created_at":"2013-02-07T19:37:06.194Z","cooked":"

            \n\n

            I've had pretty decent luck using Localeapp to localize Rails applications:

            \n\n

            http://www.localeapp.com/

            \n\n

            The developer workflow took me about an hour to really get used to, and there were a few minor glitches. But the non-technical translators had very few problems. One limitation: It insists on rewriting all those yaml files full of strings.

            \n\n

            Anyway, it's worth a look, and it's free for open source, if I recall correctly. Certainly easier than doing a whole bunch of toolsmithing from scratch.

            ","post_number":14,"post_type":1,"updated_at":"2013-02-07T19:37:06.194Z","like_count":3,"reply_count":1,"reply_to_post_number":12,"quote_count":1,"avg_time":9,"incoming_link_count":0,"reads":283,"score":137.05,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Eric Kidd","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"http://www.localeapp.com/","internal":false,"reflection":false,"title":"Easy localization for Rails apps | Locale","clicks":69}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":3,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":1860,"hidden":false,"hidden_reason_id":null,"trust_level":1,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3938,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T19:52:13.748Z","cooked":"

            \n\n

            Ohhh. Looking sexy. droool

            ","post_number":15,"post_type":1,"updated_at":"2013-02-07T19:52:13.748Z","like_count":1,"reply_count":1,"reply_to_post_number":14,"quote_count":1,"avg_time":7,"incoming_link_count":0,"reads":260,"score":72.35,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3982,"name":"Eric Kidd","username":"emk","avatar_template":"/images/avatar.png","uploaded_avatar_id":8400,"created_at":"2013-02-07T20:52:22.454Z","cooked":"

            \n\n

            Yeah, it's pretty. \"smile\" But there were still some rough edges as of a few months ago.

            \n\n

            Whether or not those rough edges are a deal-breaker will probably depends on whether or not localization is already a source of acute pain. If you're already hurting, Localeapp is a pretty useful tool, especially when it comes to enlisting non-technical translators.

            \n\n

            But it does require changing how you work with text, and adding one new tool to the mix. So for projects that just don't want to know about non-English languages, it's not yet seamless the way Unicode is these days.

            \n\n

            (Sweet forum software, by the way. I was just testing out Egyptian hieroglyphics on the test server, because they're well off the Basic Multilingual Plane, and tend to flush Unicode bugs. Everything worked flawlessly.)

            ","post_number":16,"post_type":1,"updated_at":"2013-02-07T20:52:22.454Z","like_count":1,"reply_count":1,"reply_to_post_number":15,"quote_count":1,"avg_time":7,"incoming_link_count":0,"reads":254,"score":71.15,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Eric Kidd","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":1860,"hidden":false,"hidden_reason_id":null,"trust_level":1,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3989,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T21:04:15.405Z","cooked":"

            \n\n

            Interesting, thanks for the insight. I don't think localization is seriously on their table right now, there's likely to be many other things on the table before it... but it will become an issue sooner or later.

            ","post_number":17,"post_type":1,"updated_at":"2013-02-07T21:04:15.405Z","like_count":1,"reply_count":2,"reply_to_post_number":16,"quote_count":1,"avg_time":7,"incoming_link_count":0,"reads":255,"score":76.35,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3996,"name":"Sam Saffron","username":"sam","avatar_template":"/images/avatar.png","uploaded_avatar_id":5243,"created_at":"2013-02-07T21:12:06.575Z","cooked":"

            I had an idea ... what if in dev mode, you could right-click on a page and get access to all the translations on the page, make your edits and have it refreshed live.

            \n\n

            I think it would be awesome, very doable technically.

            ","post_number":18,"post_type":1,"updated_at":"2013-02-07T21:12:06.575Z","like_count":7,"reply_count":2,"reply_to_post_number":17,"quote_count":0,"avg_time":8,"incoming_link_count":0,"reads":264,"score":168.2,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Sam Saffron","primary_group_name":"discourse","version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":"co-founder","reply_to_user":{"username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253},"actions_summary":[{"id":2,"count":7,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":true,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":4009,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T21:18:47.422Z","cooked":"

            That would be fricking cool. There'd still be some leftovers (like error messages that normally never show up, etc.) but you could corral those up on a specific page.

            \n\n

            It could have a dropdown giving you all the languages that you have a .yml for in the locale directory, and write the changes into the one selected. I'm sure people would love it.

            ","post_number":19,"post_type":1,"updated_at":"2013-02-07T21:22:10.692Z","like_count":1,"reply_count":0,"reply_to_post_number":18,"quote_count":0,"avg_time":8,"incoming_link_count":1,"reads":241,"score":68.6,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"reply_to_user":{"username":"sam","avatar_template":"/images/avatar.png","uploaded_avatar_id":5243},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":4012,"name":"Marco Ceppi","username":"marcoceppi","avatar_template":"/images/avatar.png","uploaded_avatar_id":6552,"created_at":"2013-02-07T21:22:46.376Z","cooked":"

            If you use gettext format you could leverage Launchpad translations and the community behind it.

            ","post_number":20,"post_type":1,"updated_at":"2013-02-07T21:22:46.376Z","like_count":1,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":9,"incoming_link_count":2,"reads":244,"score":74.25,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Marco Ceppi","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://translations.launchpad.net/","internal":false,"reflection":false,"title":"Launchpad Translations","clicks":13}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":761,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false}],"stream":[398,419,1060,3623,3651,3654,3655,3658,3660,3667,3673,3675,3690,3925,3938,3982,3989,3996,4009,4012],"gaps":{"before":{"20706":[20125]},"after":{}}},"id":280,"title":"Internationalization / localization","fancy_title":"Internationalization / localization","posts_count":103,"created_at":"2013-02-05T21:29:00.174Z","views":5211,"reply_count":67,"participant_count":40,"like_count":135,"last_posted_at":"2015-03-04T15:07:10.487Z","visible":true,"closed":false,"archived":false,"has_summary":true,"archetype":"regular","slug":"internationalization-localization","category_id":2,"word_count":6198,"deleted_at":null,"draft":null,"draft_key":"topic_280","draft_sequence":4,"posted":true,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":255,"username":"uwe_keim","uploaded_avatar_id":5697,"avatar_template":"/images/avatar.png"},"last_poster":{"id":14091,"username":"Luciano_Fantuzzi","uploaded_avatar_id":39484,"avatar_template":"/images/avatar.png"},"participants":[{"id":212,"username":"alxndr","uploaded_avatar_id":5619,"avatar_template":"/images/avatar.png","post_count":11},{"id":1,"username":"sam","uploaded_avatar_id":5243,"avatar_template":"/images/avatar.png","post_count":11},{"id":7,"username":"pekka","uploaded_avatar_id":5253,"avatar_template":"/images/avatar.png","post_count":8},{"id":461,"username":"kuba","uploaded_avatar_id":6049,"avatar_template":"/images/avatar.png","post_count":7},{"id":2995,"username":"tattoo","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","post_count":6},{"id":2540,"username":"jgourdon","uploaded_avatar_id":9537,"avatar_template":"/images/avatar.png","post_count":5},{"id":1860,"username":"emk","uploaded_avatar_id":8400,"avatar_template":"/images/avatar.png","post_count":4},{"id":1275,"username":"dacap","uploaded_avatar_id":7401,"avatar_template":"/images/avatar.png","post_count":4},{"id":19,"username":"eviltrout","uploaded_avatar_id":5275,"avatar_template":"/images/avatar.png","post_count":4},{"id":3704,"username":"mojzis","uploaded_avatar_id":31201,"avatar_template":"/images/avatar.png","post_count":3},{"id":3190,"username":"gururea","uploaded_avatar_id":10663,"avatar_template":"/images/avatar.png","post_count":3},{"id":1895,"username":"maciek","uploaded_avatar_id":8463,"avatar_template":"/images/avatar.png","post_count":3},{"id":22,"username":"splattne","uploaded_avatar_id":5280,"avatar_template":"/images/avatar.png","post_count":2},{"id":1979,"username":"Superuser","uploaded_avatar_id":8604,"avatar_template":"/images/avatar.png","post_count":2},{"id":3818,"username":"Tudor","uploaded_avatar_id":11675,"avatar_template":"/images/avatar.png","post_count":2},{"id":32,"username":"codinghorror","uploaded_avatar_id":5297,"avatar_template":"/images/avatar.png","post_count":2},{"id":3620,"username":"potthast","uploaded_avatar_id":11363,"avatar_template":"/images/avatar.png","post_count":2},{"id":9,"username":"tms","uploaded_avatar_id":40181,"avatar_template":"/images/avatar.png","post_count":2},{"id":14091,"username":"Luciano_Fantuzzi","uploaded_avatar_id":39484,"avatar_template":"/images/avatar.png","post_count":1},{"id":255,"username":"uwe_keim","uploaded_avatar_id":5697,"avatar_template":"/images/avatar.png","post_count":1},{"id":9006,"username":"berk","uploaded_avatar_id":19348,"avatar_template":"/images/avatar.png","post_count":1},{"id":754,"username":"danneu","uploaded_avatar_id":6540,"avatar_template":"/images/avatar.png","post_count":1},{"id":761,"username":"marcoceppi","uploaded_avatar_id":6552,"avatar_template":"/images/avatar.png","post_count":1},{"id":2753,"username":"mikl","uploaded_avatar_id":9918,"avatar_template":"/images/avatar.png","post_count":1}],"suggested_topics":[{"id":27331,"title":"Polls are still very buggy","fancy_title":"Polls are still very buggy","slug":"polls-are-still-very-buggy","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":"/uploads/default/_optimized/cd1/b8c/c162528887_690x401.png","created_at":"2015-04-08T09:51:00.357Z","last_posted_at":"2015-04-08T15:59:16.258Z","bumped":true,"bumped_at":"2015-04-08T16:05:09.842Z","unseen":false,"last_read_post_number":3,"unread":0,"new_posts":1,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":11,"views":55,"category_id":1},{"id":27343,"title":"Mobile theme doesn't show last activity time for topics on category page","fancy_title":"Mobile theme doesn’t show last activity time for topics on category page","slug":"mobile-theme-doesnt-show-last-activity-time-for-topics-on-category-page","posts_count":4,"reply_count":2,"highest_post_number":4,"image_url":"/uploads/default/_optimized/13e/25c/bd30b466be_281x500.png","created_at":"2015-04-08T14:20:51.177Z","last_posted_at":"2015-04-08T15:40:30.037Z","bumped":true,"bumped_at":"2015-04-08T15:40:30.037Z","unseen":false,"last_read_post_number":2,"unread":0,"new_posts":2,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":3,"views":23,"category_id":9},{"id":27346,"title":"Reply+{messagekey}@... optionaly in header \"from\" in addition to \"reply-to\"","fancy_title":"Reply+{messagekey}@… optionaly in header “from” in addition to “reply-to”","slug":"reply-messagekey-optionaly-in-header-from-in-addition-to-reply-to","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2015-04-08T16:05:13.103Z","last_posted_at":"2015-04-08T16:05:13.415Z","bumped":true,"bumped_at":"2015-04-08T16:05:13.415Z","unseen":true,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":0,"views":8,"category_id":2},{"id":19670,"title":"Parsing (Oneboxing) IMDB links","fancy_title":"Parsing (Oneboxing) IMDB links","slug":"parsing-oneboxing-imdb-links","posts_count":8,"reply_count":1,"highest_post_number":8,"image_url":null,"created_at":"2014-09-05T07:19:26.161Z","last_posted_at":"2015-04-07T09:21:21.570Z","bumped":true,"bumped_at":"2015-04-07T09:21:21.570Z","unseen":false,"last_read_post_number":8,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":4,"views":253,"category_id":2},{"id":7512,"title":"Support for Piwik Analytics as an alternative to Google Analytics","fancy_title":"Support for Piwik Analytics as an alternative to Google Analytics","slug":"support-for-piwik-analytics-as-an-alternative-to-google-analytics","posts_count":53,"reply_count":41,"highest_post_number":65,"image_url":"/plugins/emoji/images/smile.png","created_at":"2013-06-16T01:32:30.596Z","last_posted_at":"2015-02-22T13:46:26.845Z","bumped":true,"bumped_at":"2015-02-22T13:46:26.845Z","unseen":false,"last_read_post_number":65,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":62,"views":1877,"category_id":2},{"id":25480,"title":"CSS admin-contents reloaded","fancy_title":"CSS admin-contents reloaded","slug":"css-admin-contents-reloaded","posts_count":22,"reply_count":15,"highest_post_number":22,"image_url":null,"created_at":"2015-02-21T12:15:57.707Z","last_posted_at":"2015-03-02T23:24:18.899Z","bumped":true,"bumped_at":"2015-03-02T23:24:18.899Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":21,"views":185,"category_id":2},{"id":26576,"title":"Badge timestamp should be the time the badge was granted?","fancy_title":"Badge timestamp should be the time the badge was granted?","slug":"badge-timestamp-should-be-the-time-the-badge-was-granted","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2015-03-20T13:22:08.266Z","last_posted_at":"2015-03-21T00:33:52.243Z","bumped":true,"bumped_at":"2015-03-21T00:33:52.243Z","unseen":false,"last_read_post_number":1,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":1,"bookmarked":false,"liked":false,"archetype":"regular","like_count":9,"views":87,"category_id":2}],"links":[{"url":"https://github.com/discourse/discourse/blob/master/config/locales/en.yml","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":118,"user_id":9,"domain":"github.com"},{"url":"http://www.localeapp.com/","title":"Easy localization for Rails apps | Locale","fancy_title":null,"internal":false,"reflection":false,"clicks":69,"user_id":1860,"domain":"www.localeapp.com"},{"url":"http://stackoverflow.com/questions/4232922/why-do-people-use-plain-english-as-translation-placeholders","title":"internationalization - Why do people use plain english as translation placeholders? - Stack Overflow","fancy_title":null,"internal":false,"reflection":false,"clicks":63,"user_id":7,"domain":"stackoverflow.com"},{"url":"https://github.com/discourse/discourse/blob/master/config/locales","title":"discourse/config/locales at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":62,"user_id":32,"domain":"github.com"},{"url":"https://github.com/SlexAxton/messageformat.js","title":"SlexAxton/messageformat.js · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":46,"user_id":1,"domain":"github.com"},{"url":"http://www.localeapp.com/projects/1537/translations?utf8=%E2%9C%93&search=source_code","title":"langforums | Locale","fancy_title":null,"internal":false,"reflection":false,"clicks":25,"user_id":1860,"domain":"www.localeapp.com"},{"url":"https://translations.launchpad.net/","title":"Launchpad Translations","fancy_title":null,"internal":false,"reflection":false,"clicks":23,"user_id":761,"domain":"translations.launchpad.net"},{"url":"https://www.transifex.com/","title":"Transifex - Continuous Localization Platform","fancy_title":null,"internal":false,"reflection":false,"clicks":22,"user_id":1979,"domain":"www.transifex.com"},{"url":"https://github.com/berk/tr8n","title":"berk/tr8n · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":22,"user_id":1,"domain":"github.com"},{"url":"http://translate.wordpress.org/projects/bbpress/dev","title":"WordPress › Development < GlotPress","fancy_title":null,"internal":false,"reflection":false,"clicks":16,"user_id":7,"domain":"translate.wordpress.org"},{"url":"http://weblate.org","title":"Weblate - web-based translation","fancy_title":null,"internal":false,"reflection":false,"clicks":15,"user_id":2316,"domain":"weblate.org"},{"url":"https://github.com/discourse/discourse/tree/master/config/locales","title":"discourse/config/locales at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":14,"user_id":19,"domain":"github.com"},{"url":"https://github.com/discourse/discourse/pull/493","title":"Danish translation. by mikl · Pull Request #493 · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":12,"user_id":2753,"domain":"github.com"},{"url":"https://github.com/SlexAxton","title":"SlexAxton (Alex Sexton) · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":10,"user_id":1,"domain":"github.com"},{"url":"https://github.com/gururea/discourse/tree/master/config/locales","title":"discourse/config/locales at master · gururea/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":9,"user_id":3190,"domain":"github.com"},{"url":"https://github.com/discourse/discourse/blob/master/config/locales/client.en.yml#L691","title":"discourse/config/locales/client.en.yml at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":7,"user_id":461,"domain":"github.com"},{"url":"https://github.com/dacap/discourse/tree/spanish","title":"dacap/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":7,"user_id":1275,"domain":"github.com"},{"url":"https://github.com/discourse/discourse/blob/master/config/locales/client.nl.yml","title":"discourse/config/locales/client.nl.yml at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":7,"user_id":461,"domain":"github.com"},{"url":"https://github.com/discourse/discourse/commit/c5761eae8afe37e20cec0d0f9d14b85b6e585bda","title":"Support for Simplified Chinese thanks to tangramor · c5761ea · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":6,"user_id":212,"domain":"github.com"},{"url":"http://tr8n.github.com/","title":"tr8n","fancy_title":null,"internal":false,"reflection":false,"clicks":6,"user_id":212,"domain":"tr8n.github.com"},{"url":"http://www.getlocalization.com/","title":"Crowdsourced, Social and Collaborative App & Website Translation - Get Localization","fancy_title":null,"internal":false,"reflection":false,"clicks":6,"user_id":22,"domain":"www.getlocalization.com"},{"url":"http://blog.discourse.org/2013/04/discourse-as-your-first-rails-app/","title":"Discourse as Your First Rails App","fancy_title":null,"internal":false,"reflection":false,"clicks":5,"user_id":1995,"domain":"blog.discourse.org"},{"url":"https://github.com/alxndr/discourse/blob/i18n-chinese/config/locales/server.zh.yml","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":5,"user_id":212,"domain":"github.com"},{"url":"http://translate.sourceforge.net/wiki/virtaal/index","title":"Easy-to-use and powerful offline translation tool | Virtaal","fancy_title":null,"internal":false,"reflection":false,"clicks":4,"user_id":1979,"domain":"translate.sourceforge.net"},{"url":"https://poeditor.com/","title":"POEditor - online software localization tool","fancy_title":null,"internal":false,"reflection":false,"clicks":4,"user_id":1979,"domain":"poeditor.com"},{"url":"http://en.lichess.org/@/Hellball","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":3,"user_id":1979,"domain":"en.lichess.org"},{"url":"http://en.wikipedia.org/wiki/T%E2%80%93V_distinction","title":"T–V distinction - Wikipedia, the free encyclopedia","fancy_title":null,"internal":false,"reflection":false,"clicks":3,"user_id":3620,"domain":"en.wikipedia.org"},{"url":"http://www.slideshare.net/HeatherRivers/linguistic-potluck-crowdsourcing-localization-with-rails","title":"Linguistic Potluck: Crowdsourcing localization with Rails","fancy_title":null,"internal":false,"reflection":false,"clicks":3,"user_id":212,"domain":"www.slideshare.net"},{"url":"https://meta.discourse.org/t/language-mirrors/2378/2","title":"Language mirrors","fancy_title":null,"internal":true,"reflection":true,"clicks":3,"user_id":32,"domain":"meta.discourse.org"},{"url":"http://www.madanalogy.com/2012/06/rails-i18n-translations-in-yaml.html","title":"Mad Analogy: Rails i18n translations in Yaml: translation tool support","fancy_title":null,"internal":false,"reflection":false,"clicks":3,"user_id":3190,"domain":"www.madanalogy.com"},{"url":"https://github.com/tr8n","title":"Translation Exchange · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":3,"user_id":9006,"domain":"github.com"},{"url":"http://pootle.locamotion.org/","title":"Main | Pootle Demo","fancy_title":null,"internal":false,"reflection":false,"clicks":2,"user_id":3190,"domain":"pootle.locamotion.org"},{"url":"http://www.youtube.com/watch?v=MqqdzJ98q7s","title":"GoGaRuCo 2012 - Linguistic Potluck: Crowdsourcing Localization in Rails by Heather Rivers - YouTube","fancy_title":null,"internal":false,"reflection":false,"clicks":2,"user_id":212,"domain":"www.youtube.com"},{"url":"https://meta.discourse.org/t/translation-workflow/6102","title":"Translation workflow","fancy_title":null,"internal":true,"reflection":true,"clicks":2,"user_id":4702,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/solving-xda-developer-style-forums/4368/4","title":"Solving XDA-Developer style forums","fancy_title":null,"internal":true,"reflection":true,"clicks":2,"user_id":639,"domain":"meta.discourse.org"},{"url":"https://tr8nhub.com","title":"TranslationExchange","fancy_title":null,"internal":false,"reflection":false,"clicks":2,"user_id":9006,"domain":"tr8nhub.com"},{"url":"https://meta.discourse.org/t/roadplan-for-discourse/2939/3","title":"Roadplan for Discourse 2013","fancy_title":null,"internal":true,"reflection":true,"clicks":1,"user_id":2540,"domain":"meta.discourse.org"},{"url":"http://sugarjs.com/dates#date_locales","title":"Dates - Sugar","fancy_title":null,"internal":false,"reflection":false,"clicks":1,"user_id":461,"domain":"sugarjs.com"},{"url":"http://blog.discourse.org/2013/03/localizing-discourse/","title":"Localizing Discourse","fancy_title":null,"internal":false,"reflection":false,"clicks":1,"user_id":893,"domain":"blog.discourse.org"},{"url":"https://github.com/discourse/discourse/blob/master/app/assets/javascripts/locales/date_locales.js","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":1,"user_id":461,"domain":"github.com"},{"url":"http://transifex.com/projects/p/discourse-pt-br/","title":"Discourse-Translations-Project localization","fancy_title":null,"internal":false,"reflection":false,"clicks":1,"user_id":893,"domain":"transifex.com"},{"url":"https://github.com/discourse/discourse/issues/279","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":1,"user_id":893,"domain":"github.com"},{"url":"https://meta.discourse.org/t/comrades-lets-join-our-efforts-on-ukrainian-and-russian-translations/4403/5","title":"Comrades let's join our efforts on ukrainian and russian translations","fancy_title":null,"internal":true,"reflection":true,"clicks":1,"user_id":3417,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/translation-workflow/6102/6","title":"Translation workflow","fancy_title":null,"internal":true,"reflection":false,"clicks":0,"user_id":1995,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/bookmark-last-read-sometimes-doesn-t-go-to-the-end-of-a-topic/4825/9","title":"Bookmark/last read sometimes doesn't go to the end of a topic","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":3681,"domain":"meta.discourse.org"},{"url":"https://github.com/discourse/discourse/blob/master/config/locales/client.de.yml","title":"discourse/config/locales/client.de.yml at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":0,"user_id":2,"domain":"github.com"},{"url":"https://meta.discourse.org/t/what-i-love-about-wordpress-plugins/5697","title":"What I love about WordPress plugins","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":1,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/github-onebox-rendering-issue/7616","title":"GitHub OneBox Rendering Issue","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":5372,"domain":"meta.discourse.org"},{"url":"https://github.com/discourse/discourse/blob/master/config/locales/server.de.yml","title":"discourse/config/locales/server.de.yml at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":0,"user_id":2,"domain":"github.com"},{"url":"https://meta.discourse.org/t/roadplan-for-discourse/2939/5","title":"Roadplan for Discourse 2013","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":32,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/translation-tools-transifex-localeapp/7763","title":"Translation Tools: Transifex? Localeapp?","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":2,"domain":"meta.discourse.org"},{"url":"http://guides.rubyonrails.org/i18n.html#the-public-i18n-api","title":"Rails Internationalization (I18n) API — Ruby on Rails Guides","fancy_title":null,"internal":false,"reflection":false,"clicks":0,"user_id":1895,"domain":"guides.rubyonrails.org"},{"url":"https://meta.discourse.org/t/hi-support-chinese/4393/6","title":"Hi, support Chinese?","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":2014,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/translation-tools-transifex-localeapp/7763/41","title":"Translation Tools: Transifex? Localeapp?","fancy_title":null,"internal":true,"reflection":false,"clicks":0,"user_id":6626,"domain":"meta.discourse.org"}],"notification_level":2,"notifications_reason_id":4,"can_move_posts":true,"can_edit":true,"can_delete":true,"can_recover":true,"can_remove_allowed_users":true,"can_invite_to":true,"can_create_post":true,"can_reply_as_new_topic":true,"can_flag_topic":true},"highest_post_number":10,"last_read_post_number":10,"deleted_by":null,"has_deleted":true,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":true},{"id":7,"count":0,"hidden":false,"can_act":true},{"id":8,"count":0,"hidden":false,"can_act":true}],"chunk_size":20,"bookmarked":false,"tags":null}, +"/t/28830/1.json": {"post_stream":{"posts":[{"id":118591,"name":"spends too much time on WTDWTF","username":"RaceProUK","avatar_template":"/images/avatar.png","uploaded_avatar_id":40071,"created_at":"2015-05-14T20:18:17.954Z","cooked":"

            Normally, actions such as Liking are rate-limited, and when you hit the limit, you get a message telling you you've hit the limit. However, in 1.3.0beta9, it seems those popups are no longer appearing.

            \n\n

            Edit: Possibly linked to this issue?

            ","post_number":1,"post_type":1,"updated_at":"2015-05-14T20:21:42.825Z","like_count":6,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":14,"reads":24,"score":224.6,"yours":false,"topic_id":28830,"topic_slug":"1-3-0beta9-no-rate-limit-popups","display_username":"spends too much time on WTDWTF","primary_group_name":null,"version":1,"can_edit":false,"can_delete":false,"can_recover":false,"link_counts":[{"url":"https://meta.discourse.org/t/post-reply-on-different-topic-no-longer-works/28825","internal":true,"reflection":false,"title":"Post reply on different topic no longer works","clicks":6}],"read":true,"user_title":"Contributor","actions_summary":[{"id":2,"count":6,"hidden":false,"can_act":false},{"id":3,"count":0,"hidden":false,"can_act":false},{"id":4,"count":0,"hidden":false,"can_act":false},{"id":5,"count":0,"hidden":true,"can_act":false},{"id":6,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"moderator":false,"admin":false,"staff":false,"user_id":14169,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":"","can_view_edit_history":true,"wiki":false},{"id":118597,"name":"Sam","username":"Yuun","avatar_template":"/images/avatar.png","uploaded_avatar_id":null,"created_at":"2015-05-14T20:35:03.793Z","cooked":"

            I'm seeing this issue as well. When you hit the rate limit, any further likes look like the forum is attempting and failing to apply them - the text saying 'you liked this' comes into place before quickly being removed.

            \n\n

            This makes it look (to the user) like the forum software is running into errors instead of said user hitting an intentional limit, which is a bit unfortunate.

            ","post_number":2,"post_type":1,"updated_at":"2015-05-14T20:35:03.793Z","like_count":0,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":6,"reads":22,"score":34.2,"yours":false,"topic_id":28830,"topic_slug":"1-3-0beta9-no-rate-limit-popups","display_username":"Sam","primary_group_name":null,"version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":0,"hidden":false,"can_act":false},{"id":3,"count":0,"hidden":false,"can_act":false},{"id":4,"count":0,"hidden":false,"can_act":false},{"id":5,"count":0,"hidden":true,"can_act":false},{"id":6,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"moderator":false,"admin":false,"staff":false,"user_id":14795,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":118601,"name":"Kane York","username":"riking","avatar_template":"/images/avatar.png","uploaded_avatar_id":40212,"created_at":"2015-05-14T21:05:19.837Z","cooked":"

            I'm going to guess that the bootbox library got broken somehow?

            ","post_number":3,"post_type":1,"updated_at":"2015-05-14T21:05:19.837Z","like_count":0,"reply_count":1,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":14,"score":7.2,"yours":false,"topic_id":28830,"topic_slug":"1-3-0beta9-no-rate-limit-popups","display_username":"Kane York","primary_group_name":null,"version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":true,"user_title":"team summer intern 2014","actions_summary":[{"id":2,"count":0,"hidden":false,"can_act":false},{"id":3,"count":0,"hidden":false,"can_act":false},{"id":4,"count":0,"hidden":false,"can_act":false},{"id":5,"count":0,"hidden":true,"can_act":false},{"id":6,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"moderator":false,"admin":false,"staff":false,"user_id":6626,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":118606,"name":"Jeff Atwood","username":"codinghorror","avatar_template":"/images/avatar.png","uploaded_avatar_id":5297,"created_at":"2015-05-14T21:15:41.612Z","cooked":"

            Yeah maybe another Ember 1.10 regression for @eviltrout ?

            ","post_number":4,"post_type":1,"updated_at":"2015-05-14T21:15:41.612Z","like_count":0,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":6,"reads":12,"score":31.6,"yours":false,"topic_id":28830,"topic_slug":"1-3-0beta9-no-rate-limit-popups","display_username":"Jeff Atwood","primary_group_name":"discourse","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":true,"user_title":"co-founder","actions_summary":[{"id":2,"count":0,"hidden":false,"can_act":false},{"id":3,"count":0,"hidden":false,"can_act":false},{"id":4,"count":0,"hidden":false,"can_act":false},{"id":5,"count":0,"hidden":true,"can_act":false},{"id":6,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"moderator":true,"admin":true,"staff":true,"user_id":32,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":118612,"name":"TDWTF member","username":"Onyx","avatar_template":"/images/avatar.png","uploaded_avatar_id":33015,"created_at":"2015-05-14T21:23:09.562Z","cooked":"\n\n

            You mean the popup box library, guessing by the name? Still shows up when you want to cancel a post, so it's not all popups it seems.

            ","post_number":5,"post_type":1,"updated_at":"2015-05-14T21:23:09.562Z","like_count":1,"reply_count":0,"reply_to_post_number":3,"quote_count":1,"avg_time":null,"incoming_link_count":0,"reads":11,"score":16.0,"yours":false,"topic_id":28830,"topic_slug":"1-3-0beta9-no-rate-limit-popups","display_username":"TDWTF member","primary_group_name":null,"version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":false},{"id":3,"count":0,"hidden":false,"can_act":false},{"id":4,"count":0,"hidden":false,"can_act":false},{"id":5,"count":0,"hidden":true,"can_act":false},{"id":6,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"moderator":false,"admin":false,"staff":false,"user_id":10886,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false}],"stream":[118591,118597,118601,118606,118612]},"id":28830,"title":"1.3.0beta9: No rate-limit popups","fancy_title":"1.3.0beta9: No rate-limit popups","posts_count":5,"created_at":"2015-05-14T20:18:17.877Z","views":38,"reply_count":1,"participant_count":5,"like_count":7,"last_posted_at":"2015-05-14T21:23:09.562Z","visible":true,"closed":false,"archived":false,"has_summary":false,"archetype":"regular","slug":"1-3-0beta9-no-rate-limit-popups","category_id":1,"word_count":198,"deleted_at":null,"pending_posts_count":0,"draft":null,"draft_key":"topic_28830","draft_sequence":null,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":14169,"username":"RaceProUK","uploaded_avatar_id":40071,"avatar_template":"/images/avatar.png"},"last_poster":{"id":10886,"username":"Onyx","uploaded_avatar_id":33015,"avatar_template":"/images/avatar.png"},"participants":[{"id":14795,"username":"Yuun","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","post_count":1},{"id":10886,"username":"Onyx","uploaded_avatar_id":33015,"avatar_template":"/images/avatar.png","post_count":1},{"id":14169,"username":"RaceProUK","uploaded_avatar_id":40071,"avatar_template":"/images/avatar.png","post_count":1},{"id":6626,"username":"riking","uploaded_avatar_id":40212,"avatar_template":"/images/avatar.png","post_count":1},{"id":32,"username":"codinghorror","uploaded_avatar_id":5297,"avatar_template":"/images/avatar.png","post_count":1}],"suggested_topics":[{"id":2890,"title":"Expanded quoted text not highlighting when text is formatted","fancy_title":"Expanded quoted text not highlighting when text is formatted","slug":"expanded-quoted-text-not-highlighting-when-text-is-formatted","posts_count":8,"reply_count":5,"highest_post_number":8,"image_url":null,"created_at":"2013-02-12T12:18:02.181Z","last_posted_at":"2013-02-14T15:59:40.014Z","bumped":true,"bumped_at":"2013-02-14T15:59:40.014Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":3,"views":361,"category_id":1},{"id":14213,"title":"Plugins not being parsed in correct javascript context when loaded for jobs","fancy_title":"Plugins not being parsed in correct javascript context when loaded for jobs","slug":"plugins-not-being-parsed-in-correct-javascript-context-when-loaded-for-jobs","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":"/plugins/emoji/images/frowning.png","created_at":"2014-03-27T23:57:00.974Z","last_posted_at":"2015-03-20T04:56:03.982Z","bumped":true,"bumped_at":"2015-03-20T04:56:03.982Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":0,"views":156,"category_id":1},{"id":22544,"title":"Like count on profile off by one","fancy_title":"Like count on profile off by one","slug":"like-count-on-profile-off-by-one","posts_count":7,"reply_count":2,"highest_post_number":7,"image_url":null,"created_at":"2014-11-26T08:15:39.802Z","last_posted_at":"2014-11-27T07:23:37.638Z","bumped":true,"bumped_at":"2014-11-27T07:23:37.638Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":18,"views":192,"category_id":1},{"id":27670,"title":"Using back still shows unread indicator on the topic","fancy_title":"Using back still shows unread indicator on the topic","slug":"using-back-still-shows-unread-indicator-on-the-topic","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2015-04-16T23:21:42.739Z","last_posted_at":"2015-04-17T02:43:08.447Z","bumped":true,"bumped_at":"2015-04-17T02:43:08.447Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":1,"views":85,"category_id":1},{"id":26628,"title":"Embed blacklist selector is broken","fancy_title":"Embed blacklist selector is broken","slug":"embed-blacklist-selector-is-broken","posts_count":11,"reply_count":7,"highest_post_number":11,"image_url":null,"created_at":"2015-03-22T11:21:14.825Z","last_posted_at":"2015-04-20T09:11:38.999Z","bumped":true,"bumped_at":"2015-04-20T09:11:38.999Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":1,"views":247,"category_id":1},{"id":18027,"title":"Minor: delete/undelete needs a rate limit","fancy_title":"Minor: delete/undelete needs a rate limit","slug":"minor-delete-undelete-needs-a-rate-limit","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2014-07-25T02:51:41.158Z","last_posted_at":"2014-07-25T04:01:15.343Z","bumped":true,"bumped_at":"2014-07-25T11:06:46.213Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":1,"views":165,"category_id":1},{"id":17396,"title":"Bad Reply Key when pulling Autoforwarded Emails to Discourse","fancy_title":"Bad Reply Key when pulling Autoforwarded Emails to Discourse","slug":"bad-reply-key-when-pulling-autoforwarded-emails-to-discourse","posts_count":20,"reply_count":15,"highest_post_number":20,"image_url":null,"created_at":"2014-07-09T18:34:57.114Z","last_posted_at":"2014-10-21T15:08:50.441Z","bumped":true,"bumped_at":"2014-10-21T15:08:50.441Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":7,"views":542,"category_id":1}],"links":[{"url":"https://meta.discourse.org/t/post-reply-on-different-topic-no-longer-works/28825","title":"Post reply on different topic no longer works","fancy_title":null,"internal":true,"reflection":false,"clicks":6,"user_id":14169,"domain":"meta.discourse.org"}],"notification_level":1,"can_flag_topic":false},"highest_post_number":5,"deleted_by":null,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"chunk_size":20,"bookmarked":null,"tags":null}, +"/t/9/1.json": {"post_stream":{"posts":[{"id":18,"username":"eviltrout","avatar_template":"/images/avatar.png","name":"Evil Trout","uploaded_avatar_id":9,"created_at":"2015-08-13T14:49:11.840Z","cooked":"

            This is the first post.

            ","post_number":1,"post_type":1,"updated_at":"2015-08-13T14:49:11.840Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":9,"topic_slug":"this-is-a-test-topic","display_username":"","primary_group_name":null,"version":1,"can_edit":true,"can_delete":false,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":19,"username":"eviltrout","avatar_template":"/images/avatar.png","name":"Evil Trout","uploaded_avatar_id":9,"created_at":"2015-08-13T14:49:18.231Z","cooked":"

            This is the second post.

            ","post_number":2,"post_type":1,"updated_at":"2015-08-13T14:49:18.231Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":9,"topic_slug":"this-is-a-test-topic","display_username":"","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":20,"username":"eviltrout","avatar_template":"/images/avatar.png","name":"Evil Trout","uploaded_avatar_id":9,"created_at":"2015-08-13T14:49:23.927Z","cooked":"

            This is the third post.

            ","post_number":3,"post_type":1,"updated_at":"2015-08-13T14:49:23.927Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":9,"topic_slug":"this-is-a-test-topic","display_username":"","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false}],"stream":[18,19,20]},"id":9,"title":"This is a test topic!","fancy_title":"This is a test topic!","posts_count":3,"created_at":"2015-08-13T14:49:11.720Z","views":1,"reply_count":0,"participant_count":1,"like_count":0,"last_posted_at":"2015-08-13T14:49:23.927Z","visible":true,"closed":false,"archived":false,"has_summary":false,"archetype":"regular","slug":"this-is-a-test-topic","category_id":1,"word_count":15,"deleted_at":null,"pending_posts_count":0,"user_id":1,"draft":null,"draft_key":"topic_9","draft_sequence":3,"posted":true,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"pinned_until":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":1,"username":"tgxworld","uploaded_avatar_id":9,"avatar_template":"/images/avatar.png"},"last_poster":{"id":1,"username":"tgxworld","uploaded_avatar_id":9,"avatar_template":"/images/avatar.png"},"participants":[{"id":1,"username":"tgxworld","uploaded_avatar_id":9,"avatar_template":"/images/avatar.png","post_count":3}],"suggested_topics":[{"id":8,"title":"This is a new and awesome topic!","fancy_title":"This is a new and awesome topic!","slug":"this-is-a-new-and-awesome-topic","posts_count":3,"reply_count":0,"highest_post_number":5,"image_url":null,"created_at":"2015-08-13T05:17:00.000Z","last_posted_at":"2015-08-13T10:14:34.799Z","bumped":true,"bumped_at":"2015-08-13T10:14:34.799Z","unseen":false,"last_read_post_number":5,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":2,"category_id":1},{"id":7,"title":"This is a test category!","fancy_title":"This is a test category!","slug":"this-is-a-test-category","posts_count":3,"reply_count":0,"highest_post_number":3,"image_url":null,"created_at":"2015-08-10T13:40:38.439Z","last_posted_at":"2015-08-13T01:59:44.928Z","bumped":true,"bumped_at":"2015-08-13T01:58:35.206Z","unseen":false,"last_read_post_number":3,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":2,"category_id":1}],"notification_level":3,"notifications_reason_id":1,"can_move_posts":true,"can_edit":true,"can_delete":true,"can_recover":true,"can_remove_allowed_users":true,"can_invite_to":true,"can_create_post":true,"can_reply_as_new_topic":true,"can_flag_topic":true},"highest_post_number":3,"last_read_post_number":3,"deleted_by":null,"has_deleted":false,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":true},{"id":7,"count":0,"hidden":false,"can_act":true},{"id":8,"count":0,"hidden":false,"can_act":true}],"chunk_size":20,"bookmarked":false}, +"/t/12/1.json": {"post_stream":{"posts":[{"id":15,"name":null,"username":"test","avatar_template":"/images/avatar.png","created_at":"2017-01-27T03:53:58.394Z","cooked":"

            I have a pen, I have an apple

            ","post_number":1,"post_type":1,"updated_at":"2017-01-27T03:53:58.394Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":12,"topic_slug":"pm-for-testing","display_username":null,"primary_group_name":null,"primary_group_flair_url":null,"primary_group_flair_bg_color":null,"primary_group_flair_color":null,"version":1,"can_edit":true,"can_delete":false,"can_recover":true,"can_wiki":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false,"can_translate":false},{"id":16,"name":null,"username":"test","avatar_template":"/images/avatar.png","created_at":"2017-01-27T04:10:02.941Z","cooked":"","post_number":2,"post_type":3,"updated_at":"2017-01-27T04:10:02.941Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":12,"topic_slug":"pm-for-testing","display_username":null,"primary_group_name":null,"primary_group_flair_url":null,"primary_group_flair_bg_color":null,"primary_group_flair_color":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"can_wiki":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false,"action_code":"invited_group","action_code_who":"Group","can_translate":false}],"stream":[15,16]},"timeline_lookup":[[1,0]],"id":12,"title":"PM for testing","fancy_title":"PM for testing","posts_count":2,"created_at":"2017-01-27T03:53:58.360Z","views":1,"reply_count":0,"participant_count":1,"like_count":0,"last_posted_at":"2017-01-27T04:10:02.941Z","visible":true,"closed":false,"archived":false,"has_summary":false,"archetype":"private_message","slug":"pm-for-testing","category_id":null,"word_count":8,"deleted_at":null,"user_id":1,"draft":null,"draft_key":"topic_12","draft_sequence":2,"posted":true,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"pinned_until":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":1,"username":"test","avatar_template":"/images/avatar.png"},"last_poster":{"id":1,"username":"test","avatar_template":"/images/avatar.png"},"allowed_groups":[{"id":41,"automatic":false,"name":"Group","user_count":0,"alias_level":99,"visible":true,"automatic_membership_email_domains":"","automatic_membership_retroactive":false,"primary_group":false,"title":null,"grant_trust_level":null,"incoming_email":null,"has_messages":false,"flair_url":null,"flair_bg_color":null,"flair_color":null,"bio_raw":null,"bio_cooked":null,"public":false,"allow_membership_requests":false,"full_name":null}],"allowed_users":[{"id":2,"username":"someguy","avatar_template":"/images/avatar.png"},{"id":1,"username":"test","avatar_template":"/images/avatar.png"}],"participants":[{"id":1,"username":"test","avatar_template":"/images/avatar.png","post_count":2,"primary_group_name":null,"primary_group_flair_url":null,"primary_group_flair_color":null,"primary_group_flair_bg_color":null}],"suggested_topics":[{"id":11,"title":"This is a very important announcement","fancy_title":"This is a very important announcement","slug":"this-is-a-very-important-announcement","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2017-01-27T03:52:02.061Z","last_posted_at":"2017-01-27T03:52:02.119Z","bumped":true,"bumped_at":"2017-01-27T03:52:02.119Z","unseen":false,"last_read_post_number":1,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"private_message","like_count":0,"views":1,"category_id":null,"featured_link":null,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":1,"username":"test","avatar_template":"/images/avatar.png"}}]}],"notification_level":3,"notifications_reason_id":1,"can_move_posts":true,"can_edit":true,"can_delete":true,"can_recover":true,"can_remove_allowed_users":true,"can_invite_to":true,"can_create_post":true,"can_reply_as_new_topic":true,"can_flag_topic":true},"highest_post_number":2,"last_read_post_number":2,"last_read_post_id":16,"deleted_by":null,"has_deleted":false,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":true},{"id":7,"count":0,"hidden":false,"can_act":true},{"id":8,"count":0,"hidden":false,"can_act":true}],"chunk_size":20,"bookmarked":false,"message_archived":false,"featured_link":null} }; diff --git a/test/javascripts/fixtures/user_fixtures.js.es6 b/test/javascripts/fixtures/user_fixtures.js.es6 index cfdbd38ddf..28e5a9cf04 100644 --- a/test/javascripts/fixtures/user_fixtures.js.es6 +++ b/test/javascripts/fixtures/user_fixtures.js.es6 @@ -1,6 +1,6 @@ /*jshint maxlen:10000000 */ export default { -"/users/eviltrout.json": {"user_badges":[{"id":5870,"granted_at":"2014-05-16T02:39:38.388Z","badge_id":4,"user_id":19,"granted_by_id":-1},{"id":40673,"granted_at":"2014-03-31T14:23:18.060Z","post_id":7241,"post_number":19,"badge_id":23,"user_id":19,"granted_by_id":-1,"topic_id":3153},{"id":5868,"granted_at":"2014-05-16T02:39:38.380Z","badge_id":3,"user_id":19,"granted_by_id":-1}],"badges":[{"id":4,"name":"Leader","description":null,"grant_count":7,"allow_title":true,"multiple_grant":false,"icon":"fa-user","image":null,"listable":true,"enabled":true,"badge_grouping_id":4,"system":true,"badge_type_id":1},{"id":23,"name":"Great Share","description":null,"grant_count":14,"allow_title":false,"multiple_grant":true,"icon":"fa-certificate","image":null,"listable":true,"enabled":true,"badge_grouping_id":2,"system":true,"badge_type_id":1},{"id":3,"name":"Regular","description":null,"grant_count":30,"allow_title":true,"multiple_grant":false,"icon":"fa-user","image":null,"listable":true,"enabled":true,"badge_grouping_id":4,"system":true,"badge_type_id":2}],"badge_types":[{"id":1,"name":"Gold","sort_order":9},{"id":2,"name":"Silver","sort_order":8},{"id":3,"name":"Bronze","sort_order":7}],"users":[{"id":19,"username":"eviltrout","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/eviltrout/{size}/3_f9720745f5ce6dfc2b5641fca999d934.png"},{"id":-1,"username":"system","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/system/{size}/3_f9720745f5ce6dfc2b5641fca999d934.png"}],"topics":[{"id":3153,"title":"Is it better for Discourse to use JavaScript or CoffeeScript?","fancy_title":"Is it better for Discourse to use JavaScript or CoffeeScript?","slug":"is-it-better-for-discourse-to-use-javascript-or-coffeescript","posts_count":56}],"user":{"user_option":{},"id":19,"username":"eviltrout","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/eviltrout/{size}/3_f9720745f5ce6dfc2b5641fca999d934.png","name":"Robin Ward","email":"robin.ward@gmail.com","last_posted_at":"2015-05-07T15:23:35.074Z","last_seen_at":"2015-05-13T14:34:23.188Z","bio_raw":"Co-founder of Discourse. Previously, I created Forumwarz. Follow me on Twitter.","bio_cooked":"

            Co-founder of Discourse. Previously, I created Forumwarz. Follow me on Twitter.

            ","created_at":"2013-02-03T15:19:22.704Z","website":"http://eviltrout.com","location":"Toronto","can_edit":false,"can_edit_username":true,"can_edit_email":true,"can_edit_name":true,"stats":[{"action_type":13,"count":342,"id":null},{"action_type":12,"count":109,"id":null},{"action_type":4,"count":27,"id":null},{"action_type":5,"count":1607,"id":null},{"action_type":6,"count":771,"id":null},{"action_type":1,"count":333,"id":null},{"action_type":2,"count":2671,"id":null},{"action_type":7,"count":949,"id":null},{"action_type":9,"count":42,"id":null},{"action_type":3,"count":8,"id":null},{"action_type":11,"count":20,"id":null}],"can_send_private_messages":true,"can_send_private_message_to_user":false,"bio_excerpt":"Co-founder of Discourse. Previously, I created Forumwarz. Follow me on Twitter.","trust_level":4,"moderator":true,"admin":true,"title":"co-founder","badge_count":23,"notification_count":3244,"has_title_badges":true,"custom_fields":{},"user_fields":{"1":"33"},"pending_count":0,"post_count":1987,"can_be_deleted":false,"can_delete_all_posts":false,"locale":"","email_digests":true,"email_private_messages":true,"email_direct":true,"email_always":true,"digest_after_minutes":10080,"mailing_list_mode":false,"auto_track_topics_after_msecs":60000,"new_topic_duration_minutes":1440,"external_links_in_new_tab":false,"dynamic_favicon":true,"enable_quoting":true,"muted_category_ids":[],"tracked_category_ids":[],"watched_category_ids":[3],"private_messages_stats":{"all":101,"mine":13,"unread":3},"disable_jump_reply":false,"gravatar_avatar_upload_id":5275,"custom_avatar_upload_id":1573,"card_image_badge":"/images/avatar.png","card_image_badge_id":120,"muted_usernames":[],"invited_by":{"id":1,"username":"sam","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/sam/{size}/3_f9720745f5ce6dfc2b5641fca999d934.png"},"custom_groups":[{"id":44,"automatic":false,"name":"ubuntu","user_count":11,"alias_level":0,"visible":true,"automatic_membership_email_domains":null,"automatic_membership_retroactive":false,"primary_group":false,"title":null},{"id":47,"automatic":false,"name":"discourse","user_count":7,"alias_level":0,"visible":true,"automatic_membership_email_domains":null,"automatic_membership_retroactive":false,"primary_group":false,"title":null}],"featured_user_badge_ids":[5870,40673,5868],"card_badge":{"id":120,"name":"Garbage Man","description":"This Discourse developer successfully called something \"garbage!\"","grant_count":3,"allow_title":false,"multiple_grant":false,"icon":"/images/avatar.png","image":"/images/avatar.png","listable":false,"enabled":false,"badge_grouping_id":8,"system":false,"badge_type_id":3}}}, +"/u/eviltrout.json": {"user_badges":[{"id":5870,"granted_at":"2014-05-16T02:39:38.388Z","badge_id":4,"user_id":19,"granted_by_id":-1},{"id":40673,"granted_at":"2014-03-31T14:23:18.060Z","post_id":7241,"post_number":19,"badge_id":23,"user_id":19,"granted_by_id":-1,"topic_id":3153},{"id":5868,"granted_at":"2014-05-16T02:39:38.380Z","badge_id":3,"user_id":19,"granted_by_id":-1}],"badges":[{"id":4,"name":"Leader","description":null,"grant_count":7,"allow_title":true,"multiple_grant":false,"icon":"fa-user","image":null,"listable":true,"enabled":true,"badge_grouping_id":4,"system":true,"badge_type_id":1},{"id":23,"name":"Great Share","description":null,"grant_count":14,"allow_title":false,"multiple_grant":true,"icon":"fa-certificate","image":null,"listable":true,"enabled":true,"badge_grouping_id":2,"system":true,"badge_type_id":1},{"id":3,"name":"Regular","description":null,"grant_count":30,"allow_title":true,"multiple_grant":false,"icon":"fa-user","image":null,"listable":true,"enabled":true,"badge_grouping_id":4,"system":true,"badge_type_id":2}],"badge_types":[{"id":1,"name":"Gold","sort_order":9},{"id":2,"name":"Silver","sort_order":8},{"id":3,"name":"Bronze","sort_order":7}],"users":[{"id":19,"username":"eviltrout","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/eviltrout/{size}/3_f9720745f5ce6dfc2b5641fca999d934.png"},{"id":-1,"username":"system","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/system/{size}/3_f9720745f5ce6dfc2b5641fca999d934.png"}],"topics":[{"id":3153,"title":"Is it better for Discourse to use JavaScript or CoffeeScript?","fancy_title":"Is it better for Discourse to use JavaScript or CoffeeScript?","slug":"is-it-better-for-discourse-to-use-javascript-or-coffeescript","posts_count":56}],"user":{"user_option":{},"id":19,"username":"eviltrout","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/eviltrout/{size}/3_f9720745f5ce6dfc2b5641fca999d934.png","name":"Robin Ward","email":"robin.ward@example.com","last_posted_at":"2015-05-07T15:23:35.074Z","last_seen_at":"2015-05-13T14:34:23.188Z","bio_raw":"Co-founder of Discourse. Previously, I created Forumwarz. Follow me on Twitter.","bio_cooked":"

            Co-founder of Discourse. Previously, I created Forumwarz. Follow me on Twitter.

            ","created_at":"2013-02-03T15:19:22.704Z","website":"http://eviltrout.com","location":"Toronto","can_edit":false,"can_edit_username":true,"can_edit_email":true,"can_edit_name":true,"stats":[{"action_type":13,"count":342,"id":null},{"action_type":12,"count":109,"id":null},{"action_type":4,"count":27,"id":null},{"action_type":5,"count":1607,"id":null},{"action_type":6,"count":771,"id":null},{"action_type":1,"count":333,"id":null},{"action_type":2,"count":2671,"id":null},{"action_type":7,"count":949,"id":null},{"action_type":9,"count":42,"id":null},{"action_type":3,"count":8,"id":null},{"action_type":11,"count":20,"id":null}],"can_send_private_messages":true,"can_send_private_message_to_user":false,"bio_excerpt":"Co-founder of Discourse. Previously, I created Forumwarz. Follow me on Twitter.","trust_level":4,"moderator":true,"admin":true,"title":"co-founder","badge_count":23,"notification_count":3244,"has_title_badges":true,"custom_fields":{},"user_fields":{"1":"33"},"pending_count":0,"post_count":1987,"can_be_deleted":false,"can_delete_all_posts":false,"locale":"","email_digests":true,"email_private_messages":true,"email_direct":true,"email_always":true,"digest_after_minutes":10080,"mailing_list_mode":false,"auto_track_topics_after_msecs":60000,"new_topic_duration_minutes":1440,"external_links_in_new_tab":false,"dynamic_favicon":true,"enable_quoting":true,"muted_category_ids":[],"tracked_category_ids":[],"watched_category_ids":[3],"private_messages_stats":{"all":101,"mine":13,"unread":3},"disable_jump_reply":false,"gravatar_avatar_upload_id":5275,"custom_avatar_upload_id":1573,"card_image_badge":"/images/avatar.png","card_image_badge_id":120,"muted_usernames":[],"invited_by":{"id":1,"username":"sam","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/sam/{size}/3_f9720745f5ce6dfc2b5641fca999d934.png"},"custom_groups":[{"id":44,"automatic":false,"name":"ubuntu","user_count":11,"alias_level":0,"visible":true,"automatic_membership_email_domains":null,"automatic_membership_retroactive":false,"primary_group":false,"title":null},{"id":47,"automatic":false,"name":"discourse","user_count":7,"alias_level":0,"visible":true,"automatic_membership_email_domains":null,"automatic_membership_retroactive":false,"primary_group":false,"title":null}],"featured_user_badge_ids":[5870,40673,5868],"card_badge":{"id":120,"name":"Garbage Man","description":"This Discourse developer successfully called something \"garbage!\"","grant_count":3,"allow_title":false,"multiple_grant":false,"icon":"/images/avatar.png","image":"/images/avatar.png","listable":false,"enabled":false,"badge_grouping_id":8,"system":false,"badge_type_id":3}}}, "/user_actions.json": {"user_actions":[{"action_type":7,"created_at":"2014-01-16T14:13:05Z","excerpt":"So again, \n\nWhat is the problem?\n\nI need to check user_trust_level , i get the 'username' from a form via ajax, i need to check what level he is on discourse \n\nAlso, if possible, i would like to get other details as well, like email address etc. \n\nI took a look at : https://github.com/discourse/dis…","avatar_template":"//www.gravatar.com/avatar/bdab7e61b3191e483492fd680f563fed.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/bdab7e61b3191e483492fd680f563fed.png?s={size}&r=pg&d=identicon","slug":"how-to-check-the-user-level-via-ajax","topic_id":11993,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":1,"reply_to_post_number":null,"username":"Abhishek_Gupta","name":"Abhishek Gupta","user_id":8021,"acting_username":"Abhishek_Gupta","acting_name":"Abhishek Gupta","acting_user_id":8021,"title":"How to check the user level via ajax?","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-15T16:53:49Z","excerpt":"A good fix would be to have the ERB template do an if statement. We'd happily accept a PR that did this if you feel up to it: \n\n <% if SiteSetting.logo_url.present? %>\n display logo html\n<% else %>\n display title html\n<% end %>","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","slug":"users-activate-account-pulling-blank-logo-instead-of-defaulting-to-h2","topic_id":10911,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":3,"reply_to_post_number":2,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"codinghorror","acting_name":"Jeff Atwood","acting_user_id":32,"title":"/users/activate-account pulling blank logo instead of defaulting to h2","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":5,"created_at":"2014-01-15T15:21:37Z","excerpt":"A good fix would be to have the ERB template do an if statement. We'd happily accept a PR that did this if you feel up to it: \n\n <% if SiteSetting.logo_url.present? %>\n display logo html\n<% else %>\n display title html\n<% end %>","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","slug":"users-activate-account-pulling-blank-logo-instead-of-defaulting-to-h2","topic_id":10911,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":3,"reply_to_post_number":2,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"eviltrout","acting_name":"Robin Ward","acting_user_id":19,"title":"/users/activate-account pulling blank logo instead of defaulting to h2","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":6,"created_at":"2014-01-15T12:22:12Z","excerpt":"OK - i see what you mean. From the piwik code I should add: \n\n_paq.push(["setDocumentTitle", document.domain + "/" + document.title]);\n\n? \n\nUnfortunately I have had to give up on Piwik for now because I have switched the forum to SSL on a free cert and have used up the free subdomain for the forum. …","avatar_template":"//localhost:3000/uploads/default/avatars/2a8/a3c/8fddcac642/{size}.jpg","acting_avatar_template":"//localhost:3000/uploads/default/avatars/2a8/a3c/8fddcac642/{size}.jpg","slug":"support-for-piwik-analytics-as-an-alternative-to-google-analytics","topic_id":7512,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":26,"reply_to_post_number":25,"username":"citkane","name":"Michael Jonker","user_id":7604,"acting_username":"citkane","acting_name":"Michael Jonker","acting_user_id":7604,"title":"Support for Piwik Analytics as an alternative to Google Analytics","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":7,"created_at":"2014-01-15T11:16:36Z","excerpt":"@eviltrout recently added support for multiple API keys [wink] \n\n[]","avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon","slug":"allow-for-multiple-api-keys","topic_id":7444,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":3,"reply_to_post_number":null,"username":"zogstrip","name":"Régis Hanol","user_id":1995,"acting_username":"zogstrip","acting_name":"Régis Hanol","acting_user_id":1995,"title":"Allow for multiple API Keys","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":7,"created_at":"2014-01-15T10:58:46Z","excerpt":"@eviltrout added a tooltip when you click on the user's avatar which allows you to show the posts made by that user \n\n[image]","avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon","slug":"to-group-posts-by-a-user","topic_id":7412,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":4,"reply_to_post_number":3,"username":"zogstrip","name":"Régis Hanol","user_id":1995,"acting_username":"zogstrip","acting_name":"Régis Hanol","acting_user_id":1995,"title":"To group posts by a user","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":7,"created_at":"2014-01-15T10:36:15Z","excerpt":"@eviltrout implemented per-user API key a while ago [wink] \n\n [image]\nTopics_-_Discourse_Meta-5.png884x339 29.6 KB\n","avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon","slug":"auth-using-rest-api","topic_id":5937,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":3,"reply_to_post_number":2,"username":"zogstrip","name":"Régis Hanol","user_id":1995,"acting_username":"zogstrip","acting_name":"Régis Hanol","acting_user_id":1995,"title":"Auth using REST API?","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":7,"created_at":"2014-01-15T09:55:17Z","excerpt":"@eviltrout has recently introduced this feature and has even blogged about it: \n\n \n \n \n \n eviltrout.com\n \n \n \n \n \n Hiding Offscreen Content in Ember.js - Evil Trout's Blog","avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon","slug":"infinite-scrolling-reusing-dom-nodes","topic_id":5186,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":3,"reply_to_post_number":null,"username":"zogstrip","name":"Régis Hanol","user_id":1995,"acting_username":"zogstrip","acting_name":"Régis Hanol","acting_user_id":1995,"title":"Infinite scrolling: Reusing DOM nodes","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-15T00:54:32Z","excerpt":"You can retrieve a user's JSON by making a call to /users/username.json but that assumes you know the user's username. If that's impossible, I would be happy to accept a PR that would return the current user JSON from /session/current-user or something like that. \n\nAdditionally, if you're looking to…","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/035d12bad251759d8fbc9fb10574d1f6.png?s={size}&r=pg&d=identicon","slug":"get-current-user-information-via-json","topic_id":11959,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":2,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"watchmanmonitor","acting_name":"Watchman Monitoring","acting_user_id":8085,"title":"Get current user information via JSON","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-14T21:59:51Z","excerpt":"You can retrieve a user's JSON by making a call to /users/username.json but that assumes you know the user's username. If that's impossible, I would be happy to accept a PR that would return the current user JSON from /session/current-user or something like that. \n\nAdditionally, if you're looking to…","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/9cfd2536afac32d209335b092094c12c.png?s={size}&r=pg&d=identicon","slug":"get-current-user-information-via-json","topic_id":11959,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":2,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"znation","acting_name":"znation","acting_user_id":8163,"title":"Get current user information via JSON","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-14T21:46:50Z","excerpt":"Okay I've fixed the https [point_right] http links on the server side and in the Javascript click tracking as @BhaelOchon pointed out. \n\nLet me know if you find anything else broken.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","slug":"broken-links-possibly-related-to-https","topic_id":11831,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":18,"reply_to_post_number":16,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"codinghorror","acting_name":"Jeff Atwood","acting_user_id":32,"title":"Broken links, possibly related to HTTPS","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":6,"created_at":"2014-01-14T21:43:28Z","excerpt":"Thanks for your help @eviltrout! I will consider making that change and sending a pull request. I may not get to it for a while. \n\nI am embedding Discourse on another site and it is mostly going well. I have indeed been using your blog for inspiration.","avatar_template":"//www.gravatar.com/avatar/9cfd2536afac32d209335b092094c12c.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/9cfd2536afac32d209335b092094c12c.png?s={size}&r=pg&d=identicon","slug":"get-current-user-information-via-json","topic_id":11959,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":3,"reply_to_post_number":2,"username":"znation","name":"znation","user_id":8163,"acting_username":"znation","acting_name":"znation","acting_user_id":8163,"title":"Get current user information via JSON","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-14T21:21:52Z","excerpt":"Okay I've fixed the https [point_right] http links on the server side and in the Javascript click tracking as @BhaelOchon pointed out. \n\nLet me know if you find anything else broken.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon","slug":"broken-links-possibly-related-to-https","topic_id":11831,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":18,"reply_to_post_number":16,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"zogstrip","acting_name":"Régis Hanol","acting_user_id":1995,"title":"Broken links, possibly related to HTTPS","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":5,"created_at":"2014-01-14T21:03:07Z","excerpt":"Okay I've fixed the https [point_right] http links on the server side and in the Javascript click tracking as @BhaelOchon pointed out. \n\nLet me know if you find anything else broken.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","slug":"broken-links-possibly-related-to-https","topic_id":11831,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":18,"reply_to_post_number":16,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"eviltrout","acting_name":"Robin Ward","acting_user_id":19,"title":"Broken links, possibly related to HTTPS","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-14T20:42:51Z","excerpt":"You can retrieve a user's JSON by making a call to /users/username.json but that assumes you know the user's username. If that's impossible, I would be happy to accept a PR that would return the current user JSON from /session/current-user or something like that. \n\nAdditionally, if you're looking to…","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","slug":"get-current-user-information-via-json","topic_id":11959,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":2,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"codinghorror","acting_name":"Jeff Atwood","acting_user_id":32,"title":"Get current user information via JSON","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":5,"created_at":"2014-01-14T20:29:23Z","excerpt":"You can retrieve a user's JSON by making a call to /users/username.json but that assumes you know the user's username. If that's impossible, I would be happy to accept a PR that would return the current user JSON from /session/current-user or something like that. \n\nAdditionally, if you're looking to…","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","slug":"get-current-user-information-via-json","topic_id":11959,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":2,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"eviltrout","acting_name":"Robin Ward","acting_user_id":19,"title":"Get current user information via JSON","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":5,"created_at":"2014-01-14T19:20:28Z","excerpt":"Perhaps the ['trackpageView'] is not the correct API call? We can probably send more information across such as the URL.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","slug":"support-for-piwik-analytics-as-an-alternative-to-google-analytics","topic_id":7512,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":25,"reply_to_post_number":24,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"eviltrout","acting_name":"Robin Ward","acting_user_id":19,"title":"Support for Piwik Analytics as an alternative to Google Analytics","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":5,"created_at":"2014-01-14T19:19:46Z","excerpt":"Nope but I bet you can find one!","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","slug":"how-far-to-take-user-documentation","topic_id":11943,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":4,"reply_to_post_number":3,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"eviltrout","acting_name":"Robin Ward","acting_user_id":19,"title":"How far to take user documentation?","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":6,"created_at":"2014-01-14T18:37:05Z","excerpt":"I'd be glad to write a pull request to take use there. Is there a specific part of their documentation you have in mind?","avatar_template":"//www.gravatar.com/avatar/035d12bad251759d8fbc9fb10574d1f6.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/035d12bad251759d8fbc9fb10574d1f6.png?s={size}&r=pg&d=identicon","slug":"how-far-to-take-user-documentation","topic_id":11943,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":3,"reply_to_post_number":2,"username":"watchmanmonitor","name":"Watchman Monitoring","user_id":8085,"acting_username":"watchmanmonitor","acting_name":"Watchman Monitoring","acting_user_id":8085,"title":"How far to take user documentation?","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":6,"created_at":"2014-01-14T16:04:28Z","excerpt":"Thanks @eviltrout , the code in the 'bottom of pages' now reads: \n\n<script type="text/javascript">\nDiscourse.PageTracker.current().on('change', function() {\n console.log('tracked!')\n _paq.push(['trackPageView']);\n});\n</script>\n\nThe console is logging 'tracked!' and piwik is logging for each page c…","avatar_template":"//localhost:3000/uploads/default/avatars/2a8/a3c/8fddcac642/{size}.jpg","acting_avatar_template":"//localhost:3000/uploads/default/avatars/2a8/a3c/8fddcac642/{size}.jpg","slug":"support-for-piwik-analytics-as-an-alternative-to-google-analytics","topic_id":7512,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":23,"reply_to_post_number":22,"username":"citkane","name":"Michael Jonker","user_id":7604,"acting_username":"citkane","acting_name":"Michael Jonker","acting_user_id":7604,"title":"Support for Piwik Analytics as an alternative to Google Analytics","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":5,"created_at":"2014-01-14T15:58:27Z","excerpt":"This topic is now archived. It is frozen and cannot be changed in any way.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","slug":"regression-cannot-sort-topic-list","topic_id":11944,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":4,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"eviltrout","acting_name":"Robin Ward","acting_user_id":19,"title":"Regression: Cannot sort topic list","deleted":false,"hidden":false,"moderator_action":true,"edit_reason":null},{"action_type":5,"created_at":"2014-01-14T15:26:57Z","excerpt":"I do think that leading them into the official rails documentation at that point is not a bad idea. Like "congratulations, everything is ready but now you'll need to understand the platform we built it in to be productive."","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","slug":"how-far-to-take-user-documentation","topic_id":11943,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":2,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"eviltrout","acting_name":"Robin Ward","acting_user_id":19,"title":"How far to take user documentation?","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-14T08:28:00Z","excerpt":"I've just added the ability to list reply counts on your blog index and archive pages as you can see here. \n\nIt works with a similar API to embedding comments: \n\n <script type="text/javascript">\n var discourseUrl = "http://fishtank.eviltrout.com/";\n\n (function() {\n var d = document.createEleme…","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","slug":"discourse-plugin-for-static-site-generators-like-jekyll-or-octopress","topic_id":7965,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":98,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"sam","acting_name":"Sam Saffron","acting_user_id":1,"title":"Discourse plugin for static site generators like Jekyll or Octopress","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":7,"created_at":"2014-01-14T00:21:26Z","excerpt":"In pull request 1821, @eviltrout asked: \n\n "About rails s: I wouldn't be against adding it but at what point do we stop holding their hand and expect them to know how rails works? I'm sure rails documentation could do a better job than us. Actually maybe we should just link to that? \n\nWhat point to …","avatar_template":"//www.gravatar.com/avatar/035d12bad251759d8fbc9fb10574d1f6.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/035d12bad251759d8fbc9fb10574d1f6.png?s={size}&r=pg&d=identicon","slug":"how-far-to-take-user-documentation","topic_id":11943,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":1,"reply_to_post_number":null,"username":"watchmanmonitor","name":"Watchman Monitoring","user_id":8085,"acting_username":"watchmanmonitor","acting_name":"Watchman Monitoring","acting_user_id":8085,"title":"How far to take user documentation?","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":6,"created_at":"2014-01-13T21:58:28Z","excerpt":"It looks uneeded, but you need to review a fair amount of code to confirm it is not needed. \n\nI am going to keep it for now cause its safer under some weird edge conditions.","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","slug":"ruby-question-about-use-of-klass-self-in-the-site-customization-rb","topic_id":11889,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":3,"reply_to_post_number":2,"username":"sam","name":"Sam Saffron","user_id":1,"acting_username":"sam","acting_name":"Sam Saffron","acting_user_id":1,"title":"Ruby question about use of klass=self in the site_customization.rb","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-13T21:11:32Z","excerpt":"I had to fix an issue with Google analytics so I added a new API hook that can be used. \n\nIf you add the following it should work: \n\n Discourse.PageTracker.current().on('change', function() {\n _paq.push(['trackPageView']);\n});","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","slug":"support-for-piwik-analytics-as-an-alternative-to-google-analytics","topic_id":7512,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":22,"reply_to_post_number":16,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"sam","acting_name":"Sam Saffron","acting_user_id":1,"title":"Support for Piwik Analytics as an alternative to Google Analytics","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":6,"created_at":"2014-01-13T21:10:57Z","excerpt":"Having a look, the fix is a bit scary imho, we should fix the root issue.","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","slug":"error-after-update-to-0-9-8-1","topic_id":11903,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":11,"reply_to_post_number":10,"username":"sam","name":"Sam Saffron","user_id":1,"acting_username":"sam","acting_name":"Sam Saffron","acting_user_id":1,"title":"Error after update to 0.9.8.1","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-13T20:50:34Z","excerpt":"I've just added the ability to list reply counts on your blog index and archive pages as you can see here. \n\nIt works with a similar API to embedding comments: \n\n <script type="text/javascript">\n var discourseUrl = "http://fishtank.eviltrout.com/";\n\n (function() {\n var d = document.createEleme…","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//localhost:3000/uploads/default/avatars/527/614/d16e1504d9/{size}.jpg","slug":"discourse-plugin-for-static-site-generators-like-jekyll-or-octopress","topic_id":7965,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":98,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"trident","acting_name":"Ben T","acting_user_id":5707,"title":"Discourse plugin for static site generators like Jekyll or Octopress","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-13T20:44:56Z","excerpt":"I had to fix an issue with Google analytics so I added a new API hook that can be used. \n\nIf you add the following it should work: \n\n Discourse.PageTracker.current().on('change', function() {\n _paq.push(['trackPageView']);\n});","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon","slug":"support-for-piwik-analytics-as-an-alternative-to-google-analytics","topic_id":7512,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":22,"reply_to_post_number":16,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"zogstrip","acting_name":"Régis Hanol","acting_user_id":1995,"title":"Support for Piwik Analytics as an alternative to Google Analytics","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":5,"created_at":"2014-01-13T20:40:21Z","excerpt":"I had to fix an issue with Google analytics so I added a new API hook that can be used. \n\nIf you add the following it should work: \n\n Discourse.PageTracker.current().on('change', function() {\n _paq.push(['trackPageView']);\n});","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","slug":"support-for-piwik-analytics-as-an-alternative-to-google-analytics","topic_id":7512,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":22,"reply_to_post_number":16,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"eviltrout","acting_name":"Robin Ward","acting_user_id":19,"title":"Support for Piwik Analytics as an alternative to Google Analytics","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-13T19:52:04Z","excerpt":"@Sam do you have any idea why only some people are getting this issue? I dont' mind the proposed fix but I'd prefer to know why it happens in the first place.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","slug":"error-after-update-to-0-9-8-1","topic_id":11903,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":10,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"sam","acting_name":"Sam Saffron","acting_user_id":1,"title":"Error after update to 0.9.8.1","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-13T19:01:19Z","excerpt":"I've just added the ability to list reply counts on your blog index and archive pages as you can see here. \n\nIt works with a similar API to embedding comments: \n\n <script type="text/javascript">\n var discourseUrl = "http://fishtank.eviltrout.com/";\n\n (function() {\n var d = document.createEleme…","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","slug":"discourse-plugin-for-static-site-generators-like-jekyll-or-octopress","topic_id":7965,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":98,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"codinghorror","acting_name":"Jeff Atwood","acting_user_id":32,"title":"Discourse plugin for static site generators like Jekyll or Octopress","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-13T18:50:14Z","excerpt":"I've just added the ability to list reply counts on your blog index and archive pages as you can see here. \n\nIt works with a similar API to embedding comments: \n\n <script type="text/javascript">\n var discourseUrl = "http://fishtank.eviltrout.com/";\n\n (function() {\n var d = document.createEleme…","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon","slug":"discourse-plugin-for-static-site-generators-like-jekyll-or-octopress","topic_id":7965,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":98,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"zogstrip","acting_name":"Régis Hanol","acting_user_id":1995,"title":"Discourse plugin for static site generators like Jekyll or Octopress","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":5,"created_at":"2014-01-13T18:47:33Z","excerpt":"I am pretty sure that the denizens of SO are correct and the variable is unneeded. @sam can confirm but it seems like it was once needed for something that has since been removed and the variable declaration was left intact.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","slug":"ruby-question-about-use-of-klass-self-in-the-site-customization-rb","topic_id":11889,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":2,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"eviltrout","acting_name":"Robin Ward","acting_user_id":19,"title":"Ruby question about use of klass=self in the site_customization.rb","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":5,"created_at":"2014-01-13T18:45:41Z","excerpt":"I've just added the ability to list reply counts on your blog index and archive pages as you can see here. \n\nIt works with a similar API to embedding comments: \n\n <script type="text/javascript">\n var discourseUrl = "http://fishtank.eviltrout.com/";\n\n (function() {\n var d = document.createEleme…","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","slug":"discourse-plugin-for-static-site-generators-like-jekyll-or-octopress","topic_id":7965,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":98,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"eviltrout","acting_name":"Robin Ward","acting_user_id":19,"title":"Discourse plugin for static site generators like Jekyll or Octopress","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-13T17:19:08Z","excerpt":"@Sam do you have any idea why only some people are getting this issue? I dont' mind the proposed fix but I'd prefer to know why it happens in the first place.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/5120fc4e345db0d1a964888272073819.png?s={size}&r=pg&d=identicon","slug":"error-after-update-to-0-9-8-1","topic_id":11903,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":10,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"riking","acting_name":"Kane York","acting_user_id":6626,"title":"Error after update to 0.9.8.1","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":7,"created_at":"2014-01-13T16:41:31Z","excerpt":"I'd love to see API support. @sam and @eviltrout, I can facilitate an intro to the piwik guys if you want—I've written about them before and they're typically super-responsive. Because I know you guys are totally hunting for new stuff to do [wink]","avatar_template":"//localhost:3000/uploads/default/avatars/95a/06d/c337428568/{size}.png","acting_avatar_template":"//localhost:3000/uploads/default/avatars/95a/06d/c337428568/{size}.png","slug":"support-for-piwik-analytics-as-an-alternative-to-google-analytics","topic_id":7512,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":20,"reply_to_post_number":null,"username":"Lee_Ars","name":"Lee_Ars","user_id":4457,"acting_username":"Lee_Ars","acting_name":"Lee_Ars","acting_user_id":4457,"title":"Support for Piwik Analytics as an alternative to Google Analytics","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-13T16:15:51Z","excerpt":"The code looks okay but it's hard to debug this way. \n\nOne thing you could do is add a: console.log('tracked!') just before line 8. Then open a developer console and see if the javascript is running properly.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","slug":"support-for-piwik-analytics-as-an-alternative-to-google-analytics","topic_id":7512,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":18,"reply_to_post_number":16,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"codinghorror","acting_name":"Jeff Atwood","acting_user_id":32,"title":"Support for Piwik Analytics as an alternative to Google Analytics","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":5,"created_at":"2014-01-13T15:10:41Z","excerpt":"This is really interesting. I'd like to hear your findings.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","slug":"focus-events-track-which-window-is-the-last-active-instance-of-a-forum-edit","topic_id":11872,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":9,"reply_to_post_number":8,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"eviltrout","acting_name":"Robin Ward","acting_user_id":19,"title":"Focus events: Track which window is the last active instance of a forum Edit","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":5,"created_at":"2014-01-13T15:02:45Z","excerpt":"The code looks okay but it's hard to debug this way. \n\nOne thing you could do is add a: console.log('tracked!') just before line 8. Then open a developer console and see if the javascript is running properly.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","slug":"support-for-piwik-analytics-as-an-alternative-to-google-analytics","topic_id":7512,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":18,"reply_to_post_number":16,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"eviltrout","acting_name":"Robin Ward","acting_user_id":19,"title":"Support for Piwik Analytics as an alternative to Google Analytics","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":5,"created_at":"2014-01-13T14:53:13Z","excerpt":"@Sam do you have any idea why only some people are getting this issue? I dont' mind the proposed fix but I'd prefer to know why it happens in the first place.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","slug":"error-after-update-to-0-9-8-1","topic_id":11903,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":10,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"eviltrout","acting_name":"Robin Ward","acting_user_id":19,"title":"Error after update to 0.9.8.1","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":7,"created_at":"2014-01-13T06:27:26Z","excerpt":"Can this be archived @eviltrout?","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","slug":"search-not-working-for-staff-users","topic_id":11371,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":13,"reply_to_post_number":null,"username":"codinghorror","name":"Jeff Atwood","user_id":32,"acting_username":"codinghorror","acting_name":"Jeff Atwood","acting_user_id":32,"title":"Search not working for Staff users","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":7,"created_at":"2014-01-13T05:32:46Z","excerpt":"When you navigate to another topic using the "suggested topics" area we are not registering a page view with Google. \n\n@eviltrout perhaps we should do this from discourse location instead of application controller?","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","slug":"google-analytics-is-not-registering-page-views","topic_id":11914,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":1,"reply_to_post_number":null,"username":"sam","name":"Sam Saffron","user_id":1,"acting_username":"sam","acting_name":"Sam Saffron","acting_user_id":1,"title":"Google analytics is not registering page views","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":7,"created_at":"2014-01-13T02:50:25Z","excerpt":"@eviltrout any ideas here, the code seems correct","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","slug":"support-for-piwik-analytics-as-an-alternative-to-google-analytics","topic_id":7512,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":17,"reply_to_post_number":16,"username":"sam","name":"Sam Saffron","user_id":1,"acting_username":"sam","acting_name":"Sam Saffron","acting_user_id":1,"title":"Support for Piwik Analytics as an alternative to Google Analytics","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":7,"created_at":"2014-01-12T22:31:35Z","excerpt":"This is an interesting approach an an interesting feature. @eviltrout your thoughts. Essentially allows us to have notifications cross tabs.","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","slug":"focus-events-track-which-window-is-the-last-active-instance-of-a-forum-edit","topic_id":11872,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":4,"reply_to_post_number":1,"username":"sam","name":"Sam Saffron","user_id":1,"acting_username":"sam","acting_name":"Sam Saffron","acting_user_id":1,"title":"Focus events: Track which window is the last active instance of a forum Edit","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":7,"created_at":"2014-01-12T18:01:04Z","excerpt":"This was the link \n\nmetric_fu \n\n[metric_fu](https://github.com/metricfu/metric_fu/blob/b1bf8feb921916fc265f041efa3157a6a6530a9b/lib/metric_fu/logging/mf_debugger.rb#L24)\n\nSeems to work fine now that @eviltrout worked so hard to get us MDTest 1.1 compliant.","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","slug":"underscores-in-linked-text-can-cause-markdown-bug","topic_id":10848,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":3,"reply_to_post_number":null,"username":"codinghorror","name":"Jeff Atwood","user_id":32,"acting_username":"codinghorror","acting_name":"Jeff Atwood","acting_user_id":32,"title":"Underscores in linked text can cause markdown bug","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":6,"created_at":"2014-01-12T04:14:06Z","excerpt":"Awesome plugin, but doesn't seem to work out of the box with images \n\nhttps://github.com/discourse/discourse-spoiler-alert/issues/2","avatar_template":"//localhost:3000/uploads/default/avatars/276/f19/3826efe463/{size}.jpg","acting_avatar_template":"//localhost:3000/uploads/default/avatars/276/f19/3826efe463/{size}.jpg","slug":"brand-new-plugin-interface","topic_id":8793,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":64,"reply_to_post_number":44,"username":"xrvk","name":"Eero Heikkinen","user_id":8068,"acting_username":"xrvk","acting_name":"Eero Heikkinen","acting_user_id":8068,"title":"Brand new plugin interface","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":7,"created_at":"2014-01-11T23:36:11Z","excerpt":"A few things, \n\n@eviltrout myself and many others have discourse_docker hosted on DigitalOcean, my user cpu is usually around 2% I have plenty of capacity. \n\nI know that stonehearth and other larger scale discourse work on DigitalOcean fine. Officially we strongly recommend a 2GB instance, thoug…","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","slug":"performance-issue-on-digital-ocean-with-discourse-docker","topic_id":11895,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":2,"reply_to_post_number":null,"username":"sam","name":"Sam Saffron","user_id":1,"acting_username":"sam","acting_name":"Sam Saffron","acting_user_id":1,"title":"Performance issue on DigitalOcean with discourse_docker","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":7,"created_at":"2014-01-11T00:58:23Z","excerpt":"Confirmed on try.discourse.org, this is still an issue. \n\n@eviltrout can you add that to your list -- unless you are a staff member you should not be able to delete (your own) posts from an archived topic.","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","slug":"archived-discussions-still-allow-posts-to-be-deleted","topic_id":6479,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":3,"reply_to_post_number":null,"username":"codinghorror","name":"Jeff Atwood","user_id":32,"acting_username":"codinghorror","acting_name":"Jeff Atwood","acting_user_id":32,"title":"Archived discussions still allow posts to be deleted","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":7,"created_at":"2014-01-11T00:35:38Z","excerpt":"Agree, @eviltrout can you make sure the usercard is using the same logic as the user page in displaying profile info?","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","slug":"usercard-does-not-resize-for-obnoxiously-large-images","topic_id":11007,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":5,"reply_to_post_number":4,"username":"codinghorror","name":"Jeff Atwood","user_id":32,"acting_username":"codinghorror","acting_name":"Jeff Atwood","acting_user_id":32,"title":"Usercard does not resize for obnoxiously large images","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":7,"created_at":"2014-01-11T00:34:06Z","excerpt":"@eviltrout can you make sure the "import post" button is suppressed on the user page when editing "about me"? \n\n(I agree it is like a "lose all my work" button on that page if you happen to press it..) \n\nThen I can archive this.","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","slug":"quote-post-button-should-be-disabled-or-raise-an-error-when-creating-a-new-topic","topic_id":834,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":5,"reply_to_post_number":4,"username":"codinghorror","name":"Jeff Atwood","user_id":32,"acting_username":"codinghorror","acting_name":"Jeff Atwood","acting_user_id":32,"title":"\"Quote Post\" button should be disabled or raise an error when creating a new topic","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":7,"created_at":"2014-01-10T21:00:11Z","excerpt":">\n\nLooks good now. Thanks for these fixes @eviltrout, we (and markdown-js) are now MDTest 1.1 compliant!","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","slug":"text-editor-issue-with-the-code-block","topic_id":10050,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":5,"reply_to_post_number":null,"username":"codinghorror","name":"Jeff Atwood","user_id":32,"acting_username":"codinghorror","acting_name":"Jeff Atwood","acting_user_id":32,"title":"Text Editor issue with the code block","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":1,"created_at":"2014-01-10T20:07:46Z","excerpt":"We can't repro that one, also seems a bit obscure. But thank you very much for all the reports, whenever I see a bug entry from YOU I always know it is going to be a good one based on experience here and elsewhere. [trophy]","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","slug":"security-error-on-console-noticed-on-meta","topic_id":11825,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":12,"reply_to_post_number":11,"username":"codinghorror","name":"Jeff Atwood","user_id":32,"acting_username":"eviltrout","acting_name":"Robin Ward","acting_user_id":19,"title":"Security Error on console (noticed on meta)","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-10T19:48:08Z","excerpt":"Thanks for letting us know. It turns out that by using minutely(5) instead of minutely causes ice_cube to peg a core at 100% usage. I've pushed out a fix in master.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","slug":"sidekiq-cpu-load-since-latest-release","topic_id":9515,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":22,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"codinghorror","acting_name":"Jeff Atwood","acting_user_id":32,"title":"Sidekiq CPU load since latest release","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-10T19:47:17Z","excerpt":"Thanks for letting us know. It turns out that by using minutely(5) instead of minutely causes ice_cube to peg a core at 100% usage. I've pushed out a fix in master.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/42776c4982dff1fa45ee8248532f8ad0.png?s={size}&r=pg&d=identicon","slug":"sidekiq-cpu-load-since-latest-release","topic_id":9515,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":22,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"neil","acting_name":"Neil","acting_user_id":2,"title":"Sidekiq CPU load since latest release","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-10T17:39:24Z","excerpt":"We should consider doing what Google Drive does: they intercept cmd-f and pop up a box that allows you to dynamically search.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/5120fc4e345db0d1a964888272073819.png?s={size}&r=pg&d=identicon","slug":"ctrl-f-search-is-interrupted-by-quotation-popup","topic_id":7114,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":12,"reply_to_post_number":11,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"riking","acting_name":"Kane York","acting_user_id":6626,"title":"Ctrl+F search is interrupted by quotation popup","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-10T17:29:15Z","excerpt":"Thanks for letting us know. It turns out that by using minutely(5) instead of minutely causes ice_cube to peg a core at 100% usage. I've pushed out a fix in master.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/5120fc4e345db0d1a964888272073819.png?s={size}&r=pg&d=identicon","slug":"sidekiq-cpu-load-since-latest-release","topic_id":9515,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":22,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"riking","acting_name":"Kane York","acting_user_id":6626,"title":"Sidekiq CPU load since latest release","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-10T17:24:37Z","excerpt":"Thanks for letting us know. It turns out that by using minutely(5) instead of minutely causes ice_cube to peg a core at 100% usage. I've pushed out a fix in master.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon","slug":"sidekiq-cpu-load-since-latest-release","topic_id":9515,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":22,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"zogstrip","acting_name":"Régis Hanol","acting_user_id":1995,"title":"Sidekiq CPU load since latest release","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":6,"created_at":"2014-01-10T17:02:35Z","excerpt":"Fixed [smile] \n\ntop - 12:02:00 up 12 days, 2:16, 1 user, load average: 0.28, 0.92, 0.97\nTasks: 115 total, 1 running, 114 sleeping, 0 stopped, 0 zombie\nCpu0 : 0.7%us, 0.3%sy, 0.0%ni, 99.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st\nCpu1 : 0.7%us, 0.3%sy, 0.0%ni, 99.0%id, 0.0%wa, 0.0%hi,…","avatar_template":"//localhost:3000/uploads/default/avatars/886/ea8/e533d87fd9/{size}.png","acting_avatar_template":"//localhost:3000/uploads/default/avatars/886/ea8/e533d87fd9/{size}.png","slug":"sidekiq-cpu-load-since-latest-release","topic_id":9515,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":23,"reply_to_post_number":22,"username":"michaeld","name":"Michael","user_id":6548,"acting_username":"michaeld","acting_name":"Michael","acting_user_id":6548,"title":"Sidekiq CPU load since latest release","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null},{"action_type":2,"created_at":"2014-01-10T16:58:12Z","excerpt":"Thanks for letting us know. It turns out that by using minutely(5) instead of minutely causes ice_cube to peg a core at 100% usage. I've pushed out a fix in master.","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","acting_avatar_template":"//localhost:3000/uploads/default/avatars/527/614/d16e1504d9/{size}.jpg","slug":"sidekiq-cpu-load-since-latest-release","topic_id":9515,"target_user_id":19,"target_name":"Robin Ward","target_username":"eviltrout","post_number":22,"reply_to_post_number":null,"username":"eviltrout","name":"Robin Ward","user_id":19,"acting_username":"trident","acting_name":"Ben T","acting_user_id":5707,"title":"Sidekiq CPU load since latest release","deleted":false,"hidden":false,"moderator_action":false,"edit_reason":null}]}, "/topics/created-by/eviltrout.json": {"users":[{"id":19,"username":"eviltrout","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon"},{"id":5460,"username":"ned","avatar_template":"//localhost:3000/uploads/default/avatars/06b/90d/3b3ea7e56b/{size}.png"},{"id":402,"username":"thebrianbarlow","avatar_template":"//www.gravatar.com/avatar/5ddf2459e8edd6cf52dfff6cb41ca70d.png?s={size}&r=pg&d=identicon"},{"id":5707,"username":"trident","avatar_template":"//localhost:3000/uploads/default/avatars/527/614/d16e1504d9/{size}.jpg"},{"id":32,"username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon"},{"id":1995,"username":"zogstrip","avatar_template":"//www.gravatar.com/avatar/b7797beb47cfb7aa0fe60d09604aaa09.png?s={size}&r=pg&d=identicon"},{"id":2702,"username":"ryanflorence","avatar_template":"//www.gravatar.com/avatar/749001c9fe6927c4b069a45c2a3d68f7.png?s={size}&r=pg&d=identicon"},{"id":9,"username":"tms","avatar_template":"//www.gravatar.com/avatar/3981cd271c302f5cba628c6b6d2b32ee.png?s={size}&r=pg&d=identicon"},{"id":1,"username":"sam","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon"},{"id":2636,"username":"lonnon","avatar_template":"//www.gravatar.com/avatar/9489ef302fbff6c19bba507d09f8cd1d.png?s={size}&r=pg&d=identicon"}],"topic_list":{"can_create_topic":false,"draft":null,"draft_key":"new_topic","draft_sequence":null,"topics":[{"id":7764,"title":"New: Reply via Email Support!","fancy_title":"New: Reply via Email Support!","slug":"new-reply-via-email-support","posts_count":32,"reply_count":24,"highest_post_number":35,"image_url":"/uploads/meta_discourse/1227/8f4e5818dfaa56c7.png","created_at":"2013-06-25T11:58:39.000-04:00","last_posted_at":"2014-01-09T18:53:06.000-05:00","bumped":true,"bumped_at":"2014-01-09T17:09:40.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":2201,"like_count":46,"has_summary":false,"archetype":"regular","last_poster_username":"codinghorror","category_id":2,"posters":[{"extras":null,"description":"Original Poster","user_id":19},{"extras":null,"description":"Most Posts","user_id":5460},{"extras":null,"description":"Frequent Poster","user_id":402},{"extras":null,"description":"Frequent Poster","user_id":5707},{"extras":"latest","description":"Most Recent Poster","user_id":32}]},{"id":9318,"title":"Discourse has a new Markdown Parser!","fancy_title":"Discourse has a new Markdown Parser!","slug":"discourse-has-a-new-markdown-parser","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2013-08-24T14:08:06.000-04:00","last_posted_at":"2013-08-24T14:08:06.000-04:00","bumped":true,"bumped_at":"2013-08-24T14:13:25.000-04:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":812,"like_count":13,"has_summary":false,"archetype":"regular","last_poster_username":"eviltrout","category_id":7,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":19}]},{"id":7019,"title":"Discourse Ember Refactorings","fancy_title":"Discourse Ember Refactorings","slug":"discourse-ember-refactorings","posts_count":5,"reply_count":3,"highest_post_number":5,"image_url":null,"created_at":"2013-05-30T11:16:36.000-04:00","last_posted_at":"2013-06-02T11:22:58.000-04:00","bumped":true,"bumped_at":"2013-06-02T11:22:58.000-04:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":1075,"like_count":15,"has_summary":false,"archetype":"regular","last_poster_username":"eviltrout","category_id":7,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":19},{"extras":null,"description":"Most Posts","user_id":1995},{"extras":null,"description":"Frequent Poster","user_id":2702}]},{"id":4650,"title":"Migrating off Active Record Observers","fancy_title":"Migrating off Active Record Observers","slug":"migrating-off-active-record-observers","posts_count":8,"reply_count":7,"highest_post_number":8,"image_url":null,"created_at":"2013-03-11T11:26:13.000-04:00","last_posted_at":"2013-05-14T18:40:16.000-04:00","bumped":true,"bumped_at":"2013-05-14T18:40:16.000-04:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":377,"like_count":3,"has_summary":false,"archetype":"regular","last_poster_username":"sam","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":19},{"extras":null,"description":"Most Posts","user_id":9},{"extras":null,"description":"Frequent Poster","user_id":1995},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":1}]},{"id":4960,"title":"Vagrant Updates!","fancy_title":"Vagrant Updates!","slug":"vagrant-updates","posts_count":5,"reply_count":3,"highest_post_number":5,"image_url":"/plugins/emoji/images/fish.png","created_at":"2013-03-20T22:29:22.000-04:00","last_posted_at":"2013-03-21T19:06:40.000-04:00","bumped":true,"bumped_at":"2013-03-21T19:06:40.000-04:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":500,"like_count":4,"has_summary":false,"archetype":"regular","last_poster_username":"zogstrip","category_id":7,"posters":[{"extras":null,"description":"Original Poster","user_id":19},{"extras":null,"description":"Most Posts","user_id":1},{"extras":null,"description":"Frequent Poster","user_id":32},{"extras":"latest","description":"Most Recent Poster","user_id":1995}]},{"id":2918,"title":"New: Updated Docs","fancy_title":"New: Updated Docs","slug":"new-updated-docs","posts_count":3,"reply_count":2,"highest_post_number":3,"image_url":null,"created_at":"2013-02-12T12:13:02.000-05:00","last_posted_at":"2013-02-15T17:57:19.000-05:00","bumped":true,"bumped_at":"2013-02-15T17:57:19.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"views":457,"like_count":10,"has_summary":false,"archetype":"regular","last_poster_username":"eviltrout","category_id":10,"posters":[{"extras":"latest","description":"Original Poster, Most Recent Poster","user_id":19},{"extras":null,"description":"Most Posts","user_id":2636}]}]}} }; diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6 index 809aec596b..85d171a37f 100644 --- a/test/javascripts/helpers/create-pretender.js.es6 +++ b/test/javascripts/helpers/create-pretender.js.es6 @@ -1,7 +1,7 @@ import storePretender from 'helpers/store-pretender'; import fixturePretender from 'helpers/fixture-pretender'; -function parsePostData(query) { +export function parsePostData(query) { const result = {}; query.split("&").forEach(function(part) { const item = part.split("="); @@ -18,7 +18,7 @@ function parsePostData(query) { }); return result; -} +}; function response(code, obj) { if (typeof code === "object") { @@ -63,17 +63,17 @@ export default function() { }] }); }); - this.get(`/users/eviltrout/emails.json`, () => { + this.get(`/u/eviltrout/emails.json`, () => { return response({ email: 'eviltrout@example.com' }); }); - this.get('/users/eviltrout.json', () => { - const json = fixturesByUrl['/users/eviltrout.json']; + this.get('/u/eviltrout.json', () => { + const json = fixturesByUrl['/u/eviltrout.json']; json.user.can_edit = loggedIn(); return response(json); }); - this.get('/users/eviltrout/summary.json', () => { + this.get('/u/eviltrout/summary.json', () => { return response({ user_summary: { topics: [], @@ -85,13 +85,13 @@ export default function() { }); }); - this.get('/users/eviltrout/invited_count.json', () => { + this.get('/u/eviltrout/invited_count.json', () => { return response({ "counts": { "pending": 1, "redeemed": 0, "total": 0 } }); }); - this.get('/users/eviltrout/invited.json', () => { + this.get('/u/eviltrout/invited.json', () => { return response({ "invites": [ {id: 1} ] }); }); @@ -113,11 +113,12 @@ export default function() { return response({}); }); - this.put('/users/eviltrout.json', () => response({ user: {} })); + this.put('/u/eviltrout.json', () => response({ user: {} })); this.get("/t/280.json", () => response(fixturesByUrl['/t/280/1.json'])); this.get("/t/28830.json", () => response(fixturesByUrl['/t/28830/1.json'])); this.get("/t/9.json", () => response(fixturesByUrl['/t/9/1.json'])); + this.get("/t/12.json", () => response(fixturesByUrl['/t/12/1.json'])); this.get("/t/id_for/:slug", () => { return response({id: 280, slug: "internationalization-localization", url: "/t/internationalization-localization/280"}); @@ -133,7 +134,7 @@ export default function() { this.delete('/draft.json', success); this.post('/draft.json', success); - this.get('/users/:username/staff-info.json', () => response({})); + this.get('/u/:username/staff-info.json', () => response({})); this.get('/post_action_users', () => { return response({ @@ -194,12 +195,20 @@ export default function() { current_email: 'current@example.com' }); } + if (data.password === 'not-activated-edit') { + return response({ error: "not active", + reason: "not_activated", + sent_to_email: 'eviltrout@example.com', + current_email: 'current@example.com' }); + } + return response(400, {error: 'invalid login'}); }); - this.post('/users/action/send_activation_email', success); + this.post('/u/action/send_activation_email', success); + this.put('/u/update-activation-email', success); - this.get('/users/hp.json', function() { + this.get('/u/hp.json', function() { return response({"value":"32faff1b1ef1ac3","challenge":"61a3de0ccf086fb9604b76e884d75801"}); }); @@ -207,14 +216,14 @@ export default function() { return response({"csrf":"mgk906YLagHo2gOgM1ddYjAN4hQolBdJCqlY6jYzAYs="}); }); - this.get('/users/check_username', function(request) { + this.get('/u/check_username', function(request) { if (request.queryParams.username === 'taken') { return response({available: false, suggestion: 'nottaken'}); } return response({available: true}); }); - this.post('/users', () => response({success: true})); + this.post('/u', () => response({success: true})); this.get('/login.html', () => [200, {}, 'LOGIN PAGE']); @@ -326,7 +335,8 @@ export default function() { this.delete('/admin/badges/:id', success); this.get('/onebox', request => { - if (request.queryParams.url === 'http://www.example.com/has-title.html') { + if (request.queryParams.url === 'http://www.example.com/has-title.html' || + request.queryParams.url === 'http://www.example.com/has-title-and-a-url-that-is-more-than-80-characters-because-thats-good-for-seo-i-guess.html') { return [ 200, {"Content-Type": "application/html"}, @@ -342,6 +352,14 @@ export default function() { ]; } + if (request.queryParams.url.indexOf('/internal-page.html') > -1) { + return [ + 200, + {"Content-Type": "application/html"}, + '' + ]; + } + return [404, {"Content-Type": "application/html"}, ''];; }); }); diff --git a/test/javascripts/helpers/qunit-helpers.js.es6 b/test/javascripts/helpers/qunit-helpers.js.es6 index 1fdc796b69..32747f9c5a 100644 --- a/test/javascripts/helpers/qunit-helpers.js.es6 +++ b/test/javascripts/helpers/qunit-helpers.js.es6 @@ -8,6 +8,7 @@ import { resetPluginApi } from 'discourse/lib/plugin-api'; import { clearCache as clearOutletCache, resetExtraClasses } from 'discourse/lib/plugin-connectors'; import { clearHTMLCache } from 'discourse/helpers/custom-html'; import { flushMap } from 'discourse/models/store'; +import { clearRewrites } from 'discourse/lib/url'; function currentUser() { @@ -88,6 +89,7 @@ function acceptance(name, options) { clearOutletCache(); clearHTMLCache(); resetPluginApi(); + clearRewrites(); Discourse.reset(); } }); diff --git a/test/javascripts/helpers/site-settings.js b/test/javascripts/helpers/site-settings.js index a0db9b5e09..e44e4434e1 100644 --- a/test/javascripts/helpers/site-settings.js +++ b/test/javascripts/helpers/site-settings.js @@ -19,9 +19,7 @@ Discourse.SiteSettingsOriginal = { "category_colors":"BF1E2E|F1592A|F7941D|9EB83B|3AB54A|12A89D|25AAE2|0E76BD|652D90|92278F|ED207B|8C6238|231F20|808281|B3B5B4|283890", "enable_mobile_theme":true, "relative_date_duration":14, - "category_featured_topics":4, "fixed_category_positions":false, - "show_subcategory_list":false, "enable_badges":true, "invite_only":false, "login_required":false, diff --git a/test/javascripts/initializers/localization-test.js.es6 b/test/javascripts/initializers/localization-test.js.es6 new file mode 100644 index 0000000000..4f6e0670e5 --- /dev/null +++ b/test/javascripts/initializers/localization-test.js.es6 @@ -0,0 +1,43 @@ +import PreloadStore from 'preload-store'; +import LocalizationInitializer from 'discourse/initializers/localization'; + +module("initializer:localization", { + _locale: I18n.locale, + _translations: I18n.translations, + + setup() { + I18n.locale = "fr"; + + I18n.translations = { + "fr": { + "js": { + "composer": { + "reply": "Répondre" + } + } + }, + "en": { + "js": { + "topic": { + "reply": { + "help": "begin composing a reply to this topic" + } + } + } + } + }; + }, + + teardown() { + I18n.locale = this._locale; + I18n.translations = this._translations; + } +}); + +test("translation overrides", function() { + PreloadStore.store('translationOverrides', {"js.composer.reply":"WAT","js.topic.reply.help":"foobar"}); + LocalizationInitializer.initialize(this.registry); + + equal(I18n.t("composer.reply"), "WAT", "overrides existing translation in current locale"); + equal(I18n.t("topic.reply.help"), "foobar", "overrides translation in default locale"); +}); diff --git a/test/javascripts/lib/click-track-test.js.es6 b/test/javascripts/lib/click-track-test.js.es6 index bebe653d6f..cb05ba7e56 100644 --- a/test/javascripts/lib/click-track-test.js.es6 +++ b/test/javascripts/lib/click-track-test.js.es6 @@ -8,25 +8,26 @@ var windowOpen, module("lib:click-track", { setup: function() { - // Prevent any of these tests from navigating away win = {focus: function() { } }; redirectTo = sandbox.stub(DiscourseURL, "redirectTo"); windowOpen = sandbox.stub(window, "open").returns(win); sandbox.stub(win, "focus"); + sessionStorage.clear(); + fixture().html( `
            google.com - google.com - google.com1 - google.com1 + google.fr + google.de1 + google.es1 - google.com + google.com.br forum log.txt #hashtag diff --git a/test/javascripts/lib/computed-test.js.es6 b/test/javascripts/lib/computed-test.js.es6 index a708ac21e1..658f35e675 100644 --- a/test/javascripts/lib/computed-test.js.es6 +++ b/test/javascripts/lib/computed-test.js.es6 @@ -92,13 +92,13 @@ test("url", function() { var t, testClass; testClass = Em.Object.extend({ - userUrl: url('username', "/users/%@") + userUrl: url('username', "/u/%@") }); t = testClass.create({ username: 'eviltrout' }); - equal(t.get('userUrl'), "/users/eviltrout", "it supports urls without a prefix"); + equal(t.get('userUrl'), "/u/eviltrout", "it supports urls without a prefix"); Discourse.BaseUri = "/prefixed"; t = testClass.create({ username: 'eviltrout' }); - equal(t.get('userUrl'), "/prefixed/users/eviltrout", "it supports urls with a prefix"); + equal(t.get('userUrl'), "/prefixed/u/eviltrout", "it supports urls with a prefix"); }); diff --git a/test/javascripts/lib/discourse-test.js.es6 b/test/javascripts/lib/discourse-test.js.es6 index 8f3725fa44..a654774123 100644 --- a/test/javascripts/lib/discourse-test.js.es6 +++ b/test/javascripts/lib/discourse-test.js.es6 @@ -3,5 +3,5 @@ module("lib:discourse"); test("getURL on subfolder install", function() { Discourse.BaseUri = "/forum"; equal(Discourse.getURL("/"), "/forum/", "root url has subfolder"); - equal(Discourse.getURL("/users/neil"), "/forum/users/neil", "relative url has subfolder"); -}); \ No newline at end of file + equal(Discourse.getURL("/u/neil"), "/forum/u/neil", "relative url has subfolder"); +}); diff --git a/test/javascripts/lib/i18n-test.js.es6 b/test/javascripts/lib/i18n-test.js.es6 new file mode 100644 index 0000000000..9fbfa7beac --- /dev/null +++ b/test/javascripts/lib/i18n-test.js.es6 @@ -0,0 +1,95 @@ +module("lib:i18n", { + _locale: I18n.locale, + _translations: I18n.translations, + + setup() { + I18n.locale = "fr"; + + I18n.translations = { + "fr": { + "js": { + "hello": "Bonjour", + "topic": { + "reply": { + "title": "Répondre", + } + }, + "character_count": { + "zero": "{{count}} ZERO", + "one": "{{count}} ONE", + "two": "{{count}} TWO", + "few": "{{count}} FEW", + "many": "{{count}} MANY", + "other": "{{count}} OTHER" + } + } + }, + "en": { + "js": { + "hello": { + "world": "Hello World!", + "universe": "" + }, + "topic": { + "reply": { + "help": "begin composing a reply to this topic" + } + }, + "word_count": { + "one": "1 word", + "other": "{{count}} words" + } + } + } + }; + + // fake pluralization rules + I18n.pluralizationRules.fr = function(n) { + if (n === 0) return "zero"; + if (n === 1) return "one"; + if (n === 2) return "two"; + if (n >= 3 && n <= 9) return "few"; + if (n >= 10 && n <= 99) return "many"; + return "other"; + }; + }, + + teardown() { + I18n.locale = this._locale; + I18n.translations = this._translations; + } +}); + +test("defaults", function() { + equal(I18n.defaultLocale, "en", "it has English as default locale"); + ok(I18n.pluralizationRules["en"], "it has English pluralizer"); +}); + +test("translations", function() { + equal(I18n.t("topic.reply.title"), "Répondre", "uses locale translations when they exist"); + equal(I18n.t("topic.reply.help"), "begin composing a reply to this topic", "fallbacks to English translations"); + equal(I18n.t("hello.world"), "Hello World!", "doesn't break if a key is overriden in a locale"); + equal(I18n.t("hello.universe"), "", "allows empty strings"); +}); + +test("extra translations", function() { + I18n.extras = [{ "admin": { "title": "Discourse Admin" }}]; + + equal(I18n.t("admin.title"), "Discourse Admin", "it check extra translations when they exists"); +}); + +test("pluralizations", function() { + equal(I18n.t("character_count", { count: 0 }), "0 ZERO"); + equal(I18n.t("character_count", { count: 1 }), "1 ONE"); + equal(I18n.t("character_count", { count: 2 }), "2 TWO"); + equal(I18n.t("character_count", { count: 3 }), "3 FEW"); + equal(I18n.t("character_count", { count: 10 }), "10 MANY"); + equal(I18n.t("character_count", { count: 100 }), "100 OTHER"); + + equal(I18n.t("word_count", { count: 0 }), "0 words"); + equal(I18n.t("word_count", { count: 1 }), "1 word"); + equal(I18n.t("word_count", { count: 2 }), "2 words"); + equal(I18n.t("word_count", { count: 3 }), "3 words"); + equal(I18n.t("word_count", { count: 10 }), "10 words"); + equal(I18n.t("word_count", { count: 100 }), "100 words"); +}); diff --git a/test/javascripts/lib/pretty-text-test.js.es6 b/test/javascripts/lib/pretty-text-test.js.es6 index 790967119a..72c1adf30b 100644 --- a/test/javascripts/lib/pretty-text-test.js.es6 +++ b/test/javascripts/lib/pretty-text-test.js.es6 @@ -11,7 +11,7 @@ const defaultOpts = buildOptions({ emoji_set: 'emoji_one', highlighted_languages: 'json|ruby|javascript', default_code_lang: 'auto', - censored_words: 'shucks|whiz|whizzer', + censored_words: 'shucks|whiz|whizzer|a**le', censored_pattern: '\\d{3}-\\d{4}|tech\\w*' }, getURL: url => url @@ -130,8 +130,8 @@ test("Links", function() { "

            Here's a tweet:
            https://twitter.com/evil_trout/status/345954894420787200

            ", "It doesn't strip the new line."); - cooked("1. View @eviltrout's profile here: http://meta.discourse.org/users/eviltrout/activity
            next line.", - "
            1. View @eviltrout's profile here: http://meta.discourse.org/users/eviltrout/activity
              next line.
            ", + cooked("1. View @eviltrout's profile here: http://meta.discourse.org/u/eviltrout/activity
            next line.", + "
            1. View @eviltrout's profile here: http://meta.discourse.org/u/eviltrout/activity
              next line.
            ", "allows autolinking within a list without inserting a paragraph."); cooked("[3]: http://eviltrout.com", "", "It doesn't autolink markdown link references"); @@ -220,6 +220,11 @@ test("Quotes", function() { "

            1

            \n\n\n\n

            2

            ", "includes no avatar if none is found"); + + cooked(`[quote]\na\n\n[quote]\nb\n[/quote]\n[/quote]`, + "

            ", + "handles nested quotes properly"); + }); test("Mentions", function() { @@ -227,7 +232,7 @@ test("Mentions", function() { const alwaysTrue = { mentionLookup: (function() { return "user"; }) }; cookedOptions("Hello @sam", alwaysTrue, - "

            Hello @sam

            ", + "

            Hello @sam

            ", "translates mentions to links"); cooked("[@codinghorror](https://twitter.com/codinghorror)", @@ -303,11 +308,11 @@ test("Mentions", function() { "handles mentions separated by a slash."); cookedOptions("@eviltrout", alwaysTrue, - "

            @eviltrout

            ", + "

            @eviltrout

            ", "it doesn't onebox mentions"); cookedOptions("a @sam c", alwaysTrue, - "

            a @sam c

            ", + "

            a @sam c

            ", "it allows mentions within HTML tags"); }); @@ -524,18 +529,26 @@ test("censoring", function() { cooked("aw shucks, golly gee whiz.", "

            aw ■■■■■■, golly gee ■■■■.

            ", "it censors words in the Site Settings"); + cooked("you are a whizzard! I love cheesewhiz. Whiz.", "

            you are a whizzard! I love cheesewhiz. ■■■■.

            ", "it doesn't censor words unless they have boundaries."); + cooked("you are a whizzer! I love cheesewhiz. Whiz.", "

            you are a ■■■■■■■! I love cheesewhiz. ■■■■.

            ", "it censors words even if previous partial matches exist."); + cooked("The link still works. [whiz](http://www.whiz.com)", "

            The link still works. ■■■■

            ", "it won't break links by censoring them."); + cooked("Call techapj the computer whiz at 555-555-1234 for free help.", "

            Call ■■■■■■■ the computer ■■■■ at 555-■■■■■■■■ for free help.

            ", "uses both censored words and patterns from site settings"); + + cooked("I have a pen, I have an a**le", + "

            I have a pen, I have an ■■■■■

            ", + "it escapes regexp chars"); }); test("code blocks/spans hoisting", function() { diff --git a/test/javascripts/lib/url-test.js.es6 b/test/javascripts/lib/url-test.js.es6 index 7c420b58cc..dd938b5d0c 100644 --- a/test/javascripts/lib/url-test.js.es6 +++ b/test/javascripts/lib/url-test.js.es6 @@ -1,4 +1,4 @@ -import DiscourseURL from 'discourse/lib/url'; +import { default as DiscourseURL, userPath } from 'discourse/lib/url'; module("lib:url"); @@ -25,3 +25,16 @@ test("isInternal on subfolder install", function() { not(DiscourseURL.isInternal("http://eviltrout.com/tophat"), "a url on the same host but on a different folder is not internal"); ok(DiscourseURL.isInternal("http://eviltrout.com/forum/moustache"), "a url on the same host and on the same folder is internal"); }); + +test("userPath", assert => { + assert.equal(userPath(), '/u'); + assert.equal(userPath('eviltrout'), '/u/eviltrout'); + assert.equal(userPath('hp.json'), '/u/hp.json'); +}); + +test("userPath with BaseUri", assert => { + Discourse.BaseUri = "/forum"; + assert.equal(userPath(), '/forum/u'); + assert.equal(userPath('eviltrout'), '/forum/u/eviltrout'); + assert.equal(userPath('hp.json'), '/forum/u/hp.json'); +}); diff --git a/test/javascripts/lib/user-search-test.js.es6 b/test/javascripts/lib/user-search-test.js.es6 index 2b72910bff..fba45e9ff7 100644 --- a/test/javascripts/lib/user-search-test.js.es6 +++ b/test/javascripts/lib/user-search-test.js.es6 @@ -10,7 +10,7 @@ module("lib:user-search", { ]; }; - server.get('/users/search/users', () => { //eslint-disable-line + server.get('/u/search/users', () => { //eslint-disable-line return response( { users: [ @@ -58,7 +58,7 @@ module("lib:user-search", { }); test("it places groups unconditionally for exact match", function() { - return userSearch({term: 'team'}).then((results)=>{ + return userSearch({term: 'Team'}).then((results)=>{ equal(results[results.length-1]["name"], "team"); }); }); diff --git a/test/javascripts/models/composer-test.js.es6 b/test/javascripts/models/composer-test.js.es6 index d7bcf53af7..039d3a18d5 100644 --- a/test/javascripts/models/composer-test.js.es6 +++ b/test/javascripts/models/composer-test.js.es6 @@ -160,6 +160,14 @@ test("Title length for private messages", function() { ok(composer.get('titleLengthValid'), "in the range is okay"); }); +test("Post length for private messages with non human users", function() { + const composer = createComposer({ + topic: Ember.Object.create({ pm_with_non_human_user: true }) + }); + + equal(composer.get('minimumPostLength'), 1); +}); + test('editingFirstPost', function() { const composer = createComposer(); ok(!composer.get('editingFirstPost'), "it's false by default"); diff --git a/test/javascripts/models/group-test.js.es6 b/test/javascripts/models/group-test.js.es6 new file mode 100644 index 0000000000..60f04ed1bb --- /dev/null +++ b/test/javascripts/models/group-test.js.es6 @@ -0,0 +1,13 @@ +import Group from 'discourse/models/group'; + +module("model:group"); + +test('displayName', function() { + const group = Group.create({ name: "test", display_name: 'donkey' }); + + ok(group.get('displayName'), "donkey", 'it should return the display name'); + + group.set('display_name', null); + + ok(group.get('displayName'), "test", "it should return the group's name"); +}); diff --git a/test/javascripts/models/user-test.js.es6 b/test/javascripts/models/user-test.js.es6 index 0414ef44e3..ea845d582f 100644 --- a/test/javascripts/models/user-test.js.es6 +++ b/test/javascripts/models/user-test.js.es6 @@ -1,7 +1,10 @@ -module("Discourse.User"); +import User from 'discourse/models/user'; +import Group from 'discourse/models/group'; + +module("model:user"); test('staff', function(){ - var user = Discourse.User.create({id: 1, username: 'eviltrout'}); + var user = User.create({id: 1, username: 'eviltrout'}); ok(!user.get('staff'), "user is not staff"); @@ -13,15 +16,31 @@ test('staff', function(){ }); test('searchContext', function() { - var user = Discourse.User.create({id: 1, username: 'EvilTrout'}); + var user = User.create({id: 1, username: 'EvilTrout'}); deepEqual(user.get('searchContext'), {type: 'user', id: 'eviltrout', user: user}, "has a search context"); }); test("isAllowedToUploadAFile", function() { - var user = Discourse.User.create({ trust_level: 0, admin: true }); + var user = User.create({ trust_level: 0, admin: true }); ok(user.isAllowedToUploadAFile("image"), "admin can always upload a file"); user.setProperties({ admin: false, moderator: true }); ok(user.isAllowedToUploadAFile("image"), "moderator can always upload a file"); }); + +test('canMangeGroup', function() { + let user = User.create({ admin: true }); + let group = Group.create({ automatic: true }); + + equal(user.canManageGroup(group), false, "automatic groups cannot be managed."); + + group.set("automatic", false); + + equal(user.canManageGroup(group), true, "an admin should be able to manage the group"); + + user.set('admin', false); + group.setProperties({ is_group_owner: true }); + + equal(user.canManageGroup(group), true, "a group owner should be able to manage the group"); +}); diff --git a/test/javascripts/test_helper.js b/test/javascripts/test_helper.js index f82d4b7065..12b73912ba 100644 --- a/test/javascripts/test_helper.js +++ b/test/javascripts/test_helper.js @@ -39,9 +39,8 @@ //= require plugin_tests //= require_self // -//= require jquery.magnific-popup-min.js +//= require jquery.magnific-popup.min.js -window.TestPreloadStore = require('preload-store').default; window.inTestEnv = true; // Stop the message bus so we don't get ajax calls @@ -49,6 +48,7 @@ window.MessageBus.stop(); // Trick JSHint into allow document.write var d = document; +d.write(''); d.write('
            '); d.write(''); diff --git a/test/javascripts/widgets/hamburger-menu-test.js.es6 b/test/javascripts/widgets/hamburger-menu-test.js.es6 index a6ed500761..9c6454e47c 100644 --- a/test/javascripts/widgets/hamburger-menu-test.js.es6 +++ b/test/javascripts/widgets/hamburger-menu-test.js.es6 @@ -114,6 +114,7 @@ widgetTest('general links', { anonymous: true, test(assert) { + assert.ok(this.$("li[class='']").length === 0); assert.ok(this.$('.latest-topics-link').length); assert.ok(!this.$('.new-topics-link').length); assert.ok(!this.$('.unread-topics-link').length); diff --git a/test/javascripts/widgets/header-test.js.es6 b/test/javascripts/widgets/header-test.js.es6 index f3a7b8f907..71676a0338 100644 --- a/test/javascripts/widgets/header-test.js.es6 +++ b/test/javascripts/widgets/header-test.js.es6 @@ -35,3 +35,41 @@ widgetTest('sign up / login buttons', { }); } }); + +widgetTest('anon when login required', { + template: '{{mount-widget widget="header" showCreateAccount="showCreateAccount" showLogin="showLogin" args=args}}', + anonymous: true, + + setup() { + this.set('args', { canSignUp: true }); + this.on('showCreateAccount', () => this.signupShown = true); + this.on('showLogin', () => this.loginShown = true); + this.siteSettings.login_required = true; + }, + + test(assert) { + assert.ok(exists('button.login-button')); + assert.ok(exists('button.sign-up-button')); + assert.ok(!exists('#search-button')); + assert.ok(!exists('#toggle-hamburger-menu')); + } +}); + +widgetTest('logged in when login required', { + template: '{{mount-widget widget="header" showCreateAccount="showCreateAccount" showLogin="showLogin" args=args}}', + + setup() { + this.set('args', { canSignUp: true }); + this.on('showCreateAccount', () => this.signupShown = true); + this.on('showLogin', () => this.loginShown = true); + this.siteSettings.login_required = true; + }, + + test(assert) { + assert.ok(!exists('button.login-button')); + assert.ok(!exists('button.sign-up-button')); + assert.ok(exists('#search-button')); + assert.ok(exists('#toggle-hamburger-menu')); + assert.ok(exists('#current-user')); + } +}); diff --git a/test/javascripts/widgets/home-logo-test.js.es6 b/test/javascripts/widgets/home-logo-test.js.es6 index e2acb50382..81db555c20 100644 --- a/test/javascripts/widgets/home-logo-test.js.es6 +++ b/test/javascripts/widgets/home-logo-test.js.es6 @@ -11,7 +11,7 @@ widgetTest('basics', { template: '{{mount-widget widget="home-logo" args=args}}', setup() { this.siteSettings.logo_url = bigLogo; - this.siteSettings.logo_small_url= smallLogo; + this.siteSettings.logo_small_url = smallLogo; this.siteSettings.title = title; this.set('args', { minimized: false }); }, @@ -25,7 +25,7 @@ widgetTest('basics', { } }); -widgetTest('basics - minmized', { +widgetTest('basics - minimized', { template: '{{mount-widget widget="home-logo" args=args}}', setup() { this.siteSettings.logo_url = bigLogo; diff --git a/test/javascripts/widgets/post-test.js.es6 b/test/javascripts/widgets/post-test.js.es6 index 67886da768..80b97b548a 100644 --- a/test/javascripts/widgets/post-test.js.es6 +++ b/test/javascripts/widgets/post-test.js.es6 @@ -17,15 +17,29 @@ widgetTest('basic elements', { }); widgetTest('wiki', { + template: '{{mount-widget widget="post" args=args showHistory="showHistory"}}', + setup() { + this.set('args', { wiki: true, version: 2, canViewEditHistory: true }); + this.on('showHistory', () => this.historyShown = true); + }, + test(assert) { + click('.post-info .wiki'); + andThen(() => { + assert.ok(this.historyShown, 'clicking the wiki icon displays the post history'); + }); + } +}); + +widgetTest('wiki without revision', { template: '{{mount-widget widget="post" args=args editPost="editPost"}}', setup() { - this.set('args', { wiki: true }); + this.set('args', { wiki: true, version: 1, canViewEditHistory: true }); this.on('editPost', () => this.editPostCalled = true); }, test(assert) { - click('.post-info.wiki'); + click('.post-info .wiki'); andThen(() => { - assert.ok(this.editPostCalled, 'clicking the wiki icon edits the post'); + assert.ok(this.editPostCalled, 'clicking wiki icon edits the post'); }); } }); diff --git a/test/javascripts/widgets/poster-name-test.js.es6 b/test/javascripts/widgets/poster-name-test.js.es6 index fee817c582..fe9add7afd 100644 --- a/test/javascripts/widgets/poster-name-test.js.es6 +++ b/test/javascripts/widgets/poster-name-test.js.es6 @@ -7,7 +7,7 @@ widgetTest('basic rendering', { setup() { this.set('args', { username: 'eviltrout', - usernameUrl: '/users/eviltrout', + usernameUrl: '/u/eviltrout', name: 'Robin Ward', user_title: 'Trout Master' }); }, @@ -26,7 +26,7 @@ widgetTest('extra classes and glyphs', { setup() { this.set('args', { username: 'eviltrout', - usernameUrl: '/users/eviltrout', + usernameUrl: '/u/eviltrout', staff: true, admin: true, moderator: true, diff --git a/test/javascripts/widgets/topic-participant-test.js.es6 b/test/javascripts/widgets/topic-participant-test.js.es6 new file mode 100644 index 0000000000..078d562457 --- /dev/null +++ b/test/javascripts/widgets/topic-participant-test.js.es6 @@ -0,0 +1,43 @@ +import { moduleForWidget, widgetTest } from 'helpers/widget-test'; + +moduleForWidget('topic-participant'); + +widgetTest('one post', { + template: '{{mount-widget widget="topic-participant" args=args}}', + + setup() { + this.set('args', { + username: 'test', + avatar_template: '/images/avatar.png', + post_count: 1 + }); + }, + + test(assert) { + assert.ok(exists('a.poster.trigger-user-card')); + assert.ok(!exists('span.post-count'), "don't show count for only 1 post"); + assert.ok(!exists('.avatar-flair'), "no avatar flair"); + } +}); + +widgetTest('many posts, a primary group with flair', { + template: '{{mount-widget widget="topic-participant" args=args}}', + + setup() { + this.set('args', { + username: 'test', + avatar_template: '/images/avatar.png', + post_count: 5, + primary_group_name: 'devs', + primary_group_flair_url: "/images/d-logo-sketch-small.png", + primary_group_flair_bg_color: "222" + }); + }, + + test(assert) { + assert.ok(exists('a.poster.trigger-user-card')); + assert.ok(exists('span.post-count'), "show count for many posts"); + assert.ok(exists('.group-devs a.poster'), "add class for the group outside the link"); + assert.ok(exists('.avatar-flair.avatar-flair-devs'), "show flair with group class"); + } +}); diff --git a/test/stylesheets/test_helper.css b/test/stylesheets/test_helper.css index f0e5ac700c..f9eddae24d 100644 --- a/test/stylesheets/test_helper.css +++ b/test/stylesheets/test_helper.css @@ -1,7 +1,5 @@ -/* - *= require desktop - *= require_tree . -*/ +@import '/stylesheets/desktop.css'; + .modal-backdrop { display: none; } diff --git a/vendor/assets/javascripts/jquery.autoellipsis-1.0.10.js b/vendor/assets/javascripts/jquery.autoellipsis-1.0.10.js new file mode 100644 index 0000000000..7bd28ef0ec --- /dev/null +++ b/vendor/assets/javascripts/jquery.autoellipsis-1.0.10.js @@ -0,0 +1,447 @@ +/*! + + Copyright (c) 2011 Peter van der Spek + + 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. + + */ + + +(function($) { + + /** + * Hash containing mapping of selectors to settings hashes for target selectors that should be live updated. + * + * @type {Object.} + * @private + */ + var liveUpdatingTargetSelectors = {}; + + /** + * Interval ID for live updater. Contains interval ID when the live updater interval is active, or is undefined + * otherwise. + * + * @type {number} + * @private + */ + var liveUpdaterIntervalId; + + /** + * Boolean indicating whether the live updater is running. + * + * @type {boolean} + * @private + */ + var liveUpdaterRunning = false; + + /** + * Set of default settings. + * + * @type {Object.} + * @private + */ + var defaultSettings = { + ellipsis: '...', + setTitle: 'never', + live: false + }; + + /** + * Perform ellipsis on selected elements. + * + * @param {string} selector the inner selector of elements that ellipsis may work on. Inner elements not referred to by this + * selector are left untouched. + * @param {Object.=} options optional options to override default settings. + * @return {jQuery} the current jQuery object for chaining purposes. + * @this {jQuery} the current jQuery object. + */ + $.fn.ellipsis = function(selector, options) { + var subjectElements, settings; + + subjectElements = $(this); + + // Check for options argument only. + if (typeof selector !== 'string') { + options = selector; + selector = undefined; + } + + // Create the settings from the given options and the default settings. + settings = $.extend({}, defaultSettings, options); + + // If selector is not set, work on immediate children (default behaviour). + settings.selector = selector; + + // Do ellipsis on each subject element. + subjectElements.each(function() { + var elem = $(this); + + // Do ellipsis on subject element. + ellipsisOnElement(elem, settings); + }); + + // If live option is enabled, add subject elements to live updater. Otherwise remove from live updater. + if (settings.live) { + addToLiveUpdater(subjectElements.selector, settings); + + } else { + removeFromLiveUpdater(subjectElements.selector); + } + + // Return jQuery object for chaining. + return this; + }; + + + /** + * Perform ellipsis on the given container. + * + * @param {jQuery} containerElement jQuery object containing one DOM element to perform ellipsis on. + * @param {Object.} settings the settings for this ellipsis operation. + * @private + */ + function ellipsisOnElement(containerElement, settings) { + var containerData = containerElement.data('jqae'); + if (!containerData) containerData = {}; + + // Check if wrapper div was already created and bound to the container element. + var wrapperElement = containerData.wrapperElement; + + // If not, create wrapper element. + if (!wrapperElement) { + wrapperElement = containerElement.wrapInner('
            ').find('>div'); + + // Wrapper div should not add extra size. + wrapperElement.css({ + margin: 0, + padding: 0, + border: 0 + }); + } + + // Check if the original wrapper element content was already bound to the wrapper element. + var wrapperElementData = wrapperElement.data('jqae'); + if (!wrapperElementData) wrapperElementData = {}; + + var wrapperOriginalContent = wrapperElementData.originalContent; + + // If so, clone the original content, re-bind the original wrapper content to the clone, and replace the + // wrapper with the clone. + if (wrapperOriginalContent) { + wrapperElement = wrapperElementData.originalContent.clone(true) + .data('jqae', {originalContent: wrapperOriginalContent}).replaceAll(wrapperElement); + + } else { + // Otherwise, clone the current wrapper element and bind it as original content to the wrapper element. + + wrapperElement.data('jqae', {originalContent: wrapperElement.clone(true)}); + } + + // Bind the wrapper element and current container width and height to the container element. Current container + // width and height are stored to detect changes to the container size. + containerElement.data('jqae', { + wrapperElement: wrapperElement, + containerWidth: containerElement.width(), + containerHeight: containerElement.height() + }); + + // Calculate with current container element height. + var containerElementHeight = containerElement.height(); + + // Calculate wrapper offset. + var wrapperOffset = (parseInt(containerElement.css('padding-top'), 10) || 0) + (parseInt(containerElement.css('border-top-width'), 10) || 0) - (wrapperElement.offset().top - containerElement.offset().top); + + // Normally the ellipsis characters are applied to the last non-empty text-node in the selected element. If the + // selected element becomes empty during ellipsis iteration, the ellipsis characters cannot be applied to that + // selected element, and must be deferred to the previous selected element. This parameter keeps track of that. + var deferAppendEllipsis = false; + + // Loop through all selected elements in reverse order. + var selectedElements = wrapperElement; + if (settings.selector) selectedElements = $(wrapperElement.find(settings.selector).get().reverse()); + + selectedElements.each(function() { + var selectedElement = $(this), + originalText = selectedElement.text(), + ellipsisApplied = false; + + // Check if we can safely remove the selected element. This saves a lot of unnecessary iterations. + if (wrapperElement.innerHeight() - selectedElement.innerHeight() > containerElementHeight + wrapperOffset) { + selectedElement.remove(); + + } else { + // Reverse recursively remove empty elements, until the element that contains a non-empty text-node. + removeLastEmptyElements(selectedElement); + + // If the selected element has not become empty, start ellipsis iterations on the selected element. + if (selectedElement.contents().length) { + + // If a deffered ellipsis is still pending, apply it now to the last text-node. + if (deferAppendEllipsis) { + getLastTextNode(selectedElement).get(0).nodeValue += settings.ellipsis; + deferAppendEllipsis = false; + } + + // Iterate until wrapper element height is less than or equal to the original container element + // height plus possible wrapperOffset. + while (wrapperElement.innerHeight() > containerElementHeight + wrapperOffset) { + // Apply ellipsis on last text node, by removing one word. + ellipsisApplied = ellipsisOnLastTextNode(selectedElement); + + // If ellipsis was succesfully applied, remove any remaining empty last elements and append the + // ellipsis characters. + if (ellipsisApplied) { + removeLastEmptyElements(selectedElement); + + // If the selected element is not empty, append the ellipsis characters. + if (selectedElement.contents().length) { + getLastTextNode(selectedElement).get(0).nodeValue += settings.ellipsis; + + } else { + // If the selected element has become empty, defer the appending of the ellipsis characters + // to the previous selected element. + deferAppendEllipsis = true; + selectedElement.remove(); + break; + } + + } else { + // If ellipsis could not be applied, defer the appending of the ellipsis characters to the + // previous selected element. + deferAppendEllipsis = true; + selectedElement.remove(); + break; + } + } + + // If the "setTitle" property is set to "onEllipsis" and the ellipsis has been applied, or if the + // property is set to "always", the add the "title" attribute with the original text. Else remove the + // "title" attribute. When the "setTitle" property is set to "never" we do not touch the "title" + // attribute. + if (((settings.setTitle == 'onEllipsis') && ellipsisApplied) || (settings.setTitle == 'always')) { + selectedElement.attr('title', originalText); + + } else if (settings.setTitle != 'never') { + selectedElement.removeAttr('title'); + } + } + } + }); + } + + /** + * Performs ellipsis on the last text node of the given element. Ellipsis is done by removing a full word. + * + * @param {jQuery} element jQuery object containing a single DOM element. + * @return {boolean} true when ellipsis has been done, false otherwise. + * @private + */ + function ellipsisOnLastTextNode(element) { + var lastTextNode = getLastTextNode(element); + + // If the last text node is found, do ellipsis on that node. + if (lastTextNode.length) { + var text = lastTextNode.get(0).nodeValue; + + // Find last space character, and remove text from there. If no space is found the full remaining text is + // removed. + var pos = text.lastIndexOf(' '); + if (pos > -1) { + text = $.trim(text.substring(0, pos)); + lastTextNode.get(0).nodeValue = text; + + } else { + lastTextNode.get(0).nodeValue = ''; + } + + return true; + } + + return false; + } + + /** + * Get last text node of the given element. + * + * @param {jQuery} element jQuery object containing a single element. + * @return {jQuery} jQuery object containing a single text node. + * @private + */ + function getLastTextNode(element) { + if (element.contents().length) { + + // Get last child node. + var contents = element.contents(); + var lastNode = contents.eq(contents.length - 1); + + // If last node is a text node, return it. + if (lastNode.filter(textNodeFilter).length) { + return lastNode; + + } else { + // Else it is an element node, and we recurse into it. + + return getLastTextNode(lastNode); + } + + } else { + // If there is no last child node, we append an empty text node and return that. Normally this should not + // happen, as we test for emptiness before calling getLastTextNode. + + element.append(''); + var contents = element.contents(); + return contents.eq(contents.length - 1); + } + } + + /** + * Remove last empty elements. This is done recursively until the last element contains a non-empty text node. + * + * @param {jQuery} element jQuery object containing a single element. + * @return {boolean} true when elements have been removed, false otherwise. + * @private + */ + function removeLastEmptyElements(element) { + if (element.contents().length) { + + // Get last child node. + var contents = element.contents(); + var lastNode = contents.eq(contents.length - 1); + + // If last child node is a text node, check for emptiness. + if (lastNode.filter(textNodeFilter).length) { + var text = lastNode.get(0).nodeValue; + text = $.trim(text); + + if (text == '') { + // If empty, remove the text node. + lastNode.remove(); + + return true; + + } else { + return false; + } + + } else { + // If the last child node is an element node, remove the last empty child nodes on that node. + while (removeLastEmptyElements(lastNode)) { + } + + // If the last child node contains no more child nodes, remove the last child node. + if (lastNode.contents().length) { + return false; + + } else { + lastNode.remove(); + + return true; + } + } + } + + return false; + } + + /** + * Filter for testing on text nodes. + * + * @return {boolean} true when this node is a text node, false otherwise. + * @this {Node} + * @private + */ + function textNodeFilter() { + return this.nodeType === 3; + } + + /** + * Add target selector to hash of target selectors. If this is the first target selector added, start the live + * updater. + * + * @param {string} targetSelector the target selector to run the live updater for. + * @param {Object.} settings the settings to apply on this target selector. + * @private + */ + function addToLiveUpdater(targetSelector, settings) { + // Store target selector with its settings. + liveUpdatingTargetSelectors[targetSelector] = settings; + + // If the live updater has not yet been started, start it now. + if (!liveUpdaterIntervalId) { + liveUpdaterIntervalId = window.setInterval(function() { + doLiveUpdater(); + }, 200); + } + } + + /** + * Remove the target selector from the hash of target selectors. It this is the last remaining target selector + * being removed, stop the live updater. + * + * @param {string} targetSelector the target selector to stop running the live updater for. + * @private + */ + function removeFromLiveUpdater(targetSelector) { + // If the hash contains the target selector, remove it. + if (liveUpdatingTargetSelectors[targetSelector]) { + delete liveUpdatingTargetSelectors[targetSelector]; + + // If no more target selectors are in the hash, stop the live updater. + if (!liveUpdatingTargetSelectors.length) { + if (liveUpdaterIntervalId) { + window.clearInterval(liveUpdaterIntervalId); + liveUpdaterIntervalId = undefined; + } + } + } + }; + + /** + * Run the live updater. The live updater is periodically run to check if its monitored target selectors require + * re-applying of the ellipsis. + * + * @private + */ + function doLiveUpdater() { + // If the live updater is already running, skip this time. We only want one instance running at a time. + if (!liveUpdaterRunning) { + liveUpdaterRunning = true; + + // Loop through target selectors. + for (var targetSelector in liveUpdatingTargetSelectors) { + $(targetSelector).each(function() { + var containerElement, containerData; + + containerElement = $(this); + containerData = containerElement.data('jqae'); + + // If container element dimensions have changed, or the container element is new, run ellipsis on + // that container element. + if ((containerData.containerWidth != containerElement.width()) || + (containerData.containerHeight != containerElement.height())) { + ellipsisOnElement(containerElement, liveUpdatingTargetSelectors[targetSelector]); + } + }); + } + + liveUpdaterRunning = false; + } + }; + +})(jQuery); \ No newline at end of file diff --git a/vendor/assets/javascripts/jquery.autoellipsis-1.0.10.min.js b/vendor/assets/javascripts/jquery.autoellipsis-1.0.10.min.js deleted file mode 100644 index ffc82eb436..0000000000 --- a/vendor/assets/javascripts/jquery.autoellipsis-1.0.10.min.js +++ /dev/null @@ -1,23 +0,0 @@ -/*! - - Copyright (c) 2011 Peter van der Spek - - 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. - - */(function(a){function m(){if(!d){d=!0;for(var c in b)a(c).each(function(){var d,e;d=a(this),e=d.data("jqae"),(e.containerWidth!=d.width()||e.containerHeight!=d.height())&&f(d,b[c])});d=!1}}function l(a){b[a]&&(delete b[a],b.length||c&&(window.clearInterval(c),c=undefined))}function k(a,d){b[a]=d,c||(c=window.setInterval(function(){m()},200))}function j(){return this.nodeType===3}function i(b){if(b.contents().length){var c=b.contents(),d=c.eq(c.length-1);if(d.filter(j).length){var e=d.get(0).nodeValue;e=a.trim(e);if(e==""){d.remove();return!0}return!1}while(i(d));if(d.contents().length)return!1;d.remove();return!0}return!1}function h(a){if(a.contents().length){var b=a.contents(),c=b.eq(b.length-1);return c.filter(j).length?c:h(c)}a.append("");var b=a.contents();return b.eq(b.length-1)}function g(b){var c=h(b);if(c.length){var d=c.get(0).nodeValue,e=d.lastIndexOf(" ");e>-1?(d=a.trim(d.substring(0,e)),c.get(0).nodeValue=d):c.get(0).nodeValue="";return!0}return!1}function f(b,c){var d=b.data("jqae");d||(d={});var e=d.wrapperElement;e||(e=b.wrapInner("
            ").find(">div"),e.css({margin:0,padding:0,border:0}));var f=e.data("jqae");f||(f={});var j=f.originalContent;j?e=f.originalContent.clone(!0).data("jqae",{originalContent:j}).replaceAll(e):e.data("jqae",{originalContent:e.clone(!0)}),b.data("jqae",{wrapperElement:e,containerWidth:b.width(),containerHeight:b.height()});var k=b.height(),l=(parseInt(b.css("padding-top"),10)||0)+(parseInt(b.css("border-top-width"),10)||0)-(e.offset().top-b.offset().top),m=!1,n=e;c.selector&&(n=a(e.find(c.selector).get().reverse())),n.each(function(){var b=a(this),d=b.text(),f=!1;if(e.innerHeight()-b.innerHeight()>k+l)b.remove();else{i(b);if(b.contents().length){m&&(h(b).get(0).nodeValue+=c.ellipsis,m=!1);while(e.innerHeight()>k+l){f=g(b);if(!f){m=!0,b.remove();break}i(b);if(b.contents().length)h(b).get(0).nodeValue+=c.ellipsis;else{m=!0,b.remove();break}}c.setTitle=="onEllipsis"&&f||c.setTitle=="always"?b.attr("title",d):c.setTitle!="never"&&b.removeAttr("title")}}})}var b={},c,d=!1,e={ellipsis:"...",setTitle:"never",live:!1};a.fn.ellipsis=function(b,c){var d,g;d=a(this),typeof b!="string"&&(c=b,b=undefined),g=a.extend({},e,c),g.selector=b,d.each(function(){var b=a(this);f(b,g)}),g.live?k(d.selector,g):l(d.selector);return this}})(jQuery) \ No newline at end of file diff --git a/vendor/assets/javascripts/jquery.ba-resize.js b/vendor/assets/javascripts/jquery.ba-resize.js new file mode 100644 index 0000000000..1f41d37915 --- /dev/null +++ b/vendor/assets/javascripts/jquery.ba-resize.js @@ -0,0 +1,246 @@ +/*! + * jQuery resize event - v1.1 - 3/14/2010 + * http://benalman.com/projects/jquery-resize-plugin/ + * + * Copyright (c) 2010 "Cowboy" Ben Alman + * Dual licensed under the MIT and GPL licenses. + * http://benalman.com/about/license/ + */ + +// Script: jQuery resize event +// +// *Version: 1.1, Last updated: 3/14/2010* +// +// Project Home - http://benalman.com/projects/jquery-resize-plugin/ +// GitHub - http://github.com/cowboy/jquery-resize/ +// Source - http://github.com/cowboy/jquery-resize/raw/master/jquery.ba-resize.js +// (Minified) - http://github.com/cowboy/jquery-resize/raw/master/jquery.ba-resize.min.js (1.0kb) +// +// About: License +// +// Copyright (c) 2010 "Cowboy" Ben Alman, +// Dual licensed under the MIT and GPL licenses. +// http://benalman.com/about/license/ +// +// About: Examples +// +// This working example, complete with fully commented code, illustrates a few +// ways in which this plugin can be used. +// +// resize event - http://benalman.com/code/projects/jquery-resize/examples/resize/ +// +// About: Support and Testing +// +// Information about what version or versions of jQuery this plugin has been +// tested with, what browsers it has been tested in, and where the unit tests +// reside (so you can test it yourself). +// +// jQuery Versions - 1.3.2, 1.4.1, 1.4.2 +// Browsers Tested - Internet Explorer 6-8, Firefox 2-3.6, Safari 3-4, Chrome, Opera 9.6-10.1. +// Unit Tests - http://benalman.com/code/projects/jquery-resize/unit/ +// +// About: Release History +// +// 1.1 - (3/14/2010) Fixed a minor bug that was causing the event to trigger +// immediately after bind in some circumstances. Also changed $.fn.data +// to $.data to improve performance. +// 1.0 - (2/10/2010) Initial release + +(function($,window,undefined){ + '$:nomunge'; // Used by YUI compressor. + + // A jQuery object containing all non-window elements to which the resize + // event is bound. + var elems = $([]), + + // Extend $.resize if it already exists, otherwise create it. + jq_resize = $.resize = $.extend( $.resize, {} ), + + timeout_id, + + // Reused strings. + str_setTimeout = 'setTimeout', + str_resize = 'resize', + str_data = str_resize + '-special-event', + str_delay = 'delay', + str_throttle = 'throttleWindow'; + + // Property: jQuery.resize.delay + // + // The numeric interval (in milliseconds) at which the resize event polling + // loop executes. Defaults to 250. + + jq_resize[ str_delay ] = 250; + + // Property: jQuery.resize.throttleWindow + // + // Throttle the native window object resize event to fire no more than once + // every milliseconds. Defaults to true. + // + // Because the window object has its own resize event, it doesn't need to be + // provided by this plugin, and its execution can be left entirely up to the + // browser. However, since certain browsers fire the resize event continuously + // while others do not, enabling this will throttle the window resize event, + // making event behavior consistent across all elements in all browsers. + // + // While setting this property to false will disable window object resize + // event throttling, please note that this property must be changed before any + // window object resize event callbacks are bound. + + jq_resize[ str_throttle ] = true; + + // Event: resize event + // + // Fired when an element's width or height changes. Because browsers only + // provide this event for the window element, for other elements a polling + // loop is initialized, running every milliseconds + // to see if elements' dimensions have changed. You may bind with either + // .resize( fn ) or .bind( "resize", fn ), and unbind with .unbind( "resize" ). + // + // Usage: + // + // > jQuery('selector').bind( 'resize', function(e) { + // > // element's width or height has changed! + // > ... + // > }); + // + // Additional Notes: + // + // * The polling loop is not created until at least one callback is actually + // bound to the 'resize' event, and this single polling loop is shared + // across all elements. + // + // Double firing issue in jQuery 1.3.2: + // + // While this plugin works in jQuery 1.3.2, if an element's event callbacks + // are manually triggered via .trigger( 'resize' ) or .resize() those + // callbacks may double-fire, due to limitations in the jQuery 1.3.2 special + // events system. This is not an issue when using jQuery 1.4+. + // + // > // While this works in jQuery 1.4+ + // > $(elem).css({ width: new_w, height: new_h }).resize(); + // > + // > // In jQuery 1.3.2, you need to do this: + // > var elem = $(elem); + // > elem.css({ width: new_w, height: new_h }); + // > elem.data( 'resize-special-event', { width: elem.width(), height: elem.height() } ); + // > elem.resize(); + + $.event.special[ str_resize ] = { + + // Called only when the first 'resize' event callback is bound per element. + setup: function() { + // Since window has its own native 'resize' event, return false so that + // jQuery will bind the event using DOM methods. Since only 'window' + // objects have a .setTimeout method, this should be a sufficient test. + // Unless, of course, we're throttling the 'resize' event for window. + if ( !jq_resize[ str_throttle ] && this[ str_setTimeout ] ) { return false; } + + var elem = $(this); + + // Add this element to the list of internal elements to monitor. + elems = elems.add( elem ); + + // Initialize data store on the element. + $.data( this, str_data, { w: elem.width(), h: elem.height() } ); + + // If this is the first element added, start the polling loop. + if ( elems.length === 1 ) { + loopy(); + } + }, + + // Called only when the last 'resize' event callback is unbound per element. + teardown: function() { + // Since window has its own native 'resize' event, return false so that + // jQuery will unbind the event using DOM methods. Since only 'window' + // objects have a .setTimeout method, this should be a sufficient test. + // Unless, of course, we're throttling the 'resize' event for window. + if ( !jq_resize[ str_throttle ] && this[ str_setTimeout ] ) { return false; } + + var elem = $(this); + + // Remove this element from the list of internal elements to monitor. + elems = elems.not( elem ); + + // Remove any data stored on the element. + elem.removeData( str_data ); + + // If this is the last element removed, stop the polling loop. + if ( !elems.length ) { + clearTimeout( timeout_id ); + } + }, + + // Called every time a 'resize' event callback is bound per element (new in + // jQuery 1.4). + add: function( handleObj ) { + // Since window has its own native 'resize' event, return false so that + // jQuery doesn't modify the event object. Unless, of course, we're + // throttling the 'resize' event for window. + if ( !jq_resize[ str_throttle ] && this[ str_setTimeout ] ) { return false; } + + var old_handler; + + // The new_handler function is executed every time the event is triggered. + // This is used to update the internal element data store with the width + // and height when the event is triggered manually, to avoid double-firing + // of the event callback. See the "Double firing issue in jQuery 1.3.2" + // comments above for more information. + + function new_handler( e, w, h ) { + var elem = $(this), + data = $.data( this, str_data ); + + // If called from the polling loop, w and h will be passed in as + // arguments. If called manually, via .trigger( 'resize' ) or .resize(), + // those values will need to be computed. + data.w = w !== undefined ? w : elem.width(); + data.h = h !== undefined ? h : elem.height(); + + old_handler.apply( this, arguments ); + }; + + // This may seem a little complicated, but it normalizes the special event + // .add method between jQuery 1.4/1.4.1 and 1.4.2+ + if ( $.isFunction( handleObj ) ) { + // 1.4, 1.4.1 + old_handler = handleObj; + return new_handler; + } else { + // 1.4.2+ + old_handler = handleObj.handler; + handleObj.handler = new_handler; + } + } + + }; + + function loopy() { + + // Start the polling loop, asynchronously. + timeout_id = window[ str_setTimeout ](function(){ + + // Iterate over all elements to which the 'resize' event is bound. + elems.each(function(){ + var elem = $(this), + width = elem.width(), + height = elem.height(), + data = $.data( this, str_data ); + + // If element size has changed since the last time, update the element + // data store and trigger the 'resize' event. + if ( width !== data.w || height !== data.h ) { + elem.trigger( str_resize, [ data.w = width, data.h = height ] ); + } + + }); + + // Loop. + loopy(); + + }, jq_resize[ str_delay ] ); + + }; + +})(jQuery,this); diff --git a/vendor/assets/javascripts/jquery.ba-resize.min.js b/vendor/assets/javascripts/jquery.ba-resize.min.js deleted file mode 100644 index c678883148..0000000000 --- a/vendor/assets/javascripts/jquery.ba-resize.min.js +++ /dev/null @@ -1,9 +0,0 @@ -/* - * jQuery resize event - v1.1 - 3/14/2010 - * http://benalman.com/projects/jquery-resize-plugin/ - * - * Copyright (c) 2010 "Cowboy" Ben Alman - * Dual licensed under the MIT and GPL licenses. - * http://benalman.com/about/license/ - */ -(function($,h,c){var a=$([]),e=$.resize=$.extend($.resize,{}),i,k="setTimeout",j="resize",d=j+"-special-event",b="delay",f="throttleWindow";e[b]=250;e[f]=true;$.event.special[j]={setup:function(){if(!e[f]&&this[k]){return false}var l=$(this);a=a.add(l);$.data(this,d,{w:l.width(),h:l.height()});if(a.length===1){g()}},teardown:function(){if(!e[f]&&this[k]){return false}var l=$(this);a=a.not(l);l.removeData(d);if(!a.length){clearTimeout(i)}},add:function(l){if(!e[f]&&this[k]){return false}var n;function m(s,o,p){var q=$(this),r=$.data(this,d);r.w=o!==c?o:q.width();r.h=p!==c?p:q.height();n.apply(this,arguments)}if($.isFunction(l)){n=l;return m}else{n=l.handler;l.handler=m}}};function g(){i=h[k](function(){a.each(function(){var n=$(this),m=n.width(),l=n.height(),o=$.data(this,d);if(m!==o.w||l!==o.h){n.trigger(j,[o.w=m,o.h=l])}});g()},e[b])}})(jQuery,this); \ No newline at end of file diff --git a/vendor/assets/javascripts/jquery.debug.js b/vendor/assets/javascripts/jquery.js similarity index 100% rename from vendor/assets/javascripts/jquery.debug.js rename to vendor/assets/javascripts/jquery.js diff --git a/vendor/assets/javascripts/jquery.prod.js b/vendor/assets/javascripts/jquery.prod.js deleted file mode 100644 index 06ac263150..0000000000 --- a/vendor/assets/javascripts/jquery.prod.js +++ /dev/null @@ -1,4 +0,0 @@ -/*! jQuery v2.2.0 | (c) jQuery Foundation | jquery.org/license */ -!function(a,b){"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){var c=[],d=a.document,e=c.slice,f=c.concat,g=c.push,h=c.indexOf,i={},j=i.toString,k=i.hasOwnProperty,l={},m="2.2.0",n=function(a,b){return new n.fn.init(a,b)},o=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,p=/^-ms-/,q=/-([\da-z])/gi,r=function(a,b){return b.toUpperCase()};n.fn=n.prototype={jquery:m,constructor:n,selector:"",length:0,toArray:function(){return e.call(this)},get:function(a){return null!=a?0>a?this[a+this.length]:this[a]:e.call(this)},pushStack:function(a){var b=n.merge(this.constructor(),a);return b.prevObject=this,b.context=this.context,b},each:function(a){return n.each(this,a)},map:function(a){return this.pushStack(n.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(e.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(0>a?b:0);return this.pushStack(c>=0&&b>c?[this[c]]:[])},end:function(){return this.prevObject||this.constructor()},push:g,sort:c.sort,splice:c.splice},n.extend=n.fn.extend=function(){var a,b,c,d,e,f,g=arguments[0]||{},h=1,i=arguments.length,j=!1;for("boolean"==typeof g&&(j=g,g=arguments[h]||{},h++),"object"==typeof g||n.isFunction(g)||(g={}),h===i&&(g=this,h--);i>h;h++)if(null!=(a=arguments[h]))for(b in a)c=g[b],d=a[b],g!==d&&(j&&d&&(n.isPlainObject(d)||(e=n.isArray(d)))?(e?(e=!1,f=c&&n.isArray(c)?c:[]):f=c&&n.isPlainObject(c)?c:{},g[b]=n.extend(j,f,d)):void 0!==d&&(g[b]=d));return g},n.extend({expando:"jQuery"+(m+Math.random()).replace(/\D/g,""),isReady:!0,error:function(a){throw new Error(a)},noop:function(){},isFunction:function(a){return"function"===n.type(a)},isArray:Array.isArray,isWindow:function(a){return null!=a&&a===a.window},isNumeric:function(a){var b=a&&a.toString();return!n.isArray(a)&&b-parseFloat(b)+1>=0},isPlainObject:function(a){return"object"!==n.type(a)||a.nodeType||n.isWindow(a)?!1:a.constructor&&!k.call(a.constructor.prototype,"isPrototypeOf")?!1:!0},isEmptyObject:function(a){var b;for(b in a)return!1;return!0},type:function(a){return null==a?a+"":"object"==typeof a||"function"==typeof a?i[j.call(a)]||"object":typeof a},globalEval:function(a){var b,c=eval;a=n.trim(a),a&&(1===a.indexOf("use strict")?(b=d.createElement("script"),b.text=a,d.head.appendChild(b).parentNode.removeChild(b)):c(a))},camelCase:function(a){return a.replace(p,"ms-").replace(q,r)},nodeName:function(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()},each:function(a,b){var c,d=0;if(s(a)){for(c=a.length;c>d;d++)if(b.call(a[d],d,a[d])===!1)break}else for(d in a)if(b.call(a[d],d,a[d])===!1)break;return a},trim:function(a){return null==a?"":(a+"").replace(o,"")},makeArray:function(a,b){var c=b||[];return null!=a&&(s(Object(a))?n.merge(c,"string"==typeof a?[a]:a):g.call(c,a)),c},inArray:function(a,b,c){return null==b?-1:h.call(b,a,c)},merge:function(a,b){for(var c=+b.length,d=0,e=a.length;c>d;d++)a[e++]=b[d];return a.length=e,a},grep:function(a,b,c){for(var d,e=[],f=0,g=a.length,h=!c;g>f;f++)d=!b(a[f],f),d!==h&&e.push(a[f]);return e},map:function(a,b,c){var d,e,g=0,h=[];if(s(a))for(d=a.length;d>g;g++)e=b(a[g],g,c),null!=e&&h.push(e);else for(g in a)e=b(a[g],g,c),null!=e&&h.push(e);return f.apply([],h)},guid:1,proxy:function(a,b){var c,d,f;return"string"==typeof b&&(c=a[b],b=a,a=c),n.isFunction(a)?(d=e.call(arguments,2),f=function(){return a.apply(b||this,d.concat(e.call(arguments)))},f.guid=a.guid=a.guid||n.guid++,f):void 0},now:Date.now,support:l}),"function"==typeof Symbol&&(n.fn[Symbol.iterator]=c[Symbol.iterator]),n.each("Boolean Number String Function Array Date RegExp Object Error Symbol".split(" "),function(a,b){i["[object "+b+"]"]=b.toLowerCase()});function s(a){var b=!!a&&"length"in a&&a.length,c=n.type(a);return"function"===c||n.isWindow(a)?!1:"array"===c||0===b||"number"==typeof b&&b>0&&b-1 in a}var t=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=ga(),z=ga(),A=ga(),B=function(a,b){return a===b&&(l=!0),0},C=1<<31,D={}.hasOwnProperty,E=[],F=E.pop,G=E.push,H=E.push,I=E.slice,J=function(a,b){for(var c=0,d=a.length;d>c;c++)if(a[c]===b)return c;return-1},K="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",L="[\\x20\\t\\r\\n\\f]",M="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",N="\\["+L+"*("+M+")(?:"+L+"*([*^$|!~]?=)"+L+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+M+"))|)"+L+"*\\]",O=":("+M+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+N+")*)|.*)\\)|)",P=new RegExp(L+"+","g"),Q=new RegExp("^"+L+"+|((?:^|[^\\\\])(?:\\\\.)*)"+L+"+$","g"),R=new RegExp("^"+L+"*,"+L+"*"),S=new RegExp("^"+L+"*([>+~]|"+L+")"+L+"*"),T=new RegExp("="+L+"*([^\\]'\"]*?)"+L+"*\\]","g"),U=new RegExp(O),V=new RegExp("^"+M+"$"),W={ID:new RegExp("^#("+M+")"),CLASS:new RegExp("^\\.("+M+")"),TAG:new RegExp("^("+M+"|[*])"),ATTR:new RegExp("^"+N),PSEUDO:new RegExp("^"+O),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+L+"*(even|odd|(([+-]|)(\\d*)n|)"+L+"*(?:([+-]|)"+L+"*(\\d+)|))"+L+"*\\)|)","i"),bool:new RegExp("^(?:"+K+")$","i"),needsContext:new RegExp("^"+L+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+L+"*((?:-\\d)?\\d*)"+L+"*\\)|)(?=[^-]|$)","i")},X=/^(?:input|select|textarea|button)$/i,Y=/^h\d$/i,Z=/^[^{]+\{\s*\[native \w/,$=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,_=/[+~]/,aa=/'|\\/g,ba=new RegExp("\\\\([\\da-f]{1,6}"+L+"?|("+L+")|.)","ig"),ca=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:0>d?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},da=function(){m()};try{H.apply(E=I.call(v.childNodes),v.childNodes),E[v.childNodes.length].nodeType}catch(ea){H={apply:E.length?function(a,b){G.apply(a,I.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function fa(a,b,d,e){var f,h,j,k,l,o,r,s,w=b&&b.ownerDocument,x=b?b.nodeType:9;if(d=d||[],"string"!=typeof a||!a||1!==x&&9!==x&&11!==x)return d;if(!e&&((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,p)){if(11!==x&&(o=$.exec(a)))if(f=o[1]){if(9===x){if(!(j=b.getElementById(f)))return d;if(j.id===f)return d.push(j),d}else if(w&&(j=w.getElementById(f))&&t(b,j)&&j.id===f)return d.push(j),d}else{if(o[2])return H.apply(d,b.getElementsByTagName(a)),d;if((f=o[3])&&c.getElementsByClassName&&b.getElementsByClassName)return H.apply(d,b.getElementsByClassName(f)),d}if(c.qsa&&!A[a+" "]&&(!q||!q.test(a))){if(1!==x)w=b,s=a;else if("object"!==b.nodeName.toLowerCase()){(k=b.getAttribute("id"))?k=k.replace(aa,"\\$&"):b.setAttribute("id",k=u),r=g(a),h=r.length,l=V.test(k)?"#"+k:"[id='"+k+"']";while(h--)r[h]=l+" "+qa(r[h]);s=r.join(","),w=_.test(a)&&oa(b.parentNode)||b}if(s)try{return H.apply(d,w.querySelectorAll(s)),d}catch(y){}finally{k===u&&b.removeAttribute("id")}}}return i(a.replace(Q,"$1"),b,d,e)}function ga(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ha(a){return a[u]=!0,a}function ia(a){var b=n.createElement("div");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function ja(a,b){var c=a.split("|"),e=c.length;while(e--)d.attrHandle[c[e]]=b}function ka(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&(~b.sourceIndex||C)-(~a.sourceIndex||C);if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function la(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function ma(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function na(a){return ha(function(b){return b=+b,ha(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function oa(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=fa.support={},f=fa.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return b?"HTML"!==b.nodeName:!1},m=fa.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=n.documentElement,p=!f(n),(e=n.defaultView)&&e.top!==e&&(e.addEventListener?e.addEventListener("unload",da,!1):e.attachEvent&&e.attachEvent("onunload",da)),c.attributes=ia(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ia(function(a){return a.appendChild(n.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=Z.test(n.getElementsByClassName),c.getById=ia(function(a){return o.appendChild(a).id=u,!n.getElementsByName||!n.getElementsByName(u).length}),c.getById?(d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c?[c]:[]}},d.filter.ID=function(a){var b=a.replace(ba,ca);return function(a){return a.getAttribute("id")===b}}):(delete d.find.ID,d.filter.ID=function(a){var b=a.replace(ba,ca);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){return"undefined"!=typeof b.getElementsByClassName&&p?b.getElementsByClassName(a):void 0},r=[],q=[],(c.qsa=Z.test(n.querySelectorAll))&&(ia(function(a){o.appendChild(a).innerHTML="",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+L+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+L+"*(?:value|"+K+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),ia(function(a){var b=n.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+L+"*[*^$|!~]?="),a.querySelectorAll(":enabled").length||q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=Z.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ia(function(a){c.disconnectedMatch=s.call(a,"div"),s.call(a,"[s!='']:x"),r.push("!=",O)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=Z.test(o.compareDocumentPosition),t=b||Z.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===n||a.ownerDocument===v&&t(v,a)?-1:b===n||b.ownerDocument===v&&t(v,b)?1:k?J(k,a)-J(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,g=[a],h=[b];if(!e||!f)return a===n?-1:b===n?1:e?-1:f?1:k?J(k,a)-J(k,b):0;if(e===f)return ka(a,b);c=a;while(c=c.parentNode)g.unshift(c);c=b;while(c=c.parentNode)h.unshift(c);while(g[d]===h[d])d++;return d?ka(g[d],h[d]):g[d]===v?-1:h[d]===v?1:0},n):n},fa.matches=function(a,b){return fa(a,null,null,b)},fa.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(T,"='$1']"),c.matchesSelector&&p&&!A[b+" "]&&(!r||!r.test(b))&&(!q||!q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return fa(b,n,null,[a]).length>0},fa.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},fa.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&D.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},fa.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},fa.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=fa.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=fa.selectors={cacheLength:50,createPseudo:ha,match:W,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(ba,ca),a[3]=(a[3]||a[4]||a[5]||"").replace(ba,ca),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||fa.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&fa.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return W.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&U.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(ba,ca).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+L+")"+a+"("+L+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=fa.attr(d,a);return null==e?"!="===b:b?(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(P," ")+" ").indexOf(c)>-1:"|="===b?e===c||e.slice(0,c.length+1)===c+"-":!1):!0}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h,t=!1;if(q){if(f){while(p){m=b;while(m=m[p])if(h?m.nodeName.toLowerCase()===r:1===m.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){m=q,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n&&j[2],m=n&&q.childNodes[n];while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if(1===m.nodeType&&++t&&m===b){k[a]=[w,n,t];break}}else if(s&&(m=b,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n),t===!1)while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if((h?m.nodeName.toLowerCase()===r:1===m.nodeType)&&++t&&(s&&(l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),k[a]=[w,t]),m===b))break;return t-=e,t===d||t%d===0&&t/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||fa.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ha(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=J(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ha(function(a){var b=[],c=[],d=h(a.replace(Q,"$1"));return d[u]?ha(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ha(function(a){return function(b){return fa(a,b).length>0}}),contains:ha(function(a){return a=a.replace(ba,ca),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ha(function(a){return V.test(a||"")||fa.error("unsupported lang: "+a),a=a.replace(ba,ca).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:function(a){return a.disabled===!1},disabled:function(a){return a.disabled===!0},checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return Y.test(a.nodeName)},input:function(a){return X.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:na(function(){return[0]}),last:na(function(a,b){return[b-1]}),eq:na(function(a,b,c){return[0>c?c+b:c]}),even:na(function(a,b){for(var c=0;b>c;c+=2)a.push(c);return a}),odd:na(function(a,b){for(var c=1;b>c;c+=2)a.push(c);return a}),lt:na(function(a,b,c){for(var d=0>c?c+b:c;--d>=0;)a.push(d);return a}),gt:na(function(a,b,c){for(var d=0>c?c+b:c;++db;b++)d+=a[b].value;return d}function ra(a,b,c){var d=b.dir,e=c&&"parentNode"===d,f=x++;return b.first?function(b,c,f){while(b=b[d])if(1===b.nodeType||e)return a(b,c,f)}:function(b,c,g){var h,i,j,k=[w,f];if(g){while(b=b[d])if((1===b.nodeType||e)&&a(b,c,g))return!0}else while(b=b[d])if(1===b.nodeType||e){if(j=b[u]||(b[u]={}),i=j[b.uniqueID]||(j[b.uniqueID]={}),(h=i[d])&&h[0]===w&&h[1]===f)return k[2]=h[2];if(i[d]=k,k[2]=a(b,c,g))return!0}}}function sa(a){return a.length>1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function ta(a,b,c){for(var d=0,e=b.length;e>d;d++)fa(a,b[d],c);return c}function ua(a,b,c,d,e){for(var f,g=[],h=0,i=a.length,j=null!=b;i>h;h++)(f=a[h])&&(!c||c(f,d,e))&&(g.push(f),j&&b.push(h));return g}function va(a,b,c,d,e,f){return d&&!d[u]&&(d=va(d)),e&&!e[u]&&(e=va(e,f)),ha(function(f,g,h,i){var j,k,l,m=[],n=[],o=g.length,p=f||ta(b||"*",h.nodeType?[h]:h,[]),q=!a||!f&&b?p:ua(p,m,a,h,i),r=c?e||(f?a:o||d)?[]:g:q;if(c&&c(q,r,h,i),d){j=ua(r,n),d(j,[],h,i),k=j.length;while(k--)(l=j[k])&&(r[n[k]]=!(q[n[k]]=l))}if(f){if(e||a){if(e){j=[],k=r.length;while(k--)(l=r[k])&&j.push(q[k]=l);e(null,r=[],j,i)}k=r.length;while(k--)(l=r[k])&&(j=e?J(f,l):m[k])>-1&&(f[j]=!(g[j]=l))}}else r=ua(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):H.apply(g,r)})}function wa(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=ra(function(a){return a===b},h,!0),l=ra(function(a){return J(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];f>i;i++)if(c=d.relative[a[i].type])m=[ra(sa(m),c)];else{if(c=d.filter[a[i].type].apply(null,a[i].matches),c[u]){for(e=++i;f>e;e++)if(d.relative[a[e].type])break;return va(i>1&&sa(m),i>1&&qa(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(Q,"$1"),c,e>i&&wa(a.slice(i,e)),f>e&&wa(a=a.slice(e)),f>e&&qa(a))}m.push(c)}return sa(m)}function xa(a,b){var c=b.length>0,e=a.length>0,f=function(f,g,h,i,k){var l,o,q,r=0,s="0",t=f&&[],u=[],v=j,x=f||e&&d.find.TAG("*",k),y=w+=null==v?1:Math.random()||.1,z=x.length;for(k&&(j=g===n||g||k);s!==z&&null!=(l=x[s]);s++){if(e&&l){o=0,g||l.ownerDocument===n||(m(l),h=!p);while(q=a[o++])if(q(l,g||n,h)){i.push(l);break}k&&(w=y)}c&&((l=!q&&l)&&r--,f&&t.push(l))}if(r+=s,c&&s!==r){o=0;while(q=b[o++])q(t,u,g,h);if(f){if(r>0)while(s--)t[s]||u[s]||(u[s]=F.call(i));u=ua(u)}H.apply(i,u),k&&!f&&u.length>0&&r+b.length>1&&fa.uniqueSort(i)}return k&&(w=y,j=v),t};return c?ha(f):f}return h=fa.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=wa(b[c]),f[u]?d.push(f):e.push(f);f=A(a,xa(e,d)),f.selector=a}return f},i=fa.select=function(a,b,e,f){var i,j,k,l,m,n="function"==typeof a&&a,o=!f&&g(a=n.selector||a);if(e=e||[],1===o.length){if(j=o[0]=o[0].slice(0),j.length>2&&"ID"===(k=j[0]).type&&c.getById&&9===b.nodeType&&p&&d.relative[j[1].type]){if(b=(d.find.ID(k.matches[0].replace(ba,ca),b)||[])[0],!b)return e;n&&(b=b.parentNode),a=a.slice(j.shift().value.length)}i=W.needsContext.test(a)?0:j.length;while(i--){if(k=j[i],d.relative[l=k.type])break;if((m=d.find[l])&&(f=m(k.matches[0].replace(ba,ca),_.test(j[0].type)&&oa(b.parentNode)||b))){if(j.splice(i,1),a=f.length&&qa(j),!a)return H.apply(e,f),e;break}}}return(n||h(a,o))(f,b,!p,e,!b||_.test(a)&&oa(b.parentNode)||b),e},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ia(function(a){return 1&a.compareDocumentPosition(n.createElement("div"))}),ia(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||ja("type|href|height|width",function(a,b,c){return c?void 0:a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ia(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||ja("value",function(a,b,c){return c||"input"!==a.nodeName.toLowerCase()?void 0:a.defaultValue}),ia(function(a){return null==a.getAttribute("disabled")})||ja(K,function(a,b,c){var d;return c?void 0:a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),fa}(a);n.find=t,n.expr=t.selectors,n.expr[":"]=n.expr.pseudos,n.uniqueSort=n.unique=t.uniqueSort,n.text=t.getText,n.isXMLDoc=t.isXML,n.contains=t.contains;var u=function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&n(a).is(c))break;d.push(a)}return d},v=function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c},w=n.expr.match.needsContext,x=/^<([\w-]+)\s*\/?>(?:<\/\1>|)$/,y=/^.[^:#\[\.,]*$/;function z(a,b,c){if(n.isFunction(b))return n.grep(a,function(a,d){return!!b.call(a,d,a)!==c});if(b.nodeType)return n.grep(a,function(a){return a===b!==c});if("string"==typeof b){if(y.test(b))return n.filter(b,a,c);b=n.filter(b,a)}return n.grep(a,function(a){return h.call(b,a)>-1!==c})}n.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?n.find.matchesSelector(d,a)?[d]:[]:n.find.matches(a,n.grep(b,function(a){return 1===a.nodeType}))},n.fn.extend({find:function(a){var b,c=this.length,d=[],e=this;if("string"!=typeof a)return this.pushStack(n(a).filter(function(){for(b=0;c>b;b++)if(n.contains(e[b],this))return!0}));for(b=0;c>b;b++)n.find(a,e[b],d);return d=this.pushStack(c>1?n.unique(d):d),d.selector=this.selector?this.selector+" "+a:a,d},filter:function(a){return this.pushStack(z(this,a||[],!1))},not:function(a){return this.pushStack(z(this,a||[],!0))},is:function(a){return!!z(this,"string"==typeof a&&w.test(a)?n(a):a||[],!1).length}});var A,B=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,C=n.fn.init=function(a,b,c){var e,f;if(!a)return this;if(c=c||A,"string"==typeof a){if(e="<"===a[0]&&">"===a[a.length-1]&&a.length>=3?[null,a,null]:B.exec(a),!e||!e[1]&&b)return!b||b.jquery?(b||c).find(a):this.constructor(b).find(a);if(e[1]){if(b=b instanceof n?b[0]:b,n.merge(this,n.parseHTML(e[1],b&&b.nodeType?b.ownerDocument||b:d,!0)),x.test(e[1])&&n.isPlainObject(b))for(e in b)n.isFunction(this[e])?this[e](b[e]):this.attr(e,b[e]);return this}return f=d.getElementById(e[2]),f&&f.parentNode&&(this.length=1,this[0]=f),this.context=d,this.selector=a,this}return a.nodeType?(this.context=this[0]=a,this.length=1,this):n.isFunction(a)?void 0!==c.ready?c.ready(a):a(n):(void 0!==a.selector&&(this.selector=a.selector,this.context=a.context),n.makeArray(a,this))};C.prototype=n.fn,A=n(d);var D=/^(?:parents|prev(?:Until|All))/,E={children:!0,contents:!0,next:!0,prev:!0};n.fn.extend({has:function(a){var b=n(a,this),c=b.length;return this.filter(function(){for(var a=0;c>a;a++)if(n.contains(this,b[a]))return!0})},closest:function(a,b){for(var c,d=0,e=this.length,f=[],g=w.test(a)||"string"!=typeof a?n(a,b||this.context):0;e>d;d++)for(c=this[d];c&&c!==b;c=c.parentNode)if(c.nodeType<11&&(g?g.index(c)>-1:1===c.nodeType&&n.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?n.uniqueSort(f):f)},index:function(a){return a?"string"==typeof a?h.call(n(a),this[0]):h.call(this,a.jquery?a[0]:a):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(n.uniqueSort(n.merge(this.get(),n(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function F(a,b){while((a=a[b])&&1!==a.nodeType);return a}n.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return u(a,"parentNode")},parentsUntil:function(a,b,c){return u(a,"parentNode",c)},next:function(a){return F(a,"nextSibling")},prev:function(a){return F(a,"previousSibling")},nextAll:function(a){return u(a,"nextSibling")},prevAll:function(a){return u(a,"previousSibling")},nextUntil:function(a,b,c){return u(a,"nextSibling",c)},prevUntil:function(a,b,c){return u(a,"previousSibling",c)},siblings:function(a){return v((a.parentNode||{}).firstChild,a)},children:function(a){return v(a.firstChild)},contents:function(a){return a.contentDocument||n.merge([],a.childNodes)}},function(a,b){n.fn[a]=function(c,d){var e=n.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=n.filter(d,e)),this.length>1&&(E[a]||n.uniqueSort(e),D.test(a)&&e.reverse()),this.pushStack(e)}});var G=/\S+/g;function H(a){var b={};return n.each(a.match(G)||[],function(a,c){b[c]=!0}),b}n.Callbacks=function(a){a="string"==typeof a?H(a):n.extend({},a);var b,c,d,e,f=[],g=[],h=-1,i=function(){for(e=a.once,d=b=!0;g.length;h=-1){c=g.shift();while(++h-1)f.splice(c,1),h>=c&&h--}),this},has:function(a){return a?n.inArray(a,f)>-1:f.length>0},empty:function(){return f&&(f=[]),this},disable:function(){return e=g=[],f=c="",this},disabled:function(){return!f},lock:function(){return e=g=[],c||(f=c=""),this},locked:function(){return!!e},fireWith:function(a,c){return e||(c=c||[],c=[a,c.slice?c.slice():c],g.push(c),b||i()),this},fire:function(){return j.fireWith(this,arguments),this},fired:function(){return!!d}};return j},n.extend({Deferred:function(a){var b=[["resolve","done",n.Callbacks("once memory"),"resolved"],["reject","fail",n.Callbacks("once memory"),"rejected"],["notify","progress",n.Callbacks("memory")]],c="pending",d={state:function(){return c},always:function(){return e.done(arguments).fail(arguments),this},then:function(){var a=arguments;return n.Deferred(function(c){n.each(b,function(b,f){var g=n.isFunction(a[b])&&a[b];e[f[1]](function(){var a=g&&g.apply(this,arguments);a&&n.isFunction(a.promise)?a.promise().progress(c.notify).done(c.resolve).fail(c.reject):c[f[0]+"With"](this===d?c.promise():this,g?[a]:arguments)})}),a=null}).promise()},promise:function(a){return null!=a?n.extend(a,d):d}},e={};return d.pipe=d.then,n.each(b,function(a,f){var g=f[2],h=f[3];d[f[1]]=g.add,h&&g.add(function(){c=h},b[1^a][2].disable,b[2][2].lock),e[f[0]]=function(){return e[f[0]+"With"](this===e?d:this,arguments),this},e[f[0]+"With"]=g.fireWith}),d.promise(e),a&&a.call(e,e),e},when:function(a){var b=0,c=e.call(arguments),d=c.length,f=1!==d||a&&n.isFunction(a.promise)?d:0,g=1===f?a:n.Deferred(),h=function(a,b,c){return function(d){b[a]=this,c[a]=arguments.length>1?e.call(arguments):d,c===i?g.notifyWith(b,c):--f||g.resolveWith(b,c)}},i,j,k;if(d>1)for(i=new Array(d),j=new Array(d),k=new Array(d);d>b;b++)c[b]&&n.isFunction(c[b].promise)?c[b].promise().progress(h(b,j,i)).done(h(b,k,c)).fail(g.reject):--f;return f||g.resolveWith(k,c),g.promise()}});var I;n.fn.ready=function(a){return n.ready.promise().done(a),this},n.extend({isReady:!1,readyWait:1,holdReady:function(a){a?n.readyWait++:n.ready(!0)},ready:function(a){(a===!0?--n.readyWait:n.isReady)||(n.isReady=!0,a!==!0&&--n.readyWait>0||(I.resolveWith(d,[n]),n.fn.triggerHandler&&(n(d).triggerHandler("ready"),n(d).off("ready"))))}});function J(){d.removeEventListener("DOMContentLoaded",J),a.removeEventListener("load",J),n.ready()}n.ready.promise=function(b){return I||(I=n.Deferred(),"complete"===d.readyState||"loading"!==d.readyState&&!d.documentElement.doScroll?a.setTimeout(n.ready):(d.addEventListener("DOMContentLoaded",J),a.addEventListener("load",J))),I.promise(b)},n.ready.promise();var K=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===n.type(c)){e=!0;for(h in c)K(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,n.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(n(a),c)})),b))for(;i>h;h++)b(a[h],c,g?d:d.call(a[h],h,b(a[h],c)));return e?a:j?b.call(a):i?b(a[0],c):f},L=function(a){return 1===a.nodeType||9===a.nodeType||!+a.nodeType};function M(){this.expando=n.expando+M.uid++}M.uid=1,M.prototype={register:function(a,b){var c=b||{};return a.nodeType?a[this.expando]=c:Object.defineProperty(a,this.expando,{value:c,writable:!0,configurable:!0}),a[this.expando]},cache:function(a){if(!L(a))return{};var b=a[this.expando];return b||(b={},L(a)&&(a.nodeType?a[this.expando]=b:Object.defineProperty(a,this.expando,{value:b,configurable:!0}))),b},set:function(a,b,c){var d,e=this.cache(a);if("string"==typeof b)e[b]=c;else for(d in b)e[d]=b[d];return e},get:function(a,b){return void 0===b?this.cache(a):a[this.expando]&&a[this.expando][b]},access:function(a,b,c){var d;return void 0===b||b&&"string"==typeof b&&void 0===c?(d=this.get(a,b),void 0!==d?d:this.get(a,n.camelCase(b))):(this.set(a,b,c),void 0!==c?c:b)},remove:function(a,b){var c,d,e,f=a[this.expando];if(void 0!==f){if(void 0===b)this.register(a);else{n.isArray(b)?d=b.concat(b.map(n.camelCase)):(e=n.camelCase(b),b in f?d=[b,e]:(d=e,d=d in f?[d]:d.match(G)||[])),c=d.length;while(c--)delete f[d[c]]}(void 0===b||n.isEmptyObject(f))&&(a.nodeType?a[this.expando]=void 0:delete a[this.expando])}},hasData:function(a){var b=a[this.expando];return void 0!==b&&!n.isEmptyObject(b)}};var N=new M,O=new M,P=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,Q=/[A-Z]/g;function R(a,b,c){var d;if(void 0===c&&1===a.nodeType)if(d="data-"+b.replace(Q,"-$&").toLowerCase(),c=a.getAttribute(d),"string"==typeof c){try{c="true"===c?!0:"false"===c?!1:"null"===c?null:+c+""===c?+c:P.test(c)?n.parseJSON(c):c}catch(e){}O.set(a,b,c); -}else c=void 0;return c}n.extend({hasData:function(a){return O.hasData(a)||N.hasData(a)},data:function(a,b,c){return O.access(a,b,c)},removeData:function(a,b){O.remove(a,b)},_data:function(a,b,c){return N.access(a,b,c)},_removeData:function(a,b){N.remove(a,b)}}),n.fn.extend({data:function(a,b){var c,d,e,f=this[0],g=f&&f.attributes;if(void 0===a){if(this.length&&(e=O.get(f),1===f.nodeType&&!N.get(f,"hasDataAttrs"))){c=g.length;while(c--)g[c]&&(d=g[c].name,0===d.indexOf("data-")&&(d=n.camelCase(d.slice(5)),R(f,d,e[d])));N.set(f,"hasDataAttrs",!0)}return e}return"object"==typeof a?this.each(function(){O.set(this,a)}):K(this,function(b){var c,d;if(f&&void 0===b){if(c=O.get(f,a)||O.get(f,a.replace(Q,"-$&").toLowerCase()),void 0!==c)return c;if(d=n.camelCase(a),c=O.get(f,d),void 0!==c)return c;if(c=R(f,d,void 0),void 0!==c)return c}else d=n.camelCase(a),this.each(function(){var c=O.get(this,d);O.set(this,d,b),a.indexOf("-")>-1&&void 0!==c&&O.set(this,a,b)})},null,b,arguments.length>1,null,!0)},removeData:function(a){return this.each(function(){O.remove(this,a)})}}),n.extend({queue:function(a,b,c){var d;return a?(b=(b||"fx")+"queue",d=N.get(a,b),c&&(!d||n.isArray(c)?d=N.access(a,b,n.makeArray(c)):d.push(c)),d||[]):void 0},dequeue:function(a,b){b=b||"fx";var c=n.queue(a,b),d=c.length,e=c.shift(),f=n._queueHooks(a,b),g=function(){n.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return N.get(a,c)||N.access(a,c,{empty:n.Callbacks("once memory").add(function(){N.remove(a,[b+"queue",c])})})}}),n.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.length",""],thead:[1,"","
            "],col:[2,"","
            "],tr:[2,"","
            "],td:[3,"","
            "],_default:[0,"",""]};$.optgroup=$.option,$.tbody=$.tfoot=$.colgroup=$.caption=$.thead,$.th=$.td;function _(a,b){var c="undefined"!=typeof a.getElementsByTagName?a.getElementsByTagName(b||"*"):"undefined"!=typeof a.querySelectorAll?a.querySelectorAll(b||"*"):[];return void 0===b||b&&n.nodeName(a,b)?n.merge([a],c):c}function aa(a,b){for(var c=0,d=a.length;d>c;c++)N.set(a[c],"globalEval",!b||N.get(b[c],"globalEval"))}var ba=/<|&#?\w+;/;function ca(a,b,c,d,e){for(var f,g,h,i,j,k,l=b.createDocumentFragment(),m=[],o=0,p=a.length;p>o;o++)if(f=a[o],f||0===f)if("object"===n.type(f))n.merge(m,f.nodeType?[f]:f);else if(ba.test(f)){g=g||l.appendChild(b.createElement("div")),h=(Y.exec(f)||["",""])[1].toLowerCase(),i=$[h]||$._default,g.innerHTML=i[1]+n.htmlPrefilter(f)+i[2],k=i[0];while(k--)g=g.lastChild;n.merge(m,g.childNodes),g=l.firstChild,g.textContent=""}else m.push(b.createTextNode(f));l.textContent="",o=0;while(f=m[o++])if(d&&n.inArray(f,d)>-1)e&&e.push(f);else if(j=n.contains(f.ownerDocument,f),g=_(l.appendChild(f),"script"),j&&aa(g),c){k=0;while(f=g[k++])Z.test(f.type||"")&&c.push(f)}return l}!function(){var a=d.createDocumentFragment(),b=a.appendChild(d.createElement("div")),c=d.createElement("input");c.setAttribute("type","radio"),c.setAttribute("checked","checked"),c.setAttribute("name","t"),b.appendChild(c),l.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,b.innerHTML="",l.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var da=/^key/,ea=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,fa=/^([^.]*)(?:\.(.+)|)/;function ga(){return!0}function ha(){return!1}function ia(){try{return d.activeElement}catch(a){}}function ja(a,b,c,d,e,f){var g,h;if("object"==typeof b){"string"!=typeof c&&(d=d||c,c=void 0);for(h in b)ja(a,h,c,d,b[h],f);return a}if(null==d&&null==e?(e=c,d=c=void 0):null==e&&("string"==typeof c?(e=d,d=void 0):(e=d,d=c,c=void 0)),e===!1)e=ha;else if(!e)return this;return 1===f&&(g=e,e=function(a){return n().off(a),g.apply(this,arguments)},e.guid=g.guid||(g.guid=n.guid++)),a.each(function(){n.event.add(this,b,e,d,c)})}n.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=N.get(a);if(r){c.handler&&(f=c,c=f.handler,e=f.selector),c.guid||(c.guid=n.guid++),(i=r.events)||(i=r.events={}),(g=r.handle)||(g=r.handle=function(b){return"undefined"!=typeof n&&n.event.triggered!==b.type?n.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(G)||[""],j=b.length;while(j--)h=fa.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o&&(l=n.event.special[o]||{},o=(e?l.delegateType:l.bindType)||o,l=n.event.special[o]||{},k=n.extend({type:o,origType:q,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&n.expr.match.needsContext.test(e),namespace:p.join(".")},f),(m=i[o])||(m=i[o]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,p,g)!==!1||a.addEventListener&&a.addEventListener(o,g)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),n.event.global[o]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=N.hasData(a)&&N.get(a);if(r&&(i=r.events)){b=(b||"").match(G)||[""],j=b.length;while(j--)if(h=fa.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o){l=n.event.special[o]||{},o=(d?l.delegateType:l.bindType)||o,m=i[o]||[],h=h[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&q!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,p,r.handle)!==!1||n.removeEvent(a,o,r.handle),delete i[o])}else for(o in i)n.event.remove(a,o+b[j],c,d,!0);n.isEmptyObject(i)&&N.remove(a,"handle events")}},dispatch:function(a){a=n.event.fix(a);var b,c,d,f,g,h=[],i=e.call(arguments),j=(N.get(this,"events")||{})[a.type]||[],k=n.event.special[a.type]||{};if(i[0]=a,a.delegateTarget=this,!k.preDispatch||k.preDispatch.call(this,a)!==!1){h=n.event.handlers.call(this,a,j),b=0;while((f=h[b++])&&!a.isPropagationStopped()){a.currentTarget=f.elem,c=0;while((g=f.handlers[c++])&&!a.isImmediatePropagationStopped())(!a.rnamespace||a.rnamespace.test(g.namespace))&&(a.handleObj=g,a.data=g.data,d=((n.event.special[g.origType]||{}).handle||g.handler).apply(f.elem,i),void 0!==d&&(a.result=d)===!1&&(a.preventDefault(),a.stopPropagation()))}return k.postDispatch&&k.postDispatch.call(this,a),a.result}},handlers:function(a,b){var c,d,e,f,g=[],h=b.delegateCount,i=a.target;if(h&&i.nodeType&&("click"!==a.type||isNaN(a.button)||a.button<1))for(;i!==this;i=i.parentNode||this)if(1===i.nodeType&&(i.disabled!==!0||"click"!==a.type)){for(d=[],c=0;h>c;c++)f=b[c],e=f.selector+" ",void 0===d[e]&&(d[e]=f.needsContext?n(e,this).index(i)>-1:n.find(e,this,null,[i]).length),d[e]&&d.push(f);d.length&&g.push({elem:i,handlers:d})}return h]*)\/>/gi,la=/\s*$/g;function pa(a,b){return n.nodeName(a,"table")&&n.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a:a}function qa(a){return a.type=(null!==a.getAttribute("type"))+"/"+a.type,a}function ra(a){var b=na.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function sa(a,b){var c,d,e,f,g,h,i,j;if(1===b.nodeType){if(N.hasData(a)&&(f=N.access(a),g=N.set(b,f),j=f.events)){delete g.handle,g.events={};for(e in j)for(c=0,d=j[e].length;d>c;c++)n.event.add(b,e,j[e][c])}O.hasData(a)&&(h=O.access(a),i=n.extend({},h),O.set(b,i))}}function ta(a,b){var c=b.nodeName.toLowerCase();"input"===c&&X.test(a.type)?b.checked=a.checked:("input"===c||"textarea"===c)&&(b.defaultValue=a.defaultValue)}function ua(a,b,c,d){b=f.apply([],b);var e,g,h,i,j,k,m=0,o=a.length,p=o-1,q=b[0],r=n.isFunction(q);if(r||o>1&&"string"==typeof q&&!l.checkClone&&ma.test(q))return a.each(function(e){var f=a.eq(e);r&&(b[0]=q.call(this,e,f.html())),ua(f,b,c,d)});if(o&&(e=ca(b,a[0].ownerDocument,!1,a,d),g=e.firstChild,1===e.childNodes.length&&(e=g),g||d)){for(h=n.map(_(e,"script"),qa),i=h.length;o>m;m++)j=e,m!==p&&(j=n.clone(j,!0,!0),i&&n.merge(h,_(j,"script"))),c.call(a[m],j,m);if(i)for(k=h[h.length-1].ownerDocument,n.map(h,ra),m=0;i>m;m++)j=h[m],Z.test(j.type||"")&&!N.access(j,"globalEval")&&n.contains(k,j)&&(j.src?n._evalUrl&&n._evalUrl(j.src):n.globalEval(j.textContent.replace(oa,"")))}return a}function va(a,b,c){for(var d,e=b?n.filter(b,a):a,f=0;null!=(d=e[f]);f++)c||1!==d.nodeType||n.cleanData(_(d)),d.parentNode&&(c&&n.contains(d.ownerDocument,d)&&aa(_(d,"script")),d.parentNode.removeChild(d));return a}n.extend({htmlPrefilter:function(a){return a.replace(ka,"<$1>")},clone:function(a,b,c){var d,e,f,g,h=a.cloneNode(!0),i=n.contains(a.ownerDocument,a);if(!(l.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||n.isXMLDoc(a)))for(g=_(h),f=_(a),d=0,e=f.length;e>d;d++)ta(f[d],g[d]);if(b)if(c)for(f=f||_(a),g=g||_(h),d=0,e=f.length;e>d;d++)sa(f[d],g[d]);else sa(a,h);return g=_(h,"script"),g.length>0&&aa(g,!i&&_(a,"script")),h},cleanData:function(a){for(var b,c,d,e=n.event.special,f=0;void 0!==(c=a[f]);f++)if(L(c)){if(b=c[N.expando]){if(b.events)for(d in b.events)e[d]?n.event.remove(c,d):n.removeEvent(c,d,b.handle);c[N.expando]=void 0}c[O.expando]&&(c[O.expando]=void 0)}}}),n.fn.extend({domManip:ua,detach:function(a){return va(this,a,!0)},remove:function(a){return va(this,a)},text:function(a){return K(this,function(a){return void 0===a?n.text(this):this.empty().each(function(){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&(this.textContent=a)})},null,a,arguments.length)},append:function(){return ua(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=pa(this,a);b.appendChild(a)}})},prepend:function(){return ua(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=pa(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return ua(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return ua(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},empty:function(){for(var a,b=0;null!=(a=this[b]);b++)1===a.nodeType&&(n.cleanData(_(a,!1)),a.textContent="");return this},clone:function(a,b){return a=null==a?!1:a,b=null==b?a:b,this.map(function(){return n.clone(this,a,b)})},html:function(a){return K(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a&&1===b.nodeType)return b.innerHTML;if("string"==typeof a&&!la.test(a)&&!$[(Y.exec(a)||["",""])[1].toLowerCase()]){a=n.htmlPrefilter(a);try{for(;d>c;c++)b=this[c]||{},1===b.nodeType&&(n.cleanData(_(b,!1)),b.innerHTML=a);b=0}catch(e){}}b&&this.empty().append(a)},null,a,arguments.length)},replaceWith:function(){var a=[];return ua(this,arguments,function(b){var c=this.parentNode;n.inArray(this,a)<0&&(n.cleanData(_(this)),c&&c.replaceChild(b,this))},a)}}),n.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){n.fn[a]=function(a){for(var c,d=[],e=n(a),f=e.length-1,h=0;f>=h;h++)c=h===f?this:this.clone(!0),n(e[h])[b](c),g.apply(d,c.get());return this.pushStack(d)}});var wa,xa={HTML:"block",BODY:"block"};function ya(a,b){var c=n(b.createElement(a)).appendTo(b.body),d=n.css(c[0],"display");return c.detach(),d}function za(a){var b=d,c=xa[a];return c||(c=ya(a,b),"none"!==c&&c||(wa=(wa||n("