From 17d8fea796405447d1412055d7203316cf639cce Mon Sep 17 00:00:00 2001 From: Kris Date: Tue, 7 Aug 2018 16:15:28 -0400 Subject: [PATCH 001/179] Markdown tables should have vertical margin --- app/assets/stylesheets/common/base/compose.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/common/base/compose.scss b/app/assets/stylesheets/common/base/compose.scss index 74951ddf77..81c3c019ee 100644 --- a/app/assets/stylesheets/common/base/compose.scss +++ b/app/assets/stylesheets/common/base/compose.scss @@ -437,4 +437,5 @@ div.ac-wrap { .md-table { overflow-y: auto; + margin: 1em 0; } From 4e6e4a83df5661d26ec07deee2881b8ffb3b6521 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Tue, 7 Aug 2018 16:38:05 -0400 Subject: [PATCH 002/179] FIX: subfolder digest emails have incorrect URLs --- app/views/user_notifications/digest.html.erb | 2 +- app/views/user_notifications/digest.text.erb | 8 ++++---- lib/markdown_linker.rb | 2 +- spec/mailers/user_notifications_spec.rb | 20 +++++++++++++++++--- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/app/views/user_notifications/digest.html.erb b/app/views/user_notifications/digest.html.erb index b09bc3b736..de8e805c8d 100644 --- a/app/views/user_notifications/digest.html.erb +++ b/app/views/user_notifications/digest.html.erb @@ -420,7 +420,7 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo <%=raw(t 'user_notifications.digest.unsubscribe', site_link: html_site_link(@anchor_color), email_preferences_link: link_to(t('user_notifications.digest.your_email_settings'), Discourse.base_url + '/my/preferences/emails'), - unsubscribe_link: link_to(t('user_notifications.digest.click_here'), email_unsubscribe_url(host: Discourse.base_url, key: @unsubscribe_key), {:style=>"color: ##{@anchor_color}"})) %> + unsubscribe_link: link_to(t('user_notifications.digest.click_here'), "#{Discourse.base_url}/email/unsubscribe/#{@unsubscribe_key}", {:style=>"color: ##{@anchor_color}"})) %> <%= digest_custom_html("below_footer") %> diff --git a/app/views/user_notifications/digest.text.erb b/app/views/user_notifications/digest.text.erb index e3bbc1f818..40ce99b8ef 100644 --- a/app/views/user_notifications/digest.text.erb +++ b/app/views/user_notifications/digest.text.erb @@ -13,7 +13,7 @@ ### <%=t 'user_notifications.digest.popular_topics' %> <%- @popular_topics.each_with_index do |t,i| %> -<%= raw(@markdown_linker.create(t.title, t.relative_url)) %> +<%= raw(@markdown_linker.create(t.title, t.url)) %> <%- if t.best_post.present? %> <%= raw(t.best_post.excerpt(1000, strip_links: true, text_entities: true, markdown_images: true)) %> @@ -29,7 +29,7 @@ ### <%=t 'user_notifications.digest.popular_posts' %> <%- @popular_posts.each_with_index do |post,i| %> -<%= post.user.username -%> - <%= raw(@markdown_linker.create(post.topic.title, post.topic.relative_url)) %> +<%= post.user.username -%> - <%= raw(@markdown_linker.create(post.topic.title, post.topic.url)) %> <%= raw(post.excerpt(1000, strip_links: true, text_entities: true, markdown_images: true)) %> -------------------------------------------------------------------------------- @@ -41,7 +41,7 @@ **<%=t 'user_notifications.digest.more_new' %>** <%- @other_new_for_you.each do |t| %> -* <%= raw(@markdown_linker.create(t.title, t.relative_url)) %> - <%= t.posts_count %> - <%- if t.category %>[<%= t.category.name %>]<%- end %> +* <%= raw(@markdown_linker.create(t.title, t.url)) %> - <%= t.posts_count %> - <%- if t.category %>[<%= t.category.name %>]<%- end %> <%- end -%> <%- end %> @@ -53,7 +53,7 @@ <%=raw(t :'user_notifications.digest.unsubscribe', site_link: site_link, email_preferences_link: raw(@markdown_linker.create(t('user_notifications.digest.your_email_settings'), '/my/preferences/emails')), - unsubscribe_link: raw(@markdown_linker.create(t('user_notifications.digest.click_here'), email_unsubscribe_url(key: @unsubscribe_key, only_path: true)))) %> + unsubscribe_link: raw(@markdown_linker.create(t('user_notifications.digest.click_here'), "/email/unsubscribe/#{@unsubscribe_key}"))) %> <%= raw(@markdown_linker.references) %> diff --git a/lib/markdown_linker.rb b/lib/markdown_linker.rb index fab7c5a6b6..9e69f6c106 100644 --- a/lib/markdown_linker.rb +++ b/lib/markdown_linker.rb @@ -9,7 +9,7 @@ class MarkdownLinker end def create(title, url) - @markdown_links[@index] = "#{@base_url}#{url}" + @markdown_links[@index] = url.start_with?(@base_url) ? url : "#{@base_url}#{url}" result = "[#{title}][#{@index}]" @index += 1 result diff --git a/spec/mailers/user_notifications_spec.rb b/spec/mailers/user_notifications_spec.rb index b144995f1b..015789e0b7 100644 --- a/spec/mailers/user_notifications_spec.rb +++ b/spec/mailers/user_notifications_spec.rb @@ -127,9 +127,7 @@ describe UserNotifications do context "with new topics" do - before do - Fabricate(:topic, user: Fabricate(:coding_horror), created_at: 1.hour.ago) - end + let!(:popular_topic) { Fabricate(:topic, user: Fabricate(:coding_horror), created_at: 1.hour.ago) } it "works" do expect(subject.to).to eq([user.email]) @@ -206,6 +204,22 @@ describe UserNotifications do expect(html).to include '1E1E1E' expect(html).to include '858585' end + + it "supports subfolder" do + GlobalSetting.stubs(:relative_url_root).returns('/forum') + Discourse.stubs(:base_uri).returns("/forum") + html = subject.html_part.body.to_s + text = subject.text_part.body.to_s + expect(html).to be_present + expect(text).to be_present + expect(html).to_not include("/forum/forum") + expect(text).to_not include("/forum/forum") + expect(subject.header["List-Unsubscribe"].to_s).to match(/http:\/\/test.localhost\/forum\/email\/unsubscribe\/\h{64}/) + + topic_url = "http://test.localhost/forum/t/#{popular_topic.slug}/#{popular_topic.id}" + expect(html).to include(topic_url) + expect(text).to include(topic_url) + end end end From 3f6ad65aec3db07210423fa570d5b4c6af12d895 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 8 Aug 2018 11:15:49 +1000 Subject: [PATCH 003/179] FEATURE: include excerpt in HTML view for pinned topics --- app/views/list/list.erb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/views/list/list.erb b/app/views/list/list.erb index cb2dc7d37e..14d6965497 100644 --- a/app/views/list/list.erb +++ b/app/views/list/list.erb @@ -52,6 +52,12 @@ [<%= t.category.name %>] <% end %> '>(<%= t.posts_count %>) + + <% if t.pinned_until && t.pinned_until > Time.zone.now && (t.pinned_globally || @list.category) %> +

+ <%= t.excerpt.html_safe %> +

+ <% end %> <% end %> From aafff740d2abb628fed0bc848a6b414527ad45ab Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 8 Aug 2018 11:26:05 +0800 Subject: [PATCH 004/179] Add `FileStore::S3Store#copy_file`. --- lib/file_store/s3_store.rb | 5 +++ lib/s3_helper.rb | 25 +++++++++----- spec/components/file_store/s3_store_spec.rb | 37 ++++++++++++++++++--- 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/lib/file_store/s3_store.rb b/lib/file_store/s3_store.rb index 483b41e02f..076ea2171a 100644 --- a/lib/file_store/s3_store.rb +++ b/lib/file_store/s3_store.rb @@ -50,6 +50,11 @@ module FileStore @s3_helper.remove(path, true) end + def copy_file(url, source, destination) + return unless has_been_uploaded?(url) + @s3_helper.copy(source, destination) + end + def has_been_uploaded?(url) return false if url.blank? diff --git a/lib/s3_helper.rb b/lib/s3_helper.rb index 9e1ae3ec4e..61d5855954 100644 --- a/lib/s3_helper.rb +++ b/lib/s3_helper.rb @@ -30,20 +30,25 @@ class S3Helper end def remove(s3_filename, copy_to_tombstone = false) - bucket = s3_bucket - # copy the file in tombstone if copy_to_tombstone && @tombstone_prefix.present? - bucket - .object(File.join(@tombstone_prefix, s3_filename)) - .copy_from(copy_source: File.join(@s3_bucket_name, get_path_for_s3_upload(s3_filename))) + self.copy( + File.join(@tombstone_prefix, s3_filename), + get_path_for_s3_upload(s3_filename) + ) end # delete the file - bucket.object(get_path_for_s3_upload(s3_filename)).delete + s3_bucket.object(get_path_for_s3_upload(s3_filename)).delete rescue Aws::S3::Errors::NoSuchKey end + def copy(source, destination) + s3_bucket + .object(source) + .copy_from(copy_source: File.join(@s3_bucket_name, destination)) + end + # make sure we have a cors config for assets # otherwise we will have no fonts def ensure_cors! @@ -186,9 +191,11 @@ class S3Helper end def s3_bucket - bucket = s3_resource.bucket(@s3_bucket_name) - bucket.create unless bucket.exists? - bucket + @s3_bucket ||= begin + bucket = s3_resource.bucket(@s3_bucket_name) + bucket.create unless bucket.exists? + bucket + end end def check_missing_site_options diff --git a/spec/components/file_store/s3_store_spec.rb b/spec/components/file_store/s3_store_spec.rb index 00df4f36f9..58cecf5e00 100644 --- a/spec/components/file_store/s3_store_spec.rb +++ b/spec/components/file_store/s3_store_spec.rb @@ -41,7 +41,7 @@ describe FileStore::S3Store do describe "#store_upload" do it "returns an absolute schemaless url" do store.expects(:get_depth_for).with(upload.id).returns(0) - s3_helper.expects(:s3_bucket).returns(s3_bucket) + s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once s3_object = stub s3_bucket.expects(:object).with("original/1X/#{upload.sha1}.png").returns(s3_object) @@ -109,13 +109,40 @@ describe FileStore::S3Store do end end + context 'copying files in S3' do + include_context "s3 helpers" + + describe '#copy_file' do + it "copies the from in S3 with the right paths" do + s3_helper.expects(:s3_bucket).returns(s3_bucket) + + upload.update!( + url: "//s3-upload-bucket.s3-us-west-1.amazonaws.com/original/1X/#{upload.sha1}.png" + ) + + source = Discourse.store.get_path_for_upload(upload) + destination = Discourse.store.get_path_for_upload(upload).sub('.png', '.jpg') + + s3_object = stub + + s3_bucket.expects(:object).with(source).returns(s3_object) + + s3_object.expects(:copy_from).with( + copy_source: "s3-upload-bucket/#{destination}" + ) + + store.copy_file(upload.url, source, destination) + end + end + end + context 'removal from s3' do include_context "s3 helpers" describe "#remove_upload" do it "removes the file from s3 with the right paths" do store.expects(:get_depth_for).with(upload.id).returns(0) - s3_helper.expects(:s3_bucket).returns(s3_bucket) + s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once upload.update_attributes!(url: "//s3-upload-bucket.s3-us-west-1.amazonaws.com/original/1X/#{upload.sha1}.png") s3_object = stub @@ -134,7 +161,7 @@ describe FileStore::S3Store do it "removes the file from s3 with the right paths" do store.expects(:get_depth_for).with(upload.id).returns(0) - s3_helper.expects(:s3_bucket).returns(s3_bucket) + s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once upload.update_attributes!(url: "//s3-upload-bucket.s3-us-west-1.amazonaws.com/discourse-uploads/original/1X/#{upload.sha1}.png") s3_object = stub @@ -158,7 +185,7 @@ describe FileStore::S3Store do it "removes the file from s3 with the right paths" do store.expects(:get_depth_for).with(optimized_image.upload.id).returns(0) - s3_helper.expects(:s3_bucket).returns(s3_bucket) + s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once s3_object = stub s3_bucket.expects(:object).with("tombstone/optimized/1X/#{upload.sha1}_1_100x200.png").returns(s3_object) @@ -176,7 +203,7 @@ describe FileStore::S3Store do it "removes the file from s3 with the right paths" do store.expects(:get_depth_for).with(optimized_image.upload.id).returns(0) - s3_helper.expects(:s3_bucket).returns(s3_bucket) + s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once s3_object = stub s3_bucket.expects(:object).with("discourse-uploads/tombstone/optimized/1X/#{upload.sha1}_1_100x200.png").returns(s3_object) From 0b7ed8ffaf6e306bacbdeb6623e73fa5f6375e8f Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Wed, 8 Aug 2018 07:46:34 +0300 Subject: [PATCH 005/179] FEATURE: backend support for user-selectable components * FEATURE: backend support for user-selectable components * fix problems with previewing default theme * rename preview_key => preview_theme_id * omit default theme from child themes dropdown and try a different fix * cache & freeze stylesheets arrays --- .../admin-customize-themes-show.js.es6 | 28 ++- .../javascripts/admin/models/theme.js.es6 | 5 +- .../admin/templates/customize-themes-show.hbs | 2 +- .../controllers/preferences/interface.js.es6 | 2 +- .../initializers/live-development.js.es6 | 14 +- .../discourse/lib/theme-selector.js.es6 | 60 +++--- app/controllers/admin/themes_controller.rb | 8 +- app/controllers/application_controller.rb | 37 ++-- app/controllers/stylesheets_controller.rb | 2 +- app/controllers/themes_controller.rb | 33 ++- app/helpers/application_helper.rb | 14 +- app/models/child_theme.rb | 18 ++ app/models/color_scheme.rb | 13 +- app/models/theme.rb | 202 ++++++++++-------- app/models/theme_field.rb | 11 + .../common/_discourse_stylesheet.html.erb | 2 +- app/views/layouts/application.html.erb | 2 +- app/views/layouts/crawler.html.erb | 2 +- app/views/layouts/embed.html.erb | 2 +- .../layouts/finish_installation.html.erb | 2 +- app/views/wizard/index.html.erb | 2 +- app/views/wizard/qunit.html.erb | 2 +- config/locales/server.en.yml | 4 + config/routes.rb | 2 +- ..._disallow_multi_levels_theme_components.rb | 68 ++++++ lib/guardian.rb | 12 +- lib/middleware/anonymous_cache.rb | 8 +- lib/stylesheet/manager.rb | 93 +++++--- lib/stylesheet/watcher.rb | 13 +- spec/components/guardian_spec.rb | 40 ++++ .../middleware/anonymous_cache_spec.rb | 2 +- spec/components/stylesheet/manager_spec.rb | 27 +-- .../components/theme_settings_manager_spec.rb | 2 +- spec/components/wizard/step_updater_spec.rb | 1 + spec/fabricators/theme_fabricator.rb | 4 + spec/mailers/user_notifications_spec.rb | 6 +- spec/models/admin_dashboard_data_spec.rb | 2 +- spec/models/child_theme_spec.rb | 43 ++++ spec/models/color_scheme_spec.rb | 6 +- spec/models/remote_theme_spec.rb | 2 +- spec/models/site_spec.rb | 4 +- spec/models/theme_field_spec.rb | 20 ++ spec/models/theme_spec.rb | 122 +++++++---- .../staff_action_logs_controller_spec.rb | 2 +- spec/requests/admin/themes_controller_spec.rb | 20 +- spec/requests/application_controller_spec.rb | 70 ++++++ spec/requests/embed_controller_spec.rb | 2 +- spec/requests/stylesheets_controller_spec.rb | 2 +- spec/requests/topics_controller_spec.rb | 42 ---- spec/requests/users_controller_spec.rb | 2 +- spec/services/staff_action_logger_spec.rb | 4 +- spec/services/user_merger_spec.rb | 2 +- spec/services/user_updater_spec.rb | 2 +- 53 files changed, 737 insertions(+), 355 deletions(-) create mode 100644 db/migrate/20180710172959_disallow_multi_levels_theme_components.rb create mode 100644 spec/fabricators/theme_fabricator.rb create mode 100644 spec/models/child_theme_spec.rb 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 index 20a16ab626..955d5a6710 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 @@ -1,4 +1,7 @@ -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { + default as computed, + observes +} 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"; @@ -9,6 +12,18 @@ const THEME_UPLOAD_VAR = 2; export default Ember.Controller.extend({ editRouteName: "adminCustomizeThemes.edit", + @observes("allowChildThemes") + setSelectedThemeId() { + const available = this.get("selectableChildThemes"); + if ( + !this.get("selectedChildThemeId") && + available && + available.length > 0 + ) { + this.set("selectedChildThemeId", available[0].get("id")); + } + }, + @computed("model", "allThemes") parentThemes(model, allThemes) { let parents = allThemes.filter(theme => @@ -64,16 +79,21 @@ export default Ember.Controller.extend({ let themes = []; available.forEach(t => { - if (!childThemes || childThemes.indexOf(t) === -1) { + if ( + (!childThemes || childThemes.indexOf(t) === -1) && + Em.isEmpty(t.get("childThemes")) && + !t.get("user_selectable") && + !t.get("default") + ) { themes.push(t); } }); return themes.length === 0 ? null : themes; }, - @computed("allThemes", "allThemes.length", "model") + @computed("allThemes", "allThemes.length", "model", "parentThemes") availableChildThemes(allThemes, count) { - if (count === 1) { + if (count === 1 || this.get("parentThemes")) { return null; } diff --git a/app/assets/javascripts/admin/models/theme.js.es6 b/app/assets/javascripts/admin/models/theme.js.es6 index ae4bc73eec..c3d7fd800d 100644 --- a/app/assets/javascripts/admin/models/theme.js.es6 +++ b/app/assets/javascripts/admin/models/theme.js.es6 @@ -1,5 +1,6 @@ import RestModel from "discourse/models/rest"; import { default as computed } from "ember-addons/ember-computed-decorators"; +import { popupAjaxError } from "discourse/lib/ajax-error"; const THEME_UPLOAD_VAR = 2; @@ -150,7 +151,9 @@ const Theme = RestModel.extend({ saveChanges() { const hash = this.getProperties.apply(this, arguments); - return this.save(hash).then(() => this.set("changed", false)); + return this.save(hash) + .finally(() => this.set("changed", false)) + .catch(popupAjaxError); }, saveSettings(name, value) { diff --git a/app/assets/javascripts/admin/templates/customize-themes-show.hbs b/app/assets/javascripts/admin/templates/customize-themes-show.hbs index c83b93a176..375a7e95b5 100644 --- a/app/assets/javascripts/admin/templates/customize-themes-show.hbs +++ b/app/assets/javascripts/admin/templates/customize-themes-show.hbs @@ -137,7 +137,7 @@ {{/unless}} {{#if selectableChildThemes}}

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

{{/if}} diff --git a/app/assets/javascripts/discourse/controllers/preferences/interface.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/interface.js.es6 index 29ee8aa194..8cf32d9cc6 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/interface.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/interface.js.es6 @@ -66,7 +66,7 @@ export default Ember.Controller.extend(PreferencesTabController, { @observes("themeId") themeIdChanged() { const id = this.get("themeId"); - previewTheme(id); + previewTheme([id]); }, homeChanged() { diff --git a/app/assets/javascripts/discourse/initializers/live-development.js.es6 b/app/assets/javascripts/discourse/initializers/live-development.js.es6 index 994e4e36e6..fd1f6642cb 100644 --- a/app/assets/javascripts/discourse/initializers/live-development.js.es6 +++ b/app/assets/javascripts/discourse/initializers/live-development.js.es6 @@ -1,5 +1,5 @@ import DiscourseURL from "discourse/lib/url"; -import { currentThemeId, refreshCSS } from "discourse/lib/theme-selector"; +import { currentThemeIds, refreshCSS } from "discourse/lib/theme-selector"; // Use the message bus for live reloading of components for faster development. export default { @@ -58,12 +58,16 @@ export default { // Refresh if necessary document.location.reload(true); } else { - let themeId = currentThemeId(); - + const themeIds = currentThemeIds(); $("link").each(function() { if (me.hasOwnProperty("theme_id") && me.new_href) { - let target = $(this).data("target"); - if (me.theme_id === themeId && target === me.target) { + const target = $(this).data("target"); + const themeId = $(this).data("theme-id"); + if ( + themeIds.indexOf(me.theme_id) !== -1 && + target === me.target && + (!themeId || themeId === me.theme_id) + ) { refreshCSS(this, null, me.new_href); } } else if (this.href.match(me.name) && (me.hash || me.new_href)) { diff --git a/app/assets/javascripts/discourse/lib/theme-selector.js.es6 b/app/assets/javascripts/discourse/lib/theme-selector.js.es6 index 1c5d9c4f39..ce806b4496 100644 --- a/app/assets/javascripts/discourse/lib/theme-selector.js.es6 +++ b/app/assets/javascripts/discourse/lib/theme-selector.js.es6 @@ -1,7 +1,7 @@ import { ajax } from "discourse/lib/ajax"; import deprecated from "discourse-common/lib/deprecated"; -const keySelector = "meta[name=discourse_theme_id]"; +const keySelector = "meta[name=discourse_theme_ids]"; export function currentThemeKey() { if (console && console.warn && console.trace) { @@ -12,21 +12,26 @@ export function currentThemeKey() { } } -export function currentThemeId() { - let themeId = null; - let elem = _.first($(keySelector)); +export function currentThemeIds() { + const themeIds = []; + const elem = _.first($(keySelector)); if (elem) { - themeId = elem.content; - if (_.isEmpty(themeId)) { - themeId = null; - } else { - themeId = parseInt(themeId); - } + elem.content.split(",").forEach(num => { + num = parseInt(num, 10); + if (!isNaN(num)) { + themeIds.push(num); + } + }); } - return themeId; + return themeIds; +} + +export function currentThemeId() { + return currentThemeIds()[0]; } export function setLocalTheme(ids, themeSeq) { + ids = ids.reject(id => !id); if (ids && ids.length > 0) { $.cookie("theme_ids", `${ids.join(",")}|${themeSeq}`, { path: "/", @@ -76,23 +81,28 @@ export function refreshCSS(node, hash, newHref, options) { $orig.data("copy", reloaded); } -export function previewTheme(id) { - if (currentThemeId() !== id) { +export function previewTheme(ids = []) { + ids = ids.reject(id => !id); + if (!ids.includes(currentThemeId())) { Discourse.set("assetVersion", "forceRefresh"); - ajax(`/themes/assets/${id ? id : "default"}`).then(results => { - let elem = _.first($(keySelector)); - if (elem) { - elem.content = id; - } - - results.themes.forEach(theme => { - let node = $(`link[rel=stylesheet][data-target=${theme.target}]`)[0]; - if (node) { - refreshCSS(node, null, theme.url, { force: true }); + ajax(`/themes/assets/${ids.length > 0 ? ids.join("-") : "default"}`).then( + results => { + const elem = _.first($(keySelector)); + if (elem) { + elem.content = ids.join(","); } - }); - }); + + results.themes.forEach(theme => { + const node = $( + `link[rel=stylesheet][data-target=${theme.target}]` + )[0]; + if (node) { + refreshCSS(node, null, theme.new_href, { force: true }); + } + }); + } + ); } } diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb index 5630724b77..08a4d10b76 100644 --- a/app/controllers/admin/themes_controller.rb +++ b/app/controllers/admin/themes_controller.rb @@ -182,11 +182,13 @@ class Admin::ThemesController < Admin::AdminController log_theme_change(original_json, @theme) format.json { render json: @theme, status: :ok } else - format.json { + format.json do + error = @theme.errors.full_messages.join(", ").presence + error = I18n.t("themes.bad_color_scheme") if @theme.errors[:color_scheme].present? + error ||= I18n.t("themes.other_error") - error = @theme.errors[:color_scheme] ? I18n.t("themes.bad_color_scheme") : I18n.t("themes.other_error") render json: { errors: [ error ] }, status: :unprocessable_entity - } + end end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 3972fa18c0..87d372f629 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -22,7 +22,7 @@ class ApplicationController < ActionController::Base include GlobalPath include Hijack - attr_reader :theme_id + attr_reader :theme_ids serialization_scope :guardian @@ -62,8 +62,8 @@ class ApplicationController < ActionController::Base after_action :remember_theme_id def remember_theme_id - if @theme_id - Stylesheet::Watcher.theme_id = @theme_id if defined? Stylesheet::Watcher + if @theme_ids.present? + Stylesheet::Watcher.theme_id = @theme_ids.first if defined? Stylesheet::Watcher end end end @@ -331,28 +331,33 @@ class ApplicationController < ActionController::Base resolve_safe_mode return if request.env[NO_CUSTOM] - theme_id = request[:preview_theme_id]&.to_i + theme_ids = [] + + if preview_theme_id = request[:preview_theme_id]&.to_i + theme_ids << preview_theme_id + end user_option = current_user&.user_option - unless theme_id + if theme_ids.blank? ids, seq = cookies[:theme_ids]&.split("|") ids = ids&.split(",")&.map(&:to_i) - if ids && ids.size > 0 && seq && seq.to_i == user_option&.theme_key_seq.to_i - theme_id = ids.first + if ids.present? && seq && seq.to_i == user_option&.theme_key_seq.to_i + theme_ids = ids if guardian.allow_themes?(ids) end end - theme_id ||= user_option&.theme_ids&.first + theme_ids = user_option&.theme_ids || [] if theme_ids.blank? - if theme_id && !guardian.allow_themes?(theme_id) - theme_id = nil + unless guardian.allow_themes?(theme_ids) + theme_ids = [] end - theme_id ||= SiteSetting.default_theme_id - theme_id = nil if theme_id.blank? || theme_id == -1 + if theme_ids.blank? && SiteSetting.default_theme_id != -1 + theme_ids << SiteSetting.default_theme_id + end - @theme_id = request.env[:resolved_theme_id] = theme_id + @theme_ids = request.env[:resolved_theme_ids] = theme_ids end def guardian @@ -502,10 +507,10 @@ class ApplicationController < ActionController::Base target = view_context.mobile_view? ? :mobile : :desktop data = - if @theme_id + if @theme_ids.present? { - top: Theme.lookup_field(@theme_id, target, "after_header"), - footer: Theme.lookup_field(@theme_id, target, "footer") + top: Theme.lookup_field(@theme_ids, target, "after_header"), + footer: Theme.lookup_field(@theme_ids, target, "footer") } else {} diff --git a/app/controllers/stylesheets_controller.rb b/app/controllers/stylesheets_controller.rb index 6b0fe317cf..364c3e58dc 100644 --- a/app/controllers/stylesheets_controller.rb +++ b/app/controllers/stylesheets_controller.rb @@ -29,7 +29,7 @@ class StylesheetsController < ApplicationController # we hold of re-compilation till someone asks for asset if target.include?("theme") split_target, theme_id = target.split(/_(-?[0-9]+)/) - theme = Theme.find(theme_id) if theme_id + theme = Theme.find_by(id: theme_id) if theme_id.present? else split_target, color_scheme_id = target.split(/_(-?[0-9]+)/) theme = Theme.find_by(color_scheme_id: color_scheme_id) diff --git a/app/controllers/themes_controller.rb b/app/controllers/themes_controller.rb index 22c2925107..2cc8e284a1 100644 --- a/app/controllers/themes_controller.rb +++ b/app/controllers/themes_controller.rb @@ -1,27 +1,26 @@ class ThemesController < ::ApplicationController def assets - theme_id = params[:id].to_i + theme_ids = params[:ids].to_s.split("-").map(&:to_i) - if params[:id] == "default" - theme_id = nil + if params[:ids] == "default" + theme_ids = nil else - raise Discourse::NotFound unless Theme.where(id: theme_id).exists? + raise Discourse::NotFound unless guardian.allow_themes?(theme_ids) end - object = [:mobile, :desktop, :desktop_theme, :mobile_theme].map do |target| - link = Stylesheet::Manager.stylesheet_link_tag(target, 'all', params[:id]) - if link - href = link.split(/["']/)[1] - if Rails.env.development? - href << (href.include?("?") ? "&" : "?") - href << SecureRandom.hex - end - { - target: target, - url: href - } + targets = view_context.mobile_view? ? [:mobile, :mobile_theme] : [:desktop, :desktop_theme] + targets << :admin if guardian.is_staff? + + object = targets.map do |target| + Stylesheet::Manager.stylesheet_data(target, theme_ids).map do |hash| + return hash unless Rails.env.development? + + dup_hash = hash.dup + dup_hash[:new_href] << (dup_hash[:new_href].include?("?") ? "&" : "?") + dup_hash[:new_href] << SecureRandom.hex + dup_hash end - end.compact + end.flatten render json: object.as_json end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 90d148be7f..046ab45f01 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -350,11 +350,11 @@ module ApplicationHelper end end - def theme_id + def theme_ids if customization_disabled? nil else - request.env[:resolved_theme_id] + request.env[:resolved_theme_ids] end end @@ -378,17 +378,17 @@ module ApplicationHelper end def theme_lookup(name) - lookup = Theme.lookup_field(theme_id, mobile_view? ? :mobile : :desktop, name) + lookup = Theme.lookup_field(theme_ids, mobile_view? ? :mobile : :desktop, name) lookup.html_safe if lookup end def discourse_stylesheet_link_tag(name, opts = {}) - if opts.key?(:theme_id) - id = opts[:theme_id] unless customization_disabled? + if opts.key?(:theme_ids) + ids = opts[:theme_ids] unless customization_disabled? else - id = theme_id + ids = theme_ids end - Stylesheet::Manager.stylesheet_link_tag(name, 'all', id) + Stylesheet::Manager.stylesheet_link_tag(name, 'all', ids) end end diff --git a/app/models/child_theme.rb b/app/models/child_theme.rb index e4eb2d0ef7..c37e417dbe 100644 --- a/app/models/child_theme.rb +++ b/app/models/child_theme.rb @@ -1,6 +1,24 @@ class ChildTheme < ActiveRecord::Base belongs_to :parent_theme, class_name: 'Theme' belongs_to :child_theme, class_name: 'Theme' + + validate :child_validations + + private + + def child_validations + if ChildTheme.exists?(["parent_theme_id = ? OR child_theme_id = ?", child_theme_id, parent_theme_id]) + errors.add(:base, I18n.t("themes.errors.no_multilevels_components")) + end + + if Theme.exists?(id: child_theme_id, user_selectable: true) + errors.add(:base, I18n.t("themes.errors.component_no_user_selectable")) + end + + if child_theme_id == SiteSetting.default_theme_id + errors.add(:base, I18n.t("themes.errors.component_no_default")) + end + end end # == Schema Information diff --git a/app/models/color_scheme.rb b/app/models/color_scheme.rb index a80b1e40b0..bd3efd15d8 100644 --- a/app/models/color_scheme.rb +++ b/app/models/color_scheme.rb @@ -241,12 +241,15 @@ class ColorScheme < ActiveRecord::Base def publish_discourse_stylesheet if self.id - themes = Theme.where(color_scheme_id: self.id).to_a - if themes.present? + theme_ids = Theme.where(color_scheme_id: self.id).pluck(:id) + if theme_ids.present? Stylesheet::Manager.cache.clear - themes.each do |theme| - theme.notify_scheme_change(_clear_manager_cache = false) - end + Theme.notify_theme_change( + theme_ids, + with_scheme: true, + clear_manager_cache: false, + all_themes: true + ) end end end diff --git a/app/models/theme.rb b/app/models/theme.rb index dcc99f3ce3..039d18953b 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -20,6 +20,12 @@ class Theme < ActiveRecord::Base has_many :color_schemes belongs_to :remote_theme + validate :user_selectable_validation + + scope :user_selectable, ->() { + where('user_selectable OR id = ?', SiteSetting.default_theme_id) + } + def notify_color_change(color) changed_colors << color end @@ -45,7 +51,6 @@ class Theme < ActiveRecord::Base remove_from_cache! clear_cached_settings! - notify_scheme_change if saved_change_to_color_scheme_id? end after_destroy do @@ -70,29 +75,37 @@ class Theme < ActiveRecord::Base end after_commit ->(theme) do - theme.notify_theme_change - end, on: :update + theme.notify_theme_change(with_scheme: theme.saved_change_to_color_scheme_id?) + end, on: [:create, :update] + + def self.get_set_cache(key, &blk) + if val = @cache[key] + return val + end + @cache[key] = blk.call + end def self.theme_ids - if ids = @cache["theme_ids"] - return ids + get_set_cache "theme_ids" do + Theme.pluck(:id) end - @cache["theme_ids"] = Set.new(Theme.pluck(:id)) end def self.user_theme_ids - if ids = @cache["user_theme_ids"] - return ids + get_set_cache "user_theme_ids" do + Theme.user_selectable.pluck(:id) + end + end + + def self.components_for(theme_id) + get_set_cache "theme_components_for_#{theme_id}" do + ChildTheme.where(parent_theme_id: theme_id).distinct.pluck(:child_theme_id) end - @cache["user_theme_ids"] = Set.new( - Theme - .where('user_selectable OR id = ?', SiteSetting.default_theme_id) - .pluck(:id) - ) end def self.expire_site_cache! Site.clear_anon_cache! + clear_cache! ApplicationSerializer.expire_cache_fragment!("user_themes") end @@ -101,7 +114,25 @@ class Theme < ActiveRecord::Base expire_site_cache! end + def self.transform_ids(ids, extend: true) + return [] if ids.blank? + + ids.uniq! + parent = ids.first + + components = ids[1..-1] + components.push(*components_for(parent)) if extend + components.sort!.uniq! + + [parent, *components] + end + def set_default! + if component? + raise Discourse::InvalidParameters.new( + I18n.t("themes.errors.component_no_default") + ) + end SiteSetting.default_theme_id = id Theme.expire_site_cache! end @@ -110,22 +141,32 @@ class Theme < ActiveRecord::Base SiteSetting.default_theme_id == id end - def self.lookup_field(theme_id, target, field) - return if theme_id.blank? + def component? + ChildTheme.exists?(child_theme_id: id) + end - cache_key = "#{theme_id}:#{target}:#{field}:#{ThemeField::COMPILER_VERSION}" + def user_selectable_validation + if component? && user_selectable + errors.add(:base, I18n.t("themes.errors.component_no_user_selectable")) + end + end + + def self.lookup_field(theme_ids, target, field) + return if theme_ids.blank? + theme_ids = [theme_ids] unless Array === theme_ids + + theme_ids = transform_ids(theme_ids) + cache_key = "#{theme_ids.join(",")}:#{target}:#{field}:#{ThemeField::COMPILER_VERSION}" lookup = @cache[cache_key] return lookup.html_safe if lookup target = target.to_sym - theme = find_by(id: theme_id) - - val = theme.resolve_baked_field(target, field) if theme + val = resolve_baked_field(theme_ids, target, field) (@cache[cache_key] = val || "").html_safe end - def self.remove_from_cache!(themes = nil) + def self.remove_from_cache! clear_cache! end @@ -141,33 +182,32 @@ class Theme < ActiveRecord::Base self.targets.invert[target_id] end - def notify_scheme_change(clear_manager_cache = true) - Stylesheet::Manager.cache.clear if clear_manager_cache - message = refresh_message_for_targets(["desktop", "mobile", "admin"], self) - MessageBus.publish('/file-change', message) - end - - def notify_theme_change + def self.notify_theme_change(theme_ids, with_scheme: false, clear_manager_cache: true, all_themes: false) Stylesheet::Manager.clear_theme_cache! + targets = [:mobile_theme, :desktop_theme] - themes = [self] + dependant_themes + if with_scheme + targets.prepend(:desktop, :mobile, :admin) + Stylesheet::Manager.cache.clear if clear_manager_cache + end + + if all_themes + message = theme_ids.map { |id| refresh_message_for_targets(targets, id) }.flatten + else + message = refresh_message_for_targets(targets, theme_ids).flatten + end - message = themes.map do |theme| - refresh_message_for_targets([:mobile_theme, :desktop_theme], theme) - end.compact.flatten MessageBus.publish('/file-change', message) end - def refresh_message_for_targets(targets, theme) + def notify_theme_change(with_scheme: false) + theme_ids = (dependant_themes&.pluck(:id) || []).unshift(self.id) + self.class.notify_theme_change(theme_ids, with_scheme: with_scheme) + end + + def self.refresh_message_for_targets(targets, theme_ids) targets.map do |target| - href = Stylesheet::Manager.stylesheet_href(target.to_sym, theme.id) - if href - { - target: target, - new_href: href, - theme_id: theme.id - } - end + Stylesheet::Manager.stylesheet_data(target.to_sym, theme_ids) end end @@ -180,48 +220,34 @@ class Theme < ActiveRecord::Base end def resolve_dependant_themes(direction) - - select_field, where_field = nil - if direction == :up - select_field = "parent_theme_id" + join_field = "parent_theme_id" where_field = "child_theme_id" elsif direction == :down - select_field = "child_theme_id" + join_field = "child_theme_id" where_field = "parent_theme_id" else raise "Unknown direction" end - themes = [] return [] unless id - uniq = Set.new - uniq << id + Theme.joins("JOIN child_themes ON themes.id = child_themes.#{join_field}").where("#{where_field} = ?", id) + end - iterations = 0 - added = [id] + def self.resolve_baked_field(theme_ids, target, name) + list_baked_fields(theme_ids, target, name).map { |f| f.value_baked || f.value }.join("\n") + end - while added.length > 0 && iterations < 5 + def self.list_baked_fields(theme_ids, target, name) + target = target.to_sym - iterations += 1 + fields = ThemeField.find_by_theme_ids(theme_ids) + .where(target_id: [Theme.targets[target], Theme.targets[:common]]) + .where(name: name.to_s) - new_themes = Theme.where("id in (SELECT #{select_field} - FROM child_themes - WHERE #{where_field} in (?))", added).to_a - - added = [] - new_themes.each do |theme| - unless uniq.include?(theme.id) - added << theme.id - uniq << theme.id - themes << theme - end - end - - end - - themes + fields.each(&:ensure_baked!) + fields end def resolve_baked_field(target, name) @@ -229,22 +255,8 @@ class Theme < ActiveRecord::Base end def list_baked_fields(target, name) - - target = target.to_sym - - theme_ids = [self.id] + (included_themes.map(&:id) || []) - fields = ThemeField.where(target_id: [Theme.targets[target], Theme.targets[:common]]) - .where(name: name.to_s) - .includes(:theme) - .joins(" - JOIN ( - SELECT #{theme_ids.map.with_index { |id, idx| "#{id} AS theme_id, #{idx} AS sort_column" }.join(" UNION ALL SELECT ")} - ) as X ON X.theme_id = theme_fields.theme_id" - ) - .order('sort_column, target_id') - - fields.each(&:ensure_baked!) - fields + theme_ids = (included_themes&.pluck(:id) || []).unshift(self.id) + self.class.list_baked_fields(theme_ids, target, name) end def remove_from_cache! @@ -288,21 +300,23 @@ class Theme < ActiveRecord::Base def all_theme_variables fields = {} - ([self] + (included_themes || [])).each do |theme| - theme&.theme_fields.each do |field| - next unless ThemeField.theme_var_type_ids.include?(field.type_id) - next if fields.key?(field.name) - fields[field.name] = field - end + ids = (included_themes&.pluck(:id) || []).unshift(self.id) + ThemeField.find_by_theme_ids(ids).where(type_id: ThemeField.theme_var_type_ids).each do |field| + next if fields.key?(field.name) + fields[field.name] = field end fields.values end def add_child_theme!(theme) - child_theme_relation.create!(child_theme_id: theme.id) - @included_themes = nil - child_themes.reload - save! + new_relation = child_theme_relation.new(child_theme_id: theme.id) + if new_relation.save + @included_themes = nil + child_themes.reload + save! + else + raise Discourse::InvalidParameters.new(new_relation.errors.full_messages.join(", ")) + end end def settings diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb index 92d7fd2d17..c4d6ab4e94 100644 --- a/app/models/theme_field.rb +++ b/app/models/theme_field.rb @@ -4,6 +4,17 @@ class ThemeField < ActiveRecord::Base belongs_to :upload + scope :find_by_theme_ids, ->(theme_ids) { + return none unless theme_ids.present? + + where(theme_id: theme_ids) + .joins( + "JOIN ( + SELECT #{theme_ids.map.with_index { |id, idx| "#{id.to_i} AS theme_id, #{idx} AS sort_column" }.join(" UNION ALL SELECT ")} + ) as X ON X.theme_id = theme_fields.theme_id") + .order("sort_column") + } + def self.types @types ||= Enum.new(html: 0, scss: 1, diff --git a/app/views/common/_discourse_stylesheet.html.erb b/app/views/common/_discourse_stylesheet.html.erb index 5e66ec2ab2..45f6476e90 100644 --- a/app/views/common/_discourse_stylesheet.html.erb +++ b/app/views/common/_discourse_stylesheet.html.erb @@ -8,6 +8,6 @@ <%= discourse_stylesheet_link_tag(:admin) %> <%- end %> -<%- if theme_id %> +<%- if theme_ids.present? %> <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_theme : :desktop_theme) %> <%- end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 934695067b..94bf307037 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -4,7 +4,7 @@ <%= content_for?(:title) ? yield(:title) : SiteSetting.title %> - + "> <%= render partial: "layouts/head" %> <%= discourse_csrf_tags %> diff --git a/app/views/layouts/crawler.html.erb b/app/views/layouts/crawler.html.erb index 9e2c662f80..98f15b0e46 100644 --- a/app/views/layouts/crawler.html.erb +++ b/app/views/layouts/crawler.html.erb @@ -10,7 +10,7 @@ <%- else %> <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile : :desktop) %> <%- end %> - <%- if theme_id %> + <%- if theme_ids.present? %> <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_theme : :desktop_theme) %> <%- end %> <%= theme_lookup("head_tag") %> diff --git a/app/views/layouts/embed.html.erb b/app/views/layouts/embed.html.erb index b1faf885ed..8f91159c53 100644 --- a/app/views/layouts/embed.html.erb +++ b/app/views/layouts/embed.html.erb @@ -3,7 +3,7 @@ - <%= discourse_stylesheet_link_tag 'embed', theme_id: nil %> + <%= discourse_stylesheet_link_tag 'embed', theme_ids: nil %> <%- unless customization_disabled? %> <%= discourse_stylesheet_link_tag :embedded_theme %> <%- end %> diff --git a/app/views/layouts/finish_installation.html.erb b/app/views/layouts/finish_installation.html.erb index f573616b19..8fc25c08eb 100644 --- a/app/views/layouts/finish_installation.html.erb +++ b/app/views/layouts/finish_installation.html.erb @@ -1,6 +1,6 @@ - <%= discourse_stylesheet_link_tag 'wizard', theme_id: nil %> + <%= discourse_stylesheet_link_tag 'wizard', theme_ids: nil %> <%= render partial: "common/special_font_face" %> <%= preload_script 'ember_jquery' %> <%= preload_script 'wizard-vendor' %> diff --git a/app/views/wizard/index.html.erb b/app/views/wizard/index.html.erb index fb4f420bd1..cecca0e834 100644 --- a/app/views/wizard/index.html.erb +++ b/app/views/wizard/index.html.erb @@ -1,6 +1,6 @@ - <%= discourse_stylesheet_link_tag :wizard, theme_id: nil %> + <%= discourse_stylesheet_link_tag :wizard, theme_ids: nil %> <%= preload_script 'ember_jquery' %> <%= preload_script 'wizard-vendor' %> <%= preload_script 'wizard-application' %> diff --git a/app/views/wizard/qunit.html.erb b/app/views/wizard/qunit.html.erb index 561a2ade04..31755ff4d8 100644 --- a/app/views/wizard/qunit.html.erb +++ b/app/views/wizard/qunit.html.erb @@ -4,7 +4,7 @@ QUnit Test Runner <%= stylesheet_link_tag "qunit" %> <%= stylesheet_link_tag "test_helper" %> - <%= discourse_stylesheet_link_tag :wizard %> + <%= discourse_stylesheet_link_tag :wizard, theme_ids: nil %> <%= javascript_include_tag "qunit" %> <%= javascript_include_tag "wizard/test/test_helper" %> <%= csrf_meta_tags %> diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index da0aa91dbb..84d70adf62 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -60,6 +60,10 @@ en: bad_color_scheme: "Can not update theme, invalid color scheme" other_error: "Something went wrong updating theme" error_importing: "Error cloning git repository, access is denied or repository is not found" + errors: + component_no_user_selectable: "Theme components can't be user-selectable" + component_no_default: "Theme components can't be default theme" + no_multilevels_components: "Themes with child themes can't be child themes themselves" settings_errors: invalid_yaml: "Provided YAML is invalid." data_type_not_a_number: "Setting `%{name}` type is unsupported. Supported types are `integer`, `bool`, `list` and `enum`" diff --git a/config/routes.rb b/config/routes.rb index 0ef2a56020..441f448240 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -814,7 +814,7 @@ Discourse::Application.routes.draw do get "/safe-mode" => "safe_mode#index" post "/safe-mode" => "safe_mode#enter", as: "safe_mode_enter" - get "/themes/assets/:id" => "themes#assets" + get "/themes/assets/:ids" => "themes#assets" if Rails.env == "test" || Rails.env == "development" get "/qunit" => "qunit#index" diff --git a/db/migrate/20180710172959_disallow_multi_levels_theme_components.rb b/db/migrate/20180710172959_disallow_multi_levels_theme_components.rb new file mode 100644 index 0000000000..7567ed5988 --- /dev/null +++ b/db/migrate/20180710172959_disallow_multi_levels_theme_components.rb @@ -0,0 +1,68 @@ +class DisallowMultiLevelsThemeComponents < ActiveRecord::Migration[5.2] + def up + @handled = [] + top_parents = DB.query(" + SELECT parent_theme_id, child_theme_id + FROM child_themes + WHERE parent_theme_id NOT IN (SELECT child_theme_id FROM child_themes) + ") + + top_parents.each do |top_parent| + migrate_child(top_parent, top_parent) + end + + if @handled.size > 0 + execute(" + DELETE FROM child_themes + WHERE parent_theme_id NOT IN (#{top_parents.map(&:parent_theme_id).join(", ")}) + ") + end + + execute(" + UPDATE themes + SET user_selectable = false + FROM child_themes + WHERE themes.id = child_themes.child_theme_id + AND themes.user_selectable = true + ") + + default = DB.query_single("SELECT value FROM site_settings WHERE name = 'default_theme_id'").first + if default + default_child = DB.query("SELECT 1 AS one FROM child_themes WHERE child_theme_id = ?", default.to_i).present? + execute("DELETE FROM site_settings WHERE name = 'default_theme_id'") if default_child + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end + + private + + def migrate_child(parent, top_parent) + unless already_exists?(top_parent.parent_theme_id, parent.child_theme_id) + execute(" + INSERT INTO child_themes (parent_theme_id, child_theme_id, created_at, updated_at) + VALUES (#{top_parent.parent_theme_id}, #{parent.child_theme_id}, now(), now()) + ") + end + + @handled << [top_parent.parent_theme_id, parent.parent_theme_id, parent.child_theme_id] + + children = DB.query(" + SELECT parent_theme_id, child_theme_id + FROM child_themes + WHERE parent_theme_id = :child", child: parent.child_theme_id + ) + + children.each do |child| + unless @handled.include?([top_parent.parent_theme_id, child.parent_theme_id, child.child_theme_id]) + migrate_child(child, top_parent) + end + end + end + + def already_exists?(parent, child) + DB.query("SELECT 1 AS one FROM child_themes WHERE child_theme_id = :child AND parent_theme_id = :parent", child: child, parent: parent).present? + end +end diff --git a/lib/guardian.rb b/lib/guardian.rb index 9c70f4b901..db7d168428 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -359,9 +359,15 @@ class Guardian end def allow_themes?(theme_ids) - theme_ids = [theme_ids] unless theme_ids.is_a?(Array) - allowed_ids = is_staff? ? Theme.theme_ids : Theme.user_theme_ids - (theme_ids - allowed_ids.to_a).empty? + if is_staff? && (theme_ids - Theme.theme_ids).blank? + return true + end + + parent = theme_ids.first + components = theme_ids[1..-1] || [] + + Theme.user_theme_ids.include?(parent) && + (components - Theme.components_for(parent)).empty? end private diff --git a/lib/middleware/anonymous_cache.rb b/lib/middleware/anonymous_cache.rb index 93620be0c9..f2539b0610 100644 --- a/lib/middleware/anonymous_cache.rb +++ b/lib/middleware/anonymous_cache.rb @@ -66,16 +66,16 @@ module Middleware end def cache_key - @cache_key ||= "ANON_CACHE_#{@env["HTTP_ACCEPT"]}_#{@env["HTTP_HOST"]}#{@env["REQUEST_URI"]}|m=#{is_mobile?}|c=#{is_crawler?}|b=#{has_brotli?}|t=#{theme_id}" + @cache_key ||= "ANON_CACHE_#{@env["HTTP_ACCEPT"]}_#{@env["HTTP_HOST"]}#{@env["REQUEST_URI"]}|m=#{is_mobile?}|c=#{is_crawler?}|b=#{has_brotli?}|t=#{theme_ids.join(",")}" end - def theme_id + def theme_ids ids, _ = @request.cookies['theme_ids']&.split('|') ids = ids&.split(",")&.map(&:to_i) if ids && Guardian.new.allow_themes?(ids) - ids.first + Theme.transform_ids(ids) else - nil + [] end end diff --git a/lib/stylesheet/manager.rb b/lib/stylesheet/manager.rb index a70ccae052..dec9325ac8 100644 --- a/lib/stylesheet/manager.rb +++ b/lib/stylesheet/manager.rb @@ -8,6 +8,7 @@ class Stylesheet::Manager CACHE_PATH ||= 'tmp/stylesheet-cache' MANIFEST_DIR ||= "#{Rails.root}/tmp/cache/assets/#{Rails.env}" MANIFEST_FULL_PATH ||= "#{MANIFEST_DIR}/stylesheet-manifest" + THEME_REGEX ||= /_theme$/ @lock = Mutex.new @@ -19,38 +20,65 @@ class Stylesheet::Manager cache.hash.keys.select { |k| k =~ /theme/ }.each { |k|cache.delete(k) } end - def self.stylesheet_href(target = :desktop, theme_id = :missing) - href = stylesheet_link_tag(target, 'all', theme_id) - if href - href.split(/["']/)[1] - end + def self.stylesheet_data(target = :desktop, theme_ids = :missing) + stylesheet_details(target, "all", theme_ids) end - def self.stylesheet_link_tag(target = :desktop, media = 'all', theme_id = :missing) + def self.stylesheet_link_tag(target = :desktop, media = 'all', theme_ids = :missing) + stylesheets = stylesheet_details(target, media, theme_ids) + stylesheets.map do |stylesheet| + href = stylesheet[:new_href] + theme_id = stylesheet[:theme_id] + data_theme_id = theme_id ? "data-theme-id=\"#{theme_id}\"" : "" + %[] + end.join("\n").html_safe + end + + def self.stylesheet_details(target = :desktop, media = 'all', theme_ids = :missing) + if theme_ids == :missing + theme_ids = [SiteSetting.default_theme_id] + end target = target.to_sym - if theme_id == :missing - theme_id = SiteSetting.default_theme_id - end + theme_ids = [theme_ids] unless Array === theme_ids + theme_ids = [theme_ids.first] unless target =~ THEME_REGEX + theme_ids = Theme.transform_ids(theme_ids, extend: false) current_hostname = Discourse.current_hostname - cache_key = "#{target}_#{theme_id}_#{current_hostname}" - tag = cache[cache_key] - return tag.dup.html_safe if tag + array_cache_key = "array_themes_#{theme_ids.join(",")}_#{target}_#{current_hostname}" + stylesheets = cache[array_cache_key] + return stylesheets if stylesheets.present? @lock.synchronize do - builder = self.new(target, theme_id) - if builder.is_theme? && !builder.theme - tag = "" - else - builder.compile unless File.exists?(builder.stylesheet_fullpath) - tag = %[] - end + stylesheets = [] + theme_ids.each do |theme_id| + data = { target: target } + cache_key = "path_#{target}_#{theme_id}_#{current_hostname}" + href = cache[cache_key] - cache[cache_key] = tag - tag.dup.html_safe + unless href + builder = self.new(target, theme_id) + is_theme = builder.is_theme? + has_theme = builder.theme.present? + + if is_theme && !has_theme + next + else + data[:theme_id] = builder.theme.id if has_theme && is_theme + builder.compile unless File.exists?(builder.stylesheet_fullpath) + href = builder.stylesheet_path(current_hostname) + end + cache[cache_key] = href + end + + data[:theme_id] = theme_id if theme_id.present? && data[:theme_id].blank? + data[:new_href] = href + stylesheets << data + end + cache[array_cache_key] = stylesheets.freeze + stylesheets end end @@ -100,6 +128,10 @@ class Stylesheet::Manager end.compact.max.to_i end + def self.cache_fullpath + "#{Rails.root}/#{CACHE_PATH}" + end + def initialize(target = :desktop, theme_id) @target = target @theme_id = theme_id @@ -162,10 +194,6 @@ class Stylesheet::Manager css end - def self.cache_fullpath - "#{Rails.root}/#{CACHE_PATH}" - end - def cache_fullpath self.class.cache_fullpath end @@ -225,7 +253,7 @@ class Stylesheet::Manager end def is_theme? - !!(@target.to_s =~ /_theme$/) + !!(@target.to_s =~ THEME_REGEX) end # digest encodes the things that trigger a recompile @@ -240,7 +268,7 @@ class Stylesheet::Manager end def theme - @theme ||= (Theme.find_by(id: @theme_id) || :nil) + @theme ||= Theme.find_by(id: @theme_id) || :nil @theme == :nil ? nil : @theme end @@ -271,7 +299,16 @@ class Stylesheet::Manager end def settings_digest - Digest::SHA1.hexdigest((theme&.included_settings || {}).to_json) + fields = ThemeField.where( + name: "yaml", + type_id: ThemeField.types[:yaml], + theme_id: @theme_id + ).pluck(:updated_at) + + settings = ThemeSetting.where(theme_id: @theme_id).pluck(:updated_at) + timestamps = fields.concat(settings).map!(&:to_f).sort!.join(",") + + Digest::SHA1.hexdigest(timestamps) end def uploads_digest diff --git a/lib/stylesheet/watcher.rb b/lib/stylesheet/watcher.rb index 91b13343d8..e2425c4fd7 100644 --- a/lib/stylesheet/watcher.rb +++ b/lib/stylesheet/watcher.rb @@ -8,7 +8,10 @@ module Stylesheet end def self.theme_id - @theme_id || SiteSetting.default_theme_id + if @theme_id.blank? && SiteSetting.default_theme_id != -1 + @theme_id = SiteSetting.default_theme_id + end + @theme_id end def self.watch(paths = nil) @@ -76,12 +79,8 @@ module Stylesheet Stylesheet::Manager.cache.clear message = ["desktop", "mobile", "admin"].map do |name| - { - target: name, - new_href: Stylesheet::Manager.stylesheet_href(name.to_sym), - theme_id: Stylesheet::Watcher.theme_id - } - end + Stylesheet::Manager.stylesheet_data(name.to_sym, Stylesheet::Watcher.theme_id) + end.flatten MessageBus.publish '/file-change', message end diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb index 9b95f66e32..bdd0ef6092 100644 --- a/spec/components/guardian_spec.rb +++ b/spec/components/guardian_spec.rb @@ -2537,6 +2537,46 @@ describe Guardian do end end + describe "#allow_themes?" do + let(:theme) { Fabricate(:theme) } + let(:theme2) { Fabricate(:theme) } + + it "allows staff to use any themes" do + expect(Guardian.new(moderator).allow_themes?([theme.id, theme2.id])).to eq(true) + expect(Guardian.new(admin).allow_themes?([theme.id, theme2.id])).to eq(true) + end + + it "only allows normal users to use user-selectable themes or default theme" do + user_guardian = Guardian.new(user) + + expect(user_guardian.allow_themes?([theme.id, theme2.id])).to eq(false) + expect(user_guardian.allow_themes?([theme.id])).to eq(false) + expect(user_guardian.allow_themes?([theme2.id])).to eq(false) + + theme.set_default! + expect(user_guardian.allow_themes?([theme.id])).to eq(true) + expect(user_guardian.allow_themes?([theme2.id])).to eq(false) + expect(user_guardian.allow_themes?([theme.id, theme2.id])).to eq(false) + + theme2.update!(user_selectable: true) + expect(user_guardian.allow_themes?([theme2.id])).to eq(true) + expect(user_guardian.allow_themes?([theme2.id, theme.id])).to eq(false) + end + + it "allows child themes to be only used with their parent" do + user_guardian = Guardian.new(user) + + theme.update!(user_selectable: true) + theme2.update!(user_selectable: true) + expect(user_guardian.allow_themes?([theme.id, theme2.id])).to eq(false) + + theme2.update!(user_selectable: false) + theme.add_child_theme!(theme2) + expect(user_guardian.allow_themes?([theme.id, theme2.id])).to eq(true) + expect(user_guardian.allow_themes?([theme2.id])).to eq(false) + end + end + describe 'can_wiki?' do let(:post) { build(:post, created_at: 1.minute.ago) } diff --git a/spec/components/middleware/anonymous_cache_spec.rb b/spec/components/middleware/anonymous_cache_spec.rb index ce0b01e576..4d63d4f5e4 100644 --- a/spec/components/middleware/anonymous_cache_spec.rb +++ b/spec/components/middleware/anonymous_cache_spec.rb @@ -32,7 +32,7 @@ describe Middleware::AnonymousCache::Helper do context "per theme cache" do it "handles theme keys" do - theme = Theme.create(name: "test", user_id: -1, user_selectable: true) + theme = Fabricate(:theme, user_selectable: true) with_bad_theme_key = new_helper("HTTP_COOKIE" => "theme_ids=abc").cache_key with_no_theme_key = new_helper().cache_key diff --git a/spec/components/stylesheet/manager_spec.rb b/spec/components/stylesheet/manager_spec.rb index 1d4c19fd90..2df206297b 100644 --- a/spec/components/stylesheet/manager_spec.rb +++ b/spec/components/stylesheet/manager_spec.rb @@ -8,7 +8,7 @@ describe Stylesheet::Manager do link = Stylesheet::Manager.stylesheet_link_tag(:embedded_theme) expect(link).to eq("") - theme = Theme.create(name: "embedded", user_id: -1) + theme = Fabricate(:theme) SiteSetting.default_theme_id = theme.id link = Stylesheet::Manager.stylesheet_link_tag(:embedded_theme) @@ -16,10 +16,7 @@ describe Stylesheet::Manager do end it 'can correctly compile theme css' do - theme = Theme.new( - name: 'parent', - user_id: -1 - ) + theme = Fabricate(:theme) theme.set_field(target: :common, name: "scss", value: ".common{.scss{color: red;}}") theme.set_field(target: :desktop, name: "scss", value: ".desktop{.scss{color: red;}}") @@ -28,10 +25,7 @@ describe Stylesheet::Manager do theme.save! - child_theme = Theme.new( - name: 'parent', - user_id: -1, - ) + child_theme = Fabricate(:theme) 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;}}") @@ -72,10 +66,7 @@ describe Stylesheet::Manager do it 'can correctly account for plugins in digest' do - theme = Theme.create!( - name: 'parent', - user_id: -1 - ) + theme = Fabricate(:theme) manager = Stylesheet::Manager.new(:desktop_theme, theme.id) digest1 = manager.digest @@ -92,10 +83,7 @@ describe Stylesheet::Manager do let(:image2) { file_from_fixtures("logo-dev.png") } it 'can correctly account for theme uploads in digest' do - theme = Theme.create!( - name: 'parent', - user_id: -1 - ) + theme = Fabricate(:theme) upload = UploadCreator.new(image, "logo.png").create_for(-1) field = ThemeField.create!( @@ -130,10 +118,7 @@ describe Stylesheet::Manager do describe 'color_scheme_digest' do it "changes with category background image" do - theme = Theme.new( - name: 'parent', - user_id: -1 - ) + theme = Fabricate(:theme) category1 = Fabricate(:category, uploaded_background_id: 123, updated_at: 1.week.ago) category2 = Fabricate(:category, uploaded_background_id: 456, updated_at: 2.days.ago) diff --git a/spec/components/theme_settings_manager_spec.rb b/spec/components/theme_settings_manager_spec.rb index eb29ed2024..1dd8a93d06 100644 --- a/spec/components/theme_settings_manager_spec.rb +++ b/spec/components/theme_settings_manager_spec.rb @@ -4,7 +4,7 @@ require 'theme_settings_manager' describe ThemeSettingsManager do let(:theme_settings) do - theme = Theme.create!(name: "awesome theme", user_id: -1) + theme = Fabricate(:theme) yaml = File.read("#{Rails.root}/spec/fixtures/theme_settings/valid_settings.yaml") theme.set_field(target: :settings, name: "yaml", value: yaml) theme.save! diff --git a/spec/components/wizard/step_updater_spec.rb b/spec/components/wizard/step_updater_spec.rb index 0a858c8b88..54b4535c48 100644 --- a/spec/components/wizard/step_updater_spec.rb +++ b/spec/components/wizard/step_updater_spec.rb @@ -194,6 +194,7 @@ describe Wizard::StepUpdater do context "without an existing scheme" do it "creates the scheme" do + ColorScheme.destroy_all updater = wizard.create_updater('colors', theme_previews: 'Dark', allow_dark_light_selection: true) updater.update expect(updater.success?).to eq(true) diff --git a/spec/fabricators/theme_fabricator.rb b/spec/fabricators/theme_fabricator.rb new file mode 100644 index 0000000000..39712756c3 --- /dev/null +++ b/spec/fabricators/theme_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:theme) do + name { sequence(:name) { |i| "Cool theme #{i + 1}" } } + user +end diff --git a/spec/mailers/user_notifications_spec.rb b/spec/mailers/user_notifications_spec.rb index 015789e0b7..a0400e5d33 100644 --- a/spec/mailers/user_notifications_spec.rb +++ b/spec/mailers/user_notifications_spec.rb @@ -191,12 +191,12 @@ describe UserNotifications do 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, + theme = Fabricate(:theme, user_selectable: true, + user: Fabricate(:admin), color_scheme_id: cs.id ) + theme.set_default! html = subject.html_part.body.to_s diff --git a/spec/models/admin_dashboard_data_spec.rb b/spec/models/admin_dashboard_data_spec.rb index 1a96b1fe9e..2f4db5fa47 100644 --- a/spec/models/admin_dashboard_data_spec.rb +++ b/spec/models/admin_dashboard_data_spec.rb @@ -338,7 +338,7 @@ describe AdminDashboardData do describe '#out_of_date_themes' do let(:remote) { RemoteTheme.create!(remote_url: "https://github.com/org/testtheme") } - let!(:theme) { Theme.create!(remote_theme_id: remote.id, name: "Test< Theme", user_id: -1) } + let!(:theme) { Fabricate(:theme, remote_theme: remote, name: "Test< Theme") } it "outputs correctly formatted html" do remote.update!(local_version: "old version", remote_version: "new version", commits_behind: 2) diff --git a/spec/models/child_theme_spec.rb b/spec/models/child_theme_spec.rb new file mode 100644 index 0000000000..2c13206cb1 --- /dev/null +++ b/spec/models/child_theme_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +describe ChildTheme do + describe "validations" do + it "doesn't allow children to become parents or parents to become children" do + theme = Fabricate(:theme) + child = Fabricate(:theme) + + child_theme = ChildTheme.new(parent_theme: theme, child_theme: child) + expect(child_theme.valid?).to eq(true) + child_theme.save! + + grandchild = Fabricate(:theme) + child_theme = ChildTheme.new(parent_theme: child, child_theme: grandchild) + expect(child_theme.valid?).to eq(false) + expect(child_theme.errors.full_messages).to contain_exactly(I18n.t("themes.errors.no_multilevels_components")) + + grandparent = Fabricate(:theme) + child_theme = ChildTheme.new(parent_theme: grandparent, child_theme: theme) + expect(child_theme.valid?).to eq(false) + expect(child_theme.errors.full_messages).to contain_exactly(I18n.t("themes.errors.no_multilevels_components")) + end + + it "doesn't allow a user selectable theme to be a child" do + parent = Fabricate(:theme) + selectable_theme = Fabricate(:theme, user_selectable: true) + + child_theme = ChildTheme.new(parent_theme: parent, child_theme: selectable_theme) + expect(child_theme.valid?).to eq(false) + expect(child_theme.errors.full_messages).to contain_exactly(I18n.t("themes.errors.component_no_user_selectable")) + end + + it "doesn't allow a default theme to be child" do + parent = Fabricate(:theme) + default = Fabricate(:theme) + default.set_default! + + child_theme = ChildTheme.new(parent_theme: parent, child_theme: default) + expect(child_theme.valid?).to eq(false) + expect(child_theme.errors.full_messages).to contain_exactly(I18n.t("themes.errors.component_no_default")) + end + end +end diff --git a/spec/models/color_scheme_spec.rb b/spec/models/color_scheme_spec.rb index 25121d4997..d1ea51532f 100644 --- a/spec/models/color_scheme_spec.rb +++ b/spec/models/color_scheme_spec.rb @@ -10,15 +10,15 @@ describe ColorScheme do 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 = Fabricate(:theme, color_scheme_id: scheme.id) theme.set_field(name: :scss, target: :desktop, value: '.bob {color: $primary;}') theme.save! - href = Stylesheet::Manager.stylesheet_href(:desktop_theme, theme.id) + href = Stylesheet::Manager.stylesheet_data(:desktop_theme, theme.id)[0][:new_href] ColorSchemeRevisor.revise(scheme, colors: [{ name: 'primary', hex: 'bbb' }]) - href2 = Stylesheet::Manager.stylesheet_href(:desktop_theme, theme.id) + href2 = Stylesheet::Manager.stylesheet_data(:desktop_theme, theme.id)[0][:new_href] expect(href).not_to eq(href2) end diff --git a/spec/models/remote_theme_spec.rb b/spec/models/remote_theme_spec.rb index 1cb0211035..ab5eb010a9 100644 --- a/spec/models/remote_theme_spec.rb +++ b/spec/models/remote_theme_spec.rb @@ -189,7 +189,7 @@ describe RemoteTheme do context ".out_of_date_themes" do let(:remote) { RemoteTheme.create!(remote_url: "https://github.com/org/testtheme") } - let!(:theme) { Theme.create!(remote_theme_id: remote.id, name: "Test Theme", user_id: -1) } + let!(:theme) { Fabricate(:theme, remote_theme: remote) } it "finds out of date themes" do remote.update!(local_version: "old version", remote_version: "new version", commits_behind: 2) diff --git a/spec/models/site_spec.rb b/spec/models/site_spec.rb index ecb3d4f8f0..2f578d157d 100644 --- a/spec/models/site_spec.rb +++ b/spec/models/site_spec.rb @@ -17,9 +17,9 @@ describe Site do end it "includes user themes and expires them as needed" do - default_theme = Theme.create!(user_id: -1, name: 'default') + default_theme = Fabricate(:theme) SiteSetting.default_theme_id = default_theme.id - user_theme = Theme.create!(user_id: -1, name: 'user theme', user_selectable: true) + user_theme = Fabricate(:theme, user_selectable: true) anon_guardian = Guardian.new user_guardian = Guardian.new(Fabricate(:user)) diff --git a/spec/models/theme_field_spec.rb b/spec/models/theme_field_spec.rb index 8ec0a5249b..152531d8d1 100644 --- a/spec/models/theme_field_spec.rb +++ b/spec/models/theme_field_spec.rb @@ -7,6 +7,26 @@ describe ThemeField do ThemeField.destroy_all end + describe "scope: find_by_theme_ids" do + it "returns result in the specified order" do + theme = Fabricate(:theme) + theme2 = Fabricate(:theme) + theme3 = Fabricate(:theme) + + (0..1).each do |num| + ThemeField.create!(theme: theme, target_id: num, name: "header", value: "html") + ThemeField.create!(theme: theme2, target_id: num, name: "header", value: "html") + ThemeField.create!(theme: theme3, target_id: num, name: "header", value: "html") + end + + expect(ThemeField.find_by_theme_ids( + [theme3.id, theme.id, theme2.id] + ).pluck(:theme_id)).to eq( + [theme3.id, theme3.id, theme.id, theme.id, theme2.id, theme2.id] + ) + end + end + it "correctly generates errors for transpiled js" do html = < diff --git a/spec/models/theme_spec.rb b/spec/models/theme_spec.rb index d12e0fd10a..b17a155640 100644 --- a/spec/models/theme_spec.rb +++ b/spec/models/theme_spec.rb @@ -19,15 +19,16 @@ describe Theme do end let :customization do - Theme.create!(customization_params) + Fabricate(:theme, customization_params) end + let(:theme) { Fabricate(:theme, user: user) } + let(:child) { Fabricate(:theme, user: user) } 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) + Fabricate(:theme, color_scheme_id: scheme2.id) theme.destroy! scheme2.reload @@ -38,8 +39,6 @@ describe Theme do 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") @@ -54,7 +53,7 @@ describe Theme do expect(Theme.lookup_field(child.id, :mobile, :header)).to eq("Worldie\nMobile") - parent = Theme.new(name: '1', user_id: user.id) + parent = Fabricate(:theme, user: user) parent.set_field(target: :common, name: "header", value: "Common Parent") parent.set_field(target: :mobile, name: "header", value: "Mobile Parent") @@ -68,18 +67,39 @@ describe Theme do 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) + + expect(child.dependant_themes.length).to eq(1) + end + + it "doesn't allow multi-level theme components" do + grandchild = Fabricate(:theme, user: user) + grandparent = Fabricate(:theme, user: user) theme.add_child_theme!(child) - child.add_child_theme!(grandchild) + expect do + child.add_child_theme!(grandchild) + end.to raise_error(Discourse::InvalidParameters, I18n.t("themes.errors.no_multilevels_components")) - expect(grandchild.dependant_themes.length).to eq(2) + expect do + grandparent.add_child_theme!(theme) + end.to raise_error(Discourse::InvalidParameters, I18n.t("themes.errors.no_multilevels_components")) + end + + it "doesn't allow a child to be user selectable" do + theme.add_child_theme!(child) + child.update(user_selectable: true) + expect(child.errors.full_messages).to contain_exactly(I18n.t("themes.errors.component_no_user_selectable")) + end + + it "doesn't allow a child to be set as the default theme" do + theme.add_child_theme!(child) + expect do + child.set_default! + end.to raise_error(Discourse::InvalidParameters, I18n.t("themes.errors.component_no_default")) 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! @@ -95,7 +115,6 @@ describe Theme do {{hello}} HTML - theme = Theme.new(user_id: -1, name: "test") theme.set_field(target: :common, name: "header", value: with_template) theme.save! @@ -106,8 +125,6 @@ HTML 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 @@ -116,6 +133,41 @@ HTML expect(Theme.lookup_field(theme.id, :desktop, :body_tag)).to match(/test<\/b>/) end + it 'can find fields for multiple themes' do + theme2 = Fabricate(:theme) + + theme.set_field(target: :common, name: :body_tag, value: "testtheme1") + theme2.set_field(target: :common, name: :body_tag, value: "theme2test") + theme.save! + theme2.save! + + field = Theme.lookup_field([theme.id, theme2.id], :desktop, :body_tag) + expect(field).to match(/testtheme1<\/b>/) + expect(field).to match(/theme2test<\/b>/) + end + + describe ".transform_ids" do + it "adds the child themes of the parent" do + child = Fabricate(:theme, id: 97) + child2 = Fabricate(:theme, id: 96) + + theme.add_child_theme!(child) + theme.add_child_theme!(child2) + expect(Theme.transform_ids([theme.id])).to eq([theme.id, child2.id, child.id]) + expect(Theme.transform_ids([theme.id, 94, 90])).to eq([theme.id, 90, 94, child2.id, child.id]) + end + + it "doesn't insert children when extend is false" do + child = Fabricate(:theme, id: 97) + child2 = Fabricate(:theme, id: 96) + + theme.add_child_theme!(child) + theme.add_child_theme!(child2) + expect(Theme.transform_ids([theme.id], extend: false)).to eq([theme.id]) + expect(Theme.transform_ids([theme.id, 94, 90, 70, 70], extend: false)).to eq([theme.id, 70, 90, 94]) + end + end + context "plugin api" do def transpile(html) f = ThemeField.create!(target_id: Theme.targets[:mobile], theme_id: 1, name: "after_header", value: html) @@ -152,14 +204,12 @@ HTML context 'theme vars' do it 'works in parent theme' do - - theme = Theme.new(name: 'theme', user_id: -1) theme.set_field(target: :common, name: :scss, value: 'body {color: $magic; }') theme.set_field(target: :common, name: :magic, value: 'red', type: :theme_var) theme.set_field(target: :common, name: :not_red, value: 'red', type: :theme_var) theme.save - parent_theme = Theme.new(name: 'parent theme', user_id: -1) + parent_theme = Fabricate(:theme) parent_theme.set_field(target: :common, name: :scss, value: 'body {background-color: $not_red; }') parent_theme.set_field(target: :common, name: :not_red, value: 'blue', type: :theme_var) parent_theme.save @@ -171,7 +221,6 @@ HTML end 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) @@ -187,7 +236,6 @@ HTML 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)}') @@ -210,7 +258,6 @@ HTML context "theme settings" do it "allows values to be used in scss" do - theme = Theme.new(name: "awesome theme", user_id: -1) theme.set_field(target: :settings, name: :yaml, value: "background_color: red\nfont_size: 25px") theme.set_field(target: :common, name: :scss, value: 'body {background-color: $background_color; font-size: $font-size}') theme.save! @@ -227,7 +274,6 @@ HTML end it "allows values to be used in JS" do - theme = Theme.new(name: "awesome theme", user_id: -1) theme.set_field(target: :settings, name: :yaml, value: "name: bob") theme.set_field(target: :common, name: :after_header, value: '') theme.save! @@ -263,26 +309,30 @@ HTML it 'correctly caches theme ids' do Theme.destroy_all - theme = Theme.create!(name: "bob", user_id: -1) + theme + theme2 = Fabricate(:theme) - expect(Theme.theme_ids).to eq(Set.new([theme.id])) - expect(Theme.user_theme_ids).to eq(Set.new([])) + expect(Theme.theme_ids).to contain_exactly(theme.id, theme2.id) + expect(Theme.user_theme_ids).to eq([]) - theme.user_selectable = true - theme.save + theme.update!(user_selectable: true) - expect(Theme.user_theme_ids).to eq(Set.new([theme.id])) + expect(Theme.user_theme_ids).to contain_exactly(theme.id) - theme.user_selectable = false - theme.save + theme2.update!(user_selectable: true) + expect(Theme.user_theme_ids).to contain_exactly(theme.id, theme2.id) + + theme.update!(user_selectable: false) + theme2.update!(user_selectable: false) theme.set_default! - expect(Theme.user_theme_ids).to eq(Set.new([theme.id])) + expect(Theme.user_theme_ids).to contain_exactly(theme.id) theme.destroy + theme2.destroy - expect(Theme.theme_ids).to eq(Set.new([])) - expect(Theme.user_theme_ids).to eq(Set.new([])) + expect(Theme.theme_ids).to eq([]) + expect(Theme.user_theme_ids).to eq([]) end it 'correctly caches user_themes template' do @@ -292,8 +342,7 @@ HTML user_themes = JSON.parse(json)["user_themes"] expect(user_themes).to eq([]) - theme = Theme.create!(name: "bob", user_id: -1, user_selectable: true) - theme.save! + theme = Fabricate(:theme, name: "bob", user_selectable: true) json = Site.json_for(guardian) user_themes = JSON.parse(json)["user_themes"].map { |t| t["name"] } @@ -320,7 +369,6 @@ HTML it 'handles settings cache correctly' do Theme.destroy_all - theme = Theme.create!(name: "awesome theme", user_id: -1) expect(cached_settings(theme.id)).to eq("{}") theme.set_field(target: :settings, name: "yaml", value: "boolean_setting: true") @@ -330,7 +378,6 @@ HTML theme.settings.first.value = "false" expect(cached_settings(theme.id)).to match(/\"boolean_setting\":false/) - child = Theme.create!(name: "child theme", user_id: -1) child.set_field(target: :settings, name: "yaml", value: "integer_setting: 54") child.save! @@ -347,5 +394,4 @@ HTML expect(json).not_to match(/\"integer_setting\":54/) expect(json).to match(/\"boolean_setting\":false/) end - end diff --git a/spec/requests/admin/staff_action_logs_controller_spec.rb b/spec/requests/admin/staff_action_logs_controller_spec.rb index 12acd22244..f970be10c7 100644 --- a/spec/requests/admin/staff_action_logs_controller_spec.rb +++ b/spec/requests/admin/staff_action_logs_controller_spec.rb @@ -30,7 +30,7 @@ describe Admin::StaffActionLogsController do describe '#diff' do it 'can generate diffs for theme changes' do - theme = Theme.new(user_id: -1, name: 'bob') + theme = Fabricate(:theme) theme.set_field(target: :mobile, name: :scss, value: 'body {.up}') theme.set_field(target: :common, name: :scss, value: 'omit-dupe') diff --git a/spec/requests/admin/themes_controller_spec.rb b/spec/requests/admin/themes_controller_spec.rb index fc9e8cd5d2..9d5a6de5ba 100644 --- a/spec/requests/admin/themes_controller_spec.rb +++ b/spec/requests/admin/themes_controller_spec.rb @@ -49,7 +49,7 @@ describe Admin::ThemesController do it 'can import a theme with an upload' do upload = Fabricate(:upload) - theme = Theme.new(name: 'with-upload', user_id: -1) + theme = Fabricate(:theme) 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.save! @@ -93,7 +93,7 @@ describe Admin::ThemesController do ColorScheme.destroy_all Theme.destroy_all - theme = Theme.new(name: 'my name', user_id: -1) + theme = Fabricate(:theme) theme.set_field(target: :common, name: :scss, value: '.body{color: black;}') theme.set_field(target: :desktop, name: :after_header, value: 'test') @@ -141,7 +141,7 @@ describe Admin::ThemesController do end describe '#update' do - let(:theme) { Theme.create(name: 'my name', user_id: -1) } + let(:theme) { Fabricate(:theme) } it 'can change default theme' do SiteSetting.default_theme_id = -1 @@ -169,7 +169,7 @@ describe Admin::ThemesController do theme.set_field(target: :common, name: :scss, value: '.body{color: black;}') theme.save - child_theme = Theme.create(name: 'my name', user_id: -1) + child_theme = Fabricate(:theme) upload = Fabricate(:upload) @@ -198,5 +198,17 @@ describe Admin::ThemesController do expect(json["theme"]["child_themes"].length).to eq(1) expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) end + + it 'returns the right error message' do + parent = Fabricate(:theme) + parent.add_child_theme!(theme) + + put "/admin/themes/#{theme.id}.json", params: { + theme: { default: true } + } + + expect(response.status).to eq(400) + expect(JSON.parse(response.body)["errors"].first).to include(I18n.t("themes.errors.component_no_default")) + end end end diff --git a/spec/requests/application_controller_spec.rb b/spec/requests/application_controller_spec.rb index f498c807b5..f45f1c4fa5 100644 --- a/spec/requests/application_controller_spec.rb +++ b/spec/requests/application_controller_spec.rb @@ -33,4 +33,74 @@ RSpec.describe ApplicationController do end end + describe "#handle_theme" do + let(:theme) { Fabricate(:theme, user_selectable: true) } + let(:theme2) { Fabricate(:theme, user_selectable: true) } + let(:user) { Fabricate(:user) } + let(:admin) { Fabricate(:admin) } + + before do + sign_in(user) + end + + it "selects the theme the user has selected" do + user.user_option.update_columns(theme_ids: [theme.id]) + + get "/" + expect(response.status).to eq(200) + expect(controller.theme_ids).to eq([theme.id]) + + theme.update_attribute(:user_selectable, false) + + get "/" + expect(response.status).to eq(200) + expect(controller.theme_ids).to eq([SiteSetting.default_theme_id]) + end + + it "can be overridden with a cookie" do + user.user_option.update_columns(theme_ids: [theme.id]) + + cookies['theme_ids'] = "#{theme2.id}|#{user.user_option.theme_key_seq}" + + get "/" + expect(response.status).to eq(200) + expect(controller.theme_ids).to eq([theme2.id]) + + theme2.update!(user_selectable: false) + theme.add_child_theme!(theme2) + cookies['theme_ids'] = "#{theme.id},#{theme2.id}|#{user.user_option.theme_key_seq}" + + get "/" + expect(response.status).to eq(200) + expect(controller.theme_ids).to eq([theme.id, theme2.id]) + end + + it "falls back to the default theme when the user has no cookies or preferences" do + user.user_option.update_columns(theme_ids: []) + cookies["theme_ids"] = nil + theme2.set_default! + + get "/" + expect(response.status).to eq(200) + expect(controller.theme_ids).to eq([theme2.id]) + end + + it "can be overridden with preview_theme_id param" do + sign_in(admin) + cookies['theme_ids'] = "#{theme.id},#{theme2.id}|#{admin.user_option.theme_key_seq}" + + get "/", params: { preview_theme_id: theme2.id } + expect(response.status).to eq(200) + expect(controller.theme_ids).to eq([theme2.id]) + end + + it "cookie can fail back to user if out of sync" do + user.user_option.update_columns(theme_ids: [theme.id]) + cookies['theme_ids'] = "#{theme2.id}|#{user.user_option.theme_key_seq - 1}" + + get "/" + expect(response.status).to eq(200) + expect(controller.theme_ids).to eq([theme.id]) + end + end end diff --git a/spec/requests/embed_controller_spec.rb b/spec/requests/embed_controller_spec.rb index f7f5c11eee..85601b0b10 100644 --- a/spec/requests/embed_controller_spec.rb +++ b/spec/requests/embed_controller_spec.rb @@ -83,7 +83,7 @@ describe EmbedController do end it "includes CSS from embedded_scss field" do - theme = Theme.create!(name: "Awesome blog", user_id: -1) + theme = Fabricate(:theme) theme.set_default! ThemeField.create!( diff --git a/spec/requests/stylesheets_controller_spec.rb b/spec/requests/stylesheets_controller_spec.rb index 3ccecc7ab1..138eb202ab 100644 --- a/spec/requests/stylesheets_controller_spec.rb +++ b/spec/requests/stylesheets_controller_spec.rb @@ -27,7 +27,7 @@ describe StylesheetsController do 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) + theme = Fabricate(:theme, color_scheme_id: scheme.id) builder = Stylesheet::Manager.new(:desktop, theme.id) builder.compile diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb index e98a2ab94a..a4e43d0bf3 100644 --- a/spec/requests/topics_controller_spec.rb +++ b/spec/requests/topics_controller_spec.rb @@ -1218,48 +1218,6 @@ RSpec.describe TopicsController do expect(response.headers['X-Robots-Tag']).to eq(nil) end - describe "themes" do - let(:theme) { Theme.create!(user_id: -1, name: 'bob', user_selectable: true) } - let(:theme2) { Theme.create!(user_id: -1, name: 'bobbob', user_selectable: true) } - - before do - sign_in(user) - end - - it "selects the theme the user has selected" do - user.user_option.update_columns(theme_ids: [theme.id]) - - get "/t/#{topic.id}" - expect(response).to be_redirect - expect(controller.theme_id).to eq(theme.id) - - theme.update_attribute(:user_selectable, false) - - get "/t/#{topic.id}" - expect(response).to be_redirect - expect(controller.theme_id).not_to eq(theme.id) - end - - it "can be overridden with a cookie" do - user.user_option.update_columns(theme_ids: [theme.id]) - - cookies['theme_ids'] = "#{theme2.id}|#{user.user_option.theme_key_seq}" - - get "/t/#{topic.id}" - expect(response).to be_redirect - expect(controller.theme_id).to eq(theme2.id) - end - - it "cookie can fail back to user if out of sync" do - user.user_option.update_columns(theme_ids: [theme.id]) - cookies['theme_ids'] = "#{theme2.id}|#{user.user_option.theme_key_seq - 1}" - - get "/t/#{topic.id}" - expect(response).to be_redirect - expect(controller.theme_id).to eq(theme.id) - end - end - it "doesn't store an incoming link when there's no referer" do expect { get "/t/#{topic.id}.json" diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index c0aa78e5f1..396957bd2f 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -1440,7 +1440,7 @@ describe UsersController do notification_level: TagUser.notification_levels[:watching] ).pluck(:tag_id)).to contain_exactly(tags[0].id, tags[1].id) - theme = Theme.create(name: "test", user_selectable: true, user_id: -1) + theme = Fabricate(:theme, user_selectable: true) put "/u/#{user.username}.json", params: { muted_usernames: "", diff --git a/spec/services/staff_action_logger_spec.rb b/spec/services/staff_action_logger_spec.rb index 56c8a81c36..41ea44c06c 100644 --- a/spec/services/staff_action_logger_spec.rb +++ b/spec/services/staff_action_logger_spec.rb @@ -154,7 +154,7 @@ describe StaffActionLogger do end let :theme do - Theme.new(name: 'bob', user_id: -1) + Fabricate(:theme) end it "logs new site customizations" do @@ -188,7 +188,7 @@ describe StaffActionLogger do end it "creates a new UserHistory record" do - theme = Theme.new(name: 'Banana') + theme = Fabricate(:theme) theme.set_field(target: :common, name: :scss, value: "body{margin: 10px;}") log_record = logger.log_theme_destroy(theme) diff --git a/spec/services/user_merger_spec.rb b/spec/services/user_merger_spec.rb index 5b1dd3df76..65799eb179 100644 --- a/spec/services/user_merger_spec.rb +++ b/spec/services/user_merger_spec.rb @@ -478,7 +478,7 @@ describe UserMerger do end it "updates themes" do - theme = Theme.create!(name: 'my name', user_id: source_user.id) + theme = Fabricate(:theme, user: source_user) merge_users! expect(theme.reload.user_id).to eq(target_user.id) diff --git a/spec/services/user_updater_spec.rb b/spec/services/user_updater_spec.rb index 5e4e549f88..47e37edc0d 100644 --- a/spec/services/user_updater_spec.rb +++ b/spec/services/user_updater_spec.rb @@ -82,7 +82,7 @@ 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) + theme = Fabricate(:theme, user_selectable: true) seq = user.user_option.theme_key_seq From 6c41b54b2ef42ae80fa9bb1e923087a9fe6f19f2 Mon Sep 17 00:00:00 2001 From: Angus McLeod Date: Wed, 8 Aug 2018 14:49:09 +1000 Subject: [PATCH 006/179] FIX: create tmp if it doesn't exist when creating tmp/pids I get this error if I stop a dev server, ``rm -rf tmp`` and start it again: ``` `mkdir': No such file or directory @ dir_s_mkdir - /Users/angusmcleod/discourse/discourse/tmp/pids (Errno::ENOENT) ``` This fixes it. See: https://github.com/discourse/discourse/commit/f3549291a36157932ad015799aca6d39ccec083d#diff-26ac62db6c6a4582de3bbf2615790c23R22 --- config/unicorn.conf.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/unicorn.conf.rb b/config/unicorn.conf.rb index d1d9c44b03..2e6b213d68 100644 --- a/config/unicorn.conf.rb +++ b/config/unicorn.conf.rb @@ -19,7 +19,7 @@ listen (ENV["UNICORN_PORT"] || 3000).to_i timeout 30 if !File.exist?("#{discourse_path}/tmp/pids") - Dir.mkdir("#{discourse_path}/tmp/pids") + FileUtils.mkdir_p("#{discourse_path}/tmp/pids") end # feel free to point this anywhere accessible on the filesystem From 17047806b92260f571d0f76d62f84d2017bf3e4f Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 8 Aug 2018 13:14:52 +0800 Subject: [PATCH 007/179] Add a rake task to fix uploads with wrong extension. --- lib/tasks/uploads.rake | 4 +++ lib/upload_fixer.rb | 81 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 lib/upload_fixer.rb diff --git a/lib/tasks/uploads.rake b/lib/tasks/uploads.rake index 40839a4821..484be1e947 100644 --- a/lib/tasks/uploads.rake +++ b/lib/tasks/uploads.rake @@ -710,3 +710,7 @@ task "uploads:analyze", [:cache_path, :limit] => :environment do |_, args| puts "List of file paths @ #{path}" puts "Duration: #{Time.zone.now - now} seconds" end + +task "uploads:fix_incorrect_extensions" => :environment do + UploadFixer.fix_extensions +end diff --git a/lib/upload_fixer.rb b/lib/upload_fixer.rb new file mode 100644 index 0000000000..79764bfb52 --- /dev/null +++ b/lib/upload_fixer.rb @@ -0,0 +1,81 @@ +class UploadFixer + def self.fix_extensions + Upload.where("uploads.extension IS NOT NULL").find_each do |upload| + is_external = Discourse.store.external? + previous_url = upload.url.dup + + source = + if is_external + "https:#{previous_url}" + else + Discourse.store.path_for(upload) + end + + correct_extension = FastImage.type(source).to_s.downcase + current_extension = upload.extension.to_s.downcase + + if correct_extension.present? + correct_extension = 'jpg' if correct_extension == 'jpeg' + current_extension = 'jpg' if current_extension == 'jpeg' + + if correct_extension != current_extension + new_filename = change_extension( + upload.original_filename, + correct_extension + ) + + new_url = change_extension(previous_url, correct_extension) + + if is_external + new_url = "/#{new_url}" + source = Discourse.store.get_path_for_upload(upload) + destination = change_extension(source, correct_extension) + + Discourse.store.copy_file( + previous_url, + source, + destination + ) + + upload.update!( + original_filename: new_filename, + url: new_url, + extension: correct_extension + ) + + DbHelper.remap(previous_url, upload.url) + Discourse.store.remove_file(previous_url, source) + else + destination = change_extension(source, correct_extension) + FileUtils.copy(source, destination) + + upload.update!( + original_filename: new_filename, + url: new_url, + extension: correct_extension + ) + + DbHelper.remap(previous_url, upload.url) + + tombstone_path = source.sub("/uploads/", "/uploads/tombstone/") + FileUtils.mkdir_p(File.dirname(tombstone_path)) + + FileUtils.move( + source, + tombstone_path + ) + end + end + end + end + end + + private + + def self.change_extension(path, extension) + pathname = Pathname.new(path) + dirname = pathname.dirname.to_s != "." ? "#{pathname.dirname}/" : "" + basename = File.basename(path, File.extname(path)) + "#{dirname}#{basename}.#{extension}" + end +end From a35f2984e95304377d6797506a23c3ec9901a61e Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 8 Aug 2018 16:44:56 +1000 Subject: [PATCH 008/179] FIX: support Arrays with Marshal dump in distributed cache Theme cache uses arrays here --- lib/distributed_cache.rb | 2 +- spec/components/distributed_cache_spec.rb | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/distributed_cache.rb b/lib/distributed_cache.rb index 2c454526db..203bb2ae84 100644 --- a/lib/distributed_cache.rb +++ b/lib/distributed_cache.rb @@ -74,7 +74,7 @@ class DistributedCache def set(hash, key, value) # special support for set - marshal = (Set === value || Hash === value) + marshal = (Set === value || Hash === value || Array === value) value = Base64.encode64(Marshal.dump(value)) if marshal publish(hash, op: :set, key: key, value: value, marshalled: marshal) end diff --git a/spec/components/distributed_cache_spec.rb b/spec/components/distributed_cache_spec.rb index a1b442edf1..4ff420c96e 100644 --- a/spec/components/distributed_cache_spec.rb +++ b/spec/components/distributed_cache_spec.rb @@ -29,6 +29,20 @@ describe DistributedCache do cache(cache_name) end + it 'supports arrays with hashes' do + + c1 = cache("test1") + c2 = cache("test1") + + c1["test"] = [{ test: :test }] + + wait_for do + c2["test"] == [{ test: :test }] + end + + expect(c2[:test]).to eq([{ test: :test }]) + end + it 'allows us to store Set' do c1 = cache("test1") c2 = cache("test1") From 0879610ffd9ef9f9b29a0ebaa4cb9535434dd61b Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 8 Aug 2018 15:39:00 +0800 Subject: [PATCH 009/179] Add missing require in `uploads:fix_incorrect_extensions`. --- lib/tasks/uploads.rake | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/tasks/uploads.rake b/lib/tasks/uploads.rake index 484be1e947..b6abe20b50 100644 --- a/lib/tasks/uploads.rake +++ b/lib/tasks/uploads.rake @@ -712,5 +712,6 @@ task "uploads:analyze", [:cache_path, :limit] => :environment do |_, args| end task "uploads:fix_incorrect_extensions" => :environment do + require_dependency "upload_fixer" UploadFixer.fix_extensions end From 0d45826d22ccd68799f0b1d082f1dd47878f5a1c Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Wed, 8 Aug 2018 10:58:45 +0300 Subject: [PATCH 010/179] fix theme previewing (#6245) --- app/controllers/themes_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/themes_controller.rb b/app/controllers/themes_controller.rb index 2cc8e284a1..306badc48a 100644 --- a/app/controllers/themes_controller.rb +++ b/app/controllers/themes_controller.rb @@ -13,7 +13,7 @@ class ThemesController < ::ApplicationController object = targets.map do |target| Stylesheet::Manager.stylesheet_data(target, theme_ids).map do |hash| - return hash unless Rails.env.development? + next hash unless Rails.env.development? dup_hash = hash.dup dup_hash[:new_href] << (dup_hash[:new_href].include?("?") ? "&" : "?") From 1ea23b1eaea82320b96b4192c07736ec20ea7e2e Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 8 Aug 2018 15:57:58 +0800 Subject: [PATCH 011/179] FIX: Wrong order for `S3Helper#copy_file`. --- lib/s3_helper.rb | 8 ++++---- spec/components/file_store/s3_store_spec.rb | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/s3_helper.rb b/lib/s3_helper.rb index 61d5855954..cf81491292 100644 --- a/lib/s3_helper.rb +++ b/lib/s3_helper.rb @@ -33,8 +33,8 @@ class S3Helper # copy the file in tombstone if copy_to_tombstone && @tombstone_prefix.present? self.copy( - File.join(@tombstone_prefix, s3_filename), - get_path_for_s3_upload(s3_filename) + get_path_for_s3_upload(s3_filename), + File.join(@tombstone_prefix, s3_filename) ) end @@ -45,8 +45,8 @@ class S3Helper def copy(source, destination) s3_bucket - .object(source) - .copy_from(copy_source: File.join(@s3_bucket_name, destination)) + .object(destination) + .copy_from(copy_source: File.join(@s3_bucket_name, source)) end # make sure we have a cors config for assets diff --git a/spec/components/file_store/s3_store_spec.rb b/spec/components/file_store/s3_store_spec.rb index 58cecf5e00..06948702dd 100644 --- a/spec/components/file_store/s3_store_spec.rb +++ b/spec/components/file_store/s3_store_spec.rb @@ -125,10 +125,10 @@ describe FileStore::S3Store do s3_object = stub - s3_bucket.expects(:object).with(source).returns(s3_object) + s3_bucket.expects(:object).with(destination).returns(s3_object) s3_object.expects(:copy_from).with( - copy_source: "s3-upload-bucket/#{destination}" + copy_source: "s3-upload-bucket/#{source}" ) store.copy_file(upload.url, source, destination) From ba6f11c521b66870a6cd2c39785aa0dbcdabcecf Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 8 Aug 2018 16:25:00 +0800 Subject: [PATCH 012/179] PERF: Only log the first skipped email when user exceeds daily limit. https://meta.discourse.org/t/cleaning-up-e-mail-logs/39132 --- .../regular/notify_mailing_list_subscribers.rb | 14 ++++++++++++-- spec/jobs/notify_mailing_list_subscribers_spec.rb | 11 +++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/jobs/regular/notify_mailing_list_subscribers.rb b/app/jobs/regular/notify_mailing_list_subscribers.rb index a75603b172..d6f0484cef 100644 --- a/app/jobs/regular/notify_mailing_list_subscribers.rb +++ b/app/jobs/regular/notify_mailing_list_subscribers.rb @@ -80,13 +80,23 @@ module Jobs end def skip(to_address, user_id, post_id, reason_type) - SkippedEmailLog.create!( + attributes = { email_type: 'mailing_list', to_address: to_address, user_id: user_id, post_id: post_id, reason_type: reason_type - ) + } + + if reason_type == SkippedEmailLog.reason_types[:exceeded_emails_limit] + exists = SkippedEmailLog.exists?({ + created_at: (Time.zone.now.beginning_of_day..Time.zone.now.end_of_day) + }.merge(attributes)) + + return if exists + end + + SkippedEmailLog.create!(attributes) end end end diff --git a/spec/jobs/notify_mailing_list_subscribers_spec.rb b/spec/jobs/notify_mailing_list_subscribers_spec.rb index ec42df7bd4..7bb5b3265f 100644 --- a/spec/jobs/notify_mailing_list_subscribers_spec.rb +++ b/spec/jobs/notify_mailing_list_subscribers_spec.rb @@ -126,8 +126,15 @@ describe Jobs::NotifyMailingListSubscribers do mailing_list_user.email_logs.create(email_type: 'foobar', to_address: mailing_list_user.email) } - Jobs::NotifyMailingListSubscribers.new.execute(post_id: post.id) - UserNotifications.expects(:mailing_list_notify).with(mailing_list_user, post).never + expect do + UserNotifications.expects(:mailing_list_notify) + .with(mailing_list_user, post) + .never + + 2.times do + Jobs::NotifyMailingListSubscribers.new.execute(post_id: post.id) + end + end.to change { SkippedEmailLog.count }.by(1) expect(SkippedEmailLog.exists?( email_type: "mailing_list", From 575d9e0b1a46f8b86b5ba1285ea5b16a8e11b1d3 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 8 Aug 2018 10:09:22 +0100 Subject: [PATCH 013/179] FIX: Include parameters in function call --- .../controllers/preferences/second-factor.js.es6 | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 index e483885c91..29ef562636 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 @@ -2,6 +2,7 @@ import { default as computed } from "ember-addons/ember-computed-decorators"; import { default as DiscourseURL, userPath } from "discourse/lib/url"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { findAll } from "discourse/models/login-method"; +import { getOwner } from "discourse-common/lib/get-owner"; export default Ember.Controller.extend({ loading: false, @@ -33,7 +34,13 @@ export default Ember.Controller.extend({ @computed displayOAuthWarning() { - return findAll().length > 0; + return ( + findAll( + this.siteSettings, + getOwner(this).lookup("capabilities:main"), + this.site.isMobileDevice + ).length > 0 + ); }, toggleSecondFactor(enable) { From d4d5088324a949ce542f6e04ed171f3262c3f063 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 8 Aug 2018 10:37:25 +0100 Subject: [PATCH 014/179] FIX: Don't require device capabilities when calculating login methods --- .../controllers/preferences/second-factor.js.es6 | 8 +------- .../javascripts/discourse/models/login-method.js.es6 | 5 ++++- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 index 29ef562636..279a03f3cb 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 @@ -34,13 +34,7 @@ export default Ember.Controller.extend({ @computed displayOAuthWarning() { - return ( - findAll( - this.siteSettings, - getOwner(this).lookup("capabilities:main"), - this.site.isMobileDevice - ).length > 0 - ); + return findAll().length > 0; }, toggleSecondFactor(enable) { diff --git a/app/assets/javascripts/discourse/models/login-method.js.es6 b/app/assets/javascripts/discourse/models/login-method.js.es6 index 143deec081..aba619ee94 100644 --- a/app/assets/javascripts/discourse/models/login-method.js.es6 +++ b/app/assets/javascripts/discourse/models/login-method.js.es6 @@ -86,7 +86,10 @@ export function findAll(siteSettings, capabilities, isMobileDevice) { }); // On Mobile, Android or iOS always go with full screen - if (isMobileDevice || capabilities.isIOS || capabilities.isAndroid) { + if ( + isMobileDevice || + (capabilities && (capabilities.isIOS || capabilities.isAndroid)) + ) { methods.forEach(m => m.set("full_screen_login", true)); } From d3a9596d082c58d880ac972d418c61f2a178468b Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 8 Aug 2018 10:46:43 +0100 Subject: [PATCH 015/179] Remove unused import --- .../discourse/controllers/preferences/second-factor.js.es6 | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 index 279a03f3cb..e483885c91 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 @@ -2,7 +2,6 @@ import { default as computed } from "ember-addons/ember-computed-decorators"; import { default as DiscourseURL, userPath } from "discourse/lib/url"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { findAll } from "discourse/models/login-method"; -import { getOwner } from "discourse-common/lib/get-owner"; export default Ember.Controller.extend({ loading: false, From 94622b451a19f0f5152280f146354ab663d06eb8 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 8 Aug 2018 17:47:31 +0800 Subject: [PATCH 016/179] FIX: Search does not retrigger when context has changed. https://meta.discourse.org/t/using-the-search-this-topic-check-box-blocks-search-on-other-pages/56832/6?u=tgxworld --- app/assets/javascripts/discourse/widgets/search-menu.js.es6 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/assets/javascripts/discourse/widgets/search-menu.js.es6 b/app/assets/javascripts/discourse/widgets/search-menu.js.es6 index 79fd332291..30964e614e 100644 --- a/app/assets/javascripts/discourse/widgets/search-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/search-menu.js.es6 @@ -196,6 +196,8 @@ export default createWidget("search-menu", { this.triggerSearch(); } + searchData.contextEnabled = attrs.contextEnabled; + return this.attach("menu-panel", { maxWidth: 500, contents: () => this.panelContents() From 35bef72d4ed6d530468bdc091bc076d431a2cdc4 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Wed, 8 Aug 2018 13:41:29 -0400 Subject: [PATCH 017/179] FIX: subfolder redirects to wrong URL if the subfolder appears in the slug --- app/assets/javascripts/discourse/lib/discourse-location.js.es6 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/lib/discourse-location.js.es6 b/app/assets/javascripts/discourse/lib/discourse-location.js.es6 index 67df87c2ca..576b135de8 100644 --- a/app/assets/javascripts/discourse/lib/discourse-location.js.es6 +++ b/app/assets/javascripts/discourse/lib/discourse-location.js.es6 @@ -66,7 +66,8 @@ const DiscourseLocation = Ember.Object.extend({ getURL() { const location = get(this, "location"); let url = location.pathname; - url = url.replace(Discourse.BaseUri, ""); + + url = url.replace(new RegExp(`^${Discourse.BaseUri}`), ""); const search = location.search || ""; url += search; From cc96af07d1040638894e1b26bbefc4be602e644a Mon Sep 17 00:00:00 2001 From: Kris Date: Wed, 8 Aug 2018 15:51:11 -0400 Subject: [PATCH 018/179] Full-width markdown table on mobile --- app/assets/stylesheets/common/base/compose.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/assets/stylesheets/common/base/compose.scss b/app/assets/stylesheets/common/base/compose.scss index 81c3c019ee..1d355ac9e8 100644 --- a/app/assets/stylesheets/common/base/compose.scss +++ b/app/assets/stylesheets/common/base/compose.scss @@ -438,4 +438,9 @@ div.ac-wrap { .md-table { overflow-y: auto; margin: 1em 0; + .mobile-view & { + table { + width: 100%; + } + } } From b53d3457c89a330d490d4b3b3c4a007cc00fe922 Mon Sep 17 00:00:00 2001 From: Kris Date: Wed, 8 Aug 2018 16:01:21 -0400 Subject: [PATCH 019/179] updating color scheme attribution --- app/models/color_scheme.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/color_scheme.rb b/app/models/color_scheme.rb index bd3efd15d8..9a47a14952 100644 --- a/app/models/color_scheme.rb +++ b/app/models/color_scheme.rb @@ -43,7 +43,7 @@ class ColorScheme < ActiveRecord::Base "success" => 'fdd459', "love" => 'fdd459' }, - # By @awesomerobot + # By @rafafotes 'Shades of Blue': { "primary" => '203243', "secondary" => 'eef4f7', From f7b4a2b3bac6d74b9b1949a8073f4a0314677388 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Wed, 8 Aug 2018 16:47:54 -0400 Subject: [PATCH 020/179] FIX: ensure URLs include subfolder in admin emails UI --- .../javascripts/admin/models/email-log.js.es6 | 4 +++ test/javascripts/models/email-log-test.js.es6 | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/app/assets/javascripts/admin/models/email-log.js.es6 b/app/assets/javascripts/admin/models/email-log.js.es6 index da94527838..4b6db3c6c1 100644 --- a/app/assets/javascripts/admin/models/email-log.js.es6 +++ b/app/assets/javascripts/admin/models/email-log.js.es6 @@ -11,6 +11,10 @@ EmailLog.reopenClass({ attrs.user = AdminUser.create(attrs.user); } + if (attrs.post_url) { + attrs.post_url = Discourse.getURL(attrs.post_url); + } + return this._super(attrs); }, diff --git a/test/javascripts/models/email-log-test.js.es6 b/test/javascripts/models/email-log-test.js.es6 index e322da5a90..f6e00b14e2 100644 --- a/test/javascripts/models/email-log-test.js.es6 +++ b/test/javascripts/models/email-log-test.js.es6 @@ -5,3 +5,29 @@ QUnit.module("Discourse.EmailLog"); QUnit.test("create", assert => { assert.ok(EmailLog.create(), "it can be created without arguments"); }); + +QUnit.test("subfolder support", assert => { + Discourse.BaseUri = "/forum"; + const attrs = { + id: 60, + to_address: "wikiman@asdf.com", + email_type: "user_linked", + user_id: 9, + created_at: "2018-08-08T17:21:52.022Z", + post_url: "/t/some-pro-tips-for-you/41/5", + post_description: "Some Pro Tips For You", + bounced: false, + user: { + id: 9, + username: "wikiman", + avatar_template: + "/forum/letter_avatar_proxy/v2/letter/w/dfb087/{size}.png" + } + }; + const emailLog = EmailLog.create(attrs); + assert.equal( + emailLog.get("post_url"), + "/forum/t/some-pro-tips-for-you/41/5", + "includes the subfolder in the post url" + ); +}); From ed4c0f256e2bc3edb7ea882ea799795ba570e368 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 9 Aug 2018 15:05:12 +1000 Subject: [PATCH 021/179] FIX: check permalinks for deleted topics - allow to specify 410 vs 404 in Discourse::NotFound exception - remove unused `permalink_redirect_or_not_found` which - handle JS side links to topics via Discourse-Xhr-Redirect mechanism --- .../javascripts/discourse/lib/ajax.js.es6 | 12 ++++ app/controllers/application_controller.rb | 55 ++++++++++++------- app/controllers/list_controller.rb | 4 +- app/controllers/tags_controller.rb | 2 +- app/controllers/topics_controller.rb | 8 ++- lib/discourse.rb | 13 ++++- spec/requests/application_controller_spec.rb | 20 +++++++ 7 files changed, 88 insertions(+), 26 deletions(-) diff --git a/app/assets/javascripts/discourse/lib/ajax.js.es6 b/app/assets/javascripts/discourse/lib/ajax.js.es6 index 098676437c..dc81ce6cd1 100644 --- a/app/assets/javascripts/discourse/lib/ajax.js.es6 +++ b/app/assets/javascripts/discourse/lib/ajax.js.es6 @@ -29,6 +29,17 @@ export function handleLogoff(xhr) { } } +function handleRedirect(data) { + if ( + data && + data.getResponseHeader && + data.getResponseHeader("Discourse-Xhr-Redirect") + ) { + window.location.replace(data.responseText); + window.location.reload(); + } +} + /** Our own $.ajax method. Makes sure the .then method executes in an Ember runloop for performance reasons. Also automatically adjusts the URL to support installs @@ -76,6 +87,7 @@ export function ajax() { } args.success = (data, textStatus, xhr) => { + handleRedirect(data); handleLogoff(xhr); Ember.run(() => { diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 87d372f629..d5adf6ee1a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -173,7 +173,16 @@ class ApplicationController < ActionController::Base end end - rescue_from Discourse::NotFound, PluginDisabled, ActionController::RoutingError do + rescue_from Discourse::NotFound do |e| + rescue_discourse_actions( + :not_found, + e.status, + check_permalinks: e.check_permalinks, + original_path: e.original_path + ) + end + + rescue_from PluginDisabled, ActionController::RoutingError do rescue_discourse_actions(:not_found, 404) end @@ -194,12 +203,37 @@ class ApplicationController < ActionController::Base render_json_error I18n.t('read_only_mode_enabled'), type: :read_only, status: 503 end + def redirect_with_client_support(url, options) + if request.xhr? + response.headers['Discourse-Xhr-Redirect'] = 'true' + render plain: url + else + redirect_to url, options + end + end + def rescue_discourse_actions(type, status_code, opts = nil) opts ||= {} show_json_errors = (request.format && request.format.json?) || (request.xhr?) || ((params[:external_id] || '').ends_with? '.json') + if type == :not_found && opts[:check_permalinks] + url = opts[:original_path] || request.fullpath + permalink = Permalink.find_by_url(url) + + if permalink.present? + # permalink present, redirect to that URL + if permalink.external_url + redirect_with_client_support permalink.external_url, status: :moved_permanently + return + elsif permalink.target_url + redirect_with_client_support "#{Discourse::base_uri}#{permalink.target_url}", status: :moved_permanently + return + end + end + end + message = opts[:custom_message_translated] || I18n.t(opts[:custom_message] || type) if show_json_errors @@ -444,25 +478,6 @@ class ApplicationController < ActionController::Base request.session_options[:skip] = true end - def permalink_redirect_or_not_found - url = request.fullpath - permalink = Permalink.find_by_url(url) - - if permalink.present? - # permalink present, redirect to that URL - if permalink.external_url - redirect_to permalink.external_url, status: :moved_permanently - elsif permalink.target_url - redirect_to "#{Discourse::base_uri}#{permalink.target_url}", status: :moved_permanently - else - raise Discourse::NotFound - end - else - # redirect to 404 - raise Discourse::NotFound - end - end - def secure_session SecureSession.new(session["secure_session_id"] ||= SecureRandom.hex) end diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb index 6deaf817e7..a2e94b2617 100644 --- a/app/controllers/list_controller.rb +++ b/app/controllers/list_controller.rb @@ -344,7 +344,7 @@ class ListController < ApplicationController parent_category_id = nil if parent_slug_or_id.present? parent_category_id = Category.query_parent_category(parent_slug_or_id) - permalink_redirect_or_not_found && (return) if parent_category_id.blank? && !id + raise Discourse::NotFound.new("category not found", check_permalinks: true) if parent_category_id.blank? && !id end @category = Category.query_category(slug_or_id, parent_category_id) @@ -355,7 +355,7 @@ class ListController < ApplicationController (redirect_to category.url, status: 301) && return if category end - permalink_redirect_or_not_found && (return) if !@category + raise Discourse::NotFound.new("category not found", check_permalinks: true) if !@category @description_meta = @category.description_text raise Discourse::NotFound unless guardian.can_see?(@category) diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index b2916e8c9d..fccddd0e4a 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -90,7 +90,7 @@ class TagsController < ::ApplicationController canonical_url "#{Discourse.base_url_no_prefix}#{public_send(path_name, *(params.slice(:parent_category, :category, :tag_id).values.map { |t| t.force_encoding("UTF-8") }))}" if @list.topics.size == 0 && params[:tag_id] != 'none' && !Tag.where(name: @tag_id).exists? - permalink_redirect_or_not_found + raise Discourse::NotFound.new("tag not found", check_permalinks: true) else respond_with_list(@list) end diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 44b362ccbb..a4ffc8aaf2 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -135,8 +135,12 @@ class TopicsController < ApplicationController end if ex.obj && Topic === ex.obj && guardian.can_see_topic_if_not_deleted?(ex.obj) - rescue_discourse_actions(:not_found, 410) - return + raise Discourse::NotFound.new( + "topic was deleted", + status: 410, + check_permalinks: true, + original_path: ex.obj.relative_url + ) end raise ex diff --git a/lib/discourse.rb b/lib/discourse.rb index 4ebb87eb72..50793e334c 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -77,7 +77,18 @@ module Discourse end # When something they want is not found - class NotFound < StandardError; end + class NotFound < StandardError + attr_reader :status + attr_reader :check_permalinks + attr_reader :original_path + + def initialize(message = nil, status: 404, check_permalinks: false, original_path: nil) + @status = status + @check_permalinks = check_permalinks + @original_path = original_path + super(message) + end + end # When a setting is missing class SiteSettingMissing < StandardError; end diff --git a/spec/requests/application_controller_spec.rb b/spec/requests/application_controller_spec.rb index f45f1c4fa5..5ad3aa6099 100644 --- a/spec/requests/application_controller_spec.rb +++ b/spec/requests/application_controller_spec.rb @@ -17,6 +17,26 @@ RSpec.describe ApplicationController do describe 'build_not_found_page' do describe 'topic not found' do + + it 'should return permalink for deleted topics' do + topic = create_post.topic + external_url = 'https://somewhere.over.rainbow' + Permalink.create!(url: topic.relative_url, external_url: external_url) + topic.trash! + + get topic.relative_url + expect(response.status).to eq(301) + expect(response).to redirect_to(external_url) + + get "/t/#{topic.id}.json" + expect(response.status).to eq(301) + expect(response).to redirect_to(external_url) + + get "/t/#{topic.id}.json", xhr: true + expect(response.status).to eq(200) + expect(response.body).to eq(external_url) + end + it 'should return 404 and show Google search' do get "/t/nope-nope/99999999" expect(response.status).to eq(404) From 7aef604f7d88ec827fbb87619f85054af2f5cc2c Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 9 Aug 2018 15:07:18 +1000 Subject: [PATCH 022/179] regression, if there is not excerpt skip --- app/views/list/list.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/list/list.erb b/app/views/list/list.erb index 14d6965497..99abddc494 100644 --- a/app/views/list/list.erb +++ b/app/views/list/list.erb @@ -53,7 +53,7 @@ <% end %> '>(<%= t.posts_count %>) - <% if t.pinned_until && t.pinned_until > Time.zone.now && (t.pinned_globally || @list.category) %> + <% if t.pinned_until && (t.pinned_until > Time.zone.now) && (t.pinned_globally || @list.category) && t.excerpt %>

<%= t.excerpt.html_safe %>

From 523acfcea463663bde0074412b1f4e847de37e50 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 9 Aug 2018 10:45:53 +0200 Subject: [PATCH 023/179] FIX: checks on parent visibility instead of filter itself (#6250) --- .../javascripts/select-kit/mixins/dom-helpers.js.es6 | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 b/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 index 73a3c2c6e2..13490bc91a 100644 --- a/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 +++ b/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 @@ -91,7 +91,14 @@ export default Ember.Mixin.create({ // next so we are sure it finised expand/collapse Ember.run.next(() => { Ember.run.schedule("afterRender", () => { - if (!context.$filterInput() || !context.$filterInput().is(":visible")) { + if ( + !context.$filterInput() || + !context.$filterInput().is(":visible") || + context + .$filterInput() + .parent() + .hasClass("is-hidden") + ) { if (context.$header()) { context.$header().focus(); } else { From bfcf8ed61bebba6be3082507dafa6766024ddf3a Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 9 Aug 2018 14:23:09 +0200 Subject: [PATCH 024/179] FIX: prevents focus of input on mobile (#6251) --- app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 b/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 index 13490bc91a..e805efe162 100644 --- a/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 +++ b/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 @@ -105,7 +105,11 @@ export default Ember.Mixin.create({ $(context.element).focus(); } } else { - context.$filterInput().focus(); + if (this.site && this.site.isMobileDevice) { + this.expand(); + } else { + context.$filterInput().focus(); + } } }); }); From da1d520d4c662831c6645f57fe7269898049ccb9 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 9 Aug 2018 14:23:28 +0200 Subject: [PATCH 025/179] FIX: simplifies mini tag chooser events handling (#6252) --- .../components/mini-tag-chooser.js.es6 | 51 +++---------------- .../mini-tag-chooser-header.hbs | 3 +- 2 files changed, 9 insertions(+), 45 deletions(-) diff --git a/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 b/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 index 3fc1065df5..a46f9ff541 100644 --- a/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 +++ b/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 @@ -45,19 +45,15 @@ export default ComboBox.extend(Tags, { ); }, - willDestroyElement() { - this._super(...arguments); + click(event) { + if (event.target.tagName === "BUTTON") { + const $button = $(event.target); - this.$(".selected-name").off("touchend.select-kit pointerup.select-kit"); - }, - - didInsertElement() { - this._super(...arguments); - - this.$(".selected-name").on( - "touchend.select-kit pointerup.select-kit", - () => this.focusFilterOrHeader() - ); + if ($button.hasClass("selected-tag")) { + this._destroyEvent(event); + this.destroyTags(this.computeContentItem($button.attr("data-value"))); + } + } }, @computed("hasReachedMaximum") @@ -74,37 +70,6 @@ export default ComboBox.extend(Tags, { return computedContent; }, - didRender() { - this._super(); - - this.$(".select-kit-body").on( - "click.mini-tag-chooser", - ".selected-tag", - event => { - event.stopImmediatePropagation(); - this.destroyTags( - this.computeContentItem($(event.target).attr("data-value")) - ); - } - ); - - this.$(".select-kit-header").on( - "focus.mini-tag-chooser", - ".selected-name", - event => { - event.stopImmediatePropagation(); - this.focus(event); - } - ); - }, - - willDestroyElement() { - this._super(); - - this.$(".select-kit-body").off("click.mini-tag-chooser"); - this.$(".select-kit-header").off("focus.mini-tag-chooser"); - }, - // we are directly mutatings tags to define the current selection mutateValue() {}, diff --git a/app/assets/javascripts/select-kit/templates/components/mini-tag-chooser/mini-tag-chooser-header.hbs b/app/assets/javascripts/select-kit/templates/components/mini-tag-chooser/mini-tag-chooser-header.hbs index 1bd94d922c..c50e9e371f 100644 --- a/app/assets/javascripts/select-kit/templates/components/mini-tag-chooser/mini-tag-chooser-header.hbs +++ b/app/assets/javascripts/select-kit/templates/components/mini-tag-chooser/mini-tag-chooser-header.hbs @@ -1,3 +1,2 @@ -{{input class="selected-name" value=label readonly="readonly" tabindex="-1"}} - +{{label}} {{d-icon caretIcon class="caret-icon"}} From cc90ed38704a8de6eb4e8a086de7736c2f80333d Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 9 Aug 2018 10:14:45 -0400 Subject: [PATCH 026/179] Don't look for the only argument, but the first one --- bin/rails | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/rails b/bin/rails index 6ce45ce44f..d7b24fe7ed 100755 --- a/bin/rails +++ b/bin/rails @@ -1,6 +1,6 @@ #!/usr/bin/env ruby -if !ENV["RAILS_ENV"] && (ARGV == ["s"] || ARGV == ["server"]) +if !ENV["RAILS_ENV"] && (ARGV[0] == "s" || ARGV[0] == "server") ENV["UNICORN_PORT"] = "3000" exec File.expand_path("unicorn", __dir__) end From 589550715351d195e9df0f5cde3f39465c264376 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 9 Aug 2018 10:49:14 -0400 Subject: [PATCH 027/179] FEATURE: Ability for plugins to whitelist custom fields for flags You can now call `whitelist_flag_post_custom_field` from your plugins and those custom fields will be available on the flagged posts area of the admin section. --- lib/flag_query.rb | 31 ++++++++++++++++++++++++++++++- lib/plugin/instance.rb | 6 ++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/lib/flag_query.rb b/lib/flag_query.rb index 43b3206ddf..0c8cbe9554 100644 --- a/lib/flag_query.rb +++ b/lib/flag_query.rb @@ -2,6 +2,15 @@ require 'ostruct' module FlagQuery + def self.plugin_post_custom_fields + @plugin_post_custom_fields ||= {} + end + + # Allow plugins to add custom fields to the flag views + def self.register_plugin_post_custom_field(field, plugin) + plugin_post_custom_fields[field] = plugin + end + def self.flagged_posts_report(current_user, opts = nil) opts ||= {} offset = opts[:offset] || 0 @@ -126,10 +135,30 @@ module FlagQuery user_ids << pa.disposed_by_id if pa.disposed_by_id end + post_custom_field_names = [] + plugin_post_custom_fields.each do |field, plugin| + post_custom_field_names << field if plugin.enabled? + end + + post_custom_fields = {} + if post_custom_field_names.present? + PostCustomField.where(post_id: post_ids, name: post_custom_field_names).each do |f| + post_custom_fields[f.post_id] ||= {} + post_custom_fields[f.post_id][f.name] = f.value + end + end + # maintain order posts = post_ids.map { |id| post_lookup[id] } + # TODO: add serializer so we can skip this - posts.map!(&:to_h) + posts.map! do |post| + result = post.to_h + if cfs = post_custom_fields[post.id] + result[:custom_fields] = cfs + end + result + end users = User.includes(:user_stat).where(id: user_ids.to_a).to_a User.preload_custom_fields(users, User.whitelisted_user_custom_fields(guardian)) diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index 6612570969..b47539fc53 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -109,6 +109,12 @@ class Plugin::Instance end end + def whitelist_flag_post_custom_field(field) + reloadable_patch do |plugin| + ::FlagQuery.register_plugin_post_custom_field(field, plugin) # plugin.enabled? is checked at runtime + end + end + def whitelist_staff_user_custom_field(field) reloadable_patch do |plugin| ::User.register_plugin_staff_custom_field(field, plugin) # plugin.enabled? is checked at runtime From 701c5ae781a487700bbac35b8d8fe421ec96ef73 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Thu, 9 Aug 2018 09:55:59 -0400 Subject: [PATCH 028/179] UX: admin permalink form can fit on one line --- app/assets/stylesheets/common/admin/customize.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss index 88790fa690..2faa1b3942 100644 --- a/app/assets/stylesheets/common/admin/customize.scss +++ b/app/assets/stylesheets/common/admin/customize.scss @@ -333,6 +333,10 @@ } } +.permalink-form .select-kit { + width: 150px; +} + .permalink-title { margin-bottom: 10px; } From 2c4d7225d883c050634d636378ede9a976577ac0 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Thu, 9 Aug 2018 11:05:08 -0400 Subject: [PATCH 029/179] FIX: permalink redirects with subfolder --- app/controllers/application_controller.rb | 9 ++----- spec/requests/application_controller_spec.rb | 28 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d5adf6ee1a..79371c9423 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -224,13 +224,8 @@ class ApplicationController < ActionController::Base if permalink.present? # permalink present, redirect to that URL - if permalink.external_url - redirect_with_client_support permalink.external_url, status: :moved_permanently - return - elsif permalink.target_url - redirect_with_client_support "#{Discourse::base_uri}#{permalink.target_url}", status: :moved_permanently - return - end + redirect_with_client_support permalink.target_url, status: :moved_permanently + return end end diff --git a/spec/requests/application_controller_spec.rb b/spec/requests/application_controller_spec.rb index 5ad3aa6099..957607236d 100644 --- a/spec/requests/application_controller_spec.rb +++ b/spec/requests/application_controller_spec.rb @@ -37,6 +37,34 @@ RSpec.describe ApplicationController do expect(response.body).to eq(external_url) end + it 'supports subfolder with permalinks' do + GlobalSetting.stubs(:relative_url_root).returns('/forum') + Discourse.stubs(:base_uri).returns("/forum") + + trashed_topic = create_post.topic + trashed_topic.trash! + new_topic = create_post.topic + permalink = Permalink.create!(url: trashed_topic.relative_url, topic_id: new_topic.id) + + # no subfolder because router doesn't know about subfolder in this test + get "/t/#{trashed_topic.slug}/#{trashed_topic.id}" + expect(response.status).to eq(301) + expect(response).to redirect_to("/forum/t/#{new_topic.slug}/#{new_topic.id}") + + permalink.destroy + category = Fabricate(:category) + permalink = Permalink.create!(url: trashed_topic.relative_url, category_id: category.id) + get "/t/#{trashed_topic.slug}/#{trashed_topic.id}" + expect(response.status).to eq(301) + expect(response).to redirect_to("/forum/c/#{category.slug}") + + permalink.destroy + permalink = Permalink.create!(url: trashed_topic.relative_url, post_id: new_topic.posts.last.id) + get "/t/#{trashed_topic.slug}/#{trashed_topic.id}" + expect(response.status).to eq(301) + expect(response).to redirect_to("/forum/t/#{new_topic.slug}/#{new_topic.id}/#{new_topic.posts.last.post_number}") + end + it 'should return 404 and show Google search' do get "/t/nope-nope/99999999" expect(response.status).to eq(404) From 6ddf7fcd1f39820272c848bb32a4d950f4e6bd2e Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Thu, 9 Aug 2018 17:29:02 +0200 Subject: [PATCH 030/179] Fix warnings about already initialized constants --- lib/auth/facebook_authenticator.rb | 2 +- lib/discourse.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/auth/facebook_authenticator.rb b/lib/auth/facebook_authenticator.rb index f26ce62810..01d86c7684 100644 --- a/lib/auth/facebook_authenticator.rb +++ b/lib/auth/facebook_authenticator.rb @@ -1,6 +1,6 @@ class Auth::FacebookAuthenticator < Auth::Authenticator - AVATAR_SIZE = 480 + AVATAR_SIZE ||= 480 def name "facebook" diff --git a/lib/discourse.rb b/lib/discourse.rb index 50793e334c..79be1a7464 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -211,7 +211,7 @@ module Discourse end end - BUILTIN_AUTH = [ + BUILTIN_AUTH ||= [ Auth::AuthProvider.new(authenticator: Auth::FacebookAuthenticator.new, frame_width: 580, frame_height: 400), Auth::AuthProvider.new(authenticator: Auth::GoogleOAuth2Authenticator.new, frame_width: 850, frame_height: 500), Auth::AuthProvider.new(authenticator: Auth::OpenIdAuthenticator.new("yahoo", "https://me.yahoo.com", 'enable_yahoo_logins', trusted: true)), From 04658bb2f120777e8def6ca940521b7022069b22 Mon Sep 17 00:00:00 2001 From: Kris Date: Thu, 9 Aug 2018 12:04:34 -0400 Subject: [PATCH 031/179] UX: prevent text from wrapping below notification icons --- app/assets/stylesheets/common/base/menu-panel.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss index a271fc82f4..d5858c9fbf 100644 --- a/app/assets/stylesheets/common/base/menu-panel.scss +++ b/app/assets/stylesheets/common/base/menu-panel.scss @@ -275,6 +275,9 @@ a { padding: 0; + > div { + overflow: hidden; // clears the text from wrapping below icons + } } p { From d77dccc636b30ebcb11b0ed3b2744cdd93229a5a Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Thu, 9 Aug 2018 14:54:23 -0400 Subject: [PATCH 032/179] FIX: user-deleted posts with deferred flags can be destroyed --- lib/post_destroyer.rb | 7 ++++--- spec/components/post_destroyer_spec.rb | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/post_destroyer.rb b/lib/post_destroyer.rb index ff808b418f..3b68fc60aa 100644 --- a/lib/post_destroyer.rb +++ b/lib/post_destroyer.rb @@ -26,9 +26,10 @@ class PostDestroyer .where("NOT EXISTS ( SELECT 1 FROM post_actions pa - WHERE pa.post_id = posts.id AND - pa.deleted_at IS NULL AND - pa.post_action_type_id IN (?) + WHERE pa.post_id = posts.id + AND pa.deleted_at IS NULL + AND pa.deferred_at IS NULL + AND pa.post_action_type_id IN (?) )", PostActionType.notify_flag_type_ids) .find_each do |post| diff --git a/spec/components/post_destroyer_spec.rb b/spec/components/post_destroyer_spec.rb index f1c772478f..ddd00be307 100644 --- a/spec/components/post_destroyer_spec.rb +++ b/spec/components/post_destroyer_spec.rb @@ -91,6 +91,12 @@ describe PostDestroyer do reply1.reload expect(reply1.deleted_at).to eq(nil) + # defer the flag, we should be able to delete the stub + PostAction.defer_flags!(reply1, Discourse.system_user) + PostDestroyer.destroy_stubs + + reply1.reload + expect(reply1.deleted_at).to_not eq(nil) end it 'uses the delete_removed_posts_after site setting' do From 6a2ca60b48e32802e6c54b4fc8f562b91d0435f9 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 9 Aug 2018 22:41:35 +0200 Subject: [PATCH 033/179] FIX: ember click event not reliably working on fx (#6256) --- .../components/mini-tag-chooser.js.es6 | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 b/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 index a46f9ff541..b374554461 100644 --- a/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 +++ b/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 @@ -45,15 +45,20 @@ export default ComboBox.extend(Tags, { ); }, - click(event) { - if (event.target.tagName === "BUTTON") { - const $button = $(event.target); + didInsertElement() { + this._super(...arguments); - if ($button.hasClass("selected-tag")) { - this._destroyEvent(event); - this.destroyTags(this.computeContentItem($button.attr("data-value"))); - } - } + this.$(".select-kit-body").on("click", ".selected-tag", event => { + const $button = $(event.target); + this._destroyEvent(event); + this.destroyTags(this.computeContentItem($button.attr("data-value"))); + }); + }, + + willDestroyElement() { + this._super(...arguments); + + this.$(".select-kit-body").off("click"); }, @computed("hasReachedMaximum") From 0d5ebcb21d431477b8e9c7a05cfd7eda9931a1ec Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Fri, 10 Aug 2018 03:38:36 +0300 Subject: [PATCH 034/179] fix flaky specs (#6255) --- spec/models/theme_spec.rb | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/spec/models/theme_spec.rb b/spec/models/theme_spec.rb index b17a155640..31ad460fb3 100644 --- a/spec/models/theme_spec.rb +++ b/spec/models/theme_spec.rb @@ -148,23 +148,35 @@ HTML describe ".transform_ids" do it "adds the child themes of the parent" do - child = Fabricate(:theme, id: 97) - child2 = Fabricate(:theme, id: 96) + child = Fabricate(:theme) + child2 = Fabricate(:theme) + sorted = [child.id, child2.id].sort theme.add_child_theme!(child) theme.add_child_theme!(child2) - expect(Theme.transform_ids([theme.id])).to eq([theme.id, child2.id, child.id]) - expect(Theme.transform_ids([theme.id, 94, 90])).to eq([theme.id, 90, 94, child2.id, child.id]) + expect(Theme.transform_ids([theme.id])).to eq([theme.id, *sorted]) + + fake_id = [child.id, child2.id, theme.id].min - 5 + fake_id2 = [child.id, child2.id, theme.id].max + 5 + + expect(Theme.transform_ids([theme.id, fake_id2, fake_id])) + .to eq([theme.id, fake_id, *sorted, fake_id2]) end it "doesn't insert children when extend is false" do - child = Fabricate(:theme, id: 97) - child2 = Fabricate(:theme, id: 96) + child = Fabricate(:theme) + child2 = Fabricate(:theme) theme.add_child_theme!(child) theme.add_child_theme!(child2) + + fake_id = theme.id + 1 + fake_id2 = fake_id + 2 + fake_id3 = fake_id2 + 3 + expect(Theme.transform_ids([theme.id], extend: false)).to eq([theme.id]) - expect(Theme.transform_ids([theme.id, 94, 90, 70, 70], extend: false)).to eq([theme.id, 70, 90, 94]) + expect(Theme.transform_ids([theme.id, fake_id3, fake_id, fake_id2, fake_id2], extend: false)) + .to eq([theme.id, fake_id, fake_id2, fake_id3]) end end From 3cd4dc0f5f5383d2ac23ea22ea138bba8d3d8062 Mon Sep 17 00:00:00 2001 From: Simon Cossar Date: Thu, 9 Aug 2018 17:42:23 -0700 Subject: [PATCH 035/179] Allow users with group_locked_trust_level to be promoted to tl3 (#6249) --- app/jobs/scheduled/tl3_promotions.rb | 8 +++----- spec/jobs/tl3_promotions_spec.rb | 30 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/app/jobs/scheduled/tl3_promotions.rb b/app/jobs/scheduled/tl3_promotions.rb index ce78290eae..7dd8d05db0 100644 --- a/app/jobs/scheduled/tl3_promotions.rb +++ b/app/jobs/scheduled/tl3_promotions.rb @@ -8,9 +8,8 @@ module Jobs demoted_user_ids = [] User.real.where( trust_level: TrustLevel[3], - manual_locked_trust_level: nil, - group_locked_trust_level: nil - ).find_each do |u| + manual_locked_trust_level: nil + ).where("group_locked_trust_level IS NULL OR group_locked_trust_level < ?", TrustLevel[3]).find_each do |u| # Don't demote too soon after being promoted next if u.on_tl3_grace_period? @@ -23,8 +22,7 @@ module Jobs # Promotions User.real.not_suspended.where( trust_level: TrustLevel[2], - manual_locked_trust_level: nil, - group_locked_trust_level: nil + manual_locked_trust_level: nil ).where.not(id: demoted_user_ids) .joins(:user_stat) .where("user_stats.days_visited >= ?", SiteSetting.tl3_requires_days_visited) diff --git a/spec/jobs/tl3_promotions_spec.rb b/spec/jobs/tl3_promotions_spec.rb index e96d809da9..267250239e 100644 --- a/spec/jobs/tl3_promotions_spec.rb +++ b/spec/jobs/tl3_promotions_spec.rb @@ -24,6 +24,14 @@ describe Jobs::Tl3Promotions do run_job end + it "promotes a qualifying tl2 user who has a group_locked_trust_level" do + _group_locked_user = Fabricate(:user, trust_level: TrustLevel[2], group_locked_trust_level: TrustLevel[1]) + create_qualifying_stats(_group_locked_user) + TrustLevel3Requirements.any_instance.stubs(:requirements_met?).returns(true) + Promotion.any_instance.expects(:change_trust_level!).with(TrustLevel[3], anything).once + run_job + end + it "doesn't promote tl1 and tl0 users who have met tl3 requirements" do _tl1_user = Fabricate(:user, trust_level: TrustLevel[1]) _tl0_user = Fabricate(:user, trust_level: TrustLevel[0]) @@ -83,5 +91,27 @@ describe Jobs::Tl3Promotions do expect(user.reload.trust_level).to eq(TrustLevel[3]) end + it "demotes a user with a group_locked_trust_level of 2" do + user = nil + freeze_time(4.days.ago) do + user = Fabricate(:user, trust_level: TrustLevel[3], group_locked_trust_level: TrustLevel[2]) + end + TrustLevel3Requirements.any_instance.stubs(:requirements_met?).returns(false) + TrustLevel3Requirements.any_instance.stubs(:requirements_lost?).returns(true) + run_job + expect(user.reload.trust_level).to eq(TrustLevel[2]) + + end + + it "doesn't demote user if their group_locked_trust_level is 3" do + user = nil + freeze_time(4.days.ago) do + user = Fabricate(:user, trust_level: TrustLevel[3], group_locked_trust_level: TrustLevel[3]) + end + TrustLevel3Requirements.any_instance.stubs(:requirements_met?).returns(false) + TrustLevel3Requirements.any_instance.stubs(:requirements_lost?).returns(true) + run_job + expect(user.reload.trust_level).to eq(TrustLevel[3]) + end end end From 2e1049a75a0bdf24afedd95dfba6f152f8e93dc2 Mon Sep 17 00:00:00 2001 From: Kris Date: Thu, 9 Aug 2018 20:43:18 -0400 Subject: [PATCH 036/179] Minor dashboard style adjustments --- .../common/admin/admin_report.scss | 10 +++--- .../common/admin/admin_report_counters.scss | 7 ++--- .../common/admin/admin_report_table.scss | 3 +- .../common/admin/dashboard_next.scss | 31 +++++++++++-------- 4 files changed, 27 insertions(+), 24 deletions(-) diff --git a/app/assets/stylesheets/common/admin/admin_report.scss b/app/assets/stylesheets/common/admin/admin_report.scss index f5f058ba1f..c623faa219 100644 --- a/app/assets/stylesheets/common/admin/admin_report.scss +++ b/app/assets/stylesheets/common/admin/admin_report.scss @@ -6,6 +6,7 @@ align-self: flex-start; text-align: center; padding: 3em; + margin-bottom: 1.5em; box-sizing: border-box; } @@ -36,7 +37,8 @@ display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1em; + margin-top: 0.5em; + margin-bottom: 0.5em; .report-title { align-items: center; @@ -169,16 +171,16 @@ } .admin-report.users-by-type { - margin-top: 1em; + margin-top: 1.5em; } .admin-report.users-by-type, .admin-report.users-by-trust-level { - margin-bottom: 1em; + margin-bottom: 1.5em; flex: 1; .report-header { border-bottom: 1px solid $primary-medium; - padding-bottom: 0.5em; + padding-bottom: 0.25em; border-bottom: 1px solid #e9e9e9; } } diff --git a/app/assets/stylesheets/common/admin/admin_report_counters.scss b/app/assets/stylesheets/common/admin/admin_report_counters.scss index aa82ae050c..a25da105f3 100644 --- a/app/assets/stylesheets/common/admin/admin_report_counters.scss +++ b/app/assets/stylesheets/common/admin/admin_report_counters.scss @@ -2,6 +2,7 @@ display: flex; flex: 1; flex-direction: column; + border-bottom: 1px solid $primary-low; .counters-header { display: grid; @@ -34,9 +35,6 @@ } .admin-report.counters { - &:last-child .admin-report-counters { - border-bottom: 1px solid $primary-low; - } .admin-report-counters { display: grid; @@ -110,8 +108,7 @@ flex-direction: row; align-items: center; font-size: $font-0; - border: 0; - background: $primary-low; + border-bottom: 0; color: $primary-medium; .d-icon { font-size: $font-up-1; diff --git a/app/assets/stylesheets/common/admin/admin_report_table.scss b/app/assets/stylesheets/common/admin/admin_report_table.scss index 4170fff4e8..733ab5620f 100644 --- a/app/assets/stylesheets/common/admin/admin_report_table.scss +++ b/app/assets/stylesheets/common/admin/admin_report_table.scss @@ -10,7 +10,7 @@ .pagination { display: flex; justify-content: flex-end; - margin-top: 0.5em; + margin-top: 0.25em; button { margin-left: 0.5em; @@ -36,7 +36,6 @@ table-layout: fixed; border: 1px solid $primary-low; margin-top: 0; - margin-bottom: 10px; tbody { border: none; diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index 1f53e3662c..1a7f8d9318 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -7,28 +7,33 @@ .dashboard-next { .section-top { - margin-bottom: 1em; + margin-bottom: .5em; } .navigation { display: flex; - margin: 0 0 1em 0; + margin: 0 0 2.5em 0; + border-bottom: 1px solid $primary-low-mid; .navigation-item { display: inline; background: $secondary; - padding: 0.5em 1em; + &:hover { + background: $primary-very-low; + } } .navigation-link { - font-weight: 700; + display: block; + font-weight: bold; + padding: .6em 1em .5em 1em; } } @mixin active-navigation-item { - border-radius: 3px 3px 0 0; - border: 1px solid $primary-low; - border-bottom: 10px solid $secondary; + .navigation-link { + border-bottom: .4em solid $tertiary; + } } &.dashboard-next-moderation .navigation-item.moderation { @@ -108,6 +113,9 @@ .section-body { padding: 1em 0 0; + > p { + margin-top: 0; + } } } @@ -122,7 +130,6 @@ flex-grow: 1; flex-basis: 100%; display: flex; - margin-bottom: 1em; } @include breakpoint(medium) { @@ -158,6 +165,7 @@ display: flex; flex-wrap: wrap; justify-content: space-between; + border-right: 1px solid $primary-low; .backups, .uploads { @@ -189,7 +197,6 @@ } } .last-dashboard-update { - border-left: 1px solid $primary-low; text-align: center; display: flex; justify-content: center; @@ -229,7 +236,7 @@ } .dashboard-problems { - margin-bottom: 2em; + margin-bottom: 2.5em; .d-icon-exclamation-triangle { color: $danger; @@ -242,8 +249,6 @@ } .admin-report-table { - margin-bottom: 1em; - &.is-disabled { background: $primary-low; padding: 1em; @@ -263,7 +268,7 @@ } .activity-metrics { - margin-bottom: 1em; + margin-bottom: .25em; } .user-metrics { From 6ec92d58457d7b18c9e50c7da7c86680984e434a Mon Sep 17 00:00:00 2001 From: Kris Date: Thu, 9 Aug 2018 20:45:47 -0400 Subject: [PATCH 037/179] prettier --- .../stylesheets/common/admin/admin_report_counters.scss | 1 - app/assets/stylesheets/common/admin/dashboard_next.scss | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/assets/stylesheets/common/admin/admin_report_counters.scss b/app/assets/stylesheets/common/admin/admin_report_counters.scss index a25da105f3..ac7c8610b9 100644 --- a/app/assets/stylesheets/common/admin/admin_report_counters.scss +++ b/app/assets/stylesheets/common/admin/admin_report_counters.scss @@ -35,7 +35,6 @@ } .admin-report.counters { - .admin-report-counters { display: grid; flex: 1; diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index 1a7f8d9318..d51686212d 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -7,7 +7,7 @@ .dashboard-next { .section-top { - margin-bottom: .5em; + margin-bottom: 0.5em; } .navigation { @@ -26,13 +26,13 @@ .navigation-link { display: block; font-weight: bold; - padding: .6em 1em .5em 1em; + padding: 0.6em 1em 0.5em 1em; } } @mixin active-navigation-item { .navigation-link { - border-bottom: .4em solid $tertiary; + border-bottom: 0.4em solid $tertiary; } } @@ -268,7 +268,7 @@ } .activity-metrics { - margin-bottom: .25em; + margin-bottom: 0.25em; } .user-metrics { From ef4b9f98c12f023de6a7faaec2168918e93c882a Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Fri, 10 Aug 2018 02:48:30 +0200 Subject: [PATCH 038/179] FEATURE: Allow admins to reply without topic bump --- .../discourse/controllers/composer.js.es6 | 10 ++- .../discourse/models/composer.js.es6 | 13 ++-- .../discourse/templates/composer.hbs | 8 +++ .../components/composer-actions.js.es6 | 15 ++++ .../stylesheets/common/base/compose.scss | 1 + app/controllers/posts_controller.rb | 5 ++ config/locales/client.en.yml | 4 ++ lib/guardian/post_guardian.rb | 4 ++ spec/requests/posts_controller_spec.rb | 62 +++++++++++++++- .../acceptance/composer-actions-test.js.es6 | 70 +++++++++++++++++-- 10 files changed, 182 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index bc1764c442..3f52b692ec 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -53,7 +53,8 @@ function loadDraft(store, opts) { composerTime: draft.composerTime, typingTime: draft.typingTime, whisper: draft.whisper, - tags: draft.tags + tags: draft.tags, + noBump: draft.noBump }); return composer; } @@ -194,6 +195,13 @@ export default Ember.Controller.extend({ } }, + @computed("model.noBump") + topicBumpText(noBump) { + if (noBump) { + return I18n.t("composer.no_topic_bump"); + } + }, + @computed isStaffUser() { const currentUser = this.currentUser; diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index 24f4d8fb79..483bb66eef 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -41,7 +41,8 @@ const CLOSED = "closed", composer_open_duration_msecs: "composerTime", tags: "tags", featured_link: "featuredLink", - shared_draft: "sharedDraft" + shared_draft: "sharedDraft", + no_bump: "noBump" }, _edit_topic_serializer = { title: "topic.title", @@ -71,6 +72,7 @@ const SAVE_ICONS = { const Composer = RestModel.extend({ _categoryId: null, unlistTopic: false, + noBump: false, archetypes: function() { return this.site.get("archetypes"); @@ -608,7 +610,8 @@ const Composer = RestModel.extend({ composerTotalOpened: opts.composerTime, typingTime: opts.typingTime, whisper: opts.whisper, - tags: opts.tags + tags: opts.tags, + noBump: opts.noBump }); if (opts.post) { @@ -714,7 +717,8 @@ const Composer = RestModel.extend({ typingTime: 0, composerOpened: null, composerTotalOpened: 0, - featuredLink: null + featuredLink: null, + noBump: false }); }, @@ -964,7 +968,8 @@ const Composer = RestModel.extend({ usernames: this.get("targetUsernames"), composerTime: this.get("composerTime"), typingTime: this.get("typingTime"), - tags: this.get("tags") + tags: this.get("tags"), + noBump: this.get("noBump") }; this.set("draftStatus", I18n.t("composer.saving_draft_tip")); diff --git a/app/assets/javascripts/discourse/templates/composer.hbs b/app/assets/javascripts/discourse/templates/composer.hbs index e5e9033e7b..712dd87018 100644 --- a/app/assets/javascripts/discourse/templates/composer.hbs +++ b/app/assets/javascripts/discourse/templates/composer.hbs @@ -21,6 +21,9 @@ {{#if whisperOrUnlistTopicText}} ({{whisperOrUnlistTopicText}}) {{/if}} + {{#if topicBumpText}} + {{topicBumpText}} + {{/if}} {{/unless}} {{#if canEdit}} @@ -120,6 +123,11 @@ {{d-icon "eye-slash"}} {{/if}} + {{#if topicBumpText}} + + {{d-icon "anchor"}} + + {{/if}} {{/if}} diff --git a/app/assets/javascripts/select-kit/components/composer-actions.js.es6 b/app/assets/javascripts/select-kit/components/composer-actions.js.es6 index 9072e38e92..866a9750ed 100644 --- a/app/assets/javascripts/select-kit/components/composer-actions.js.es6 +++ b/app/assets/javascripts/select-kit/components/composer-actions.js.es6 @@ -192,6 +192,17 @@ export default DropdownSelectBoxComponent.extend({ }); } + const currentUser = Discourse.User.current(); + + if (action === REPLY && currentUser && currentUser.get("staff")) { + items.push({ + name: I18n.t("composer.composer_actions.toggle_topic_bump.label"), + description: I18n.t("composer.composer_actions.toggle_topic_bump.desc"), + icon: "anchor", + id: "toggle_topic_bump" + }); + } + return items; }, @@ -234,6 +245,10 @@ export default DropdownSelectBoxComponent.extend({ model.toggleProperty("whisper"); }, + toggleTopicBumpSelected(options, model) { + model.toggleProperty("noBump"); + }, + replyToTopicSelected(options) { options.action = REPLY; options.topic = _topicSnapshot; diff --git a/app/assets/stylesheets/common/base/compose.scss b/app/assets/stylesheets/common/base/compose.scss index 1d355ac9e8..fde1550de0 100644 --- a/app/assets/stylesheets/common/base/compose.scss +++ b/app/assets/stylesheets/common/base/compose.scss @@ -153,6 +153,7 @@ } .whisper, + .no-bump, .display-edit-reason { font-style: italic; } diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index b4d1eec1f5..9719d76938 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -653,6 +653,11 @@ class PostsController < ApplicationController result[:is_warning] = false end + if params[:no_bump] == "true" + raise Discourse::InvalidParameters.new(:no_bump) unless guardian.can_skip_bump? + result[:no_bump] = true + end + if params[:shared_draft] == 'true' raise Discourse::InvalidParameters.new(:shared_draft) unless guardian.can_create_shared_draft? diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 10c5537667..89ea74359e 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1318,6 +1318,7 @@ en: whisper: "whisper" unlist: "unlisted" blockquote_text: "Blockquote" + no_topic_bump: "(no bump)" add_warning: "This is an official warning." toggle_whisper: "Toggle Whisper" @@ -1437,6 +1438,9 @@ en: shared_draft: label: "Shared Draft" desc: "Draft a topic that will only be visible to staff" + toggle_topic_bump: + label: "Toggle topic bump" + desc: "Reply without changing the topic's bump date" notifications: tooltip: diff --git a/lib/guardian/post_guardian.rb b/lib/guardian/post_guardian.rb index 49a9688c9f..af56156c02 100644 --- a/lib/guardian/post_guardian.rb +++ b/lib/guardian/post_guardian.rb @@ -255,4 +255,8 @@ module PostGuardian def can_unhide?(post) post.try(:hidden) && is_staff? end + + def can_skip_bump? + is_staff? + end end diff --git a/spec/requests/posts_controller_spec.rb b/spec/requests/posts_controller_spec.rb index 201b94bcfd..2e0264726f 100644 --- a/spec/requests/posts_controller_spec.rb +++ b/spec/requests/posts_controller_spec.rb @@ -1055,6 +1055,67 @@ describe PostsController do end end end + + context "topic bump" do + shared_examples "it works" do + let(:original_bumped_at) { 1.day.ago } + let!(:topic) { Fabricate(:topic, bumped_at: original_bumped_at) } + + it "should be able to skip topic bumping" do + post "/posts.json", params: { + raw: 'this is the test content', + topic_id: topic.id, + no_bump: true + } + + expect(response.status).to eq(200) + expect(topic.reload.bumped_at).to be_within_one_second_of(original_bumped_at) + end + + it "should be able to post with topic bumping" do + post "/posts.json", params: { + raw: 'this is the test content', + topic_id: topic.id + } + + expect(response.status).to eq(200) + expect(topic.reload.bumped_at).to eq(topic.posts.last.created_at) + end + end + + context "admins" do + before do + sign_in(Fabricate(:admin)) + end + + include_examples "it works" + end + + context "moderators" do + before do + sign_in(Fabricate(:moderator)) + end + + include_examples "it works" + end + + context "users" do + let(:topic) { Fabricate(:topic) } + + [:user, :trust_level_4].each do |user| + it "will raise an error for #{user}" do + sign_in(Fabricate(user)) + post "/posts.json", params: { + raw: 'this is the test content', + topic_id: topic.id, + no_bump: true + } + expect(response.status).to eq(400) + end + end + end + end + end describe '#revisions' do @@ -1524,5 +1585,4 @@ describe PostsController do expect(public_post).not_to be_locked end end - end diff --git a/test/javascripts/acceptance/composer-actions-test.js.es6 b/test/javascripts/acceptance/composer-actions-test.js.es6 index d6b2bfd127..7a104fb0a3 100644 --- a/test/javascripts/acceptance/composer-actions-test.js.es6 +++ b/test/javascripts/acceptance/composer-actions-test.js.es6 @@ -1,4 +1,4 @@ -import { acceptance } from "helpers/qunit-helpers"; +import { acceptance, replaceCurrentUser } from "helpers/qunit-helpers"; import { _clearSnapshots } from "select-kit/components/composer-actions"; acceptance("Composer Actions", { @@ -25,7 +25,8 @@ QUnit.test("replying to post", async assert => { ); assert.equal(composerActions.rowByIndex(2).value(), "reply_to_topic"); assert.equal(composerActions.rowByIndex(3).value(), "toggle_whisper"); - assert.equal(composerActions.rowByIndex(4).value(), undefined); + assert.equal(composerActions.rowByIndex(4).value(), "toggle_topic_bump"); + assert.equal(composerActions.rowByIndex(5).value(), undefined); }); QUnit.test("replying to post - reply_as_private_message", async assert => { @@ -179,7 +180,8 @@ QUnit.test("interactions", async assert => { "reply_as_private_message" ); assert.equal(composerActions.rowByIndex(3).value(), "toggle_whisper"); - assert.equal(composerActions.rows().length, 4); + assert.equal(composerActions.rowByIndex(4).value(), "toggle_topic_bump"); + assert.equal(composerActions.rows().length, 5); await composerActions.selectRowByValue("reply_to_post"); await composerActions.expand(); @@ -199,7 +201,8 @@ QUnit.test("interactions", async assert => { ); assert.equal(composerActions.rowByIndex(2).value(), "reply_to_topic"); assert.equal(composerActions.rowByIndex(3).value(), "toggle_whisper"); - assert.equal(composerActions.rows().length, 4); + assert.equal(composerActions.rowByIndex(4).value(), "toggle_topic_bump"); + assert.equal(composerActions.rows().length, 5); await composerActions.selectRowByValue("reply_as_new_topic"); await composerActions.expand(); @@ -243,3 +246,62 @@ QUnit.test("interactions", async assert => { assert.equal(composerActions.rowByIndex(2).value(), "reply_to_topic"); assert.equal(composerActions.rows().length, 3); }); + +QUnit.test("replying to post - toggle_topic_bump", async assert => { + const composerActions = selectKit(".composer-actions"); + + await visit("/t/internationalization-localization/280"); + await click("article#post_3 button.reply"); + + assert.ok( + find(".composer-fields .no-bump").length === 0, + "no-bump text is not visible" + ); + + await composerActions.expand(); + await composerActions.selectRowByValue("toggle_topic_bump"); + + assert.equal( + find(".composer-fields .no-bump").text(), + I18n.t("composer.no_topic_bump"), + "no-bump text is visible" + ); + + await composerActions.expand(); + await composerActions.selectRowByValue("toggle_topic_bump"); + + assert.ok( + find(".composer-fields .no-bump").length === 0, + "no-bump text is not visible" + ); +}); + +QUnit.test("replying to post as staff", async assert => { + const composerActions = selectKit(".composer-actions"); + + replaceCurrentUser({ staff: true, admin: false }); + await visit("/t/internationalization-localization/280"); + await click("article#post_3 button.reply"); + await composerActions.expand(); + + assert.equal(composerActions.rows().length, 5); + assert.equal(composerActions.rowByIndex(4).value(), "toggle_topic_bump"); +}); + +QUnit.test("replying to post as regular user", async assert => { + const composerActions = selectKit(".composer-actions"); + + replaceCurrentUser({ staff: false, admin: false }); + await visit("/t/internationalization-localization/280"); + await click("article#post_3 button.reply"); + await composerActions.expand(); + + assert.equal(composerActions.rows().length, 3); + Array.from(composerActions.rows()).forEach(row => { + assert.notEqual( + row.value, + "toggle_topic_bump", + "toggle button is not visible" + ); + }); +}); From 6db623ef6b489d1c55561846722a86bf39a992d0 Mon Sep 17 00:00:00 2001 From: Misaka 0x4e21 Date: Fri, 10 Aug 2018 08:50:05 +0800 Subject: [PATCH 039/179] UX: Improve category filtering and include subcategories * category_filtering 1. report_top_referred_topics 2. report_top_traffic_sources 3. report_post_edit * category_filtering with subcategory topics 1. report_top_referred_topics 2. report_top_traffic_sources 3. report_post_edit 4. report_posts 5. report_topics 6. report_topics_with_no_response * category_filtering tests (without subcategory topics) 1. report_posts 2. report_topics_with_no_response * subcategory topics tests `in_category_and_subcategories` in `topic_spec.rb` 1. `in_category_and_subcategories` in `topic_spec.rb` 2. topics, posts, flags and topics_with_no_response in `report_spec.rb` --- app/models/incoming_links_report.rb | 46 ++++++++------- app/models/post_action.rb | 2 +- app/models/report.rb | 38 ++++++++++--- app/models/topic.rb | 17 +++++- spec/models/report_spec.rb | 88 +++++++++++++++++++++++++++++ spec/models/topic_spec.rb | 16 ++++++ 6 files changed, 175 insertions(+), 32 deletions(-) diff --git a/app/models/incoming_links_report.rb b/app/models/incoming_links_report.rb index 38e58d0e53..8635fcdd90 100644 --- a/app/models/incoming_links_report.rb +++ b/app/models/incoming_links_report.rb @@ -1,11 +1,12 @@ class IncomingLinksReport - attr_accessor :type, :data, :y_titles, :start_date, :end_date, :limit + attr_accessor :type, :data, :y_titles, :start_date, :end_date, :limit, :category_id def initialize(type) @type = type @y_titles = {} @data = nil + @category_id = nil end def as_json(_options = nil) @@ -30,6 +31,7 @@ class IncomingLinksReport report.start_date = _opts[:start_date] || 30.days.ago report.end_date = _opts[:end_date] || Time.now.end_of_day report.limit = _opts[:limit].to_i if _opts[:limit] + report.category_id = _opts[:category_id] if _opts[:category_id] send(report_method, report) report @@ -40,8 +42,8 @@ class IncomingLinksReport report.y_titles[:num_clicks] = I18n.t("reports.#{report.type}.num_clicks") report.y_titles[:num_topics] = I18n.t("reports.#{report.type}.num_topics") - num_clicks = link_count_per_user(start_date: report.start_date, end_date: report.end_date) - num_topics = topic_count_per_user(start_date: report.start_date, end_date: report.end_date) + num_clicks = link_count_per_user(start_date: report.start_date, end_date: report.end_date, category_id: report.category_id) + num_topics = topic_count_per_user(start_date: report.start_date, end_date: report.end_date, category_id: report.category_id) user_id_lookup = User.where(username: num_clicks.keys).select(:id, :username).inject({}) { |sum, v| sum[v.username] = v.id; sum; } report.data = [] num_clicks.each_key do |username| @@ -50,19 +52,19 @@ class IncomingLinksReport report.data = report.data.sort_by { |x| x[:num_clicks] }.reverse[0, 10] end - def self.per_user(start_date:, end_date:) - @per_user_query ||= public_incoming_links + def self.per_user(start_date:, end_date:, category_id:) + @per_user_query ||= public_incoming_links(category_id: category_id) .where('incoming_links.created_at > ? AND incoming_links.created_at < ? AND incoming_links.user_id IS NOT NULL', start_date, end_date) .joins(:user) .group('users.username') end - def self.link_count_per_user(start_date:, end_date:) - per_user(start_date: start_date, end_date: end_date).count + def self.link_count_per_user(start_date:, end_date:, category_id:) + per_user(start_date: start_date, end_date: end_date, category_id: category_id).count end - def self.topic_count_per_user(start_date:, end_date:) - per_user(start_date: start_date, end_date: end_date).joins(:post).count("DISTINCT posts.topic_id") + def self.topic_count_per_user(start_date:, end_date:, category_id:) + per_user(start_date: start_date, end_date: end_date, category_id: category_id).joins(:post).count("DISTINCT posts.topic_id") end # Return top 10 domains that brought traffic to the site within the last 30 days @@ -71,8 +73,8 @@ class IncomingLinksReport report.y_titles[:num_topics] = I18n.t("reports.#{report.type}.num_topics") report.y_titles[:num_users] = I18n.t("reports.#{report.type}.num_users") - num_clicks = link_count_per_domain(start_date: report.start_date, end_date: report.end_date) - num_topics = topic_count_per_domain(num_clicks.keys) + num_clicks = link_count_per_domain(start_date: report.start_date, end_date: report.end_date, category_id: report.category_id) + num_topics = topic_count_per_domain(num_clicks.keys, category_id: report.category_id) report.data = [] num_clicks.each_key do |domain| report.data << { domain: domain, num_clicks: num_clicks[domain], num_topics: num_topics[domain] } @@ -80,8 +82,8 @@ class IncomingLinksReport report.data = report.data.sort_by { |x| x[:num_clicks] }.reverse[0, 10] end - def self.link_count_per_domain(limit: 10, start_date:, end_date:) - public_incoming_links + def self.link_count_per_domain(limit: 10, start_date:, end_date:, category_id:) + public_incoming_links(category_id: category_id) .where('incoming_links.created_at > ? AND incoming_links.created_at < ?', start_date, end_date) .joins(incoming_referer: :incoming_domain) .group('incoming_domains.name') @@ -90,24 +92,25 @@ class IncomingLinksReport .count end - def self.per_domain(domains) - public_incoming_links + def self.per_domain(domains, options = {}) + public_incoming_links(category_id: options[:category_id]) .joins(incoming_referer: :incoming_domain) .where('incoming_links.created_at > ? AND incoming_domains.name IN (?)', 30.days.ago, domains) .group('incoming_domains.name') end - def self.topic_count_per_domain(domains) + def self.topic_count_per_domain(domains, options = {}) # COUNT(DISTINCT) is slow - per_domain(domains).count("DISTINCT posts.topic_id") + per_domain(domains, options).count("DISTINCT posts.topic_id") end def self.report_top_referred_topics(report) report.y_titles[:num_clicks] = I18n.t("reports.#{report.type}.num_clicks") - num_clicks = link_count_per_topic(start_date: report.start_date, end_date: report.end_date) + num_clicks = link_count_per_topic(start_date: report.start_date, end_date: report.end_date, category_id: report.category_id) num_clicks = num_clicks.to_a.sort_by { |x| x[1] }.last(report.limit || 10).reverse report.data = [] topics = Topic.select('id, slug, title').where('id in (?)', num_clicks.map { |z| z[0] }) + topics = topics.in_category_and_subcategories(report.category_id) if report.category_id num_clicks.each do |topic_id, num_clicks_element| topic = topics.find { |t| t.id == topic_id } if topic @@ -117,16 +120,17 @@ class IncomingLinksReport report.data end - def self.link_count_per_topic(start_date:, end_date:) - public_incoming_links + def self.link_count_per_topic(start_date:, end_date:, category_id:) + public_incoming_links(category_id: category_id) .where('incoming_links.created_at > ? AND incoming_links.created_at < ? AND topic_id IS NOT NULL', start_date, end_date) .group('topic_id') .count end - def self.public_incoming_links + def self.public_incoming_links(category_id: nil) IncomingLink .joins(post: :topic) .where("topics.archetype = ?", Archetype.default) + .merge(Topic.in_category_and_subcategories(category_id)) end end diff --git a/app/models/post_action.rb b/app/models/post_action.rb index 966286d74e..e4bf8986f7 100644 --- a/app/models/post_action.rb +++ b/app/models/post_action.rb @@ -143,7 +143,7 @@ class PostAction < ActiveRecord::Base result = unscoped.where(post_action_type_id: post_action_type) result = result.where('post_actions.created_at >= ?', opts[:start_date] || (opts[:since_days_ago] || 30).days.ago) result = result.where('post_actions.created_at <= ?', opts[:end_date]) if opts[:end_date] - result = result.joins(post: :topic).where('topics.category_id = ?', opts[:category_id]) if opts[:category_id] + result = result.joins(post: :topic).merge(Topic.in_category_and_categories(opts[:category_id])) if opts[:category_id] result.group('date(post_actions.created_at)') .order('date(post_actions.created_at)') .count diff --git a/app/models/report.rb b/app/models/report.rb index 7593f790b3..d7e2b986da 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -360,7 +360,7 @@ class Report report.category_filtering = true basic_report_about report, Topic, :listable_count_per_day, report.start_date, report.end_date, report.category_id countable = Topic.listable_topics - countable = countable.where(category_id: report.category_id) if report.category_id + countable = countable.in_category_and_subcategories(report.category_id) if report.category_id add_counts report, countable, 'topics.created_at' end @@ -369,7 +369,9 @@ class Report report.category_filtering = true basic_report_about report, Post, :public_posts_count_per_day, report.start_date, report.end_date, report.category_id countable = Post.public_posts.where(post_type: Post.types[:regular]) - countable = countable.joins(:topic).where("topics.category_id = ?", report.category_id) if report.category_id + if report.category_id + countable = countable.joins(:topic).merge(Topic.in_category_and_subcategories(report.category_id)) + end add_counts report, countable, 'posts.created_at' end @@ -475,7 +477,7 @@ class Report basic_report_about report, PostAction, :flag_count_by_date, report.start_date, report.end_date, report.category_id countable = PostAction.where(post_action_type_id: PostActionType.flag_types_without_custom.values) - countable = countable.joins(post: :topic).where("topics.category_id = ?", report.category_id) if report.category_id + countable = countable.joins(post: :topic).merge(Topic.in_category_and_subcategories(report.category_id)) if report.category_id add_counts report, countable, 'post_actions.created_at' end @@ -497,7 +499,7 @@ class Report report.data << { x: date, y: count } end countable = PostAction.unscoped.where(post_action_type_id: post_action_type) - countable = countable.joins(post: :topic).where("topics.category_id = ?", report.category_id) if report.category_id + countable = countable.joins(post: :topic).merge(Topic.in_category_and_subcategories(report.category_id)) if report.category_id add_counts report, countable, 'post_actions.created_at' end @@ -600,6 +602,7 @@ class Report end def self.report_top_referred_topics(report) + report.category_filtering = true report.modes = [:table] report.labels = [ @@ -618,13 +621,19 @@ class Report } ] - options = { end_date: report.end_date, start_date: report.start_date, limit: report.limit || 8 } + options = { + end_date: report.end_date, + start_date: report.start_date, + limit: report.limit || 8, + category_id: report.category_id + } result = nil result = IncomingLinksReport.find(:top_referred_topics, options) report.data = result.data end def self.report_top_traffic_sources(report) + report.category_filtering = true report.modes = [:table] report.labels = [ @@ -644,7 +653,12 @@ class Report } ] - options = { end_date: report.end_date, start_date: report.start_date, limit: report.limit || 8 } + options = { + end_date: report.end_date, + start_date: report.start_date, + limit: report.limit || 8, + category_id: report.category_id + } result = nil result = IncomingLinksReport.find(:top_traffic_sources, options) report.data = result.data @@ -1055,6 +1069,7 @@ class Report end def self.report_post_edits(report) + report.category_filtering = true report.modes = [:table] report.labels = [ @@ -1132,7 +1147,16 @@ class Report ON u.id = p.user_id SQL - DB.query(sql).each do |r| + if report.category_id + sql += <<~SQL + JOIN topics t + ON t.id = p.topic_id + WHERE t.category_id = ? OR t.category_id IN (SELECT id FROM categories WHERE categories.parent_category_id = ?) + SQL + end + result = report.category_id ? DB.query(sql, report.category_id, report.category_id) : DB.query(sql) + + result.each do |r| revision = {} revision[:editor_id] = r.editor_id revision[:editor_username] = r.editor_username diff --git a/app/models/topic.rb b/app/models/topic.rb index 94f2a8566f..f83e1a84a6 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -190,6 +190,17 @@ class Topic < ActiveRecord::Base where("topics.category_id IS NULL OR topics.category_id IN (SELECT id FROM categories WHERE #{condition[0]})", condition[1]) } + IN_CATEGORY_AND_SUBCATEGORIES_SQL = <<~SQL + t.category_id = :category_id + OR t.category_id IN (SELECT id FROM categories WHERE categories.parent_category_id = :category_id) + SQL + + scope :in_category_and_subcategories, lambda { |category_id| + where("topics.category_id = ? OR topics.category_id IN (SELECT id FROM categories WHERE categories.parent_category_id = ?)", + category_id, + category_id) if category_id + } + scope :with_subtype, ->(subtype) { where('topics.subtype = ?', subtype) } attr_accessor :ignore_category_auto_close @@ -1258,7 +1269,7 @@ class Topic < ActiveRecord::Base builder = DB.build(sql) builder.where("t.created_at >= :start_date", start_date: opts[:start_date]) if opts[:start_date] builder.where("t.created_at < :end_date", end_date: opts[:end_date]) if opts[:end_date] - builder.where("t.category_id = :category_id", category_id: opts[:category_id]) if opts[:category_id] + builder.where(IN_CATEGORY_AND_SUBCATEGORIES_SQL, category_id: opts[:category_id]) if opts[:category_id] builder.where("t.archetype <> '#{Archetype.private_message}'") builder.where("t.deleted_at IS NULL") builder.where("p.deleted_at IS NULL") @@ -1297,7 +1308,7 @@ class Topic < ActiveRecord::Base builder = DB.build(WITH_NO_RESPONSE_SQL) builder.where("t.created_at >= :start_date", start_date: start_date) if start_date builder.where("t.created_at < :end_date", end_date: end_date) if end_date - builder.where("t.category_id = :category_id", category_id: category_id) if category_id + builder.where(IN_CATEGORY_AND_SUBCATEGORIES_SQL, category_id: category_id) if category_id builder.where("t.archetype <> '#{Archetype.private_message}'") builder.where("t.deleted_at IS NULL") builder.query_hash @@ -1317,7 +1328,7 @@ class Topic < ActiveRecord::Base def self.with_no_response_total(opts = {}) builder = DB.build(WITH_NO_RESPONSE_TOTAL_SQL) - builder.where("t.category_id = :category_id", category_id: opts[:category_id]) if opts[:category_id] + builder.where(IN_CATEGORY_AND_SUBCATEGORIES_SQL, category_id: opts[:category_id]) if opts[:category_id] builder.where("t.archetype <> '#{Archetype.private_message}'") builder.where("t.deleted_at IS NULL") builder.query_single.first.to_i diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb index 38af9ce451..71ab9b549b 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -15,6 +15,20 @@ describe Report do end end + shared_examples 'category filtering on subcategories' do + before(:all) do + c3 = Fabricate(:category, id: 3) + c2 = Fabricate(:category, id: 2, parent_category_id: 3) + Topic.find(c2.topic_id).delete + Topic.find(c3.topic_id).delete + end + after(:all) do + Category.where(id: 2).or(Category.where(id: 3)).destroy_all + User.where("id > 0").destroy_all + end + include_examples 'category filtering' + end + shared_examples 'with data x/y' do it "returns today's data" do expect(report.data.select { |v| v[:x].today? }).to be_present @@ -717,6 +731,12 @@ describe Report do let(:report) { Report.find('flags', category_id: 2) } include_examples 'category filtering' + + context "on subcategories" do + let(:report) { Report.find('flags', category_id: 3) } + + include_examples 'category filtering on subcategories' + end end end end @@ -740,6 +760,12 @@ describe Report do let(:report) { Report.find('topics', category_id: 2) } include_examples 'category filtering' + + context "on subcategories" do + let(:report) { Report.find('topics', category_id: 3) } + + include_examples 'category filtering on subcategories' + end end end end @@ -777,4 +803,66 @@ describe Report do expect(report.error).to eq(:timeout) end end + + describe 'posts' do + let(:report) { Report.find('posts') } + + include_examples 'no data' + + context 'with data' do + include_examples 'with data x/y' + + before(:each) do + topic = Fabricate(:topic) + topic_with_category_id = Fabricate(:topic, category_id: 2) + Fabricate(:post, topic: topic) + Fabricate(:post, topic: topic_with_category_id) + Fabricate(:post, topic: topic) + Fabricate(:post, created_at: 45.days.ago, topic: topic) + end + + context "with category filtering" do + let(:report) { Report.find('posts', category_id: 2) } + + include_examples 'category filtering' + + context "on subcategories" do + let(:report) { Report.find('posts', category_id: 3) } + + include_examples 'category filtering on subcategories' + end + end + end + end + + # TODO: time_to_first_response + + describe 'topics_with_no_response' do + let(:report) { Report.find('topics_with_no_response') } + + include_examples 'no data' + + context 'with data' do + include_examples 'with data x/y' + + before(:each) do + Fabricate(:topic, category_id: 2) + Fabricate(:post, topic: Fabricate(:topic)) + Fabricate(:topic) + Fabricate(:topic, created_at: 45.days.ago) + end + + context "with category filtering" do + let(:report) { Report.find('topics_with_no_response', category_id: 2) } + + include_examples 'category filtering' + + context "on subcategories" do + let(:report) { Report.find('topics_with_no_response', category_id: 3) } + + include_examples 'category filtering on subcategories' + end + end + end + end end diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 9b8afb856e..663e67056c 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -1366,6 +1366,22 @@ describe Topic do expect(Topic.visible).to include c end end + + describe '#in_category_and_subcategories' do + it 'returns topics in a category and its subcategories' do + c1 = Fabricate(:category) + c2 = Fabricate(:category, parent_category_id: c1.id) + c3 = Fabricate(:category) + + t1 = Fabricate(:topic, category_id: c1.id) + t2 = Fabricate(:topic, category_id: c2.id) + t3 = Fabricate(:topic, category_id: c3.id) + + expect(Topic.in_category_and_subcategories(c1.id)).not_to include(t3) + expect(Topic.in_category_and_subcategories(c1.id)).to include(t2) + expect(Topic.in_category_and_subcategories(c1.id)).to include(t1) + end + end end describe '#private_topic_timer' do From b9072e8292833f11f032d26f5814fa188f9ab3b3 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Fri, 10 Aug 2018 02:51:03 +0200 Subject: [PATCH 040/179] FEATURE: Add "Reset Bump Date" action to topic admin wrench (#6246) --- .../discourse/controllers/topic.js.es6 | 4 ++ .../javascripts/discourse/models/topic.js.es6 | 6 +++ .../components/topic-footer-buttons.hbs | 1 + .../javascripts/discourse/templates/topic.hbs | 4 ++ .../discourse/widgets/topic-admin-menu.js.es6 | 9 +++++ app/controllers/topics_controller.rb | 14 ++++++- app/models/topic.rb | 10 +++++ config/locales/client.en.yml | 1 + config/routes.rb | 1 + lib/guardian/topic_guardian.rb | 4 ++ spec/models/topic_spec.rb | 23 ++++++++++++ spec/requests/topics_controller_spec.rb | 37 +++++++++++++++++++ 12 files changed, 113 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index fab0f36054..ea4d8e0ed3 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -930,6 +930,10 @@ export default Ember.Controller.extend(BufferedContent, { removeFeaturedLink() { this.set("buffered.featured_link", null); + }, + + resetBumpDate() { + this.get("content").resetBumpDate(); } }, diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index 68be7f9b01..4be6543856 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -565,6 +565,12 @@ const Topic = RestModel.extend({ window.location.reload(); }) .catch(popupAjaxError); + }, + + resetBumpDate() { + return ajax(`/t/${this.get("id")}/reset-bump-date`, { type: "PUT" }).catch( + popupAjaxError + ); } }); diff --git a/app/assets/javascripts/discourse/templates/components/topic-footer-buttons.hbs b/app/assets/javascripts/discourse/templates/components/topic-footer-buttons.hbs index 1f5fd34ae7..0c0442cc4c 100644 --- a/app/assets/javascripts/discourse/templates/components/topic-footer-buttons.hbs +++ b/app/assets/javascripts/discourse/templates/components/topic-footer-buttons.hbs @@ -13,6 +13,7 @@ showTopicStatusUpdate=showTopicStatusUpdate showFeatureTopic=showFeatureTopic showChangeTimestamp=showChangeTimestamp + resetBumpDate=resetBumpDate convertToPublicTopic=convertToPublicTopic convertToPrivateMessage=convertToPrivateMessage}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index 94870de470..1ebdb92ac3 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -94,6 +94,7 @@ showTopicStatusUpdate=(action "topicRouteAction" "showTopicStatusUpdate") showFeatureTopic=(action "topicRouteAction" "showFeatureTopic") showChangeTimestamp=(action "topicRouteAction" "showChangeTimestamp") + resetBumpDate=(action "resetBumpDate") convertToPublicTopic=(action "convertToPublicTopic") convertToPrivateMessage=(action "convertToPrivateMessage")}} {{/if}} @@ -121,6 +122,7 @@ showTopicStatusUpdate=(action "topicRouteAction" "showTopicStatusUpdate") showFeatureTopic=(action "topicRouteAction" "showFeatureTopic") showChangeTimestamp=(action "topicRouteAction" "showChangeTimestamp") + resetBumpDate=(action "resetBumpDate") convertToPublicTopic=(action "convertToPublicTopic") convertToPrivateMessage=(action "convertToPrivateMessage")}} {{else}} @@ -144,6 +146,7 @@ showTopicStatusUpdate=(action "topicRouteAction" "showTopicStatusUpdate") showFeatureTopic=(action "topicRouteAction" "showFeatureTopic") showChangeTimestamp=(action "topicRouteAction" "showChangeTimestamp") + resetBumpDate=(action "resetBumpDate") convertToPublicTopic=(action "convertToPublicTopic") convertToPrivateMessage=(action "convertToPrivateMessage")}} {{/if}} @@ -256,6 +259,7 @@ showTopicStatusUpdate=(action "topicRouteAction" "showTopicStatusUpdate") showFeatureTopic=(action "topicRouteAction" "showFeatureTopic") showChangeTimestamp=(action "topicRouteAction" "showChangeTimestamp") + resetBumpDate=(action "resetBumpDate") convertToPublicTopic=(action "convertToPublicTopic") convertToPrivateMessage=(action "convertToPrivateMessage") toggleBookmark=(action "toggleBookmark") diff --git a/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 b/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 index c66887995c..52b0616abe 100644 --- a/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 @@ -203,6 +203,15 @@ export default createWidget("topic-admin-menu", { }); } + if (this.currentUser.get("staff")) { + buttons.push({ + className: "topic-admin-reset-bump-date", + action: "resetBumpDate", + icon: "anchor", + label: "actions.reset_bump_date" + }); + } + if (!isPrivateMessage) { buttons.push({ className: "topic-admin-archive", diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index a4ffc8aaf2..39f94a98ec 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -33,7 +33,8 @@ class TopicsController < ApplicationController :move_to_inbox, :convert_topic, :bookmark, - :publish + :publish, + :reset_bump_date ] before_action :consider_user_for_promotion, only: :show @@ -720,6 +721,17 @@ class TopicsController < ApplicationController render_json_error(ex) end + def reset_bump_date + params.require(:id) + guardian.ensure_can_update_bumped_at! + + topic = Topic.find_by(id: params[:id]) + raise Discourse::NotFound.new unless topic + + topic.reset_bumped_at + render body: nil + end + private def topic_params diff --git a/app/models/topic.rb b/app/models/topic.rb index f83e1a84a6..c54ab04f77 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -1381,6 +1381,16 @@ class Topic < ActiveRecord::Base @is_category_topic ||= Category.exists?(topic_id: self.id.to_i) end + def reset_bumped_at + post = ordered_posts.where( + user_deleted: false, + hidden: false, + post_type: Topic.visible_post_types + ).last + + update!(bumped_at: post.created_at) + end + private def update_category_topic_count_by(num) diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 89ea74359e..9c6f2f7482 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1862,6 +1862,7 @@ en: reset_read: "Reset Read Data" make_public: "Make Public Topic" make_private: "Make Personal Message" + reset_bump_date: "Reset Bump Date" feature: pin: "Pin Topic" diff --git a/config/routes.rb b/config/routes.rb index 441f448240..15214adabb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -628,6 +628,7 @@ Discourse::Application.routes.draw do put "t/:id/convert-topic/:type" => "topics#convert_topic" put "t/:id/publish" => "topics#publish" put "t/:id/shared-draft" => "topics#update_shared_draft" + put "t/:id/reset-bump-date" => "topics#reset_bump_date" put "topics/bulk" put "topics/reset-new" => 'topics#reset_new' post "topics/timings" diff --git a/lib/guardian/topic_guardian.rb b/lib/guardian/topic_guardian.rb index cd915f5fe3..8a7fd9d117 100644 --- a/lib/guardian/topic_guardian.rb +++ b/lib/guardian/topic_guardian.rb @@ -146,4 +146,8 @@ module TopicGuardian return false unless SiteSetting.topic_featured_link_enabled Category.where(id: category_id || SiteSetting.uncategorized_category_id, topic_featured_link_allowed: true).exists? end + + def can_update_bumped_at? + is_staff? + end end diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 663e67056c..9e6b5afce5 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -2296,4 +2296,27 @@ describe Topic do end end end + + describe "#reset_bumped_at" do + it "ignores hidden and deleted posts when resetting the topic's bump date" do + post = create_post(created_at: 10.hours.ago) + topic = post.topic + + expect { topic.reset_bumped_at }.to_not change { topic.bumped_at } + + post = Fabricate(:post, topic: topic, post_number: 2, created_at: 9.hours.ago) + Fabricate(:post, topic: topic, post_number: 3, created_at: 8.hours.ago, deleted_at: 1.hour.ago) + Fabricate(:post, topic: topic, post_number: 4, created_at: 7.hours.ago, hidden: true) + Fabricate(:post, topic: topic, post_number: 5, created_at: 6.hours.ago, user_deleted: true) + Fabricate(:post, topic: topic, post_number: 6, created_at: 5.hours.ago, post_type: Post.types[:whisper]) + + expect { topic.reset_bumped_at }.to change { topic.bumped_at }.to(post.reload.created_at) + + post = Fabricate(:post, topic: topic, post_number: 7, created_at: 4.hours.ago, post_type: Post.types[:moderator_action]) + expect { topic.reset_bumped_at }.to change { topic.bumped_at }.to(post.reload.created_at) + + post = Fabricate(:post, topic: topic, post_number: 8, created_at: 3.hours.ago, post_type: Post.types[:small_action]) + expect { topic.reset_bumped_at }.to change { topic.bumped_at }.to(post.reload.created_at) + end + end end diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb index a4e43d0bf3..ece66eef13 100644 --- a/spec/requests/topics_controller_spec.rb +++ b/spec/requests/topics_controller_spec.rb @@ -2266,4 +2266,41 @@ RSpec.describe TopicsController do end + describe "#reset_bump_date" do + context "errors" do + let(:topic) { Fabricate(:topic) } + + it "needs you to be logged in" do + put "/t/#{topic.id}/reset-bump-date.json" + expect(response.status).to eq(403) + end + + [:user, :trust_level_4].each do |user| + it "denies access for #{user}" do + sign_in(Fabricate(user)) + put "/t/#{topic.id}/reset-bump-date.json" + expect(response.status).to eq(403) + end + end + + it "should fail for non-existend topic" do + sign_in(Fabricate(:admin)) + put "/t/1/reset-bump-date.json" + expect(response.status).to eq(404) + end + end + + [:admin, :moderator].each do |user| + it "should reset bumped_at as #{user}" do + sign_in(Fabricate(user)) + topic = Fabricate(:topic, bumped_at: 1.hour.ago) + timestamp = 1.day.ago + Fabricate(:post, topic: topic, created_at: timestamp) + + put "/t/#{topic.id}/reset-bump-date.json" + expect(response.status).to eq(200) + expect(topic.reload.bumped_at).to be_within_one_second_of(timestamp) + end + end + end end From 093c3510e66ef200b34996fc487c7dfe410a8a9c Mon Sep 17 00:00:00 2001 From: Simon Cossar Date: Thu, 9 Aug 2018 17:51:31 -0700 Subject: [PATCH 041/179] Rework moderators activity query (#6230) * Order rows in query * Don't increment revisions when moderator revises their own post --- app/models/report.rb | 206 ++++++++++++++++++------------------- spec/models/report_spec.rb | 65 +++++++----- 2 files changed, 136 insertions(+), 135 deletions(-) diff --git a/app/models/report.rb b/app/models/report.rb index d7e2b986da..a732480f51 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -762,152 +762,144 @@ class Report ] report.modes = [:table] - report.data = [] - mod_data = {} - User.real.where(moderator: true).find_each do |u| - mod_data[u.id] = { - user_id: u.id, - username: u.username_lower, - user_avatar_template: u.avatar_template, - } - end - - time_read_query = <<~SQL + query = <<~SQL + WITH mods AS ( + SELECT + id AS user_id, + username_lower AS username, + uploaded_avatar_id + FROM users u + WHERE u.moderator = 'true' + AND u.id > 0 + ), + time_read AS ( SELECT SUM(uv.time_read) AS time_read, uv.user_id - FROM user_visits uv - JOIN users u - ON u.id = uv.user_id - WHERE u.moderator = 'true' - AND u.id > 0 - AND uv.visited_at >= '#{report.start_date}' + FROM mods m + JOIN user_visits uv + ON m.user_id = uv.user_id + WHERE uv.visited_at >= '#{report.start_date}' AND uv.visited_at <= '#{report.end_date}' GROUP BY uv.user_id - SQL - - flag_count_query = <<~SQL - WITH period_actions AS ( - SELECT agreed_by_id, - disagreed_by_id - FROM post_actions - WHERE post_action_type_id IN (#{PostActionType.flag_types_without_custom.values.join(',')}) - AND created_at >= '#{report.start_date}' - AND created_at <= '#{report.end_date}' ), - agreed_flags AS ( - SELECT pa.agreed_by_id AS user_id, - COUNT(*) AS flag_count - FROM period_actions pa - JOIN users u - ON u.id = pa.agreed_by_id - WHERE u.moderator = 'true' - AND u.id > 0 - GROUP BY agreed_by_id - ), - disagreed_flags AS ( - SELECT pa.disagreed_by_id AS user_id, - COUNT(*) AS flag_count - FROM period_actions pa - JOIN users u - ON u.id = pa.disagreed_by_id - WHERE u.moderator = 'true' - AND u.id > 0 - GROUP BY disagreed_by_id - ) + flag_count AS ( + WITH period_actions AS ( + SELECT agreed_by_id, + disagreed_by_id + FROM post_actions + WHERE post_action_type_id IN (#{PostActionType.flag_types_without_custom.values.join(',')}) + AND created_at >= '#{report.start_date}' + AND created_at <= '#{report.end_date}' + ), + agreed_flags AS ( + SELECT pa.agreed_by_id AS user_id, + COUNT(*) AS flag_count + FROM mods m + JOIN period_actions pa + ON pa.agreed_by_id = m.user_id + GROUP BY agreed_by_id + ), + disagreed_flags AS ( + SELECT pa.disagreed_by_id AS user_id, + COUNT(*) AS flag_count + FROM mods m + JOIN period_actions pa + ON pa.disagreed_by_id = m.user_id + GROUP BY disagreed_by_id + ) SELECT COALESCE(af.user_id, df.user_id) AS user_id, COALESCE(af.flag_count, 0) + COALESCE(df.flag_count, 0) AS flag_count FROM agreed_flags af FULL OUTER JOIN disagreed_flags df ON df.user_id = af.user_id - SQL - - revision_count_query = <<~SQL + ), + revision_count AS ( SELECT pr.user_id, COUNT(*) AS revision_count - FROM post_revisions pr - JOIN users u - ON u.id = pr.user_id - WHERE u.moderator = 'true' - AND u.id > 0 - AND pr.created_at >= '#{report.start_date}' + FROM mods m + JOIN post_revisions pr + ON pr.user_id = m.user_id + JOIN posts p + ON p.id = pr.post_id + WHERE pr.created_at >= '#{report.start_date}' AND pr.created_at <= '#{report.end_date}' + AND p.user_id <> pr.user_id GROUP BY pr.user_id - SQL - - topic_count_query = <<~SQL + ), + topic_count AS ( SELECT t.user_id, COUNT(*) AS topic_count - FROM topics t - JOIN users u - ON u.id = t.user_id - WHERE u.moderator = 'true' - AND u.id > 0 - AND t.archetype = 'regular' + FROM mods m + JOIN topics t + ON t.user_id = m.user_id + WHERE t.archetype = 'regular' AND t.created_at >= '#{report.start_date}' AND t.created_at <= '#{report.end_date}' GROUP BY t.user_id - SQL - - post_count_query = <<~SQL + ), + post_count AS ( SELECT p.user_id, COUNT(*) AS post_count - FROM posts p - JOIN users u - ON u.id = p.user_id + FROM mods m + JOIN posts p + ON p.user_id = m.user_id JOIN topics t ON t.id = p.topic_id - WHERE u.moderator = 'true' - AND u.id > 0 - AND t.archetype = 'regular' + WHERE t.archetype = 'regular' AND p.created_at >= '#{report.start_date}' AND p.created_at <= '#{report.end_date}' GROUP BY p.user_id - SQL - - pm_count_query = <<~SQL + ), + pm_count AS ( SELECT p.user_id, COUNT(*) AS pm_count - FROM posts p - JOIN users u - ON u.id = p.user_id + FROM mods m + JOIN posts p + ON p.user_id = m.user_id JOIN topics t ON t.id = p.topic_id - WHERE u.moderator = 'true' - AND u.id > 0 - AND t.archetype = 'private_message' + WHERE t.archetype = 'private_message' AND p.created_at >= '#{report.start_date}' AND p.created_at <= '#{report.end_date}' GROUP BY p.user_id + ) + + SELECT + m.user_id, + m.username, + m.uploaded_avatar_id, + tr.time_read, + fc.flag_count, + rc.revision_count, + tc.topic_count, + pc.post_count, + pmc.pm_count + FROM mods m + LEFT JOIN time_read tr ON tr.user_id = m.user_id + LEFT JOIN flag_count fc ON fc.user_id = m.user_id + LEFT JOIN revision_count rc ON rc.user_id = m.user_id + LEFT JOIN topic_count tc ON tc.user_id = m.user_id + LEFT JOIN post_count pc ON pc.user_id = m.user_id + LEFT JOIN pm_count pmc ON pmc.user_id = m.user_id + ORDER BY m.username SQL - DB.query(time_read_query).each do |row| - mod_data[row.user_id][:time_read] = row.time_read + DB.query(query).each do |row| + mod = {} + mod[:username] = row.username + mod[:user_id] = row.user_id + mod[:user_avatar_template] = User.avatar_template(row.username, row.uploaded_avatar_id) + mod[:time_read] = row.time_read + mod[:flag_count] = row.flag_count + mod[:revision_count] = row.revision_count + mod[:topic_count] = row.topic_count + mod[:post_count] = row.post_count + mod[:pm_count] = row.pm_count + report.data << mod end - - DB.query(flag_count_query).each do |row| - mod_data[row.user_id][:flag_count] = row.flag_count - end - - DB.query(revision_count_query).each do |row| - mod_data[row.user_id][:revision_count] = row.revision_count - end - - DB.query(topic_count_query).each do |row| - mod_data[row.user_id][:topic_count] = row.topic_count - end - - DB.query(post_count_query).each do |row| - mod_data[row.user_id][:post_count] = row.post_count - end - - DB.query(pm_count_query).each do |row| - mod_data[row.user_id][:pm_count] = row.pm_count - end - - report.data = mod_data.values end def self.report_flags_status(report) diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb index 71ab9b549b..7894c90f5c 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -563,6 +563,18 @@ describe Report do freeze_time(Date.today) end + context "moderators order" do + before do + Fabricate(:post, user: sam) + Fabricate(:post, user: jeff) + end + + it "returns the moderators in alphabetical order" do + expect(report.data[0][:username]).to eq('jeff') + expect(report.data[1][:username]).to eq('sam') + end + end + context "time read" do before do sam.user_visits.create(visited_at: 2.days.ago, time_read: 200) @@ -575,10 +587,10 @@ describe Report do end it "returns the correct read times" do - expect(report.data[0][:username]).to eq('sam') - expect(report.data[0][:time_read]).to eq(300) - expect(report.data[1][:username]).to eq('jeff') - expect(report.data[1][:time_read]).to eq(3000) + expect(report.data[0][:username]).to eq('jeff') + expect(report.data[0][:time_read]).to eq(3000) + expect(report.data[1][:username]).to eq('sam') + expect(report.data[1][:time_read]).to eq(300) end end @@ -589,7 +601,7 @@ describe Report do PostAction.agree_flags!(flagged_post, jeff) end - it "returns the correct read times" do + it "returns the correct flag counts" do expect(report.data.count).to eq(1) expect(report.data[0][:flag_count]).to eq(1) expect(report.data[0][:username]).to eq("jeff") @@ -604,10 +616,10 @@ describe Report do end it "returns the correct topic count" do - expect(report.data[0][:topic_count]).to eq(2) - expect(report.data[0][:username]).to eq('sam') - expect(report.data[1][:topic_count]).to eq(1) - expect(report.data[1][:username]).to eq('jeff') + expect(report.data[0][:topic_count]).to eq(1) + expect(report.data[0][:username]).to eq('jeff') + expect(report.data[1][:topic_count]).to eq(2) + expect(report.data[1][:username]).to eq('sam') end context "private messages" do @@ -616,8 +628,8 @@ describe Report do end it "doesn’t count private topic" do - expect(report.data[0][:topic_count]).to eq(2) - expect(report.data[1][:topic_count]).to eq(1) + expect(report.data[0][:topic_count]).to eq(1) + expect(report.data[1][:topic_count]).to eq(2) end end end @@ -630,10 +642,10 @@ describe Report do end it "returns the correct topic count" do - expect(report.data[0][:topic_count]).to eq(2) - expect(report.data[0][:username]).to eq('sam') - expect(report.data[1][:topic_count]).to eq(1) - expect(report.data[1][:username]).to eq('jeff') + expect(report.data[0][:topic_count]).to eq(1) + expect(report.data[0][:username]).to eq('jeff') + expect(report.data[1][:topic_count]).to eq(2) + expect(report.data[1][:username]).to eq('sam') end context "private messages" do @@ -642,8 +654,8 @@ describe Report do end it "doesn’t count private post" do - expect(report.data[0][:post_count]).to eq(2) - expect(report.data[1][:post_count]).to eq(1) + expect(report.data[0][:post_count]).to eq(1) + expect(report.data[1][:post_count]).to eq(2) end end end @@ -657,10 +669,11 @@ describe Report do end it "returns the correct topic count" do - expect(report.data[0][:pm_count]).to be_blank - expect(report.data[0][:username]).to eq('sam') - expect(report.data[1][:pm_count]).to eq(1) - expect(report.data[1][:username]).to eq('jeff') + expect(report.data[0][:pm_count]).to eq(1) + expect(report.data[0][:username]).to eq('jeff') + expect(report.data[1][:pm_count]).to be_blank + expect(report.data[1][:username]).to eq('sam') + end end @@ -678,15 +691,11 @@ describe Report do context "revise own post" do before do post = Fabricate(:post, user: sam) - Fabricate(:post, user: sam) - .revise(sam, raw: 'updated body', edit_reason: 'not cool') - - Fabricate(:post) - .revise(sam, raw: 'updated body', edit_reason: 'not cool') + post.revise(sam, raw: 'updated body') end - it "doesnt count a revison on your own post" do - expect(report.data[0][:revision_count]).to eq(2) + it "doesn't count a revison on your own post" do + expect(report.data[0][:revision_count]).to eq(1) expect(report.data[0][:username]).to eq('sam') end end From 0451dba27a6299b4c97b4283c3d3bb3cd7ccc9c6 Mon Sep 17 00:00:00 2001 From: Kris Date: Thu, 9 Aug 2018 21:24:26 -0400 Subject: [PATCH 042/179] Table margin adjustment --- app/assets/stylesheets/common/admin/admin_report.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/stylesheets/common/admin/admin_report.scss b/app/assets/stylesheets/common/admin/admin_report.scss index c623faa219..e458826aeb 100644 --- a/app/assets/stylesheets/common/admin/admin_report.scss +++ b/app/assets/stylesheets/common/admin/admin_report.scss @@ -10,6 +10,10 @@ box-sizing: border-box; } + + .table { + margin-top: 1.5em; + } + .report-error { color: $danger; border: 1px solid $danger; From 1fc25976265fb6e96015eb49599074f50e294f71 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 10 Aug 2018 11:28:05 +1000 Subject: [PATCH 043/179] better error handling for upload extension fixer --- lib/tasks/uploads.rake | 2 +- lib/upload_fixer.rb | 118 ++++++++++++++++++++++------------------- 2 files changed, 64 insertions(+), 56 deletions(-) diff --git a/lib/tasks/uploads.rake b/lib/tasks/uploads.rake index b6abe20b50..2f975e37b6 100644 --- a/lib/tasks/uploads.rake +++ b/lib/tasks/uploads.rake @@ -713,5 +713,5 @@ end task "uploads:fix_incorrect_extensions" => :environment do require_dependency "upload_fixer" - UploadFixer.fix_extensions + UploadFixer.fix_all_extensions end diff --git a/lib/upload_fixer.rb b/lib/upload_fixer.rb index 79764bfb52..e1f88f63ae 100644 --- a/lib/upload_fixer.rb +++ b/lib/upload_fixer.rb @@ -1,73 +1,81 @@ class UploadFixer - def self.fix_extensions + def self.fix_all_extensions Upload.where("uploads.extension IS NOT NULL").find_each do |upload| - is_external = Discourse.store.external? - previous_url = upload.url.dup + fix_extension_on_upload(upload) + end + end + + def self.fix_extension_on_upload(upload) + is_external = Discourse.store.external? + previous_url = upload.url.dup + + source = + if is_external + "https:#{previous_url}" + else + Discourse.store.path_for(upload) + end + + correct_extension = FastImage.type(source).to_s.downcase + current_extension = upload.extension.to_s.downcase + + if correct_extension.present? + correct_extension = 'jpg' if correct_extension == 'jpeg' + current_extension = 'jpg' if current_extension == 'jpeg' + + if correct_extension != current_extension + new_filename = change_extension( + upload.original_filename, + correct_extension + ) + + new_url = change_extension(previous_url, correct_extension) - source = if is_external - "https:#{previous_url}" - else - Discourse.store.path_for(upload) - end + new_url = "/#{new_url}" + source = Discourse.store.get_path_for_upload(upload) + destination = change_extension(source, correct_extension) - correct_extension = FastImage.type(source).to_s.downcase - current_extension = upload.extension.to_s.downcase - - if correct_extension.present? - correct_extension = 'jpg' if correct_extension == 'jpeg' - current_extension = 'jpg' if current_extension == 'jpeg' - - if correct_extension != current_extension - new_filename = change_extension( - upload.original_filename, - correct_extension + Discourse.store.copy_file( + previous_url, + source, + destination ) - new_url = change_extension(previous_url, correct_extension) + upload.update!( + original_filename: new_filename, + url: new_url, + extension: correct_extension + ) - if is_external - new_url = "/#{new_url}" - source = Discourse.store.get_path_for_upload(upload) - destination = change_extension(source, correct_extension) + DbHelper.remap(previous_url, upload.url) + Discourse.store.remove_file(previous_url, source) + else + destination = change_extension(source, correct_extension) + FileUtils.copy(source, destination) - Discourse.store.copy_file( - previous_url, - source, - destination - ) + upload.update!( + original_filename: new_filename, + url: new_url, + extension: correct_extension + ) - upload.update!( - original_filename: new_filename, - url: new_url, - extension: correct_extension - ) + DbHelper.remap(previous_url, upload.url) - DbHelper.remap(previous_url, upload.url) - Discourse.store.remove_file(previous_url, source) - else - destination = change_extension(source, correct_extension) - FileUtils.copy(source, destination) + tombstone_path = source.sub("/uploads/", "/uploads/tombstone/") + FileUtils.mkdir_p(File.dirname(tombstone_path)) - upload.update!( - original_filename: new_filename, - url: new_url, - extension: correct_extension - ) - - DbHelper.remap(previous_url, upload.url) - - tombstone_path = source.sub("/uploads/", "/uploads/tombstone/") - FileUtils.mkdir_p(File.dirname(tombstone_path)) - - FileUtils.move( - source, - tombstone_path - ) - end + FileUtils.move( + source, + tombstone_path + ) end + end end + rescue => e + STDERR.puts "Skipping upload: ailed to correct extension on upload id: #{upload.id} #{current_extension} => #{correct_extension}" + STDERR.puts e end private From ea8394b080525884333f9ddce02b6c083334f8a6 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 10 Aug 2018 11:34:01 +1000 Subject: [PATCH 044/179] typo in error message --- lib/upload_fixer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/upload_fixer.rb b/lib/upload_fixer.rb index e1f88f63ae..1bf85b0b89 100644 --- a/lib/upload_fixer.rb +++ b/lib/upload_fixer.rb @@ -74,7 +74,7 @@ class UploadFixer end end rescue => e - STDERR.puts "Skipping upload: ailed to correct extension on upload id: #{upload.id} #{current_extension} => #{correct_extension}" + STDERR.puts "Skipping upload: failed to correct extension on upload id: #{upload.id} #{current_extension} => #{correct_extension}" STDERR.puts e end From 6f6b4ff988a183c8e3f64e731b8f4c2346170dc0 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 10 Aug 2018 14:53:55 +1000 Subject: [PATCH 045/179] regression: don't return from a block also clean up some warnings (shadowed var, unused var) --- app/controllers/users_controller.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 13f4f604a2..eff18ec2e8 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -581,7 +581,6 @@ class UsersController < ApplicationController email_token_user = EmailToken.confirmable(token)&.user totp_enabled = email_token_user&.totp_enabled? - backup_enabled = email_token_user&.backup_codes_enabled? second_factor_token = params[:second_factor_token] second_factor_method = params[:second_factor_method].to_i confirm_email = false @@ -1079,7 +1078,7 @@ class UsersController < ApplicationController # Using Discourse.authenticators rather than Discourse.enabled_authenticators so users can # revoke permissions even if the admin has temporarily disabled that type of login - authenticator = Discourse.authenticators.find { |authenticator| authenticator.name == provider_name } + authenticator = Discourse.authenticators.find { |a| a.name == provider_name } raise Discourse::NotFound if authenticator.nil? || !authenticator.can_revoke? skip_remote = params.permit(:skip_remote) @@ -1088,9 +1087,9 @@ class UsersController < ApplicationController hijack do result = authenticator.revoke(user, skip_remote: skip_remote) if result - return render json: success_json + render json: success_json else - return render json: { + render json: { success: false, message: I18n.t("associated_accounts.revoke_failed", provider_name: provider_name) } From 2b2612d0f5aceb72fd27420668be74ef373b82ad Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 10 Aug 2018 16:08:07 +1000 Subject: [PATCH 046/179] correct flaky spec after(:all) and before(:all) are to be avoided, state can leak --- spec/models/report_spec.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb index 7894c90f5c..9cdd34cc4a 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -16,17 +16,17 @@ describe Report do end shared_examples 'category filtering on subcategories' do - before(:all) do - c3 = Fabricate(:category, id: 3) - c2 = Fabricate(:category, id: 2, parent_category_id: 3) - Topic.find(c2.topic_id).delete - Topic.find(c3.topic_id).delete + before do + c = Fabricate(:category, id: 3) + c.topic.destroy + c = Fabricate(:category, id: 2, parent_category_id: 3) + c.topic.destroy + # destroy the category description topics so the count is right, on filtered data end - after(:all) do - Category.where(id: 2).or(Category.where(id: 3)).destroy_all - User.where("id > 0").destroy_all + + it 'returns the filtered data' do + expect(report.total).to eq(1) end - include_examples 'category filtering' end shared_examples 'with data x/y' do From 865cb3feb9dee7df19083b22592b5cbfe6b2f245 Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Fri, 10 Aug 2018 14:12:02 +0300 Subject: [PATCH 047/179] FIX: allow selecting site's default theme from preference --- app/services/user_updater.rb | 1 + lib/guardian.rb | 2 ++ spec/services/user_updater_spec.rb | 23 +++++++++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/app/services/user_updater.rb b/app/services/user_updater.rb index a580e968da..de238e50ec 100644 --- a/app/services/user_updater.rb +++ b/app/services/user_updater.rb @@ -88,6 +88,7 @@ class UserUpdater # special handling for theme_id cause we need to bump a sequence number if attributes.key?(:theme_ids) user_guardian = Guardian.new(user) + attributes[:theme_ids].reject!(&:blank?) attributes[:theme_ids].map!(&:to_i) if user_guardian.allow_themes?(attributes[:theme_ids]) user.user_option.theme_key_seq += 1 if user.user_option.theme_ids != attributes[:theme_ids] diff --git a/lib/guardian.rb b/lib/guardian.rb index db7d168428..30bf87acd4 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -359,6 +359,8 @@ class Guardian end def allow_themes?(theme_ids) + return true if theme_ids.blank? + if is_staff? && (theme_ids - Theme.theme_ids).blank? return true end diff --git a/spec/services/user_updater_spec.rb b/spec/services/user_updater_spec.rb index 47e37edc0d..34daad1b64 100644 --- a/spec/services/user_updater_spec.rb +++ b/spec/services/user_updater_spec.rb @@ -129,6 +129,29 @@ describe UserUpdater do expect(user.user_option.mailing_list_mode).to eq true end + it "filters theme_ids blank values before updating perferences" do + user = Fabricate(:user) + user.user_option.update!(theme_ids: [1]) + updater = UserUpdater.new(acting_user, user) + + updater.update(theme_ids: [""]) + user.reload + expect(user.user_option.theme_ids).to eq([]) + + updater.update(theme_ids: [nil]) + user.reload + expect(user.user_option.theme_ids).to eq([]) + + theme = Fabricate(:theme) + child = Fabricate(:theme) + theme.add_child_theme!(child) + theme.set_default! + + updater.update(theme_ids: [theme.id.to_s, child.id.to_s, "", nil]) + user.reload + expect(user.user_option.theme_ids).to eq([theme.id, child.id]) + end + context 'when sso overrides bio' do it 'does not change bio' do SiteSetting.sso_url = "https://www.example.com/sso" From b73950692b2c07005daf104e522063c2eed0d7ff Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Fri, 10 Aug 2018 18:37:14 +0200 Subject: [PATCH 048/179] FIX: Parsing non-existent feed should not fail --- app/jobs/scheduled/poll_feed.rb | 2 ++ spec/jobs/poll_feed_spec.rb | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/app/jobs/scheduled/poll_feed.rb b/app/jobs/scheduled/poll_feed.rb index 098615bb67..7567660a42 100644 --- a/app/jobs/scheduled/poll_feed.rb +++ b/app/jobs/scheduled/poll_feed.rb @@ -89,6 +89,8 @@ module Jobs def parsed_feed raw_feed, encoding = fetch_rss + return nil if raw_feed.nil? + encoded_feed = Encodings.try_utf8(raw_feed, encoding) if encoding encoded_feed = Encodings.to_utf8(raw_feed) unless encoded_feed diff --git a/spec/jobs/poll_feed_spec.rb b/spec/jobs/poll_feed_spec.rb index 8c3e3cf22a..ce79bdb304 100644 --- a/spec/jobs/poll_feed_spec.rb +++ b/spec/jobs/poll_feed_spec.rb @@ -137,6 +137,17 @@ describe Jobs::PollFeed do include_examples 'topic creation based on the the feed' end + it "aborts when it can't fetch the feed" do + SiteSetting.feed_polling_enabled = true + SiteSetting.feed_polling_url = 'https://blog.discourse.org/feed/atom/' + SiteSetting.embed_by_username = 'eviltrout' + + stub_request(:head, SiteSetting.feed_polling_url).to_return(status: 404) + stub_request(:get, SiteSetting.feed_polling_url).to_return(status: 404) + + expect { poller.poll_feed }.to_not change { Topic.count } + end + context 'encodings' do before do SiteSetting.feed_polling_enabled = true From e53983b53bb9e65b6a42a11cf5c71ca1bcf492fb Mon Sep 17 00:00:00 2001 From: Kris Date: Fri, 10 Aug 2018 14:00:06 -0400 Subject: [PATCH 049/179] Alignment fix --- app/assets/stylesheets/common/base/menu-panel.scss | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss index d5858c9fbf..153ab25e03 100644 --- a/app/assets/stylesheets/common/base/menu-panel.scss +++ b/app/assets/stylesheets/common/base/menu-panel.scss @@ -78,6 +78,7 @@ float: left; background-color: transparent; display: inline-flex; + align-items: center; padding: 0.25em 0.5em; width: 50%; box-sizing: border-box; @@ -122,9 +123,6 @@ font-weight: normal; font-size: $font-down-1; } - .box + b.topics-count { - padding-top: 2px; - } span.badge-category { max-width: 90px; From 71a1d75d7e211cf0f156fdb443fc00b5bd1e0621 Mon Sep 17 00:00:00 2001 From: Jay Pfaffman Date: Fri, 10 Aug 2018 14:45:40 -0700 Subject: [PATCH 050/179] FIX: disable_2fa fix method selection The previous code resulted in NameError: undefined local variable or method `totp' for main:Object I now understand what @tgxworld meant about we should only disable totp when I submitted this before. This is the kind of Ruby stuff that I still don't understand well,(perhaps this isn't the most Ruby way to do this?) but this does what I think is supposed to happen. And it worked just now. --- lib/tasks/users.rake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tasks/users.rake b/lib/tasks/users.rake index 7ea7f24c2a..9bfaa79c9c 100644 --- a/lib/tasks/users.rake +++ b/lib/tasks/users.rake @@ -143,7 +143,7 @@ desc "Disable 2FA for user with the given username" task "users:disable_2fa", [:username] => [:environment] do |_, args| username = args[:username] user = find_user(username) - UserSecondFactor.totp.where(user_id: user.id).each(&:destroy!) + UserSecondFactor.where(user_id: user.id, method: UserSecondFactor.methods[:totp]).each(&:destroy!) puts "2FA disabled for #{username}" end From a960a57c72d5e39eb77f30ba2c41f1ffedd49f43 Mon Sep 17 00:00:00 2001 From: Kris Date: Fri, 10 Aug 2018 22:09:46 -0400 Subject: [PATCH 051/179] Improving category reorder modal layout --- .../templates/modal/reorder-categories.hbs | 2 +- .../stylesheets/common/base/cat_reorder.scss | 44 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/app/assets/javascripts/discourse/templates/modal/reorder-categories.hbs b/app/assets/javascripts/discourse/templates/modal/reorder-categories.hbs index acf26834e5..33221efb14 100644 --- a/app/assets/javascripts/discourse/templates/modal/reorder-categories.hbs +++ b/app/assets/javascripts/discourse/templates/modal/reorder-categories.hbs @@ -12,7 +12,7 @@ {{d-button class="no-text" action="moveUp" actionParam=cat icon="arrow-up"}} {{d-button class="no-text" action="moveDown" actionParam=cat icon="arrow-down"}} {{#if cat.hasBufferedChanges}} - {{d-button class="no-text" action="commit" icon="check"}} + {{d-button class="no-text ok" action="commit" icon="check"}} {{/if}} {{category-badge cat allowUncategorized="true"}} diff --git a/app/assets/stylesheets/common/base/cat_reorder.scss b/app/assets/stylesheets/common/base/cat_reorder.scss index c1f1ea9943..ce0155129e 100644 --- a/app/assets/stylesheets/common/base/cat_reorder.scss +++ b/app/assets/stylesheets/common/base/cat_reorder.scss @@ -1,31 +1,31 @@ .reorder-categories { + thead { + border-bottom: 1px solid $primary-low; + th { + padding-bottom: 0.5em; + text-align: left; + } + } input { width: 4em; - } - .th-pos { - width: calc(4em + 150px); - } - tbody tr { - background-color: transparent; - transition: background 0s ease; - &.highlighted { - background-color: rgba($highlight, 0.4); - &.done { - background-color: transparent; - transition-duration: 1s; - } + @include breakpoint(mobile) { + width: 2em; } - &:first-child td { - padding-top: 7px; - } - } - tbody { - border-bottom: 1px solid blend-primary-secondary(50%); } table { + width: 100%; padding-bottom: 150px; + td { + padding: 0.5em 0.5em 0.5em 0; + @include breakpoint(mobile, min-width) { + min-width: 15em; + } + } + } + .badge-wrapper span.badge-category { + max-width: 20em; + @include breakpoint(mobile) { + max-width: 30vw; + } } } -.category-admin-menu ul { - width: 320px; -} From 448e95b97d5c4ddac64438fc49af2c67a3058ce4 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Sat, 11 Aug 2018 21:51:13 +0200 Subject: [PATCH 052/179] UX: Show anchor icon instead of text when topic bump is disabled --- .../javascripts/discourse/controllers/composer.js.es6 | 7 ------- .../javascripts/discourse/templates/composer.hbs | 10 ++++------ app/assets/stylesheets/common/base/compose.scss | 1 - config/locales/client.en.yml | 1 - .../acceptance/composer-actions-test.js.es6 | 9 ++++----- 5 files changed, 8 insertions(+), 20 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index 3f52b692ec..dc298f88f4 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -195,13 +195,6 @@ export default Ember.Controller.extend({ } }, - @computed("model.noBump") - topicBumpText(noBump) { - if (noBump) { - return I18n.t("composer.no_topic_bump"); - } - }, - @computed isStaffUser() { const currentUser = this.currentUser; diff --git a/app/assets/javascripts/discourse/templates/composer.hbs b/app/assets/javascripts/discourse/templates/composer.hbs index 712dd87018..94e8d44b64 100644 --- a/app/assets/javascripts/discourse/templates/composer.hbs +++ b/app/assets/javascripts/discourse/templates/composer.hbs @@ -21,8 +21,8 @@ {{#if whisperOrUnlistTopicText}} ({{whisperOrUnlistTopicText}}) {{/if}} - {{#if topicBumpText}} - {{topicBumpText}} + {{#if model.noBump}} + {{d-icon "anchor"}} {{/if}} {{/unless}} @@ -123,10 +123,8 @@ {{d-icon "eye-slash"}} {{/if}} - {{#if topicBumpText}} - - {{d-icon "anchor"}} - + {{#if model.noBump}} + {{d-icon "anchor"}} {{/if}} {{/if}} diff --git a/app/assets/stylesheets/common/base/compose.scss b/app/assets/stylesheets/common/base/compose.scss index fde1550de0..1d355ac9e8 100644 --- a/app/assets/stylesheets/common/base/compose.scss +++ b/app/assets/stylesheets/common/base/compose.scss @@ -153,7 +153,6 @@ } .whisper, - .no-bump, .display-edit-reason { font-style: italic; } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 9c6f2f7482..b76a8e3b5d 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1318,7 +1318,6 @@ en: whisper: "whisper" unlist: "unlisted" blockquote_text: "Blockquote" - no_topic_bump: "(no bump)" add_warning: "This is an official warning." toggle_whisper: "Toggle Whisper" diff --git a/test/javascripts/acceptance/composer-actions-test.js.es6 b/test/javascripts/acceptance/composer-actions-test.js.es6 index 7a104fb0a3..f94897e99c 100644 --- a/test/javascripts/acceptance/composer-actions-test.js.es6 +++ b/test/javascripts/acceptance/composer-actions-test.js.es6 @@ -261,10 +261,9 @@ QUnit.test("replying to post - toggle_topic_bump", async assert => { await composerActions.expand(); await composerActions.selectRowByValue("toggle_topic_bump"); - assert.equal( - find(".composer-fields .no-bump").text(), - I18n.t("composer.no_topic_bump"), - "no-bump text is visible" + assert.ok( + find(".composer-fields .no-bump").length === 1, + "no-bump icon is visible" ); await composerActions.expand(); @@ -272,7 +271,7 @@ QUnit.test("replying to post - toggle_topic_bump", async assert => { assert.ok( find(".composer-fields .no-bump").length === 0, - "no-bump text is not visible" + "no-bump icon is not visible" ); }); From 1794aea9391b46b35fae0a5c2234599792312214 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Wed, 27 Jun 2018 00:02:03 +0200 Subject: [PATCH 053/179] FEATURE: Add import script for Telligent --- script/import_scripts/telligent.rb | 448 +++++++++++++++++++++++++++++ 1 file changed, 448 insertions(+) create mode 100644 script/import_scripts/telligent.rb diff --git a/script/import_scripts/telligent.rb b/script/import_scripts/telligent.rb new file mode 100644 index 0000000000..c872138895 --- /dev/null +++ b/script/import_scripts/telligent.rb @@ -0,0 +1,448 @@ +require_relative 'base' +require 'tiny_tds' + +class ImportScripts::Telligent < ImportScripts::Base + BATCH_SIZE ||= 1000 + LOCAL_AVATAR_REGEX ||= /\A~\/.*(?communityserver-components-(?:selectable)?avatars)\/(?[^\/]+)\/(?.+)/i + REMOTE_AVATAR_REGEX ||= /\Ahttps?:\/\//i + EMBEDDED_ATTACHMENT_REGEX ||= /.*?<\/a>/i + + CATEGORY_LINK_NORMALIZATION = '/.*?(f\/\d+)$/\1' + TOPIC_LINK_NORMALIZATION = '/.*?(f\/\d+\/t\/\d+)$/\1' + + def initialize + super() + + @client = TinyTds::Client.new( + host: ENV["DB_HOST"], + username: ENV["DB_USERNAME"], + password: ENV["DB_PASSWORD"], + database: ENV["DB_NAME"] + ) + end + + def execute + add_permalink_normalizations + import_users + import_categories + import_topics + import_posts + mark_topics_as_solved + end + + def import_users + puts "", "Importing users..." + + user_conditions = <<~SQL + ( + EXISTS(SELECT 1 + FROM te_Forum_Threads t + WHERE t.UserId = u.UserID) OR + EXISTS(SELECT 1 + FROM te_Forum_ThreadReplies r + WHERE r.UserId = u.UserID) + ) + SQL + + last_user_id = -1 + total_count = count(<<~SQL) + SELECT COUNT(1) AS count + FROM temp_User u + WHERE #{user_conditions} + SQL + + batches do |offset| + rows = query(<<~SQL) + SELECT TOP #{BATCH_SIZE} * + FROM + ( + SELECT + u.UserID, + u.Email, + u.UserName, + u.CommonName, + u.CreateDate, + p.PropertyName, + p.PropertyValue + FROM temp_User u + LEFT OUTER JOIN temp_UserProperties p ON (u.UserID = p.UserID) + WHERE u.UserID > #{last_user_id} AND #{user_conditions} + ) x + PIVOT ( + MAX(PropertyValue) + FOR PropertyName + IN (avatarUrl, bio, Location, webAddress, BannedUntil, UserBanReason) + ) y + ORDER BY UserID + SQL + + break if rows.blank? + last_user_id = rows[-1]["UserID"] + next if all_records_exist?(:users, rows.map { |row| row["UserID"] }) + + create_users(rows, total: total_count, offset: offset) do |row| + { + id: row["UserID"], + email: row["Email"], + username: row["UserName"], + name: row["CommonName"], + created_at: row["CreateDate"], + bio_raw: html_to_markdown(row["bio"]), + location: row["Location"], + website: row["webAddress"], + post_create_action: proc do |user| + import_avatar(user, row["avatarUrl"]) + suspend_user(user, row["BannedUntil"], row["UserBanReason"]) + end + } + end + end + end + + # TODO move into base importer (create_user) and use consistent error handling + def import_avatar(user, avatar_url) + return if avatar_url.blank? || avatar_url.include?("anonymous") + + if match_data = avatar_url.match(LOCAL_AVATAR_REGEX) + avatar_path = File.join(ENV["FILE_BASE_DIR"], + match_data[:directory].gsub("-", "."), + match_data[:path].split("-"), + match_data[:filename]) + + if File.exists?(avatar_path) + @uploader.create_avatar(user, avatar_path) + else + STDERR.puts "Could not find avatar: #{avatar_path}" + end + elsif avatar_url.match?(REMOTE_AVATAR_REGEX) + UserAvatar.import_url_for_user(avatar_url, user) rescue nil + end + end + + def suspend_user(user, banned_until, ban_reason) + return if banned_until.blank? + + if banned_until = DateTime.parse(banned_until) > DateTime.now + user.suspended_till = banned_until + user.suspended_at = DateTime.now + user.save! + + StaffActionLogger.new(Discourse.system_user).log_user_suspend(user, ban_reason) + end + end + + def import_categories + @new_parent_categories = {} + @new_parent_categories[:archives] = create_category({ name: "Archives" }, nil) + @new_parent_categories[:spotlight] = create_category({ name: "Spotlight" }, nil) + @new_parent_categories[:optimizer] = create_category({ name: "SQL Optimizer" }, nil) + + puts "", "Importing parent categories..." + parent_categories = query(<<~SQL) + SELECT + GroupID, + Name, HtmlDescription, + DateCreated, SortOrder + FROM cs_Groups g + WHERE (SELECT COUNT(1) + FROM te_Forum_Forums f + WHERE f.GroupId = g.GroupID) > 1 + ORDER BY SortOrder, Name + SQL + + create_categories(parent_categories) do |row| + { + id: "G#{row['GroupID']}", + name: clean_category_name(row["Name"]), + description: html_to_markdown(row["HtmlDescription"]), + position: row["SortOrder"] + } + end + + puts "", "Importing child categories..." + child_categories = query(<<~SQL) + SELECT + ForumId, GroupId, + Name, Description, + DateCreated, SortOrder + FROM te_Forum_Forums + ORDER BY GroupId, SortOrder, Name + SQL + + create_categories(child_categories) do |row| + parent_category_id = parent_category_id_for(row) + + if category_id = replace_with_category_id(row, child_categories, parent_category_id) + add_category(row['ForumId'], Category.find_by_id(category_id)) + Permalink.create(url: "f/#{row['ForumId']}", category_id: category_id) + nil + else + { + id: row['ForumId'], + parent_category_id: parent_category_id, + name: clean_category_name(row["Name"]), + description: html_to_markdown(row["Description"]), + position: row["SortOrder"] + } + end + end + end + + def parent_category_id_for(row) + name = row["Name"].downcase + + if name.include?("beta") + @new_parent_categories[:archives].id + elsif name.include?("spotlight") + @new_parent_categories[:spotlight].id + elsif name.include?("optimizer") + @new_parent_categories[:optimizer].id + elsif row.key?("GroupId") + category_id_from_imported_category_id("G#{row['GroupId']}") + else + nil + end + end + + def replace_with_category_id(row, child_categories, parent_category_id) + name = row["Name"].downcase + + if name.include?("data modeler") || name.include?("benchmark") + category_id_from_imported_category_id("G#{row['GroupId']}") + elsif only_child?(child_categories, parent_category_id) + parent_category_id + end + end + + def only_child?(child_categories, parent_category_id) + count = 0 + + child_categories.each do |row| + count += 1 if parent_category_id_for(row) == parent_category_id + end + + count == 1 + end + + def clean_category_name(name) + CGI.unescapeHTML(name) + .sub(/(?:\- )?Forum/i, "") + .strip + end + + def import_topics + puts "", "Importing topics..." + + last_topic_id = -1 + total_count = count("SELECT COUNT(1) AS count FROM te_Forum_Threads") + + batches do |offset| + rows = query(<<~SQL) + SELECT TOP #{BATCH_SIZE} + t.ThreadId, t.ForumId, t.UserId, + t.Subject, t.Body, t.DateCreated, t.IsLocked, t.StickyDate, + a.ApplicationTypeId, a.ApplicationId, a.ApplicationContentTypeId, a.ContentId, a.FileName + FROM te_Forum_Threads t + LEFT JOIN te_Attachments a + ON (a.ApplicationId = t.ForumId AND a.ApplicationTypeId = 0 AND a.ContentId = t.ThreadId AND + a.ApplicationContentTypeId = 0) + WHERE t.ThreadId > #{last_topic_id} + ORDER BY t.ThreadId + SQL + + break if rows.blank? + last_topic_id = rows[-1]["ThreadId"] + next if all_records_exist?(:post, rows.map { |row| import_topic_id(row["ThreadId"]) }) + + create_posts(rows, total: total_count, offset: offset) do |row| + user_id = user_id_from_imported_user_id(row["UserId"]) || Discourse::SYSTEM_USER_ID + + post = { + id: import_topic_id(row["ThreadId"]), + title: CGI.unescapeHTML(row["Subject"]), + raw: raw_with_attachment(row, user_id), + category: category_id_from_imported_category_id(row["ForumId"]), + user_id: user_id, + created_at: row["DateCreated"], + closed: row["IsLocked"], + post_create_action: proc do |post| + topic = post.topic + Jobs.enqueue_at(topic.pinned_until, :unpin_topic, topic_id: topic.id) if topic.pinned_until + Permalink.create(url: "f/#{row['ForumId']}/t/#{row['ThreadId']}", topic_id: topic.id) + end + } + + if row["StickyDate"] > Time.now + post[:pinned_until] = row["StickyDate"] + post[:pinned_at] = row["DateCreated"] + end + + post + end + end + end + + def import_topic_id(topic_id) + "T#{topic_id}" + end + + def import_posts + puts "", "Importing posts..." + + last_post_id = -1 + total_count = count("SELECT COUNT(1) AS count FROM te_Forum_ThreadReplies") + + batches do |offset| + rows = query(<<~SQL) + SELECT TOP #{BATCH_SIZE} + tr.ThreadReplyId, tr.ThreadId, tr.UserId, tr.ParentReplyId, + tr.Body, tr.ThreadReplyDate, + CONVERT(BIT, + CASE WHEN tr.AnswerVerifiedUtcDate IS NOT NULL AND NOT EXISTS( + SELECT 1 + FROM te_Forum_ThreadReplies x + WHERE + x.ThreadId = tr.ThreadId AND x.ThreadReplyId < tr.ThreadReplyId AND x.AnswerVerifiedUtcDate IS NOT NULL + ) + THEN 1 + ELSE 0 END) AS IsFirstVerifiedAnswer, + a.ApplicationTypeId, a.ApplicationId, a.ApplicationContentTypeId, a.ContentId, a.FileName + FROM te_Forum_ThreadReplies tr + JOIN te_Forum_Threads t ON (tr.ThreadId = t.ThreadId) + LEFT JOIN te_Attachments a + ON (a.ApplicationId = t.ForumId AND a.ApplicationTypeId = 0 AND a.ContentId = tr.ThreadReplyId AND + a.ApplicationContentTypeId = 1) + WHERE tr.ThreadReplyId > #{last_post_id} + ORDER BY tr.ThreadReplyId + SQL + + break if rows.blank? + last_post_id = rows[-1]["ThreadReplyId"] + next if all_records_exist?(:post, rows.map { |row| row["ThreadReplyId"] }) + + create_posts(rows, total: total_count, offset: offset) do |row| + imported_parent_id = row["ParentReplyId"] > 0 ? row["ParentReplyId"] : import_topic_id(row["ThreadId"]) + parent_post = topic_lookup_from_imported_post_id(imported_parent_id) + user_id = user_id_from_imported_user_id(row["UserId"]) || Discourse::SYSTEM_USER_ID + + if parent_post + post = { + id: row["ThreadReplyId"], + raw: raw_with_attachment(row, user_id), + user_id: user_id, + topic_id: parent_post[:topic_id], + created_at: row["ThreadReplyDate"], + reply_to_post_number: parent_post[:post_number] + } + + post[:custom_fields] = { is_accepted_answer: "true" } if row["IsFirstVerifiedAnswer"] + post + else + puts "Failed to import post #{row['ThreadReplyId']}. Parent was not found." + end + end + end + end + + def raw_with_attachment(row, user_id) + raw, embedded_paths = replace_embedded_attachments(row["Body"], user_id) + raw = html_to_markdown(raw) || "" + + filename = row["FileName"] + return raw if filename.blank? + + path = File.join( + ENV["FILE_BASE_DIR"], + "telligent.evolution.components.attachments", + "%02d" % row["ApplicationTypeId"], + "%02d" % row["ApplicationId"], + "%02d" % row["ApplicationContentTypeId"], + ("%010d" % row["ContentId"]).scan(/.{2}/), + filename + ) + + unless embedded_paths.include?(path) + if File.exists?(path) + upload = @uploader.create_upload(user_id, path, filename) + raw << "\n" << @uploader.html_for_upload(upload, filename) if upload.present? && upload.persisted? + else + STDERR.puts "Could not find file: #{path}" + end + end + + raw + end + + def replace_embedded_attachments(raw, user_id) + paths = [] + + raw = raw.gsub(EMBEDDED_ATTACHMENT_REGEX) do + match_data = Regexp.last_match + filename = match_data[:filename] + + path = File.join( + ENV["FILE_BASE_DIR"], + match_data[:directory].gsub("-", "."), + match_data[:path].split("-"), + filename + ) + + if File.exists?(path) + upload = @uploader.create_upload(user_id, path, filename) + + if upload.present? && upload.persisted? + paths << path + @uploader.html_for_upload(upload, filename) + end + else + STDERR.puts "Could not find file: #{path}" + end + end + + [raw, paths] + end + + def mark_topics_as_solved + puts "", "Marking topics as solved..." + + DB.exec <<~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' AND pcf.value = 'true' + SQL + end + + def html_to_markdown(html) + HtmlToMarkdown.new(html).to_markdown if html.present? + end + + def add_permalink_normalizations + normalizations = SiteSetting.permalink_normalizations + normalizations = normalizations.blank? ? [] : normalizations.split('|') + + add_normalization(normalizations, CATEGORY_LINK_NORMALIZATION) + add_normalization(normalizations, TOPIC_LINK_NORMALIZATION) + + SiteSetting.permalink_normalizations = normalizations.join('|') + end + + def add_normalization(normalizations, normalization) + normalizations << normalization unless normalizations.include?(normalization) + end + + def batches + super(BATCH_SIZE) + end + + def query(sql) + @client.execute(sql).to_a + end + + def count(sql) + query(sql).first["count"] + end +end + +ImportScripts::Telligent.new.perform From 6d813c2b5206d6a92ae1b90ab2a7206115effc07 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Sun, 12 Aug 2018 22:02:17 +0200 Subject: [PATCH 054/179] FIX: Importers failed to import avatars --- app/models/optimized_image.rb | 2 +- script/import_scripts/base/uploader.rb | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/models/optimized_image.rb b/app/models/optimized_image.rb index 23ae4823d8..4b6c81aeb3 100644 --- a/app/models/optimized_image.rb +++ b/app/models/optimized_image.rb @@ -122,7 +122,7 @@ class OptimizedImage < ActiveRecord::Base def self.prepend_decoder!(path) extension = File.extname(path)[1..-1] - raise Discourse::InvalidAccess unless extension[IM_DECODERS] + raise Discourse::InvalidAccess unless extension.present? && extension[IM_DECODERS] "#{extension}:#{path}" end diff --git a/script/import_scripts/base/uploader.rb b/script/import_scripts/base/uploader.rb index f9d3e135ff..465d8d6796 100644 --- a/script/import_scripts/base/uploader.rb +++ b/script/import_scripts/base/uploader.rb @@ -13,7 +13,7 @@ module ImportScripts UploadCreator.new(tmp, source_filename).create_for(user_id) rescue => e - puts "Failed to create upload: #{e}" + STDERR.puts "Failed to create upload: #{e}" nil ensure tmp.close rescue nil @@ -30,9 +30,11 @@ module ImportScripts user.user_avatar.update(custom_upload_id: upload.id) user.update(uploaded_avatar_id: upload.id) else - puts "Failed to upload avatar for user #{user.username}: #{avatar_path}" - puts upload.errors.inspect if upload + STDERR.puts "Failed to upload avatar for user #{user.username}: #{avatar_path}" + STDERR.puts upload.errors.inspect if upload end + rescue + STDERR.puts "Failed to create avatar for user #{user.username}: #{avatar_path}" ensure tempfile.close! if tempfile end @@ -59,6 +61,8 @@ module ImportScripts private def copy_to_tempfile(source_path) + # extension = File.extname(source_path) + # tmp = Tempfile.new(['discourse-upload', extension]) tmp = Tempfile.new('discourse-upload') File.open(source_path) do |source_stream| From 85136054215a342c96b6075f1ab389c2b9b92613 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Sun, 12 Aug 2018 22:26:07 +0200 Subject: [PATCH 055/179] Fix the import of avatars and attachments This time for real ;-) --- script/import_scripts/base/uploader.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/script/import_scripts/base/uploader.rb b/script/import_scripts/base/uploader.rb index 465d8d6796..2d25a3e9ae 100644 --- a/script/import_scripts/base/uploader.rb +++ b/script/import_scripts/base/uploader.rb @@ -61,9 +61,8 @@ module ImportScripts private def copy_to_tempfile(source_path) - # extension = File.extname(source_path) - # tmp = Tempfile.new(['discourse-upload', extension]) - tmp = Tempfile.new('discourse-upload') + extension = File.extname(source_path) + tmp = Tempfile.new(['discourse-upload', extension]) File.open(source_path) do |source_stream| IO.copy_stream(source_stream, tmp) From a6820d876712d3186c053d1af126d7520e85d489 Mon Sep 17 00:00:00 2001 From: Peter Borsa Date: Mon, 13 Aug 2018 01:02:35 +0200 Subject: [PATCH 056/179] Add Hungarian locale (#6260) --- .tx/config | 2 +- app/assets/javascripts/locales/hu.js.erb | 3 + config/locales/client.hu.yml | 2592 +++++++++++++++++ config/locales/server.hu.yml | 670 +++++ .../config/locales/client.hu.yml | 14 + .../config/locales/server.hu.yml | 8 + .../config/locales/client.hu.yml | 25 + .../config/locales/server.hu.yml | 8 + .../config/locales/client.hu.yml | 13 + .../config/locales/server.hu.yml | 42 + .../config/locales/server.hu.yml | 8 + .../config/locales/client.hu.yml | 8 + .../config/locales/server.hu.yml | 8 + plugins/poll/config/locales/client.hu.yml | 57 + plugins/poll/config/locales/server.hu.yml | 21 + 15 files changed, 3478 insertions(+), 1 deletion(-) create mode 100644 app/assets/javascripts/locales/hu.js.erb create mode 100644 config/locales/client.hu.yml create mode 100644 config/locales/server.hu.yml create mode 100644 plugins/discourse-details/config/locales/client.hu.yml create mode 100644 plugins/discourse-details/config/locales/server.hu.yml create mode 100644 plugins/discourse-local-dates/config/locales/client.hu.yml create mode 100644 plugins/discourse-local-dates/config/locales/server.hu.yml create mode 100644 plugins/discourse-narrative-bot/config/locales/client.hu.yml create mode 100644 plugins/discourse-narrative-bot/config/locales/server.hu.yml create mode 100644 plugins/discourse-nginx-performance-report/config/locales/server.hu.yml create mode 100644 plugins/discourse-presence/config/locales/client.hu.yml create mode 100644 plugins/discourse-presence/config/locales/server.hu.yml create mode 100644 plugins/poll/config/locales/client.hu.yml create mode 100644 plugins/poll/config/locales/server.hu.yml diff --git a/.tx/config b/.tx/config index bc1bcb84c9..7167d96bc5 100644 --- a/.tx/config +++ b/.tx/config @@ -1,6 +1,6 @@ [main] host = https://www.transifex.com -lang_map = el_GR: el, 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, hu_HU: hu, ko_KR: ko, pt_PT: pt, sk_SK: sk, vi_VN: vi [discourse-org.core-client-yml] file_filter = config/locales/client..yml diff --git a/app/assets/javascripts/locales/hu.js.erb b/app/assets/javascripts/locales/hu.js.erb new file mode 100644 index 0000000000..ee6a12ced4 --- /dev/null +++ b/app/assets/javascripts/locales/hu.js.erb @@ -0,0 +1,3 @@ +//= depend_on 'client.hu.yml' +//= require locales/i18n +<%= JsLocaleHelper.output_locale(:hu) %> diff --git a/config/locales/client.hu.yml b/config/locales/client.hu.yml new file mode 100644 index 0000000000..c19053d27d --- /dev/null +++ b/config/locales/client.hu.yml @@ -0,0 +1,2592 @@ +# 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/ + +hu: + js: + number: + format: + separator: "." + delimiter: "," + human: + storage_units: + format: '%n %u' + units: + byte: + one: Bájt + other: Bájt + gb: GB + kb: KB + mb: MB + tb: TB + short: + thousands: "{{number}} ezer" + millions: "{{number}} millió" + dates: + time: "h:mm a" + timeline_date: "YYYY MMMM" + long_no_year: "MMM D h:mm a" + long_no_year_no_time: "MMM D" + full_no_year_no_time: "MMM DD" + long_with_year: "MMM D, YYYY h:mm a" + long_with_year_no_time: "MMM D, YYYY" + full_with_year_no_time: "YYYY MMMM dddd" + long_date_with_year: "MMM D, 'YY LT" + long_date_without_year: "MMM D, LT" + long_date_with_year_without_time: "MMM D, 'YY" + long_date_without_year_with_linebreak: "MMM D
LT" + long_date_with_year_with_linebreak: "MMM D, 'YY
LT" + wrap_ago: "%{date} ezelőtt" + tiny: + half_a_minute: "< 1p" + less_than_x_seconds: + one: "< 1mp" + other: "< %{count}mp" + x_seconds: + one: "1 mp" + other: "%{count} mp" + less_than_x_minutes: + one: "< 1 perc" + other: "< %{count}perc" + x_minutes: + one: "1p" + other: "%{count}p" + about_x_hours: + one: "1ó" + other: "%{count}ó" + x_days: + one: "1n" + other: "%{count}n" + x_months: + one: "hó" + other: "%{count} hónap" + about_x_years: + one: "1é" + other: "%{count}é" + over_x_years: + one: "> 1é" + other: "> %{count}é" + almost_x_years: + one: "1é" + other: "%{count}é" + date_month: "MMM D" + date_year: "MMM 'YY" + medium: + x_minutes: + one: "1 perc" + other: "%{count} perc" + x_hours: + one: "1 óra" + other: "%{count} óra" + x_days: + one: "1 nap" + other: "%{count} nap" + date_year: "MMM D, 'YY" + medium_with_ago: + x_minutes: + one: "1 perce" + other: "%{count} perce" + x_hours: + one: "1 órája" + other: "%{count} órája" + x_days: + one: "1 napja" + other: "%{count} napja" + later: + x_days: + one: "%{count} nappal később" + other: "%{count} nappal később" + x_months: + one: "%{count} hónappal később" + other: "%{count} hónappal később" + x_years: + one: "%{count} évvel később" + other: "%{count} évvel később" + previous_month: 'Előző hónap' + next_month: 'Következő hónap' + placeholder: dátum + share: + topic: 'link megosztása ebbe a témába' + post: 'bejegyzés #%{postNumber}' + close: 'bezár' + twitter: 'a hivatkozás megosztása a Twitteren' + facebook: 'a hivatkozás megosztása a Facebookon' + google+: 'a hivatkozás megosztása a Google+ -on' + email: 'a hivatkozás elküldése e-mailben' + action_codes: + public_topic: "publikussá tette ezt a témát ekkor: %{when}" + private_topic: "ez a téma privát üzenetté téve ekkor: %{when}" + split_topic: "felosztotta a témát ekkor: %{when}" + invited_user: "meghívta %{who} %{when}" + invited_group: "meghívta %{who} %{when}" + user_left: "%{who}eltávolította magát %{when}-kor ebből az üzenetből" + removed_user: "törölve %{who} %{when}" + removed_group: "törölte %{who} %{when}" + autoclosed: + enabled: 'lezárva: %{when}' + disabled: 'megnyitva %{when}' + closed: + enabled: 'lezárva: %{when}' + disabled: 'megnyitva %{when}' + archived: + enabled: 'archiválva: %{when}' + disabled: 'visszahozva: %{when}' + pinned: + enabled: 'kitűzve: %{when}' + disabled: 'kitűzés megszűntetve: %{when}' + pinned_globally: + enabled: 'globálisan kitűzve: %{when}' + disabled: 'Kitűzés megszüntetve: %{when}' + visible: + enabled: 'listázva %{when}' + disabled: 'nem listázva: %{when}' + banner: + enabled: 'banner létrehozva %{when}-kor. Amíg a felhasználó nem törli, megjelenik minden oldal tetején.' + disabled: 'banner eltávolítva %{when}-kor. Nem jelenik meg többé minden oldal tetején.' + topic_admin_menu: "Téma admin lehetőségek" + wizard_required: "Üdvözlünk az új Discourse-on! Kezdjük a
beállításvarázslóval! ✨" + emails_are_disabled: "Egy adminisztrátor letilotta a kimenő emaileket. Semmilyen értesítő emailt nem küldünk." + bootstrap_mode_disabled: "A bootstrap mód 24 órán belül kikapcsol." + themes: + default_description: "Alapértelmezett" + s3: + regions: + ap_northeast_1: "Csendes-óceáni térség (Tokió)" + ap_northeast_2: "Csendes-óceáni térség (Szöul)" + ap_south_1: "Csendes-óceáni térség (Mumbai)" + ap_southeast_1: "Csendes-óceáni térség (Szingapúr)" + ap_southeast_2: "Csendes-óceáni térség (Sydney)" + cn_north_1: "Kína (Peking)" + eu_central_1: "EU (Frankfurt)" + eu_west_1: "EU (Írország)" + eu_west_2: "EU (London)" + eu_west_3: "EU (Párizs)" + sa_east_1: "Dél-Amerika (Sao Paolo)" + us_east_1: "US Kelet (N. Virginia)" + us_east_2: "US Kelet (Ohio)" + us_gov_west_1: "AWS GovCloud (US)" + us_west_1: "US Nyugat (N. California)" + us_west_2: "US Nyugat (Oregon)" + edit: 'a témakör címének és kategóriájának szerkesztése' + not_implemented: "Sajnáljuk, de még megvalósításra vár ez a funkció!" + no_value: "Nem" + yes_value: "Igen" + submit: "Beküldés" + generic_error: "Sajnos hiba történt." + generic_error_with_reason: "Hiba történt: %{error}" + sign_up: "Regisztráció" + log_in: "Bejelentkezés" + age: "Életkor" + joined: "Tagság kezdete" + admin_title: "Adminisztrátor" + flags_title: "Jelző" + show_more: "mutass többet" + show_help: "opciók" + links: "Hivatkozások" + links_lowercase: + one: "hivatkozás" + other: "hivatkozások" + faq: "GYIK" + guidelines: "Irányelvek" + privacy_policy: "Adatvédelmi szabályzat" + privacy: "Adatvédelem" + terms_of_service: "Szolgáltatási feltételek" + mobile_view: "Mobil nézet" + desktop_view: "Asztali nézet" + you: "Te" + or: "vagy" + now: "az imént" + read_more: 'olvass többet' + more: "Több" + less: "Kevesebb" + never: "soha" + every_30_minutes: "30 percenként" + every_hour: "óránként" + daily: "naponta" + weekly: "hetente" + every_two_weeks: "kéthetente" + every_three_days: "háromnaponta" + max_of_count: "maximum {{count}}" + alternation: "vagy" + character_count: + one: "{{count}} karakter" + other: "{{count}} karakter" + suggested_topics: + title: "Ajánlott témakörök" + pm_title: "Ajánlott üzenetek" + about: + simple_title: "A fórumról" + title: "A(z) %{title} fórumról" + stats: "Webhelystatisztika" + our_admins: "Adminisztrátoraink" + our_moderators: "Moderátoraink" + stat: + all_time: "Összes" + last_7_days: "Utolsó 7" + last_30_days: "Utolsó 30" + like_count: "Kedvelte" + topic_count: "Témák" + post_count: "Bejegyzések" + user_count: "Felhasználók" + active_user_count: "Aktív felhasználók" + contact: "Kapcsolat" + contact_info: "A webhellyel kapcsolatos sürgős vagy kritikus probléma esetén lépj velünk kapcsolatba a következő elérhetőségen: %{contact_info}." + bookmarked: + title: "Könyvjelző" + clear_bookmarks: "Könyvjelzők Törlése" + help: + bookmark: "Kattints, hogy a témakör első bejegyzését betedd a könyvjelzők közé" + unbookmark: "Kattints a témakör valamennyi könyvjelzőjének törléséhez" + bookmarks: + not_logged_in: "sajnáljuk, de a bejegyzések könyvjelzővel való ellátásához be kell jelentkezned" + created: "ezt a bejegyzést könyvjelzővel láttad el" + not_bookmarked: "elolvastad ezt a bejegyzést, kattints ide a könyvjelzővel való ellátásához" + last_read: "ez az utolsó bejegyzés, amit elolvastál, kattints ide a könyvjelzővel való ellátásához" + remove: "Könyvjelző eltávolítása" + confirm_clear: "Biztosan törölni szeretnéd a könyvjelzőket ebből a témakörből?" + drafts: + resume: "Folytatás" + remove: "Eltávolítás" + new_topic: "Új téma vázlat" + new_private_message: "Új személyes üzenet besorolása" + topic_reply: "Választervezet" + topic_count_latest: + one: "Megnézni {{count}} új vagy frissített témát" + other: "Megnézni {{count}} új vagy frissített témákat" + topic_count_unread: + one: "Megnézni {{count}} nemlátott témát" + other: "Megnézni {{count}} olvasatlan témákat" + topic_count_new: + one: "Megnézni {{count}} új témát" + other: "Megnézni {{count}} új témákat" + preview: "előnézet" + cancel: "mégse" + save: "Módosítások mentése" + saving: "Mentés..." + saved: "Mentve!" + upload: "Feltöltés" + uploading: "Feltöltés..." + uploading_filename: "{{filename}} feltöltése..." + uploaded: "Feltöltve!" + pasting: "Beillesztés..." + enable: "Engedélyezés" + disable: "Letiltás" + continue: "Folytatás" + undo: "Visszavonás" + revert: "Visszaállítás" + failed: "Sikertelen" + switch_to_anon: "Belépés az anonim módba" + switch_from_anon: "Kilépés az anonim módból" + banner: + close: "Banner eltüntetése." + edit: "Banner szerkesztése" + choose_topic: + none_found: "Nem találhatók témák." + title: + search: "Témakör keresése név, URL-cím vagy azonosító alapján:" + placeholder: "ide írd be a témakör címét" + queue: + topic: "Téma:" + approve: 'Jóváhagy' + reject: 'Elutasít' + delete_user: 'Felhasználó Törlése' + title: "Jóváhagyásra vár" + none: "Nincs átnézésre váró bejegyzés" + edit: "Szerkesztés" + cancel: "Mégse" + view_pending: "függőben lévő bejegyzések megtekintése" + has_pending_posts: + one: "Ebben a témakörben {{count}} hozzászólás vár jóváhagyásra" + other: "Ebben a témakörben {{count}} hozzászólás vár jóváhagyásra" + confirm: "Módosítások mentése" + delete_prompt: "Biztos vagy benne, hogy törölni akarod %{username} felhasználót? Ezzel törlöd minden bejegyzését és blokkolod az e-mail és IP-címét." + approval: + title: "A bejegyzés jóváhagyásra vár." + description: "Megkaptuk a bejegyzésedet, de egy moderátornak jóvá kell hagynia, mielőtt megjelenne. Köszönjük a türelmedet." + pending_posts: + one: "1 függőben lévő bejegyzés" + other: "{{count}} függőben lévő bejegyzése" + ok: "Rendben" + user_action: + user_posted_topic: "{{user}} kitett egy témát" + you_posted_topic: "Te kitettél egy témát" + user_replied_to_post: "{{user}} válaszolt a következő bejegyzésre: {{post_number}}" + you_replied_to_post: "Te válaszoltál a következő bejegyzésre: {{post_number}}" + user_replied_to_topic: "{{user}} válaszolt a témára" + you_replied_to_topic: "Te válaszoltál a témára" + user_mentioned_user: "{{user}} megemlítette a következő felhasználót: {{another_user}}" + user_mentioned_you: "{{user}} megemlített téged" + you_mentioned_user: "Te megemlítetted a következő felhasználót: {{another_user}}" + posted_by_user: "{{user}} bejegyzése" + posted_by_you: "A te bejegyzésed" + sent_by_user: "{{user}} küldte" + sent_by_you: "Te küldted" + directory: + filter_name: "szűrés felhasználónév szerint" + title: "Felhasználók" + likes_given: "Adott" + likes_received: "Kapott" + topics_entered: "Megtekintve" + topics_entered_long: "Megtekintett témakörök" + time_read: "olvasással töltött idő" + topic_count: "Témák" + topic_count_long: "Létrehozott Témák" + post_count: "Válaszok" + post_count_long: "Elküldött Válaszok" + no_results: "Nincs találat." + days_visited: "Látogatások" + days_visited_long: "látogatott napok" + posts_read: "Olvasott" + posts_read_long: "Elolvasott bejegyzések" + total_rows: + one: "1 felhasználó" + other: "%{count} felhasználó" + group_histories: + actions: + change_group_setting: "Csoport beállítások megváltoztatása." + add_user_to_group: "Felhasználó hozzáadása" + remove_user_from_group: "Felhasználó eltávolítása" + make_user_group_owner: "Tulajdonos létrehozása" + remove_user_as_group_owner: "Tulajdonosi jog visszavonása" + groups: + add_members: + title: "Tagok hozzáadása" + description: "A csoport tagságának kezelése" + usernames: "Felhasználónevek" + manage: + title: 'Kezelés' + name: 'Név' + full_name: 'Teljes név' + add_members: "Tagok hozzáadása" + delete_member_confirm: "Eltávolítod %{username} felhasználót a %{group} csoportból?" + profile: + title: Profil + interaction: + title: Interakció + notification: Értesítés + membership: + title: Tagság + access: Hozzáférés + logs: + title: "Naplók" + when: "Mikor" + action: "Művelet" + acting_user: "Cselekvő felhasználó" + target_user: "Célzott felhasználó" + subject: "Téma" + details: "Részletek" + from: "Kitől" + to: "Kinek" + public_admission: "Megengedi a felhasználóknak, hogy szabadon csatlakozzanak a csoporthoz (Szükséges, hogy a csoport publikusan látható legyen)" + public_exit: "Megengedi a felhasználóknak, hogy szabadon elhagyhassák a csoportot" + empty: + posts: "A csoport tagjai még nem írtak bejegyzést." + members: "Nincsenek tagok ebben a csoportban." + mentions: "Nincsenek említések erről a csoportról." + messages: "Nincsenek üzenetek intézve ehhez a csoporthoz." + topics: "A csoport tagjai még nem hoztak létre témát." + logs: "Nincsenek ehhez a csoporthoz tartozó naplók." + add: "Hozzáadás" + join: "Belépés" + leave: "Elhagyás" + request: "Kérelmezés" + message: "Üzenet" + allow_membership_requests: "Megengedi a felhasználóknak, hogy tagsági kérelmeket küldjenek a csoporttulajdonosoknak" + membership_request_template: "A felhasználóknak megjelenítendő egyéni sablon tagsági kérelmekhez." + membership_request: + submit: "Kérelem beküldése" + title: "Kérelem a(z) @%{group_name} nevű csoporthoz való csatlakozáshoz" + reason: "A csoport tulajdonosok tájékoztatása, hogy miért tartozol ebbe csoportba" + membership: "Tagság" + name: "Név" + group_name: "Csoport név" + user_count: "Felhasználók" + bio: "A csoportról" + selector_placeholder: "felhasználónév megadása" + owner: "tulajdonos" + index: + title: "Csoportok" + all: "Minden csoport" + empty: "Nincsenek látható csoportok." + filter: "Szűrés csoportnév alapján" + owner_groups: "Ezeknek a csoportoknak vagyok a tulajdonosa: " + close_groups: "Zárt csoportok" + automatic_groups: "Automatikus csoportok" + automatic: "Automatikus" + closed: "Lezárva" + public: "Nyilvános" + private: "Privát" + public_groups: "Nyilvános csoportok" + automatic_group: Automatikus csoport + close_group: Csoport lezárása + my_groups: "Csoportjaim" + group_type: "Csoport típus" + is_group_user: "Tag" + is_group_owner: "Tulajdonos" + title: + one: "Csoport" + other: "Csoportok" + activity: "Aktivitás" + members: + title: "Tagok" + filter_placeholder_admin: "felhasználónév vagy e-mail-cím" + filter_placeholder: "felhasználónév" + remove_member: "Tag eltávolítása" + remove_member_description: "%{username} eltávolítása a csoportból" + make_owner: "Tulajdonossá tétel" + make_owner_description: "%{username} csoporttulajdonossá tétele" + remove_owner: "Eltávolítás tulajdonosként" + remove_owner_description: "%{username} eltávolítása a csoportból csoporttulajdonoként" + owner: "Tulajdonos" + topics: "Témák" + posts: "Bejegyzések" + mentions: "Említések" + messages: "Üzenetek" + notification_level: "Csoport üzenetek alapértelmezett értesítési szintje" + alias_levels: + mentionable: "Ki @említheti meg ezt a csoportot?" + messageable: "Ki üzenhet ennek a csoportnak?" + nobody: "Senki" + only_admins: "Csak az adminisztrátorok" + mods_and_admins: "Csak a moderátorok és az adminisztrátorok" + members_mods_and_admins: "Csak a csoporttagok, a moderátorok és az adminisztrátorok" + everyone: "Mindenki" + notifications: + watching: + title: "Figyelés" + description: "Értesítést fogsz kapni minden üzenetben lévő új bejegyzésről és megjelenik az olvasatlan válaszok száma is." + watching_first_post: + title: "Első hozzászólások figyelése" + description: "Csak minden új téma első hozzászólásáról kapsz értesítést ebben a csoportban." + tracking: + title: "Követés" + description: "Csak akkor leszel értesítve, ha valaki megemlíti a @nevedet vagy válaszol neked, és megjelenik az olvasatlan válaszok száma is." + regular: + title: "Normál" + description: "Csak akkor leszel értesítve, ha valaki megemlíti a @nevedet vagy válaszol neked." + muted: + title: "Némítás" + description: "Egyáltalán nem fogsz értesítést kapni az ebben a csoportban lévő új témákról." + flair_url: "Avatar Flair kép" + flair_url_placeholder: "(Opcionális) Kép url címe vagy Font Awesome css osztálya" + flair_bg_color: "Avatar Flair háttér szín" + flair_bg_color_placeholder: "(Opcionális) Hex színkód érték" + flair_color: "Avatar Flair szín" + flair_color_placeholder: "(Opcionális) Hex színkód érték" + flair_preview_icon: "Ikon előnézete" + flair_preview_image: "Kép előnézete" + user_action_groups: + "1": "Adott tetszésnyilvánítások" + "2": "Kapott tetszésnyilvánítások" + "3": "Könyvjelzők" + "4": "Témák" + "5": "Válaszok" + "6": "Válaszok" + "7": "Említések" + "9": "Idézések" + "11": "Szerkesztések" + "12": "Elküldött elemek" + "13": "Beérkezett üzenetek" + "14": "Folyamatban" + "15": "Vázlatok" + categories: + all: "minden kategória" + all_subcategories: "összes" + no_subcategory: "egyik sem" + category: "Kategória" + category_list: "Kategória lista mutatása" + reorder: + title: "Kategóriák átrendezése" + title_long: "a kategória lista újrarendezése." + save: "Sorrend mentése" + apply_all: "Elfogadás" + position: "Pozíció" + posts: "Bejegyzések" + topics: "Témák" + latest: "Legutóbbi" + latest_by: "legkésőbb" + toggle_ordering: "rendezés megfordítása" + subcategories: "Alkategóriák" + topic_sentence: + one: "1 téma" + other: "%{count} téma" + topic_stat_sentence: + one: "%{count} új témakör az elmúlt %{unit} során." + other: "%{count} új témakör az elmúlt %{unit} során." + more: "(még %{count}) ..." + ip_lookup: + title: IP-cím keresése + hostname: Gépnév + location: Hely + location_not_found: (ismeretlen) + organisation: Szervezet + phone: Telefon + other_accounts: "További fiókok ezzel az IP-címmel:" + delete_other_accounts: "%{count} törlése" + username: "felhasználónév" + trust_level: "BSZ" + read_time: "olvasási idő" + topics_entered: "témákba lépett" + post_count: "# bejegyzés" + confirm_delete_other_accounts: "Biztosan törölni szeretnéd ezeket a fiókokat?" + powered_by: "ipinfo.io által" + user_fields: + none: "(válassz egy lehetőséget)" + user: + said: "{{username}}:" + profile: "Profil" + mute: "Némítás" + edit: "Beállítások szerkesztése" + download_archive: + button_text: "Összes letöltése" + confirm: "Biztosan le szeretnéd tölteni a bejegyzéseidet?" + success: "A letöltés elkezdődött. Értesítünk, amint befejeződött." + rate_limit_error: "A bejegyzések naponta csak egyszer töltetők le. Kérünk, próbáld újra holnap." + new_private_message: "Új Üzenet" + private_message: "Üzenet" + private_messages: "Üzenetek" + activity_stream: "Aktivitás" + preferences: "Beállítások" + expand_profile: "Kibővítés" + collapse_profile: "Összecsukás" + bookmarks: "Könyvjelzők" + bio: "Rólam" + invited_by: "Meghívta" + trust_level: "Bizalmi szint" + notifications: "Értesítések" + statistics: "Statisztikák" + desktop_notifications: + label: "Elő értesítések" + not_supported: "Az értesítések nem támogatottak ebben a böngészőben. Sajnáljuk." + perm_default: "Értesítések bekapcsolása" + perm_denied_btn: "Hozzáférés megtagadva." + perm_denied_expl: "Letiltottad a figyelmeztetéseket. Engedélyezd őket a böngésződben." + disable: "Értesítések kikapcsolása" + enable: "Értesítések bekapcsolása" + each_browser_note: "Megjegyzés: Minden böngészőben be kell állítanod ezt a beállítást, amit használsz." + consent_prompt: "Szeretnél élő értesítéseket kapni, amikor valaki visszaír a hozzászólásaidra?" + dismiss: 'Elvet' + dismiss_notifications: "Összes elvetése" + dismiss_notifications_tooltip: "Minden olvasatlan értesítés olvasottnak jelölése" + first_notification: "Az első értesítésed! Válaszd ki a kezdéshez." + disable_jump_reply: "Ne ugorj a bejegyzésemre a válaszom után" + dynamic_favicon: "Mutasd az új / frissített témák számát a böngésző ikonján" + theme_default_on_all_devices: "Ez a téma legyen az alapértelmezett minden eszközömön." + allow_private_messages: "Engedd a többi felhasználónak, hogy privát üzeneteket küldjenek nekem" + external_links_in_new_tab: "Minden külső hivatkozás új ablakban való megnyitása" + enable_quoting: "A kijelölt szöveg engedélyezése idézetként" + change: "megváltoztatás" + moderator: "{user}} egy moderátor" + admin: "{user}} egy adminisztrátor" + moderator_tooltip: "Ez a felhasználó egy moderátor" + admin_tooltip: "Ez a felhasználó egy adminisztrátor" + silenced_tooltip: "Ez a felhasználó némítva van" + suspended_notice: "Ez a felhasználó fel van függesztve eddig: {{date}}." + suspended_permanently: "Ez a felhasználó fel van függesztve." + suspended_reason: "Ok:" + github_profile: "Github" + email_activity_summary: "Aktivitás Összefoglaló" + mailing_list_mode: + label: "Levelezőlista mód" + enabled: "Levelezőlista mód bekapcsolása" + instructions: | + Ez a beállítás felülírja az aktivitás összefoglalót.
+ Némított témák és kategóriák nem lesznek benne ezekben az emailekben. + individual: "Küldjön e-mailt minden új hozzászólásról" + individual_no_echo: "Küldjön e-mailt minden új hozzászólásról kivéve a sajátokat" + many_per_day: "Küldjön e-mailt minden új hozzászólás után (naponta kb. {{dailyEmailEstimate}} db)" + few_per_day: "Küldjön e-mailt minden új hozzászólás után (naponta kb. 2 db)" + warning: "Levelezőlista üzemmód bekapcsolva. Az e-mail értesítések beállításai felülbírálva." + tag_settings: "Címkék" + watched_tags: "Figyelve" + watched_tags_instructions: "Automatikusan figyeltetni fogod az összes ezen a címkékkel ellátott témát. Értesítést fogsz kapni az összes új hozzászólásról és témáról, és az új hozzászólások száma megfog jelenni a téma mellett." + tracked_tags: "Követve" + tracked_tags_instructions: "Automatikusan követni fogsz minden ilyen címkéjű témakört. Az új hozzászólások száma megfog jelenni a téma mellett." + muted_tags: "Némítva" + muted_tags_instructions: "Egyáltalán nem fogsz kapni semmilyen értesítést ennek a kategóriának a témaköreiről és legújabbak között sem fog megjelenni." + watched_categories: "Figyelve" + watched_categories_instructions: "Automatikusan figyelni fogod ezeket a kategóriákat. Értesítést fogsz kapni minden új témáról és bejegyzésről, a témakörök neve mellett meg fog jelenni az új bejegyzések számlálója is." + tracked_categories: "Követve" + tracked_categories_instructions: "Automatikusan nyomon fog követni minden új témát ezekben a kategóriákban " + watched_first_post_categories: "Első bejegyzés megtekintése" + watched_first_post_categories_instructions: "Csak minden új témakör legelső hozzászólásáról fogsz értesítést kapni ezekben a kategóriákban." + watched_first_post_tags: "Első bejegyzés megtekintése" + muted_categories: "Lenémítva" + no_category_access: "Moderátorként kategóriákra van limitálva a hozzáférésed, mentés le van tiltva." + delete_account: "Fiókom törlése" + delete_account_confirm: "Biztosan végleg törölni akarod a fiókodat? Ez a művelet nem vonható vissza!" + deleted_yourself: "A fiókod törlése sikerült." + delete_yourself_not_allowed: "Kérlek kozlultálj egy staff taggal, ha azt szeretnéd, hogy a fiók törlésre kerüljön." + unread_message_count: "Üzenetek" + admin_delete: "Törlés" + users: "Felhasználók" + muted_users: "Lenémítva" + muted_users_instructions: "Minden értesítés letiltása ezektől a felhasználoktól" + muted_topics_link: "Némított témák mutatása" + watched_topics_link: "Figyelt témák mutatása" + tracked_topics_link: "Követett témák mutatása" + automatically_unpin_topics: "Automatikusan letűzi a témát amikor elérem az alját." + apps: "Alkalmazások" + revoke_access: "Hozzáférés visszavonása" + undo_revoke_access: "Hozzáférés visszaállítása" + api_approved: "Jóváhagyva:" + theme: "Stílus" + home: "Alap főoldal" + staff_counters: + flags_given: "hasznos jelölések" + flagged_posts: "megjelölt bejegyzések" + deleted_posts: "törölt bejegyzések" + suspensions: "felfüggesztések" + warnings_received: "figyelmeztetések" + messages: + all: "Mind" + inbox: "Bejövő" + sent: "Küldött" + archive: "Archívum" + groups: "Csoportjaim" + bulk_select: "Üzenetek kiválasztása" + move_to_inbox: "Áthelyezés a bejövő üzenetek közé" + move_to_archive: "Archívum" + failed_to_move: "Nem sikerült a kijelölt üzeneteket átmozgatni. (Valószínűleg nincs hálózat.)" + select_all: "Összes kijelölése" + tags: "Címkék" + preferences_nav: + account: "Fiók" + profile: "Profil" + emails: "Emailek" + notifications: "Értesítések" + categories: "Kategóriák" + tags: "Címkék" + interface: "Felület" + apps: "Alkalmazások" + change_password: + success: "(az e-mail elküldve)" + in_progress: "(az e-mail küldése folyamatban)" + error: "(hiba)" + action: "E-mail küldése új jelszó megadásáról" + set_password: "Jelszó megadása" + choose_new: "Új jelszó" + choose: "Új jelszó" + second_factor_backup: + regenerate: "Újragenerálás" + disable: "Kikapcsol" + enable: "Engedélyez" + manage: "Visszatérítő kódok kezelése" + copied_to_clipboard: "Vágólapra másolva" + copy_to_clipboard_error: "Hiba az adat Vágólapra másolása során" + second_factor: + title: "Két-faktoros hitelesítés" + disable: "Két-faktoros azonosítás letiltása" + enable: "Engedélyezd a két-faktoros azonosítást, hogy biztonságosabbá tedd a fiókod." + confirm_password_description: "Kérlek erősítsd meg a jelszavad a továbbhaladáshoz" + label: "Kód" + enable_description: | + Olvasd be ezt a QR-kódot egy támogatott alkalmazással (AndroidiOSWindows Phone) és add meg az azonosító kódodat. + disable_description: "Írd be az azonosító kódodat az alkalmazásból" + show_key_description: "Manuális beírás" + extended_description: | + A két-faktoros azonosítás plusz biztonságot a fiókhoz úgy, hogy egy extra tokent kér bejelentkezéskor a jelszó mellé. A tokenek generálhatóak Android, iOS vagy Windows Phone eszközökön. + oauth_enabled_warning: "A közösségi bejelentkezések nem lesznek elérhetőek a fét-faktoros azonosítás aktiválása után a fiókhoz." + change_about: + title: "Rólam megváltoztatása" + error: "Hiba történt az adat módosításakor." + change_username: + title: "Felhasználónév módosítása" + confirm: "Biztosan meg szeretnéd változtatni a felhasználónevedet?" + taken: "Sajnos ez a felhasználónév már foglalt." + invalid: "Ez a felhasználónév érvénytelen. Csak számokat és betűket kell tartalmaznia." + change_email: + title: "E-mail cím módosítása" + taken: "Sajnos az e-mail cím nem érhető el." + error: "Hiba történt az email-ed módosítása közben. Talán már használod?" + success: "Emailt küldtünk a megadott címre. Kérjük, kövesd az ott leírtakat." + success_staff: "Küldtünk egy üzenetet az email címedre. Kérlek, kövesd az utasításokat fiókod érvényesítéséhez!" + change_avatar: + title: "Profilkép megváltoztatása" + gravatar: "Gravatar, alapján" + gravatar_title: "Az avatar megváltoztatása a Gravatar weboldalon" + refresh_gravatar_title: "Gravatar frissítése" + letter_based: "A rendszer által adott profilkép" + uploaded_avatar: "Egyéni kép" + uploaded_avatar_empty: "Egyéni kép hozzáadása" + upload_title: "Saját kép feltöltése" + upload_picture: "Kép feltöltése" + image_is_not_a_square: "Figyelmeztetés: átméreteztük a képét; szélesség és hosszúság nem volt egyforma" + change_profile_background: + title: "Profil háttérképe" + instructions: "A profil háttérképek középre lesznek helyezve, egy alapértelmezett 850px-es szélességgel." + change_card_background: + title: "Felhasználói kártya háttérképe" + instructions: "A háttérképek középre lesznek helyezve, egy alapértelmezett 590px-es szélességgel." + email: + title: "E-mail" + primary: "Elsődleges Email" + secondary: "Másodlagos Emailek" + no_secondary: "Nincsenek másodlagos emailek" + instructions: "soha nem mutatjuk meg senkinek" + ok: " Jóváhagyás végett e-mailt fogunk küldeni" + invalid: "Kérünk adj meg egy érvényes e-mail címet" + authenticated: "Az email címedet {{provider}} azonosította" + frequency_immediately: "Azonnal e-mailt fogunk küldeni neked, ha nem olvastad el, amit neked küldtünk egy korábbi e-mailben." + associated_accounts: + title: "Társított fiókok" + connect: "Csatlakozás" + revoke: "Visszavonás" + not_connected: "(nincs csatlakoztatva)" + name: + title: "Név" + instructions: "a teljes neved (nem kötelező)" + instructions_required: "Teljes neved" + too_short: "A neved túl rövid" + ok: "Megfelel a neved." + username: + title: "Felhasználónév" + instructions: "egyedi, szóközök nélkül, rövid" + short_instructions: "A következő módon lehet téged megemlíteni: @{{username}}" + available: "A felhasználóneved elérhető" + not_available: "Nem elérhető. Esetleg {{suggestion}}?" + not_available_no_suggestion: "Nem elérhető" + too_short: "Túl rövid a felhasználóneved" + too_long: "Túl hosszú a felhasználóneved" + checking: "A felhasználónév elérhetőségének ellenőrzése..." + prefilled: "Az e-mail cím megfelel ennek a regisztrált felhasználónévnek" + locale: + title: "A felület nyelve" + instructions: "A felhasználói felület nyelve. A lap frissítése után fog módosulni." + default: "(alapértelmezett)" + any: "bármi" + password_confirmation: + title: "A jelszó ismét" + last_posted: "Utolsó hozzászólás" + last_emailed: "Utolsó email" + last_seen: "Utolsó látogatás" + created: "Tagság kezdete" + log_out: "Kijelentkezés" + location: "Hely" + website: "Weblap" + email_settings: "E-mail cím" + like_notification_frequency: + title: "Kedvelés esetén értesítsen" + always: "Mindig" + first_time: "Első alkalommal like-olt egy postot" + never: "Soha" + email_previous_replies: + unless_emailed: "anélkül, hogy előzőleg el lett volna küldve" + always: "mindig" + never: "soha" + email_digests: + title: "Ha nem látogatom az oldalt. küldjön kivonatos e-mailt az újdonságokról" + every_30_minutes: "minden 30 percben" + every_hour: "óránként" + daily: "naponta" + every_three_days: "minden három napban" + weekly: "hetente" + every_two_weeks: "kéthetente" + include_tl0_in_digests: "Új felhasználóktól származó tartalom mellékelése az emailekben" + email_direct: "Email küldése, ha valaki idéz, válaszol a bejegyzésemre, megemlíti a @felhasználónevem, vagy meghív egy témába" + email_private_messages: "Email küldése, ha valaki üzen nekem" + email_always: "Akkor is küldj e-mail értesítőt, ha az oldalon tartózkodom." + other_settings: "Egyéb" + categories_settings: "Kategóriák" + new_topic_duration: + label: "Témakörök friss-nek jelölése, amennyiben" + not_viewed: "Még nem láttam ezeket" + last_here: "A legutolsó bejelentkezésed óta lett létrehozva" + after_1_day: "Előző nap létrehozva." + after_2_days: "Az előző 2 napban létrezova" + after_1_week: "Előző héten létrehozva" + after_2_weeks: "Az előző 2 hétben létrehozva" + auto_track_topics: "Automatikusan nyomon követi a témát ahova belépek" + auto_track_options: + never: "soha" + immediately: "azonnal" + after_30_seconds: "30 másodperc elteltével" + after_1_minute: "1 perc elteltével" + after_2_minutes: "2 perc elteltével" + after_3_minutes: "3 perc elteltével" + after_4_minutes: "4 perc elteltével" + after_5_minutes: "5 perc elteltével" + after_10_minutes: "10 perc elteltével" + notification_level_when_replying: "Ha írok egy témába, állítsa be erre: " + invited: + search: "írd be a keresett meghívottak nevét…" + title: "Meghívók" + user: "Meghívott felhasználó" + sent: "Elküldve" + none: "Nincsenek meghívók." + redeemed: "Felhasznált meghívók" + redeemed_tab: "Felhasználva" + redeemed_tab_with_count: "Felhasználva ({{count}})" + redeemed_at: "Felhasznált" + pending: "Függőben levő meghívások" + pending_tab: "Várakozik" + pending_tab_with_count: "Várakozik ({{count}})" + topics_entered: "Megtekintett témakörök" + posts_read_count: "Elolvasott bejegyzések" + expired: "Ez a meghívó lejárt!" + rescind: "Törlés" + rescinded: "Meghívó törölve" + rescind_all: "Összes meghívó törlése" + rescinded_all: "Összes meghívó törölve!" + rescind_all_confirm: "Biztosan törölni szeretnél minden meghívót?" + reinvite: "Meghívó újraküldése" + reinvite_all: "Összes meghívó újraküldése" + reinvite_all_confirm: "Biztosan szeretnél újraküldeni minden meghívót?" + reinvited: "Meghívó újraküldve" + reinvited_all: "Összes meghívó újraküldve!" + time_read: "Olvasási idő" + days_visited: "Látogatott napok" + account_age_days: "Fiók kora napokban" + create: "Egy meghívó küldése" + generate_link: "Meghívó link másolása" + link_generated: "Meghívó link elkészült!" + valid_for: "A meghívó-link csak ehhez az e-mail címhez érvényes: %{email}" + bulk_invite: + text: "Csoportos meghívás fájlból" + success: "A file sikeresen feltöltve, értesítést kapsz, ha a folyamat készen van." + error: "A feltöltött filenak CSV formátumúnak kell lennie." + password: + title: "Jelszó" + too_short: "Túl rövid a jelszavad." + common: "Túl gyakori a jelszavad." + same_as_username: "A jelszavad megegyezik a felhasználóneveddel." + same_as_email: "A jelszavad megegyezik az email-címeddel." + ok: "Megfelel a jelszavad." + instructions: "legalább %{count} karakter" + summary: + title: "Összefoglaló" + stats: "Statisztikák" + time_read: "olvasási idő" + recent_time_read: "olvasási idő" + topic_count: + one: "téma" + other: "témák" + post_count: + one: "Bejegyzés létrehozva" + other: "Bejegyzések létrehozva" + likes_given: + one: "adott" + other: "adott" + likes_received: + one: "kapott" + other: "kapott" + days_visited: + one: "Látogatott nap" + other: "Látogatott napok" + topics_entered: + one: "Megtekintett témakör" + other: "Megtekintett témakörök" + posts_read: + one: "Elolvasott bejegyzés" + other: "Elolvasott bejegyzések" + bookmark_count: + one: "Könyvjelző" + other: "Könyvjelzők" + top_replies: "Top Válaszok" + no_replies: "Még nincs hozzászólás." + more_replies: "Több válasz" + top_topics: "Top Témák" + no_topics: "Nincsenek témák még." + more_topics: "Több téma" + top_badges: "Top jelvények" + no_badges: "Még nincsenek jelvények." + more_badges: "Több Matrica" + top_links: "Népszerű Hivatkozások" + no_links: "Nincsenek hivatkozások még." + most_liked_by: "Legtöbbet kedvelt" + most_liked_users: "Legtöbbet Lájkolt" + most_replied_to_users: "Legtöbbet válaszolt" + no_likes: "Nincsenek kedvelések még." + top_categories: "Top kategóriák" + topics: "Témák" + replies: "Válaszok" + ip_address: + title: "Legutóbbi IP" + registration_ip_address: + title: "Regisztrációkor használt IP" + avatar: + title: "Profil kép" + header_title: "profil, üzenetek, könyvjelzők és beállítások" + title: + title: "Cím" + none: "(egyik sem)" + filters: + all: "Mind" + stream: + posted_by: "Szerző:" + sent_by: "Szerző:" + private_message: "üzenet" + the_topic: "a témakör" + loading: "Töltés..." + errors: + prev_page: "amíg megpróbál betölteni" + reasons: + network: "Hálózati hiba" + server: "Szerveroldali hiba" + forbidden: "Hozzáférés megtagadva" + unknown: "Hiba" + not_found: "Oldal nem található" + desc: + network: "Kérünk ellenőrizd az internet kapcsolatodat!" + network_fixed: "Úgy néz ki, visszatért." + server: "Hibakód: {{status}}" + forbidden: "Nincs jogod megnézni ezt." + not_found: "Oops, az alkalmazás olyan URL-t próbált betölteni ami nem létezik." + unknown: "Valami félresikerült." + buttons: + back: "Visszalépés" + again: "Újrapróbál" + fixed: "Oldal betöltése" + close: "Bezárás" + assets_changed_confirm: "Az oldal frissítve lett. Frissíts a legújabb verzióért " + logout: "Kijelentkeztél." + refresh: "Frissítés" + read_only_mode: + enabled: "Az oldal \"csak olvasható\" módban van. Kérlek, folytasd tovább a böngészést, de a válaszolás, kedvelések és más tevékenységek egyelőre le vannak tiltva." + login_disabled: "A belépés le van tiltva, amíg az oldal \"csak olvasható\" módban van." + logout_disabled: "A kilépés le van tiltva, amíg az oldal \"csak olvasható\" módban van." + too_few_topics_and_posts_notice: "Kezdjük el a beszélgetést! Jelenleg %{currentTopics}/%{requiredTopics} téma és %{currentPosts}/%{requiredPosts} hozzászólás van. Az új látogatóknak kell valami, amihez hozzászólhatnak." + too_few_topics_notice: "Kezdjük el a beszélgetést! Jelenleg %{currentTopics}/%{requiredTopics} téma van. Az új látogatóknak kell valami, amihez hozzászólhatnak." + too_few_posts_notice: "Kezdjük el a beszélgetést! Jelenleg %{currentPosts}/%{requiredPosts} hozzászólás van. Az új látogatóknak kell valami, amihez hozzászólhatnak." + learn_more: "tovább..." + all_time: 'Összes' + all_time_desc: 'összes létrehozott téma' + year: 'év' + year_desc: 'az utóbbi 365 napban létrehozott témakörök' + month: 'hónap' + month_desc: 'az utóbbi 30 napban létrehozott témakörök' + week: 'hét' + week_desc: 'az utóbbi 7 napban létrehozott témakörök' + day: 'nap' + first_post: Első bejegyzés + mute: Elnémít + unmute: Némítás feloldása + last_post: Közzétett + time_read: Olvasás + time_read_recently: '%{time_read}mostanában' + time_read_tooltip: '%{time_read}olvasva eltöltött idő' + last_reply_lowercase: utolsó válasz + replies_lowercase: + one: válasz + other: válaszok + signup_cta: + sign_up: "Regisztráció" + hide_session: "Holnap emlékeztess" + hide_forever: "nem, köszönöm" + hidden_for_session: "Rendben, holnap emlékeztetni foglak. Bármikor használhatod a 'Belépés' gombot, hogy csinálj magadnak egy fiókot." + intro: "Üdv nálunk! :heart_eyes: Úgy tűnik, tetszik a beszélgetés, de még nem regisztráltál fiókot." + summary: + enabled_description: "A téma összefoglalását látod: a legérdekesebb bejegyzéseket a közösség határozta meg." + description: "Összesen {{count}} válasz." + enable: 'Téma összefoglalása' + disable: 'Összes bejegyzés mutatása' + deleted_filter: + enabled_description: "Ez a témakör törölt bejegyzéseket is tartalmaz, amik el lettek rejtve." + disabled_description: "Jelenleg a törölt megjegyzések is megjelennek." + enable: "Törölt bejegyzések elrejtése" + disable: "Törölt bejegyzések mutatása" + private_message_info: + title: "Üzenet" + invite: "Mások meghívása..." + leave_message: "Valóban el akarod hagyni a beszélgetést?" + remove_allowed_user: "Biztosan kitörlöd {{name}}-t ebből az üzenetből?" + remove_allowed_group: "Valóban el akarod távolítani {{name}}-t ebből az üzenetből?" + email: 'E-mail' + username: 'Felhasználónév' + last_seen: 'Látva' + created: 'Létrehozva' + created_lowercase: 'létrehozva' + trust_level: 'Bizalmi szint' + search_hint: 'felhasználónév, e-mail vagy IP-cím' + create_account: + title: "Új fiók létrehozása" + failed: "Valami félresikerült! Lehetséges hogy ez az e-mail cím már regisztrálva van. Próbáltad már a jelszóemlékeztetőt?" + forgot_password: + title: "Jelszó-visszaállítás" + action: "Elfelejtettem a jelszavamat" + invite: "Add meg a felhasználónevedet vagy az e-mail címedet és küldünk neked egy jelszó visszaállító e-mailt." + reset: "Jelszó visszaállítása" + complete_username: "Amennyiben létezik fiók a %{username} felhasználónévvel, hamarosan kapni fogsz egy levelet, amiben megtalálhatod a további szükséges lépéseket a jelszavad visszaállításához." + complete_email: "Amennyiben létezik fiók a %{email} e-mail, hamarosan kapni fogsz egy levelet, amiben megtalálhatod a további szükséges lépéseket a jelszavad visszaállításához." + complete_username_found: "Létezik fiók %{username} felhasználónévvel. Hamarosan kapni fogsz egy levelet, amiben megtalálhatod a további szükséges lépéseket a jelszavad visszaállításához." + complete_email_found: "Létezik fiók %{email} e-mail címmel. Hamarosan kapni fogsz egy levelet, amiben megtalálhatod a további szükséges lépéseket a jelszavad visszaállításához." + complete_username_not_found: "Nincs fiók regisztrálva a következő felhasználónévvel: %{username}" + complete_email_not_found: "Nincs fiók regisztrálva a következő e-mail címmel: %{email}" + button_ok: "Rendben" + button_help: "Segítség" + email_login: + link_label: "Küldj el egy bejelentkezési linket" + button_label: "Emaillal" + complete_username_not_found: "Nem található egyezés a felhasználónévvel%{username}" + login: + title: "Bejelentkezés" + username: "Felhasználó" + password: "Jelszó" + second_factor_title: "Kétlépcsős azonosítás" + second_factor_description: "Kérlek írd be az azonosítási kódodat az alkalmazásodból" + second_factor_backup: "Jelentkezz be a biztonsági kód használatával" + second_factor_backup_description: "Kérlek írd be valamelyik biztonsági kódodat" + second_factor: "Jelentkezz be Hitelesítő alkalmazás használatával" + email_placeholder: "e-mail vagy felhasználónév" + caps_lock_warning: "A Caps Lock be van kapcsolva" + error: "Ismeretlen hiba" + cookies_error: "Úgytűnik hogy a böngésződbe nincsenek engedélyezve a sütik. Lehet hogy nem bírsz bejelentkezni anélkül hogy engedélyeznéd őket." + rate_limit: "Kérlek várj a bejelentkezés megpróbálása előtt." + blank_username: "Kérlek add meg az e-mail címedet vagy a felhasználónevedet" + blank_username_or_password: "Kérünk add meg az e-mail címedet vagy a felhasználónevedet és a jelszavadat!" + reset_password: 'Jelszó visszaállítása' + logging_in: "Bejelentkezés folyamatban..." + or: "Vagy" + authenticating: "Azonosítás folyamatban..." + awaiting_approval: "A felhasználói fiókod még nincs jóváhagyva. A jóváhagyásról elektronikus levélben kapsz értesítést." + requires_invite: "Sajnáljuk, de ehhez a fórumhoz csak meghívott személyek férhetnek hozzá." + not_activated: "Még nem tudsz bejelentkezni, mert a felhasználói fiókod még nincs aktiválva. A fiókodat szíveskedj aktiválni a korábban a(z) {{sentTo}} e-mail címre küldött elektronikus levélben található instrukciók szerint. " + not_allowed_from_ip_address: "Nem jelentkezhetsz be erről az ip címről!" + admin_not_allowed_from_ip_address: "Nem léphetsz be erről az ip címről adminisztrátorként." + resend_activation_email: "Kattints ide az aktivációs link újboli kiküldéséhez!" + omniauth_disallow_totp: "Be van kapcsolva a kétlépcsős azonosítás a fiókodon. Kérlek jelentkezz be a jelszavaddal" + resend_title: "Aktiváló e-mail újraküldése" + change_email: "Változtasd meg az e-mail címedet" + provide_new_email: "Adj meg egy új címet és mi újraküldjük az aktivációs e-mailt" + submit_new_email: "Frissítsd az e-mail címedet" + sent_activation_email_again: "Küldtünk egy másik aktivációs emailt a {{currentEmail}} címedre. Néhány percen belül meg kell érkeznie, ha mégsem, kérjük, nézd meg a spam mappádat." + to_continue: "Kérlek jelentkezz be" + preferences: "Bejelentkezve kell lenned ahoz hogy megváltoztasd a felhasználói beállításokat" + forgot: "Nem emlékszem a fiók részleteire" + not_approved: "A felhasználót még nem aktiválták be. Értesítve leszel e-mailen keresztül hogyha bejelentkezhetsz" + google_oauth2: + name: "Google" + title: "a Google-lel" + message: "Azonosítás a Google-lel (ellenőrizd, hogy a felugró ablakok engedélyezve vannak)" + twitter: + name: "Twitter" + title: "a Twitter-rel" + message: "Azonosítás a Twitter-rel (ellenőrizd, hogy a felugró ablakok engedélyezve vannak)" + instagram: + name: "Instagram" + title: "az Instagram-mal" + message: "Azonosítás Instagrammal (győződj meg róla hogy a felugró ablakok nincsenek engedélyezve)" + facebook: + name: "Facebook" + title: "a Facebook-kal" + message: "Azonosítás a Facebook-kal (ellenőrizd, hogy a felugró ablakok engedélyezve vannak)" + yahoo: + name: "Yahoo" + title: "a Yahoo-val" + message: "Azonosítás a Yahoo-val (ellenőrizd, hogy a felugró ablakok engedélyezve vannak)" + github: + name: "GitHub" + title: "a GitHub-bal" + message: "Azonosítás a GitHub-bal (ellenőrizd, hogy a felugró ablakok engedélyezve vannak)" + invites: + accept_title: "Meghívás" + welcome_to: "Üdvözöllek a%{site_name}" + invited_by: "Meglettél hívva:" + your_email: "A fiók e-mail címed%{email}" + accept_invite: "Meghívás elfogadása" + success: "A fiók elkészült és mostmár be vagy jelentkezve" + name_label: "Név" + password_label: "Állíts be jelszót" + optional_description: "(opcionális)" + password_reset: + continue: "Tovább a%{site_name}" + emoji_set: + apple_international: "Apple/Nemzetközi" + google: "Google" + twitter: "Twitter" + emoji_one: "Emoji One" + win10: "Win10" + google_classic: "Google Classic" + facebook_messenger: "Facebook Messenger" + category_page_style: + categories_only: "Csak kategóriák szerint" + categories_with_featured_topics: "Kategóriák funkciók szerint" + categories_and_latest_topics: "Kategóriák és legutóbbi témák" + categories_and_top_topics: "Kategóriák és legfelső témák" + shortcut_modifier_key: + shift: 'Shift' + ctrl: 'Ctrl' + alt: 'Alt' + conditional_loading_section: + loading: Töltés... + select_kit: + default_header_text: Kiválasztás... + no_content: Nem található egyezés + filter_placeholder: Keresés... + create: "Létrehozás: '{{content}}'" + emoji_picker: + filter_placeholder: Emoyi keresése + people: Emberek + nature: Természet + food: Étel + activity: Aktivitás + travel: Utazás + objects: Tárgyak + celebration: Ünneplés + custom: Egyéni emojik + recent: Nemrég használt + default_tone: Nincs bőrszín + light_tone: Világos bőrszín + medium_light_tone: Közepesen világos bőrszín + medium_tone: Közepes bőrszín + medium_dark_tone: Közepesen sötét bőrszín + dark_tone: Sötét bőrszín + shared_drafts: + title: "Megosztott vázlatok" + notice: "Ez a téma azoknak látható akik látják a {{category}}kategóriát" + destination_category: "Cél kategória" + publish: "Közzétenni a megosztott vázlatot" + confirm_publish: "Biztosan közzé akarod tenni ezt a vázlatot?" + publishing: "Téma közzététele..." + composer: + emoji: "Emoji :)" + more_emoji: "több..." + options: "Beállítások" + whisper: "suttogás" + unlist: "nem listázott" + blockquote_text: "idézetblokk" + add_warning: "Ez egy hivatalos figyelmeztetés." + posting_not_on_topic: "Melyik témakörre szeretnél válaszolni?" + saving_draft_tip: "mentés..." + saved_draft_tip: "elmentve" + saved_local_draft_tip: "helyi mentés készült" + similar_topics: "A témaköröd hasonlít a..." + drafts_offline: "offline piszkozatok" + group_mentioned_limit: "Figyelem!Említetted {{group}}, azonban ennek a csoportnak több embere van mint amit az adminisztrátor által konfigurált említési határa a {{max}} felhasználóknak. Senki nem lesz értesítve." + group_mentioned: + one: "Megemlíteni a {{group}}, értesíteni fogsz 1 embert- biztos vagy benne?" + other: "Megemlíteni a {{group}}, értesíteni fogsz {{count}} embereket - biztos vagy benne?" + cannot_see_mention: + category: "Megemlítetted {{username}} de nem lesznek értesítve mivel nincsen hozzáférésük ehez a témához. Hozzá kell adnod őket egy csoporthoz hogy hozzáférjenek a témához" + private: "Említetted {{username}} de nem lesznek értesítve mivel képtelenek megnézni ezt a személyes üzenetet. Megkell hívnod őket ehez a személyes üzenethez" + error: + title_missing: "A címet kötelező megadni" + title_too_short: "A címnek legalább {{min}} karakter hosszúnak kell lennie" + title_too_long: "A cím nem lehet hosszabb, mint {{max}} katakter." + post_missing: "A bejegyzés nem lehet üres" + post_length: "A bejegyzésnek legalább {{min}} karakter hosszúnak kell lennie" + try_like: 'Próbáltad a gombot?' + category_missing: "Ki kéne választanod egy kategóriát" + tags_missing: "Kikell választanod legalább {{count}}  címkét" + save_edit: "Módosítások mentése" + reply_original: "Válasz az eredeti témakörre" + reply_here: "Válasz ide" + reply: "Válasz" + cancel: "Mégse" + create_topic: "Téma létrehozása" + create_pm: "Üzenet" + create_whisper: "Suttogás" + create_shared_draft: "Megosztott vázlat létrehozása" + edit_shared_draft: "Megosztott vázlat szerkesztése" + title: "Vagy nyomj Ctrl+Enter-t" + users_placeholder: "Felhasználó hozzáadása" + title_placeholder: "Mi lesz a témája ennek a beszélgetésnek, röviden?" + title_or_link_placeholder: "Adj címet vagy másolj idea egy linket" + edit_reason_placeholder: "miért szerkesztesz?" + show_edit_reason: "(szerkesztés okának hozzáadása)" + remove_featured_link: "Hivatkozás eltávolítása a témából." + reply_placeholder: "Ide írhatsz. A feltöltéshez húzz- vagy illessz be képet! A formázáshoz használhatsz Markdown-, BBCode- vagy HTML kódokat is." + reply_placeholder_no_images: "Ide írj. Használhatsz Markdown-t, BBCode-ot, vagy HTML-t a formázáshoz." + reply_placeholder_choose_category: "Először ki kell választanod a kategóriát hogy ide írhass" + view_new_post: "Nézd meg az új bejegyzésedet!" + saving: "Mentés" + saved: "Elmentve!" + saved_draft: "A vázlat közzététele folyatban van. Válaszd ki hogy folytasd" + uploading: "Feltöltés..." + show_preview: 'előnézet mutatása »' + hide_preview: '« előnézet elrejtése' + quote_post_title: "Teljes bejegyzés idézése" + bold_label: "Félkövér" + bold_title: "Félkövér" + bold_text: "félkövár szöveg" + italic_label: "Dőlt" + italic_title: "Kiemelt" + italic_text: "dőlt szöveg" + link_title: "Hiperhivatkozás" + link_description: "itt add meg a link leírását" + link_dialog_title: "Hiperhivatkozás beszúrása" + link_optional_text: "alternatív cím" + link_url_placeholder: "http://example.com" + quote_title: "Idézet" + quote_text: "Idézet" + code_title: "Előformázott szöveg" + code_text: "az előformázott szöveget 4 szóközzel beljebb kezdi" + paste_code_text: "Írd vagy másold be a kódot ide" + upload_title: "Feltöltés" + upload_description: "itt add meg a feltöltés leírását" + olist_title: "Számozott lista" + ulist_title: "Pontozott lista" + list_item: "Listaelem" + help: "Markdown szerkesztési segédlet" + modal_ok: "Rendben" + modal_cancel: "Mégse" + cant_send_pm: "Nemtudsz neki üzenetet küldeni %{username}" + yourself_confirm: + title: "Elfelejtettél címzettet hozzáadni?" + body: "Jelenleg ez az üzenet csak saját magadnak lett elküldve" + admin_options_title: "A témakör opcionális szervezői beállításai" + composer_actions: + reply: Válasz + draft: Vázlat + edit: Szerkesztés + reply_to_post: + label: "Válasz a posztra%{postNumber}által%{postUsername}" + desc: Válasz egy adott bejegyzésre + reply_as_new_topic: + label: Válasz egy csatolt témaként + desc: Egy új téma létrehozása ezzel a témával kapcsolatban + reply_as_private_message: + label: Új üzenet + desc: Személyes üzenet létrehozása + reply_to_topic: + label: Válasz a témára + create_topic: + label: "Új téma" + shared_draft: + label: "Megsztott vázlat" + desc: "Csak olyan témákat soroljon be amik csak a személyzet által láthatóak" + notifications: + tooltip: + regular: + one: "1 nemlátott értesítés" + other: "{{count}} nemlátott értesíések" + message: + one: "1 olvasatlan üzenet" + other: "{{count}} olvasatlan üzenet" + title: "értesítések @felhasználónév hivatkozásokról, a hozzászólásaidra adott válaszokról, üzenetekről stb." + none: "Az értesítések betöltése sikertelen." + empty: "Nincs értesítés." + more: "régebbi értesítések megtekintése" + total_flagged: "összes megjelölt bejegyzés" + mentioned: "{{username}}{{description}}" + group_mentioned: "{{username}} {{description}}" + quoted: "{{username}} {{description}}" + replied: "{{username}} {{description}}" + posted: "{{username}} {{description}}" + edited: "{{username}} {{description}}" + liked: "{{username}} {{description}}" + liked_2: "{{username}}, {{username2}} {{description}}" + liked_many: + one: "{{username}}, {{username2}} 1 más {{description}}" + other: "{{username}}, {{username2}} és {{count}} mások {{description}}" + private_message: "{{username}} {{description}}" + invited_to_private_message: "

{{username}} {{description}}" + invited_to_topic: "{{username}} {{description}}" + invitee_accepted: "{{username}} elfogadta a meghívásodat" + linked: "{{username}} {{description}}" + granted_badge: "Megszerzett '{{description}}'" + topic_reminder: "{{username}} {{description}}" + watching_first_post: "Új téma{{description}} " + group_message_summary: + one: "{{count}} Üzenet a {{group_name}} postaládában" + other: "{{count}} Üzenetek a {{group_name}} postaládában" + popup: + mentioned: '{{username}} megemlített itt: "{{topic}}" - {{site_title}}' + group_mentioned: '{{username}} megemlített téged "{{topic}}" - {{site_title}} témakörben' + quoted: '{{username}} idézett itt: "{{topic}}" - {{site_title}}' + replied: '{{username}} válaszolt neked itt: "{{topic}}" - {{site_title}}' + posted: '{{username}} hozzászólt itt: "{{topic}}" - {{site_title}}' + private_message: '{{username}} küldött egy személyes üzenetet itt: "{{topic}}" - {{site_title}}' + confirm_title: 'Értesítések bekapcsolva - %{site_title}' + upload_selector: + title: "Kép hozzáadása" + title_with_attachments: "Kép vagy file hozzáadása" + from_my_computer: "Saját gépről" + from_the_web: "Az internetről" + remote_tip: "kép linkje" + local_tip: "Kép kiválasztása a készülékről" + local_tip_with_attachments: "Képek vagy fájlok kiválasztása a készülékről {{authorized_extensions}}" + uploading: "Feltöltés" + select_file: "File kiválasztása." + image_link: "A csatolt képed oda fog mutatni" + default_image_alt_text: Kép + search: + sort_by: "Rendezés " + relevance: "Relevancia" + latest_post: "Utolsó bejegyzés" + latest_topic: "Legutóbbi téma" + most_viewed: "Legtöbbet Megtekintett" + most_liked: "Legtöbbet Lájkolt" + select_all: "Összes kijelölése" + clear_all: "Összes tisztítása" + too_short: "Keresési kifejezésed túl rövid." + title: "keresés témakörök, bejegyzések, felhasználók és kategóriák között" + full_page_title: "Témák vagy bejegyzések keresése" + no_results: "Nincs eredmény." + no_more_results: "Nincs több találat." + searching: "Keresés ..." + post_format: "#{{post_number}} általa: {{username}}" + start_new_topic: "Esetleg kezdj egy új témát?" + or_search_google: "Vagy helyette próbálj meg a Googlén keresni:" + search_google: "Helyette próbálj meg a Googlén keresni:" + search_google_button: "Google" + search_google_title: "Keresés az oldalon" + context: + user: "Keresés @{{username}} bejegyzései között" + topic: "Keresés ebben a témakörben" + private_messages: "Üzenetek keresése" + advanced: + title: Részletes kereső + posted_by: + label: "Szerző:" + in_category: + label: Kategorizált + with_tags: + label: Címke + filters: + likes: Kedveltem + watching: Figyelem + tracking: Követem + bookmarks: Könyvjelzőztem + seen: Olvastam + new_item: "új" + go_back: 'visszalépés' + not_logged_in_user: 'felhasználói oldal összesítéssel a jelenleg aktivitásokról és beállításokról' + current_user: 'a felhasználói oldalad meglátogatása' + topics: + new_messages_marker: "utoljára megtekintett" + bulk: + select_all: "Összes kijelölése" + delete: "Témakörök törlése" + dismiss: "Elvetés" + dismiss_read: "Olvasatlan üzenetek elvetése" + dismiss_button: "Elvetés..." + actions: "Csoportos műveletek" + change_category: "Kategória beállítása" + close_topics: "Témakörök lezárása" + archive_topics: "Témakörök archíválása" + notification_level: "Értesítések" + none: + unread: "Nincsenek olvasatlan témakörök." + new: "Nincsenek új témakörök." + read: "Még egy témakört sem olvastál el." + posted: "Még egy témakörhöz sem szóltál hozzá." + latest: "Szomorú, de nem állnak rendelkezésre friss témakörök." + hot: "Nem állnak rendelkezésre népszerű témakörök." + bookmarks: "Még nem adtál hozzá témakört a könyvjelzőidhez." + category: "Nincsenek témakörök a {{category}} kategóriában." + search: "Nincsenek keresési találatok." + bottom: + latest: "Nincs több friss téma." + hot: "Nincs több népszerű téma." + read: "Nincs több olvasott téma." + new: "Nincs több új témakör." + unread: "Nincs több olvasatlan témakör." + category: "Nincs több {{category}} téma." + bookmarks: "Nincs több témakör a könyvjelzők között." + search: "Nincs több keresési találat." + topic: + create: 'Új téma' + create_long: 'Új téma létrehozása' + private_message: 'Üzenj ' + archive_message: + title: 'Archívum' + move_to_inbox: + title: 'Áthelyezés a bejövő üzenetek közé' + edit_message: + title: 'Üzenet szerkesztése' + list: 'Témák' + new: 'új téma' + unread: 'olvasatlan' + new_topics: + one: '1 új téma' + other: '{{count}} új téma' + unread_topics: + one: '1 olvasatlan téma' + other: '{{count}} olvasatlan téma' + title: 'Témakör' + invalid_access: + title: "Privát témakör" + description: "Sajnáljuk, de nincs hozzáférésed ehhez a témához!" + login_required: "Be kell jelentkezned, hogy megtekinthesd ezt a témakört!" + server_error: + title: "Nem sikerült betölteni a témakört" + description: "Sajnos nem tudtuk betölteni a témát, valószínűleg kapcsolódási probléma miatt. Kérjük, próbáld újra. Ha a probléma továbbra is fennáll, értesíts minket." + not_found: + title: "Nem létező témakör" + description: "Sajnáljuk, de nem tudtuk megtalálni ezt a témakört. Talán egy moderátor kitörölte volna?" + total_unread_posts: + one: "1 hozzászólást nem olvastál a témában" + other: "{{count}} hozzászólást nem olvastál a témában" + unread_posts: + one: "1 nem olvasott bejegyzés van ebben a témában" + other: "{{count}} nem olvasott bejegyzés van ebben a témában" + new_posts: + one: "1 új bejegyzés van ebben a témában." + other: "{{count}} új bejegyzés van ebben a témában." + back_to_list: "Vissza a témakörök listájára" + options: "Témakör beállításai" + show_links: "linkek megjelenítése ebben a témakörben" + toggle_information: "témakör részleteinek megjelenítése vagy elrejtése" + read_more_in_category: "Szeretnél még többet olvasni? Nézz meg más témakat itt, {{catLink}} vagy itt: {{latestLink}}." + read_more: "Szeretnél mégtöbbet olvasni? {{catLink}} vagy {{latestLink}}." + read_more_MF: "{ UNREAD, plural, =0 {} one { is 1 unread } other { are # unread } } { NEW, plural, =0 {} one { {BOTH, select, true{and } false {is } other{}} 1 new topic} other { {BOTH, select, true{and } false {are } other{}} # new topics} } remaining, or {CATEGORY, select, true {browse other topics in {catLink}} false {{latestLink}} other {}}" + browse_all_categories: Böngéssz a kategóriák között + view_latest_topics: legutóbbi témakörök megtekintése + suggest_create_topic: "Miért nem hozol létre egy témakört?" + jump_reply_up: ugrás régebbi válaszhoz + jump_reply_down: ugrás újabb válaszhoz + deleted: "Ez a témakör ki lett törölve" + topic_status_update: + num_of_hours: "Órák száma:" + publish_to: "Közzététel itt:" + when: "Mikor:" + auto_update_input: + later_today: "A mai nap folyamán" + tomorrow: "Holnap" + later_this_week: "A hét folyamán" + this_weekend: "Hétvégén" + next_week: "Jövő héten" + two_weeks: "Két hét" + next_month: "Jövő hónapban" + three_months: "Három hónap" + six_months: "Hat hónap" + one_year: "Egy év" + forever: "Örökre" + auto_reopen: + title: "Téma automatikus megynitása" + auto_close: + title: "Téma automatikus lezárása" + error: "Érvényes dátumot kérek!" + auto_delete: + title: "Téma automatikus törlése" + reminder: + title: "Emlékeztető" + auto_close_title: 'Automatikus lezárási beállítások' + timeline: + back: "Vissza" + back_description: "Vissza az utolsó olvasatlan bejegyzéshez" + replies_short: "%{current} / %{total}" + progress: + title: téma állapota + go_top: "teteje" + go_bottom: "alja" + go: "ugrás" + jump_bottom: "Ugrás az utolsó bejegyzéshez" + jump_prompt: "ugrás..." + jump_prompt_of: "a %{count} bejegyzésből" + jump_prompt_long: "Hányadik bejegyzéshez szeretnél ugrani?" + jump_bottom_with_number: "ugrás a következő bejegyzéshez: %{post_number}" + jump_prompt_or: "vagy" + total: összes bejegyzés + current: jelenlegi bejegyzés + notifications: + title: módosítsd a témakörről érkező értesítések gyakoriságát + reasons: + mailing_list_mode: "Engedélyezted a hírlevél módot, ezért emailben fogsz értesítést kapni a válaszokról." + "3_10": 'Értesítést fogsz kapni, mert figyeled a téma egyik címkéjét.' + "3_6": 'Értesítést fogsz kapni, mert figyeled ezt a kategóriát.' + "3_5": 'Értesítést fogsz kapni, mert automatikusan figyelni kezdted ezt a témát.' + "3_2": 'Értesítést fogsz kapni, mert figyeled ezt a témát.' + "3_1": 'Értesítést fogsz kapni, mert te hoztad létre ezt a témát.' + "3": 'Értesítést fogsz kapni, mert figyeled ezt a témát.' + "1_2": 'Csak akkor leszel értesítve, ha valaki megemlíti a @nevedet vagy válaszol neked.' + "1": 'Csak akkor leszel értesítve, ha valaki megemlíti a @nevedet vagy válaszol neked.' + "0_7": 'Nem kapsz értesítést erről a kategóriáról.' + "0_2": 'Nem kapsz értesítést erről a témáról.' + "0": 'Nem kapsz értesítést erről a témáról.' + watching_pm: + title: "Figyelés" + description: "Értesítést kapsz minden erre az üzenetre érkező új válaszról és látni fogod az új válaszok számát." + watching: + title: "Figyelés" + description: "Értesítést kapsz minden új válaszról ebben a témakörben és látni fogod az új válaszok számát." + tracking_pm: + title: "Követés" + description: "Látni fogod az erre az üzenetre érkező válaszok számát. Értesítést fogsz kapni ha valaki említi a @nevedet vagy válaszol neked." + tracking: + title: "Követés" + description: "Látni fogod az ebbe a témakörbe érkező új hozzászólások számát. Értesítve leszel ha valaki megemlíti a @nevedet vagy válaszol neked." + regular: + title: "Normál" + description: "Csak akkor leszel értesítve, ha valaki megemlíti a @nevedet vagy válaszol neked." + regular_pm: + title: "Normál" + description: "Csak akkor leszel értesítve, ha valaki megemlíti a @nevedet vagy válaszol neked." + muted_pm: + title: "Némítás" + description: "Egyáltalán nem fogsz értesítést kapni erről az üzenetről." + muted: + title: "Némítás" + description: "Egyáltalán nem fogsz semmilyen értesítést sem kapni erről a témakörről és a legújabbak között sem nem fog szerepelni." + actions: + recover: "Témakör visszaállítása" + delete: "Témakör törlése" + open: "Témakör megnyitása" + close: "Témakör bezárása" + multi_select: "Bejegyzések Kiválasztása" + pin: "Témakör kiemelése..." + unpin: "Témakör kiemelésének megszüntetése..." + unarchive: "Témakör archiválásának megszüntetése" + archive: "Témakör archiválása" + invisible: "Listázás törlése" + visible: "Listázás" + reset_read: "Olvasási adatok visszaállítása" + feature: + pin: "Témakör kiemelése" + unpin: "Témakör kiemelésének megszüntetése" + pin_globally: "Témakör globális kiemelése" + make_banner: "Kiemelt Téma" + remove_banner: "Kiemelt Téma eltávolítása" + reply: + title: 'Válasz' + clear_pin: + title: "Kiemelés törlése" + help: "A téma kiemelésének törlése, ezután nem jelenik meg a témaköreid legelején" + share: + title: 'Megosztás' + help: 'a témakör hivatkozásának megosztása' + print: + title: 'Nyomtatás' + flag_topic: + title: 'Megjelölés' + success_message: 'Sikeresen megjelölted ezt a témát.' + feature_topic: + confirm_pin: "Jelenleg {{count}} kiemelt témaköröd van. Túl sok kiemelt téma megzavarhatja az új vagy névtelen felhasználókat. Biztosan kiemelsz egy újabb témát ebben a kategóriában?" + unpin: "Téma eltávolítása a {{categoryLink}} kategória elejéről." + inviting: "Meghívás..." + invite_private: + email_or_username: "A meghívott e-mail címe vagy felhasználóneve" + email_or_username_placeholder: "e-mail cím vagy felhasználónév" + action: "Meghívás" + error: "Sajnos hiba történt a felhasználó meghívásakor." + group_name: "csoport neve" + controls: "Témák kezelése" + invite_reply: + title: 'Meghívás' + username_placeholder: "felhasználónév" + action: 'Meghívó küldése' + help: 'hívj meg másokat ebbe a témakörbe emailben vagy értesítéssel' + sso_enabled: "Írd be annak a felhasználónevét, akit meg szeretnél hívni ebbe a témába." + to_topic_blank: "Írd be annak a felhasználónevét vagy email-címét, akit meg szeretnél hívni ebbe a témába." + to_username: "Írd be annak a felhasználónevét, akit meg szeretnél hívni. Értesítést fogunk neki küldeni, ennek a témának a linkjével." + email_placeholder: 'nev@pelda.hu' + login_reply: 'Jelentkezz be, hogy válaszolhass' + filters: + n_posts: + one: "1 bejegyzés" + other: "{{count}} bejegyzés" + cancel: "Szűrő törlése" + split_topic: + title: "Áthelyezés új témába" + action: "áthelyezés új témába" + topic_name: "Új témakör címe" + error: "Hiba lépett fel a bejegyzés új témakörbe való helyezése során!" + merge_topic: + title: "Áthelyezés létező témába" + action: "áthelyezés létező témába" + error: "Hiba történt a bejegyzések áthelyezésekor ebben a témába." + change_owner: + title: "A bejegyzések átruházása" + action: "tulajdonjog módosítása" + error: "Hiba történt a bejegyzések átruházásakor." + label: "A bejegyzések új tulajdonosa" + placeholder: "az új tulajdonos felhasználóneve" + multi_select: + select: 'kiválasztás' + selected: 'kiválasztva ({{count}})' + select_post: + label: 'Kiválasztás' + selected_post: + label: 'Kiválasztott' + delete: kiválasztottak törlése + cancel: kiválasztások visszavonása + select_all: mind kiválasztása + deselect_all: kijelölések törlése + description: + one: Kijelöltél 1 hozzászólást. + other: "Kijelöltél {{count}} hozzászólást." + post: + quote_reply: "Idézet" + edit_reason: "Ok:" + post_number: "bejegyzés {{number}}" + last_edited_on: "utoljára szerkesztve" + follow_quote: "ugrás az idézett bejegyzéshez" + show_full: "Teljes bejegyzés megtekintése" + show_hidden: 'Rejtett tartalom megtekintése.' + collapse: "Összeomlás" + expand_collapse: "kinyitás/bezárás" + unread: "Olvasatlan bejegyzés" + has_replies: + one: "{{count}} Válasz" + other: "{{count}} Válaszok" + has_likes: + one: "{{count}} Tetszik" + other: "{{count}} Tetszések" + has_likes_title_only_you: "kedvelted ezt a bejegyzést" + errors: + create: "Sajnáljuk, de a bejegyzésed létrehozása közben hiba lépett fel. Kérünk próbáld újra!" + edit: "Sajnáljuk, de a bejegyzésed szerkesztése közben hiba lépett fel. Kérünk próbáld újra!" + upload: "Sajnáljuk, de a fájl feltöltése közben hiba lépett fel. Kérünk próbáld újra!" + too_many_uploads: "Sajnáljuk, de egyszerre csak egy fájlt tölthetsz fel!" + image_upload_not_allowed_for_new_user: "Sajnáljuk, de az új felhasználók nem tölthetnek fel képeket!" + attachment_upload_not_allowed_for_new_user: "Sajnáljuk, de az új felhasználók nem tölthetnek fel csatolmányokat!" + attachment_download_requires_login: "Sajnáljuk, de be kell jelentkezned, hogy letölthess csatolmányokat!" + abandon: + no_value: "Nem, tartsd meg" + archetypes: + save: 'Mentési beállítások' + controls: + like: "bejegyzés kedvelése" + has_liked: "kedvelted ezt a bejegyzést" + undo_like: "kedvelés visszavonása" + edit: "bejegyzés szerkesztése" + edit_action: "Szerkesztés" + edit_anonymous: "Sajnáljuk, de be kell jelentkezned, hogy szerkeszthesd ezt a bejegyzést!" + delete: "bejegyzés törlése" + undelete: "bejegyzés visszaállítása" + share: "bejegyzés megosztása link-kel" + more: "Több" + wiki: "Wiki létrehozása" + unwiki: "Wiki eltávolítása" + convert_to_moderator: "Stáb szín hozzáadása" + revert_to_regular: "Stáb szín eltávolítása" + rebake: "HTML újjáépítése" + unhide: "Elrejtés visszavonása" + change_owner: "Tulajdonjog módosítása" + lock_post: "Bejegyzés zárolása" + unlock_post: "Bejegyzés zárolásának feloldása" + actions: + flag: 'Megjelölés' + undo: + off_topic: "Megjelölés visszavonása" + spam: "Megjelölés visszavonása" + inappropriate: "Megjelölés visszavonása" + bookmark: "Könyvjelző eltávolítása" + like: "Kedvelés visszavonása" + people: + notify_user: "üzenet küldése" + by_you: + off_topic: "Nem a témába tartozónak jelölve" + spam: "Spam-ként jelölted" + notify_user: "Üzenetet küldtél ennek a felhasználónak" + bookmark: "A bejegyzést hozzáadtad a könyvjelzőkhöz" + like: "Kedvelted ezt a bejegyzést" + by_you_and_others: + bookmark: + one: "Te és 1 másik felhasználó adta hozzá ezt a bejegyzést a könyvjelzőkhőz" + other: "Te és {{count}} másik felhasználó adta hozzá ezt a bejegyzést a könyvjelzőkhöz" + by_others: + bookmark: + one: "1 felhasználó adta hozzá ezt a bejegyzést a könyvjelzőkhöz" + other: "{{count}} felhasználó adta hozzá ezt a bejegyzést a könyvjelzőkhöz" + revisions: + controls: + edit_wiki: "Wiki szerkesztése" + edit_post: "Bejegyzés szerkesztése" + displays: + inline: + button: 'HTML' + side_by_side: + button: 'HTML' + side_by_side_markdown: + button: 'Raw' + raw_email: + displays: + raw: + button: 'Raw' + html_part: + button: 'HTML' + category: + none: '(nincs kategória)' + all: 'Minden kategória' + edit: 'szerkesztés' + edit_long: "Szerkesztés" + view: 'Témakörök megjelenítése a kategóriában' + general: 'Általános' + settings: 'Beállítások' + tags: "Címkék" + delete: 'Kategória törlése' + create: 'Új kategória' + save: 'Kategória mentése' + creation_error: Hiba lépett fel a kategória létrehozása során! + save_error: Hiba lépett fel a kategória mentése során! + name: "Kategória neve" + description: "Leírás" + logo: "Kategória képe" + background_image: "Kategória háttérképe" + badge_colors: "Jelvény színek" + background_color: "Háttér színe" + foreground_color: "Előtér színe" + name_placeholder: "Maximum egy vagy két szó" + color_placeholder: "Akármelyik web-es szín" + delete_confirm: "Biztosan törölni szeretnéd ezt a kategóriát?" + delete_error: "Hiba lépett fel a kategória törlése során!" + list: "Kategóriák listázása" + no_description: "Kérünk adj meg egy leírást ennek a kategóriának!" + change_in_category_topic: "Leírás szerkesztése" + already_used: 'Ezt a színt már egy másik kategória is használja.' + security: "Biztonság" + images: "Képek" + email_in: "Egyéni bejövő levelek email cím" + email_in_allow_strangers: "E-mail-ek elfogadása ismeretlen felhasználóktól, akik nem rendelkeznek fiókkal" + email_in_disabled: "Az új bejegyzések létrehozása e-mail-en keresztül ki van kapcsolva a Weblap Beállítások-ban. Hogy bekapcsold, " + email_in_disabled_click: 'engedélyezd az "e-mail be" beállítást.' + allow_badges_label: "Kitűzök elnyerésének engedélyezése ebben a kategóriában" + edit_permissions: "Jogok szerkesztése" + add_permission: "Jogok hozzáadása" + this_year: "ez az év" + default_position: "Alapértelmezett pozíció" + parent: "Szülő kategória" + notifications: + watching: + title: "Figyelés" + description: "Automatikusan figyelni fogsz minden témakört ezekben a kategóriákban. Értesítést kapsz az összes témakör minden új hozzászólásáról és látni fogod az új hozzászólások számát." + watching_first_post: + title: "Első hozzászólás figyelése" + description: "Csak minden új témakör legelső hozzászólásáról fogsz értesítést kapni ezekben a kategóriákban." + tracking: + title: "Követés" + description: "Automatikusan követni fogsz minden témakört ezekben a kategóriákban. Látni fogod az új hozzászólások számát és értesítve leszel ha valaki a megemlíti a @nevedet vagy válaszol neked." + regular: + title: "Normál" + description: "Csak akkor leszel értesítve, ha valaki megemlíti a @nevedet vagy válaszol neked." + muted: + title: "Némítás" + description: "Egyáltalán nem fogsz kapni semmilyen értesítést ennek a kategóriának a témaköreiről és legújabbak között sem fog szerepelni." + sort_options: + default: "alapértelmezett" + likes: "Kedvelések" + views: "Megtekintések" + posts: "Bejegyzések" + activity: "Aktivitás" + category: "Kategória" + created: "Létrehozva" + sort_ascending: 'Növekvő' + sort_descending: 'Csökkenő' + subcategory_list_styles: + rows: "Sorok" + flagging: + action: 'Bejegyzés megjelölése' + take_action: "Művelet megkezdése" + notify_action: 'Üzenet' + official_warning: 'Hivatalos figyelmeztetés' + delete_spammer: "Spammer törlése" + yes_delete_spammer: "Igen, Spammer törlése" + ip_address_missing: "(ismeretlen)" + hidden_email_address: "(elrejtett)" + cant: "Sajnáljuk, most nem jelölheted meg ezt a bejegyzést." + formatted_name: + off_topic: "Ez nem tartozik a témakörbe" + spam: "Ez szemét" + custom_placeholder_notify_user: "Légy célra törő, légy konstruktív és mindig legyél kedves másokhoz." + flagging_topic: + action: "Téma jelölése" + notify_action: "Üzenet" + topic_map: + participants_title: "Gyakori Szerzők" + links_title: "Népszerű Hivatkozások" + clicks: + one: "1 kattintás" + other: "%{count} kattintás" + topic_statuses: + warning: + help: "Ez egy hivatalos figyelmeztetés." + bookmarked: + help: "A témakört hozzáadtad a könyvjelzőkhöz" + locked: + help: "A témakör le van zárva; nem lehet válaszolni benne" + archived: + help: "Ez a témakör jelenleg archiválva van; be lett fagyasztva, szóval nem lehet megváltoztatni semmilyen módon." + locked_and_archived: + help: "Ez egy lezárt és archívált témakör ezért nem lehet bele írni vagy módosítani rajta." + unpinned: + title: "Nincs kiemelve" + help: "Ez a téma nincs kiemelve neked ezért a szokásos helyén lesz látható." + pinned_globally: + title: "Globálisan kiemelve" + help: "Ez egy globálisan kiemelt témakör ezért a legújabbak és a kategóriája elején lesz látható." + pinned: + title: "Kiemelt" + help: "Ez egy általad kiemelt témakör ezért a kategóriája elején lesz látható." + invisible: + help: "Ez egy nem listázandó témakör, ezért nem fog megjelenni a témakörök listájában és csak közvetlen hivatkozással érhető el." + posts: "Bejegyzések:" + posts_long: "{{number}} darab bejegyzés van a témában" + original_post: "Eredeti Bejegyzés" + views: "Megtekintések" + views_lowercase: + one: "megtekintés" + other: "megtekintés" + replies: "Válaszok" + activity: "Aktivitás" + likes: "Kedvelések" + likes_lowercase: + one: "kedvelés" + other: "kedvelés" + likes_long: "{{number}} darab kedvelés van a témában" + users: "Felhasználók" + users_lowercase: + one: "felhasználó" + other: "felhasználók" + category_title: "Kategória" + history: "Előzmények" + changed_by: "szerző {{author}}" + raw_email: + title: "Bejövő email" + not_available: "Nem elérhető!" + categories_list: "Kategóriák listája" + filters: + with_topics: "%{filter} témák" + latest: + title: "Legújabb" + help: "a legfrissebb bejegyzések témakörei" + hot: + title: "Népszerű" + help: "a legnépszerűbb témakörök válogatása" + read: + title: "Olvasott" + help: "olvasott témakörök, az olvasás sorrendjének megfelelően" + search: + title: "Keresés" + help: "keresés minden témában" + categories: + title: "Kategóriák" + title_in: "Kategória - {{categoryName}}" + help: "minden témakörök, kategóriákba csoportosítva" + unread: + title: "Olvasatlan" + title_with_count: + one: "Olvasatlan (1)" + other: "Olvasatlan ({{count}})" + help: "az általad figyelt vagy követett témák, melyekben olvasatlan bejegyzések vannak." + lower_title_with_count: + one: "1 olvasatlan" + other: "{{count}} olvasatlan" + new: + lower_title_with_count: + one: "1 új" + other: "{{count}} új" + lower_title: "új" + title: "Új" + title_with_count: + one: "Új (1)" + other: "Új ({{count}})" + help: "az elmúlt napokban létrehozott témakörök" + posted: + title: "Saját bejegyzéseim" + help: "témakörök, amikhez már hozzászóltál" + bookmarks: + title: "Könyvjelzők" + help: "Témakörök, amiket könyvjelzővel láttál el" + category: + title: "{{categoryName}}" + help: "legfrissebb témakörök a következő kategóriában: {{categoryName}}" + top: + title: "Top" + help: "a legaktívabb témakörök az elmúlt évben, hónapban, hétben vagy napban" + all: + title: "Mindig" + yearly: + title: "Éves" + monthly: + title: "Havi" + weekly: + title: "Heti" + daily: + title: "Napi" + all_time: "Mindig" + this_year: "év" + this_quarter: "Negyed" + this_month: "hónap" + this_week: "hét" + today: "Ma" + browser_update: 'Sajnos a böngésződ túl régi, hogy működjön ezzel az oldallal. Kérünk frissítsd azt!' + permission_types: + full: "Létrehozás / Válaszolás / Megtekintés" + create_post: "Válaszolás / Megtekintés" + readonly: "Megtekintés" + lightbox: + download: "letöltés" + keyboard_shortcuts_help: + title: 'Billentyűkombinációk' + jump_to: + home: 'g, h Home' + latest: 'g, l Legutóbbi' + new: 'g, n Új' + unread: 'g, u Olvasatlan' + categories: 'g, c Kategóriák' + bookmarks: 'g, b Könyvjelzők' + profile: 'g, p Profil' + messages: 'g, m Üzenetek' + drafts: 'g, d Vázlatok' + navigation: + title: 'Navigáció' + back: 'u Vissza' + application: + title: 'Alkalmazás' + create: 'c Új téma létrehozása' + actions: + title: 'Műveletek' + share_post: 's Bejegyzés megosztása' + badges: + title: Jelvények + badge_grouping: + community: + name: Közösség + trust_level: + name: Bizalmi szint + other: + name: Egyéb + google_search: | +

Keresés a Google-lel

+

+

+

+ tagging: + all_tags: "Összes címke" + other_tags: "Egyéb címkék" + selector_all_tags: "összes címke" + selector_no_tags: "nincs címke" + tags: "Címkék" + choose_for_topic: "Megadható címke" + delete_tag: "Címke törlése" + rename_tag: "Címke átnevezése" + sort_by: "Rendezés" + sort_by_name: "név" + notifications: + watching: + title: "Figyelés" + watching_first_post: + title: "Első hozzászólások figyelése" + tracking: + title: "Követés" + groups: + new: "Új csoport" + parent_tag_placeholder: "Opcionális" + save: "Mentés" + delete: "Törlés" + admin_js: + type_to_filter: "Írj ide a szűréshez.." + admin: + title: 'Discourse Admin' + moderator: 'Moderátor' + dashboard: + title: "Vezérlőpult" + last_updated: "Vezérlőpult utoljára frissítve:" + version: "Verzió" + up_to_date: "A rendszer naprakész!" + critical_available: "Egy fontos frissítés érhető el!" + updates_available: "Frissítések érhetőek el!" + please_upgrade: "Kérünk frissíts!" + no_check_performed: "Nem volt ellenőrizve hogy van-e új frissítés. Győződjön meg arról hogy a sidekiq fut." + stale_data: "Rég nem volt ellenőrizve hogy van-e új frissítés. Győződjön meg arról hogy a sidekiq fut." + version_check_pending: "Úgy nézik nem rég frissítettél. Fantasztikus!" + installed_version: "Telepítve" + latest_version: "Legfrissebb" + problems_found: "Találtunk néhány problémát a Discourse installációdban:" + last_checked: "Utoljára ellenőrizve: " + refresh_problems: "Újratöltés" + no_problems: "Nem találtunk semmilyen problémát." + moderators: 'Moderátorok: ' + admins: 'Adminok: ' + suspended: 'Felfüggesztettek: ' + private_messages_title: "Üzenetek" + mobile_title: "Mobiltelefon" + space_free: "{{size}} szabad" + uploads: "Feltöltések" + backups: "Biztonsági mentések" + traffic_short: "Forgalom" + show_traffic_report: "Részletes forgalom jelentés megtekintése" + reports: + today: "Ma" + yesterday: "Tegnap" + all_time: "Mindig" + 7_days_ago: "7 nappal ezelött" + 30_days_ago: "30 nappal ezelött" + all: "Mind" + view_table: "asztal" + refresh_report: "Jelentés frissítése" + start_date: "Kezdő dátum" + end_date: "Vége dátum" + groups: "Összes csoport" + commits: + latest_changes: "Legutóbbi változtatások: kérünk frissíts gyakran!" + by: "általa: " + flags: + title: "Jelölések" + agree: "Elfogadás" + delete: "Törlés" + delete_title: "A megjelölt bejegyzés törlése." + delete_post_defer_flag_title: "Bejegyzés törlése; fő bejegyzés esetén témakör törlése" + delete_post_agree_flag_title: "Bejegyzés törlése; fő bejegyzés esetén témakör törlése" + delete_flag_modal_title: "Törlés és..." + delete_spammer: "Spammer törlése" + delete_spammer_title: "Felhasználó törlése, valamennyi témakörével és bejegyzésével együtt." + disagree_flag_unhide_post: "Elutasítás (bejegyzés mutatása)" + disagree_flag_unhide_post_title: "A bejegyzés összes megjelölésének törlése és bejegyzés újra láthatóvá tétele" + disagree_flag: "Elutasítás" + disagree_flag_title: "Megjelölés törlése; érvénytelen vagy pontatlan" + clear_topic_flags: "Kész" + more: "(további válaszok...)" + dispositions: + agreed: "elfogadva" + disagreed: "elutasítva" + flagged_by: "Megjelölte" + system: "Rendszer" + error: "Valami félresikerült" + reply_message: "Válaszolás" + topic_flagged: "Ez a téma meg lett jelölve." + visit_topic: "Témakör meglátogatása a művelet végrehajtásához" + was_edited: "A bejegyzést szerkesztették az első megjelölés után" + previous_flags_count: "Ezt a bejegyzést {{count}} alkalommal jelölték meg. " + details: "részletek" + flagged_topics: + users: "Felhasználók" + short_names: + spam: "spam" + groups: + new: + title: "Új csoport" + manage: + interaction: + email: Email + primary: "Elsődleges csoport" + no_primary: "(nincs elsődleges csoport)" + title: "Csoportok" + edit: "Csoportok szerkesztése" + refresh: "Frissítés" + group_members: "A csoport tagjai" + delete: "Törlés" + delete_confirm: "Törlöd a csoportot?" + delete_failed: "A csoport törlése sikertelen. Ha ez egy automatikusan létrehozott csoport, akkor nem törölhető." + add: "Új" + custom: "Egyéni" + automatic: "Automatikus" + group_owners: tulajdonosok + add_owners: Tulajdonosok hozzáadása + api: + generate_master: "Mester API-kulcs létrehozása" + none: "Jelenleg nincsenek elérhető API kulcsok." + user: "Felhasználó" + title: "API" + key: "API kulcs" + generate: "Generálás" + regenerate: "Regenerálás" + revoke: "Visszavonás" + confirm_regen: "Biztosan ki szeretnéd cserélni ezt az API kulcsot egy újra?" + confirm_revoke: "Biztosan vissza szeretnéd vonni ezt a kulcsot?" + info_html: "Az API kulcsod lehetővé fogja tenni számodra, hogy JSON hívásokkal létrehozz és frissíts témaköröket." + all_users: "Minden felhasználó" + web_hooks: + active: "Aktív" + user_event: + name: "Felhasználói esemény" + details: "Amikor egy felhasználó bejelentkezik, kijelentkezik, jóváhagyásra kerül vagy módosul." + group_event: + name: "Csoport esemény" + details: "Amikor egy csoport létrejön, módosul vagy megsemmisül." + category_event: + name: "Kategória esemény" + details: "Amikor egy kategória létrejön, módosul vagy megsemmisül." + tag_event: + name: "Címke esemény" + details: "Amikor egy címke létrejön, módosul vagy megsemmisül." + events: + headers: "Fejlécek" + body: "Törzs" + ping: "Ping" + event_id: "ID" + actions: "Műveletek" + plugins: + title: "Pluginok" + installed: "Telepített pluginek" + name: "Név" + none_installed: "Egy plugin sincs telepítve." + version: "Verzió" + enabled: "Engedélyezed?" + is_enabled: "Y" + not_enabled: "N" + change_settings: "Beállítások változtatása" + change_settings_short: "Beállítások" + howto: "Hogyan lehet plugin-eket telepíteni?" + backups: + title: "Biztonsági mentések" + menu: + backups: "Biztonsági mentések" + logs: "Naplók" + none: "Nincsenek elérhető biztonsági mentések." + logs: + none: "Még nincsenek naplók..." + columns: + filename: "Fájlnév" + size: "Méret" + upload: + label: "Feltöltés" + uploading: "Feltültés..." + operations: + is_running: "Egy folyamat jelenleg fut már." + cancel: + label: "Mégse" + title: "Művelet visszavonása" + confirm: "Biztosan meg szeretnéd szakítani a jelenlegi folyamatot?" + backup: + label: "Biztonsági mentés" + title: "Mentés készítése" + confirm: "Új biztonsági mentést kívánsz indítani?" + download: + label: "Letöltés" + destroy: + title: "Biztonsági mentés törlése" + confirm: "Biztosan törölni szeretnéd ezt a biztonsági mentést?" + restore: + is_disabled: "A visszaállítás le van tiltva az oldal beállításaiban." + label: "Visszaállítás" + title: "Biztonsági mentés visszaállítása" + rollback: + label: "Visszavonás" + title: "Adatbázis visszaállítása az előző működő állapotba" + export_csv: + success: "Az exportálás megkezdődött, értesítést kapsz, ha a folyamat készen van." + failed: "Az exportálás sikertelen. Kérlek ellenőrizd a naplókat!" + button_text: "Exportálás" + button_title: + user: "Teljes felhasználói lista exportálása CSV formátumban." + staff_action: "Személyzeti tevékenységnapló exportálása CSV formátumban." + export_json: + button_text: "Exportálás" + invite: + button_text: "Meghívók küldése" + button_title: "Meghívók küldése" + customize: + title: "Személyre szabás" + long_title: "Oldal testreszabása" + preview: "előnézet" + explain_preview: "Az oldal megtekintése ezzel a témával" + save: "Mentés" + new: "Új" + new_style: "Új stílus" + import: "Importálás" + delete: "Törlés" + delete_confirm: "Töröljük ezt a témát?" + color: "Szín" + opacity: "Áttetszőség" + copy: "Másolás" + email_templates: + subject: "Tárgy" + body: "Test" + theme: + import_theme: "Téma importálása" + title: "Témák" + edit: "Szerkesztés" + desktop: "Desktop" + mobile: "Mobil" + settings: "Beállítások" + preview: "Előnézet" + is_default: "Ez a téma alapértelmezetten van bekapcsolva." + user_selectable: "A téma a felhasználók által választható" + color_scheme_select: "Válassz színeket a témához" + theme_components: "Téma elemek" + uploads: "Feltöltések" + upload: "Feltöltés" + child_themes_check: "A téma más témákat is tartalmaz" + css_html: "Egyedi CSS/HTML" + edit_css_html: "CSS/HTML szerkesztése" + delete_upload_confirm: "Töröljük ezt a feltöltést? (A témához tartozó CSS működésképtelenné válhat!)" + import_file_tip: "témát tartalmazó .dcstyle.json fájl" + license: "Licenc" + check_for_updates: "Frissítések keresése" + updating: "Frissítés..." + up_to_date: "A téma naprakész, utoljára ellenőrizve:" + add: "Hozzáadás" + theme_settings: "Téma beállítások" + no_settings: "Ez a téma nem rendelkezik beállításokkal." + scss: + text: "CSS" + header: + text: "Fejléc" + footer: + text: "Lábléc" + head_tag: + text: "" + body_tag: + text: "" + yaml: + text: "YAML" + title: "Téma beállításainak megadása YAML formátumban" + colors: + title: "Színek" + long_title: "Szín sémák" + about: "Módosítsd a témáid által használt színeket. Kezdésként készíts egy új színösszeállítást." + new_name: "Új szín séma" + copy_name_prefix: "másolata" + delete_confirm: "Törlöd ezt a szín sémát?" + undo: "Visszavonás" + revert: "Visszaállítás" + primary: + name: 'Elsődleges' + description: 'valamenyi szövegek,ikonok és szegélyek' + secondary: + name: 'Másodlagos' + tertiary: + name: 'harmadlagos' + quaternary: + description: "Navigációs linkek" + header_background: + name: "Fejléc háttere" + description: "Az oldal fejlécének háttérszíne." + header_primary: + description: "Az oldal fejlécében lévő szöveg és ikonok." + highlight: + name: 'kiemelés' + danger: + name: 'veszély' + success: + name: 'siker' + love: + name: 'szerelem' + description: "A kedvelés gomb színe." + email: + title: "Emailek" + settings: "Beállítások" + templates: "Sablonok" + sending_test: "Teszt Email küldése..." + error: "HIBA - %{server_error}" + sent: "Elküldve" + skipped: "Átlépve" + sent_at: "Elküldve" + time: "Idő" + user: "Felhasználó" + email_type: "E-mail tips" + to_address: "Címzett" + send_test: "Teszt Email Küldése" + sent_test: "elküldve!" + refresh: "Frissítés" + send_digest: "Küldés" + format: "Formátum" + html: "html" + text: "szöveges" + last_seen_user: "Legutóbb látott felhasználó:" + skipped_reason: "Ok kihagyása" + incoming_emails: + from_address: "From" + to_addresses: "To" + cc_addresses: "Cc" + subject: "Tárgy" + error: "Hiba" + modal: + error: "Hiba" + headers: "Fejlécek" + subject: "Téma" + body: "Test" + filters: + from_placeholder: "from@example.com" + to_placeholder: "to@example.com" + cc_placeholder: "cc@example.com" + subject_placeholder: "Tárgy..." + error_placeholder: "Hiba" + logs: + none: "Nem találhatóak naplók." + filters: + title: "Szűrő" + user_placeholder: "felhasználónév" + address_placeholder: "name@example.com" + logs: + title: "Naplók" + action: "Művelet" + created_at: "Létrehozva" + ip_address: "IP" + topic_id: "Témakör azonosító" + post_id: "Bejegyzés azonosító" + category_id: "Kategória ID" + delete: 'Törlés' + edit: 'Szerkesztés' + save: 'Mentés' + screened_actions: + block: "letilt" + do_nothing: "ne csináljon semmit" + staff_actions: + title: "Szervezői műveletek" + clear_filters: "Mind mutatása" + staff_user: "Szervező felhasználó" + target_user: "Célzott felhasználó" + subject: "Téma" + when: "Mikor" + context: "Kontextus" + details: "Részletek" + previous_value: "Előző" + new_value: "Új" + show: "Mutat" + modal_title: "Részletek" + actions: + delete_user: "felhasználó törlése" + change_trust_level: "bizalmi szint megváltoztatása" + change_username: "Felhasználónév módosítása" + change_site_setting: "oldal beállítások változtatása" + change_theme: "téma megváltoztatása" + delete_theme: "téma törlése" + change_site_text: "oldal szövegének változtatása" + suspend_user: "felhasználó felfüggesztése" + unsuspend_user: "felhasználó felfüggesztésének feloldása" + check_email: "Email ellenérzése" + delete_topic: "témakör törlése" + delete_post: "bejegyzés törlése" + post_approved: "bejegyzés jóváhagyva" + screened_emails: + email: "E-mail cím" + actions: + allow: "Engedélyez" + screened_urls: + url: "URL" + domain: "Domain" + screened_ips: + actions: + block: "Letilt" + do_nothing: "Engedélyez" + form: + label: "Új:" + ip_address: "ip cím" + add: "Új" + filter: "Keresés" + search_logs: + searches: "Keresések" + unique: "Egyedi" + types: + header: "Fejléc" + full_page: "Teljes oldal" + logster: + title: "Hiba-naplók" + watched_words: + search: "keresés" + form: + label: 'Új szó:' + placeholder_regexp: "reguláris kifejezés" + add: 'Hozzáadás' + success: 'Siker' + exists: 'Már létezik' + upload: "Feltöltés" + impersonate: + not_found: "A felhasználó nem található." + users: + title: 'Felhasználók' + create: 'Admin felhasználó hozzáadása' + not_found: "Sajnos ez a felhasználónév nem létezik." + id_not_found: "Sajnos ez a felhasználó id nem található a rendszerben." + active: "Aktív" + show_emails: "E-mailek mutatása" + nav: + new: "Új" + active: "Aktív" + pending: "Folyamatban" + staff: 'Személyzet' + suspended: 'Felfüggesztett' + suspect: 'Meggyanúsítás' + approved: "Jóváhagyott?" + titles: + active: 'Aktív felhasználók' + new: 'Új felhasználók' + staff: "Személyzet" + admins: 'Adminisztrátorok' + moderators: 'Moderátorok' + suspended: 'Felfüggesztett felhasználok' + suspect: 'Megfigyelt felhasználok' + not_verified: "Nincs megerősítve" + check_email: + title: "A felhasználó email címének megjelenítése" + text: "Mutat" + user: + suspend_reason: "oka" + suspended_by: "Felfüggesztette" + delete_all_posts: "Összes hozzászólás törlése" + moderator: "Moderátor?" + admin: "Adminisztrátor?" + suspended: "Felfüggesztett?" + show_admin_profile: "Adminisztrátor" + show_public_profile: "Publikus profil megjelenítése" + ip_lookup: "IP-cím keresés" + log_out: "Kijelentkezés" + revoke_admin: 'Admin jog visszavonása' + grant_admin: 'Admin jog adása' + revoke_moderation: 'Moderáció visszavonása' + unsuspend: 'Felfüggesztés feloldása' + suspend: 'Felfüggesztett' + permissions: Jogok + activity: Aktivitás + last_100_days: 'az elmúlt 100 napban' + private_topics_count: Privát témák + posts_read_count: Elolvasott bejegyzések + post_count: Létrehozott bejegyzések + topics_entered: Megtekintett témák + approve: 'Jóváhagy' + approved_by: "jóváhagyta:" + approve_success: "A felhasználó jóváhagyásra került és e-mailt küldtünk az aktiválási útmutatóval." + time_read: "Olvasási idő" + delete: "Felhasználó törlése" + delete_dont_block: "Csak törlés" + activate: "Fiók aktiválása" + reset_bounce_score: + label: "Alaphelyzet" + suspend_modal_title: "Felhasználó felfüggesztése" + tl3_requirements: + value_heading: "Érték" + visits: "Látogatások" + days: "nap" + sso: + title: "Single Sign On" + external_id: "Külső ID" + external_username: "Felhasználónév" + external_name: "Név" + external_email: "Email" + external_avatar_url: "Profilkép URL-je" + user_fields: + title: "Felhasználói mezők" + untitled: "Címtelen" + name: "Mező neve" + type: "Mező típusa" + description: "Mező leírása" + save: "Mentés" + edit: "Szerkesztés" + delete: "Törlés" + cancel: "Mégse" + delete_confirm: "Biztosan törölni szeretnéd a felhasználói mezőt?" + options: "Beállítások" + required: + enabled: "kötelező" + disabled: "nem kötelező" + editable: + enabled: "szerkeszthető" + disabled: "nem szerkeszthető" + field_types: + text: 'Szöveges mező' + confirm: 'Megerősítés' + site_text: + search: "A módosítani kívánt szöveg keresése" + title: 'Szöveges tartalom' + edit: 'szerkesztés' + revert: "Változtatások visszavonása" + revert_confirm: "Biztosan vissza akarod vonni a változtatásaidat?" + go_back: "Vissza a kereséshez" + recommended: "Azt tanácsoljuk, hogy módosítsd a következő szöveget az igényeidnek megfelelően:" + show_overriden: 'Csak a felülírtak megjelenítése' + settings: + show_overriden: 'Csak a felülírtak megjelenítése' + reset: 'alaphelyzet' + none: 'egy sem' + site_settings: + title: 'Beállítások' + no_results: "Nincs találat." + clear_filter: "Törlés" + add_url: "URL hozzáadása" + value_list: + default_none: "Gépelj a szűréshez vagy létrehozáshoz..." + no_choices_none: "Gépelj a létrehozáshoz..." + uploaded_image_list: + label: "Lista szerkesztése" + empty: "Még nincs hozzárendelt kép. Kérlek, tölts fel egyet." + upload: + label: "Feltöltés" + title: "Kép(ek) feltöltése" + selectable_avatars: + title: "A felhasználó által választható avatarok listája" + categories: + all_results: 'Mind' + required: 'Kötelező' + basic: 'Alapvető beállítások' + users: 'Felhasználók' + email: 'Email' + files: 'Fájlok' + trust: 'Bizalmi szintek' + security: 'Biztonság' + seo: 'SEO' + spam: 'Spam' + developer: 'Fejlesztő' + api: 'API' + user_api: 'Felhasználói API' + uncategorized: 'Egyéb' + backups: "Mentések" + login: "Bejelentkezés" + plugins: "Pluginek" + user_preferences: "Felhasználói beállítások" + tags: "Címkék" + search: "Keresés" + groups: "Csoportok" + badges: + title: Jelvények + new_badge: Új jelvény + new: Új + name: Név + badge: Jelvény + display_name: Megjelenítendő név + description: Leírás + long_description: Hosszú leírás + badge_type: Jelvény típusa + badge_grouping: Csoport + save: Mentés + delete: Törlés + revoke: Visszavonás + reason: Indok + revoke_confirm: "Biztosan vissza akarod vonni ezt a jelvényt?" + edit_badges: Jelvények szerkesztése + enabled: Jelvény engedélyezése + icon: Ikon + image: Kép + preview: + bad_count_warning: + header: "FIGYELEM!" + sample: "Minta:" + grant: + with: "%{username}" + emoji: + title: "Emoji" + add: "Új emoji hozzáadása" + name: "Név" + image: "Kép" + embedding: + get_started: "Ha szeretnéd a Discourse-t egy másik weboldalba ágyazni, kezdd a hoszt megadásával." + title: "Beágyazás" + host: "Engedélyezett hosztok" + edit: "szerkesztés" + add_host: "Hoszt hozzáadása" + settings: "Beágyazás beállításai" + crawling_settings: "Crawler beállítások" + embed_by_username: "Felhasználónév téma létrehozáshoz" + embed_post_limit: "A bejegyzések maximális száma a beágyazásban" + embed_truncate: "Beágyazott bejegyzések rövidítése" + embed_classname_whitelist: "Engedélyezett CSS-osztálynevek" + save: "Beágyazás beállításainak mentése" + permalink: + title: "Közvetlen link" + url: "URL" + topic_id: "Téma ID" + topic_title: "Téma" + post_id: "Hozzászólás azonosító" + post_title: "Hozzászólás" + category_id: "Kategória azonosító" + category_title: "Kategória" + external_url: "Külső URL" + delete_confirm: "Biztosan törölni szeretnéd ezt a közvetlen linket?" + form: + label: "Új :" + add: "Hozzáadás" + filter: "Keresés (URL vagy Külső URL)" + wizard_js: + wizard: + done: "Kész" + back: "Vissza" + next: "Tovább" + step: "%{current} / %{total}" + upload: "Feltöltés" + uploading: "Feltöltés..." + upload_error: "Sajnáljuk, de hiba lépett fel a fájl feltöltése közben. Kérünk, próbáld újra!" + quit: "Talán Később" + staff_count: + one: "A közösségedben 1 stábtag van (te)." + other: "A közösségedben veled együtt %{count} stábtag van." + invites: + add_user: "hozzáadás" + none_added: "Nem hívtál meg egy stábtagot sem. Biztos, hogy tovább lépsz?" + roles: + admin: "Adminisztrátor" + moderator: "Moderátor" + regular: "Felhasználó" + previews: + topic_title: "Vita téma" + share_button: "Megosztás" + reply_button: "Válasz" diff --git a/config/locales/server.hu.yml b/config/locales/server.hu.yml new file mode 100644 index 0000000000..dc0a05a543 --- /dev/null +++ b/config/locales/server.hu.yml @@ -0,0 +1,670 @@ +# 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/ + +hu: + dates: + short_date_no_year: "D MMM" + short_date: "D MMM, YYYY" + long_date: "MMMM D, YYYY h:mma" + datetime_formats: &datetime_formats + formats: + short: "%m-%d-%Y" + short_no_year: "%B %-d" + date_only: "%B %-d, %Y" + long: "%B %-d, %Y, %l:%M%P" + date: + month_names: [~, Január, Február, Március, Április, Május, Június, Július, Augusztus, Szeptember, Október, November, December] + <<: *datetime_formats + time: + <<: *datetime_formats + am: "de" + pm: "du" + title: "Discourse" + topics: "Témák" + posts: "Bejegyzések" + loading: "Töltés" + log_in: "Bejelentkezés" + submit: "Beküldés" + purge_reason: "Automatikusan törölve, mint elhagyott, deaktivált account" + disable_remote_images_download_reason: "A távoli képletöltés ki lett kapcsolva, mivel nem áll rendelkezésre tárhely." + anonymous: "Anonim" + remove_posts_deleted_by_author: "Szerző által törölve" + themes: + bad_color_scheme: "Nem lehet az oldal stílust frissíteni, érvénytelen szín séma." + other_error: "Hiba történt az oldal stílusának frissítése közben" + emails: + incoming: + default_subject: "Ennek a témának címre van szüksége" + show_trimmed_content: "Levágott tartalom mutatása" + errors: + empty_email_error: "Akkor fordul elő, amikor a nyers email, amit kaptunk, üres volt." + no_body_detected_error: "Történik, amikor nem tudtunk kivonatolni egy törzset és nem voltak csatolmányok." + inactive_user_error: "Történik, ha a feladó nem aktív." + bad_destination_address: "Történik, ha a To/Cc/Bcc mezőkben lévő email címek egyike sem illeszkedik a beállított beérkező e-mail címhez." + strangers_not_allowed_error: "Történik, ha egy felhasználó megpróbált létrehozni egy új témát egy kategóriában, amelynek nem tagja." + errors: &errors + format: '%{attribute} %{message}' + messages: + too_long_validation: "limitálva %{max} karakterre; megadott karakterek száma: %{length}." + invalid_boolean: "Érvénytelen logikai érték." + taken: "már foglalt" + accepted: elfogadva kell lennie + blank: nem lehet üres + present: üres kell hogy legyen + confirmation: "nem egyezik %{attribute}" + empty: nem lehet üres + equal_to: "meg kell egyeznie az alábbival: %{count}" + even: "páros szám kell, hogy legyen" + exclusion: foglalt + greater_than: "nagyobbnak kell lenni, mint %{count} " + greater_than_or_equal_to: "nagyobbnak vagy egyenlőnek kell lennie, mint %{count}" + has_already_been_used: "már használatban van" + inclusion: nincs benne a listában + invalid: érvénytelen + less_than: "kevesebbnek kell lenni, mint %{count}" + less_than_or_equal_to: "kisebbnek vagy egyenlőnek kell lennie, mint %{count}" + not_a_number: ez nem szám + not_an_integer: Az értéknek egész számnak kell lennie + odd: "páratlan szám kell, hogy legyen" + record_invalid: 'Érvényesítés sikertelen: %{error}' + restrict_dependent_destroy: + one: "Nem lehet törölni rekordot, mert egy függő %{rekord} létezik" + many: "Nem lehet törölni rekordot, mert függő %{rekord} léteznek" + too_long: + one: túl hosszú (legfeljebb 1 karakter lehet) + other: túl hosszú (legfeljebb %{count} karakter lehet) + too_short: + one: túl rövid (minimum 1 karakter) + other: túl rövid (minimum %{count} karakter) + wrong_length: + one: nem megfelelő hosszúságú. (1 karakter szükséges) + other: nem megfelelő hosszúságú. (%{count} karakter szükséges) + other_than: "nem lehet %{count}" + template: + body: 'Problémák vannak az alábbi mezőkkel:' + embed: + load_from_remote: "Hiba történt a bejegyzés betöltése során!" + site_settings: + min_username_length_exists: "Nem lehet a legrövidebb felhasználói névnél rövidebb minimum felhasználói név hosszúságot beállítani." + min_username_length_range: "Nem lehet a maximum értéknél nagyobb minimum értéket beállítani." + max_username_length_exists: "Nem lehet a leghosszabb felhasználói névnél rövidebb maximum felhasználói név hosszúságot beállítani." + max_username_length_range: "Nem lehet a minimum értéknél kisebb maximum értéket beállítani." + default_categories_already_selected: "Nem lehet olyan kategóriát kiválasztani ami másik listában már használva van." + s3_upload_bucket_is_required: "Nem lehet engedélyezni a feltöltést az S3-ba amíg nincs megadva a 's3_upload_bucket'." + activemodel: + errors: + <<: *errors + backup: + operation_already_running: "Egy művelet jelenleg is fut. Nem tudunk újat indítani jelenleg." + backup_file_should_be_tar_gz: "A biztonsági mentésnek .tar.gz formátumúnak kell lennie." + not_enough_space_on_disk: "Nincs elég hely a lemezen, hogy feltöltsük ezt a biztonsági mentést." + invalid_filename: "A mentési fájlnév érvénytelen karaktereket tartalmaz. Érvényes karakterek a-z 0-9 . - _." + not_logged_in: "Be kell jelentkezned, hogy ezt megtehesd." + not_found: "A kért hivatkozás vagy forrás nem található." + invalid_access: "Nincs engedélyed a kért forrás megtekintéséhez." + read_only_mode_enabled: "Ez a weboldal írásvédett módban van. Az interakciók le vannak tiltva." + reading_time: "Olvasási Idő" + likes: "Lájkok" + too_many_replies: + one: "Sajnáljuk, de az új felhasználók ideiglenesen csak 1 választ küldhetnek ugyanabba a témába." + other: "Sajnáljuk, de az új felhasználók ideiglenesen csak %{replies} választ küldhetnek ugyanabba a témába." + embed: + start_discussion: "Csevegés indítása" + continue: "Csevegés folytatása" + more_replies: + one: "és 1 további válasz" + other: "és %{count} további válasz" + loading: "Csevegés betöltése..." + permalink: "Közvetlen link" + imported_from: "Ez egy társ beszélgetés, az eredeti topic itt található: %{link}" + in_reply_to: "▶ %{username}" + replies: + one: "1 válasz" + other: "%{count} válasz" + no_mentions_allowed: "Sajnáljuk, de nem említhetsz meg más felhasználókat." + too_many_mentions: + one: "Sajnáljuk, de csak egy felhasználót említhetsz meg ebben a bejegyzésben." + other: "Sajnáljuk, de csak %{count} számú felhasználót említhetsz meg ebben a bejegyzésben." + no_mentions_allowed_newuser: "Sajnáljuk, de az új felhasználók nem említhetnek meg más felhasználókat." + too_many_mentions_newuser: + one: "Sajnáljuk, de az új felhasználók csak egy felhasználót említhetnek meg." + other: "Sajnáljuk, de az új felhasználók csak %{count} számú felhasználót említhetnek meg." + no_images_allowed: "Sajnáljuk, de az új felhasználók nem tehetnek képeket a bejegyzésbe." + too_many_images: + one: "Sajnáljuk, de az új felhasználók csak %{count} képet rakhatnak a bejegyzéseikbe." + other: "Sajnáljuk, de az új felhasználók csak %{count} képet rakhatnak a bejegyzéseikbe." + no_attachments_allowed: "Sajnáljuk, de az új felhasználók nem adhatnak csatolmányokat a bejegyzéseikbe." + too_many_attachments: + one: "Sajnáljuk, de az új felhasználók csak %{count} csatolmányt adhatnak a bejegyzéseikbe." + other: "Sajnáljuk, de az új felhasználók csak %{count} csatolmányt adhatnak a bejegyzéseikbe." + no_links_allowed: "Sajnáljuk, de az új felhasználók nem tehetnek hivatkozásokat a bejegyzéseikbe." + too_many_links: + one: "Sajnáljuk, de az új felhasználók csak %{count} hivatkozást tehetnek a bejegyzéseikbe." + other: "Sajnáljuk, de az új felhasználók csak %{count} hivatkozást tehetnek a bejegyzéseikbe." + spamming_host: "Sajnáljuk, nem küldhetsz linket ennek a hostnak." + user_is_suspended: "A felfüggesztett felhasználók nem hozhatnak létre új bejegyzést." + topic_not_found: "Valami elromlott. Talán ezt a téma lezárták vagy törölték, miközben nézted?" + just_posted_that: "túl hasonló ahhoz, amit legutóbb megosztottál" + invalid_characters: "érvénytelen karaktereket tartalmaz" + next_page: "következő oldal →" + prev_page: "← előző oldal" + page_num: "%{num}. oldal" + home_title: "Kezdőlap" + topics_in_category: "Témák a '%{category}' kategóriában" + rss_posts_in_topic: "A '%{topic}' RSS feed-je" + rss_topics_in_category: "A '%{category}' témáinak az RSS feed-je" + author_wrote: "%{author} írta:" + num_posts: "Bejegyzések:" + num_participants: "Résztvevők:" + read_full_topic: "Teljes téma elolvasása" + private_message_abbrev: "Üzenet" + rss_description: + latest: "Legutóbbi témák" + hot: "Legnépszerűbb témák" + top: "Top témák" + posts: "Legújabb bejegyzések" + group_posts: "Legújabb bejegyzések %{group_name}-ból" + group_mentions: "Legújabb megemlítések %{group_name}-ból" + user_posts: "Legújabb bejegyzések @%{username} felhasználótól." + user_topics: "Legújabb témák @%{username} felhasználótól." + tag: "Megjelölt témák" + too_late_to_edit: "Ez a bejegyzés túl régen lett létrehozva. Már nem lehet szerkeszteni vagy törölni." + revert_version_same: "A jelenlegi verzió megegyezik azzal a verzióval amire vissza szeretne térni." + excerpt_image: "kép" + queue: + delete_reason: "Törölve lett a moderátori sor által" + groups: + errors: + can_not_modify_automatic: "Nem módosíthasz automata csoportot" + invalid_domain: "'%{domain}' nem érvényes domain." + invalid_incoming_email: "'%{email}' nem érvényes email cím." + email_already_used_in_group: "'%{email}' már használva van a '%{group_name}' csoport által." + email_already_used_in_category: "'%{email}' már használva van a '%{category_name}' kategória által." + default_names: + everyone: "mindenki" + admins: "adminok" + moderators: "moderátorok" + staff: "személyzet" + trust_level_0: "bizalom_szint_0" + trust_level_1: "bizalom_szint_1" + trust_level_2: "bizalom_szint_2" + trust_level_3: "bizalom_szint_3" + trust_level_4: "bizalom_szint_4" + education: + until_posts: + one: "1 bejegyzés" + other: "%{count} bejegyzés" + activerecord: + attributes: + category: + name: "Kategória neve" + topic: + title: 'Cím' + post: + raw: "Test" + user_profile: + bio_raw: "Bemutatkozás" + errors: + <<: *errors + models: + topic: + attributes: + base: + too_many_users: "Egyszerre csak egy felhasználónak küldhetsz figyelmeztetést." + no_user_selected: "Érvényes felhasználót kell választanod." + user: + attributes: + password: + common: "egy a 10000 leggyakoribb jelszó közül. Arra kérünk, hogy használj egy biztonságosabb jelszót!" + same_as_username: "azonos a felhasználói neveddel. Használj biztonságosabb jelszót!" + same_as_email: "azonos az email címeddel. Használj biztonságosabb jelszót!" + same_as_current: "azonos a jelenlegi jelszavaddal." + ip_address: + signup_not_allowed: "Feliratkozás nem lehetséges erről a fiókról." + color_scheme_color: + attributes: + hex: + invalid: "egy érvénytelen szín" + vip_category_name: "Társalgó" + vip_category_description: "A kategória csak 3, vagy magasabb bizalmi szinttel rendelkező tagok számára érhető el." + meta_category_name: "Visszajelzés" + meta_category_description: "Beszélgetés erről az oldalról, a szervezetéről, arról hogy hogyan működés és hogyan tudnánk jobbá tenni." + staff_category_name: "Személyzet" + staff_category_description: "Privát kategória a személyzet számára. Ezeket a témákat csak adminok és moderátorok látják." + lounge_welcome: + title: "Üdv a társalgóban" + category: + topic_prefix: "A %{category} kategóriáról" + errors: + uncategorized_parent: "Nem kategorizált elem nem rendelkezhet szülő kategóriával" + self_parent: "Az alkategória nem tartozhat önmaga alá" + invalid_email_in: "'%{email}' nem érvényes email cím." + email_already_used_in_group: "'%{email}' már használva van a '%{group_name}' csoport által." + email_already_used_in_category: "'%{email}' már használva van a '%{category_name}' kategória által." + cannot_delete: + uncategorized: "Nem kategorizált elemet nem lehet törrölni" + has_subcategories: "Nem lehet kitörölni ezt a kategóriát, mert vannak alkategóriái." + topic_exists_no_oldest: "Nem lehet törölni ezt a kategóriát, mert benne a témák száma %{count}." + uncategorized_description: "Témák, amelyeknek nincs szükségük kategóriára, vagy nem férnek bele semmilyen más létező kategóriába." + trust_levels: + newuser: + title: "új felhasználó" + basic: + title: "egyszerű felhasználó" + member: + title: "tag" + regular: + title: "általános" + leader: + title: "vezető" + rate_limiter: + hours: + one: "1 óra" + other: "%{count} óra" + minutes: + one: "1 perc" + other: "%{count} perc" + seconds: + one: "1 másodperc" + other: "%{count} másodperc" + datetime: + distance_in_words: + half_a_minute: "< 1p" + less_than_x_seconds: + one: "< 1mp" + other: "< %{count}mp" + x_seconds: + one: "1mp" + other: "%{count}mp" + less_than_x_minutes: + one: "< 1p" + other: "< %{count}p" + x_minutes: + one: "1p" + other: "%{count}p" + about_x_hours: + one: "1ó" + other: "%{count}ó" + x_days: + one: "1n" + other: "%{count}n" + about_x_months: + one: "1hó" + other: "%{count}hó" + x_months: + one: "1hó" + other: "%{count}hó" + about_x_years: + one: "1év" + other: "%{count}év" + over_x_years: + one: "> 1év" + other: "> %{count}év" + almost_x_years: + one: "1év" + other: "%{count}év" + distance_in_words_verbose: + half_a_minute: "éppen most" + less_than_x_seconds: + one: "éppen most" + other: "éppen most" + x_seconds: + one: "1 másodperce" + other: "%{count} másodperce" + less_than_x_minutes: + one: "kevesebb, mint 1 perce" + other: "kevesebb, mint %{count} perce" + x_minutes: + one: "1 perce" + other: "%{count} perce" + about_x_hours: + one: "1 órája" + other: "%{count} órája" + x_days: + one: "1 napja" + other: "%{count} napja" + about_x_months: + one: "kb. 1 hónapja" + other: "kb. %{count} hónapja" + x_months: + one: "1 hónapja" + other: "%{count} hónapja" + about_x_years: + one: "kb. 1 éve" + other: "kb. %{count} éve" + over_x_years: + one: "több, mint 1 éve" + other: "több, mint %{count} éve" + almost_x_years: + one: "majdnem 1 éve" + other: "majdnem %{count} éve" + password_reset: + no_token: "Sajnos a jelszómódosítás link túl régi. Ahhoz, hogy egy új linket kapjál, válaszd ki a Bejelentkezés gombot és használd az 'Elfelejtettem a jelszót' opciót." + update: 'Jelszó frissítése' + save: 'Jelszó beállítása' + title: 'Jelszó visszaállítása' + success: "Sikeresen megváltoztattad a jelszavadat és bejelentkeztél!" + success_unapproved: "Sikeresen megváltoztattad a jelszavadat!" + change_email: + confirmed: "Az email címed frissítve lett!" + please_continue: "Tovább a %{site_name}-ra" + error: "Hiba az email cím változtatásánál. Lehet hogy már használatban van?" + authorizing_old: + title: "Köszönjük hogy megerősítetted az email címedet." + activation: + action: "Kattints ide hogy aktiváld a felhasználói fiókodat." + already_done: "Sajnáljuk, ez a fiók megerősítési hivatkozás már érvénytelen! Lehet, hogy a fiókod már aktiválva van?" + please_continue: "Fiókodat aktiváltuk, most visszairányítunk a kezdőoldalra." + continue_button: "Tovább a %{site_name}-ra" + welcome_to: "Üdv a %{site_name}-on!" + post_action_types: + off_topic: + title: 'Off-Topik' + long_form: 'megjelölve mint off-topic' + spam: + title: 'Szemét' + long_form: 'spam-nek jelölve' + email_title: '"%{title}" spam-nek jelölve' + inappropriate: + title: 'Alkalmatlan' + long_form: 'nem a témába tartozónak jelölve' + notify_user: + email_title: 'A bejegyzésed itt "%{title}"' + notify_moderators: + title: "Valami más" + bookmark: + title: 'Könyvjelző' + description: 'Bejegyzés hozzáadása a könyvjelzőkhöz' + long_form: 'a bejegyzés hozzáadva a könyvjelzőkhöz' + like: + title: 'Kedvel' + description: 'Bejegyzés kedvelése' + long_form: 'kedvelve' + topic_flag_types: + spam: + title: 'Szemét' + long_form: 'szemétnek jelölte ezt' + inappropriate: + title: 'Alkalmatlan' + notify_moderators: + title: "Valami más" + email_title: 'A következő témakör "%{title}" moderátori figyelmet igényel' + archetypes: + regular: + title: "Általános téma" + banner: + title: "Kiemelt Téma" + unsubscribed: + title: "Leiratkozott!" + unsubscribe: + title: "Leiratkozás" + log_out: "Kijelentkezés" + user_api_key: + authorize: "Engedélyezés" + read: "olvasható" + read_write: "olvasható/írható" + reports: + visits: + title: "Felhasználói látogatások" + xaxis: "Nap" + yaxis: "Látogatások száma" + signups: + xaxis: "Nap" + profile_views: + title: "Felhasználói Profil Megtekintések" + xaxis: "Nap" + yaxis: "Felhasználói profil megtekintések száma" + topics: + title: "Témák" + xaxis: "Nap" + yaxis: "Új témák száma" + posts: + title: "Bejegyzések" + xaxis: "Nap" + yaxis: "Új bejegyzések száma" + likes: + title: "Kedvelések" + xaxis: "Nap" + yaxis: "Új kedvelések száma" + flags: + title: "Jelölések" + xaxis: "Nap" + yaxis: "Jelölések száma" + bookmarks: + title: "Könyvjelzők" + xaxis: "Nap" + yaxis: "Új könyvjelzők száma" + starred: + title: "Csillagozott" + xaxis: "Nap" + yaxis: "Új csillagozott témák száma" + users_by_trust_level: + title: "Felhasználok láma bizalmi szintenként" + xaxis: "Bizalmi szint" + yaxis: "Felhasználók száma" + emails: + title: "Kiküldött levelek" + xaxis: "Nap" + yaxis: "Levelek száma" + user_to_user_private_messages: + xaxis: "Nap" + yaxis: "Üzenetek száma" + system_private_messages: + title: "Rendszer" + xaxis: "Nap" + yaxis: "Üzenetek száma" + moderator_warning_private_messages: + title: "Moderátori figyelmeztetés" + xaxis: "Nap" + yaxis: "Üzenetek száma" + notify_moderators_private_messages: + title: "Moderátorok értesítése" + xaxis: "Nap" + yaxis: "Üzenetek száma" + notify_user_private_messages: + title: "Felhasználó értesítése" + xaxis: "Nap" + yaxis: "Üzenetek száma" + top_referrers: + xaxis: "Felhasználó" + num_clicks: "Kattintások" + num_topics: "Témák" + top_traffic_sources: + xaxis: "Domain" + num_clicks: "Kattintások" + num_topics: "Témák" + num_users: "Felhasználók" + top_referred_topics: + title: "Legtöbbet Hivatkozott Témák" + page_view_anon_reqs: + title: "Névtelen" + xaxis: "Nap" + page_view_logged_in_reqs: + title: "Bejelentkezve" + xaxis: "Nap" + page_view_crawler_reqs: + title: "Kereső Robotok" + xaxis: "Nap" + page_view_total_reqs: + xaxis: "Nap" + page_view_logged_in_mobile_reqs: + xaxis: "Nap" + page_view_anon_mobile_reqs: + xaxis: "Nap" + http_background_reqs: + title: "Háttér" + xaxis: "Nap" + http_2xx_reqs: + xaxis: "Nap" + http_3xx_reqs: + title: "HTTP 3xx (Átirányítás)" + xaxis: "Nap" + http_4xx_reqs: + xaxis: "Nap" + http_5xx_reqs: + xaxis: "Nap" + http_total_reqs: + title: "Összes" + xaxis: "Nap" + yaxis: "Összes kérés" + time_to_first_response: + title: "Első válasz ideje" + xaxis: "Nap" + topics_with_no_response: + title: "Témák hozzászólás nélkül" + xaxis: "Nap" + yaxis: "Összes" + mobile_visits: + xaxis: "Nap" + yaxis: "Látogatások száma" + dashboard: + rails_env_warning: "A szerver jelenleg %{env} módban fut." + host_names_warning: "A config/database.yml file az alapértelmezett 'localhost' domaint használja. Állítsd be az oldalad saját domain nevét." + gc_warning: 'A szerver az alapértelmezett ruby memóriakezelést használja, ami nem a legoptimálisabb teljesítmény szempontjából. Olvasd el ezt a teljesítmény fokozásáról: Tuning Ruby and Rails for Discourse.' + memory_warning: 'A kiszolgálód kevesebb mint 1GB memóriával rendelkezik. Minimum 1GB memória ajánlott.' + site_settings: + allow_moderators_to_create_categories: "Új kategóriák létrehozásának engedélyezése a moderátorok számára" + enable_badges: "A jelvény-rendszer engedélyezése" + min_password_length: "Minimum jelszó hossz." + block_common_passwords: "A 10,000 leggyakoribb jelszó tiltása." + reply_by_email_enabled: "E-mail válaszok engedélyezése." + enable_emoji: "Emoji-k engedélyezése" + errors: + invalid_email: "Érvénytelen e-mail cím." + invalid_username: "Nincs ilyen nevű felhasználó." + invalid_integer_min_max: "Az értéknek %{min} és %{max} között kell lennie." + invalid_integer_min: "Az érték nem lehet kisebn, mint %{min}." + invalid_integer_max: "Az érték nem lehet nagyobb, mint %{max}." + invalid_integer: "Az értéknek egész számnak kell lennie." + regex_mismatch: "Az érték nem felel meg az elvárt formának." + invalid_string: "Érvénytelen érték." + invalid_string_min_max: "%{min} és %{max} között kell lennie a karakterek számának." + invalid_string_min: "Nem lehet rövidebb, mint %{min} karakter." + invalid_string_max: "Nem lehet hosszabb, mint %{max} karakter." + search: + within_post: "#%{post_number}, általa: %{username}" + types: + category: 'Kategóriák' + topic: 'Eredmények' + user: 'Felhasználók' + original_poster: "Eredeti szerző" + most_posts: "Legtöbb bejegyzés" + most_recent_poster: "Legutóbbi szerzők" + frequent_poster: "Gyakori szerzők" + redirected_to_top_reasons: + new_user: "Üdv a közösségünkben! Ezek a legnépszerűbb témakörök." + not_seen_in_a_month: "Üdv újra itt! Régóta nem láttunk. Ezek a legnépszerűbb témakörök, mióta nem látogattad az oldalt." + change_owner: + post_revision_text: "A tulajdonijog át lett ruházva a következőről: %{old_user}, a következőre: %{new_user}" + topic_statuses: + archived_enabled: "Ez a témakör jelenleg archíválva van. Be lett fagyasztva, szóval nem lehet megváltoztatni semmilyen módon." + archived_disabled: "Ez a topik jelenleg nincs archíválva. Már nincs befagyasztva, így lehet rajta változtatni." + closed_enabled: "A témakör le van zárva. Nem lehet mostantól válaszolni rá." + closed_disabled: "A témakör jelenleg nyitott. Mostantól lehet válaszolni rá." + login: + not_approved: "Még nem fogadták el a regisztrációs kérelmedet! Értesítve leszel, amint bejelentkezhetsz." + incorrect_username_email_or_password: "Hibás felhasználónév, e-mail vagy jelszó" + wait_approval: "Köszönjük, hogy regisztráltál! Értesíteni fogunk, amint elfogadták a kérelmedet." + active: "A fiókodat aktiváltuk és készen áll a használatra!" + suspended: "Nem jelentkezhetsz be eddig: %{date}." + suspended_with_reason: "A fiók felfüggesztve %{date} -ig: %{reason}" + errors: "%{errors}" + something_already_taken: "Valami félrecsúszott. Talán a felhasználónév vagy az e-mail cím már regisztrálva van. Próbáld meg az elfelejtett jelszó részt." + omniauth_error_unknown: "Valamiért nem sikerült bejelentkezned. Kérünk próbáld újra!" + new_registrations_disabled: "Az új fiókok létrehozása nem engedélyezett jelenleg." + password_too_long: "A jelszavak 200 karakter hosszúságúra vannak limitálva." + reserved_username: "Ez a felhasználónév nem engedélyezett." + missing_user_field: "Nem töltötted ki az összes felhasználói mezőt" + user: + username: + short: "nem lehet rövidebb, mint %{min} karakter" + long: "nem lehet hosszabb, mint %{max} karakter" + unique: "egyedi kell, hogy legyen" + blank: "megadása kötelező" + must_begin_with_alphanumeric_or_underscore: "betűvel, számmal vagy aláhúzással kell kezdődni." + must_end_with_alphanumeric: "betűvel vagy számmal kell, hogy végződjön." + email: + blocked: "nincs engedélyezve." + flags_dispositions: + disagreed: "Köszönjük, hogy jelezted felénk! Utánanézünk!" + deferred: "Köszönjük, hogy jelezted felénk! Utánanézünk!" + deferred_and_deleted: "Köszönjük, hogy jelezted felénk! Eltávolítottuk a bejegyzést!" + system_messages: + welcome_user: + subject_template: "Üdvözlünk a %{site_name} oldalán!" + welcome_invite: + subject_template: "Üdvözlünk a %{site_name} oldalán!" + backup_succeeded: + subject_template: "Sikeresen befejeződött a biztonsági mentés" + backup_failed: + subject_template: "Sikertelen biztonsági mentés" + restore_succeeded: + subject_template: "Sikeresen befejeződött a visszaállítás" + restore_failed: + subject_template: "Sikertelen visszaállítás" + bulk_invite_succeeded: + subject_template: "Sikeresen végbement a csoportos felhasználó-meghívás" + text_body_template: "Sikeresen végbement a csoportos felhasználó-meghívás! %{sent} meghívó lett kiküldve." + bulk_invite_failed: + subject_template: "Hiba lépett fel a csoportos felhasználó-meghívás folyamán" + csv_export_failed: + subject_template: "Az adatok exportálása sikertelen" + download_remote_images_disabled: + subject_template: "A távoli képek letöltése le lett tiltva" + text_body_template: "A `download_remote_images_to_local` beállítás le lett tiltva, mivel elértük az erre szánt `download_remote_image_threshold` tárhelylimitet." + subject_re: "Re: " + subject_pm: "[Privát Üzenet]" + user_notifications: + previous_discussion: "Régebbi válaszok" + in_reply_to: "Válasz" + unsubscribe: + title: "Leiratkozás" + description: "Nem szeretné ezeket az e-maileket megkapni? Nem probléma! Kattints ide és iratkozzon le azonnal:" + posted_by: "Beküldve %{username} által, ekkor: %{post_date}" + digest: + why: "Egy kis összefoglaló az oldalról - %{site_link} -, hogy mik történtek a legutóbbi látogatásod - %{last_seen_at} - óta" + click_here: "kattints ide" + page_not_found: + popular_topics: "Népszerű" + search_title: "Keresés az oldalon" + search_google: "Google" + deleted: 'törölt' + upload: + images: + size_not_found: "Sajnáljuk, de nem tudtuk megállapítani a kép méretét. Lehetséges hogy megsérült?" + guidelines: "Irányelvek" + privacy: "Adatvédelem" + csv_export: + boolean_yes: "Igen" + boolean_no: "Nem" + guidelines_topic: + title: "GYIK/Irányelvek" + tos_topic: + title: "Szolgáltatási feltételek" + privacy_topic: + title: "Adatvédelmi szabályzat" + badges: + welcome: + name: Üdvözöllek + wizard: + step: + forum_title: + title: "Név" + icons: + title: "Ikonok" + fields: + favicon_url: + label: "Kicsi ikon" + homepage: + title: "Főoldal" + fields: + homepage_style: + choices: + latest: + label: "Legutóbbi témák" + emoji: + title: "Emoji" diff --git a/plugins/discourse-details/config/locales/client.hu.yml b/plugins/discourse-details/config/locales/client.hu.yml new file mode 100644 index 0000000000..ae01e5179a --- /dev/null +++ b/plugins/discourse-details/config/locales/client.hu.yml @@ -0,0 +1,14 @@ +# 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/ + +hu: + js: + details: + title: Részletek elrejtése + composer: + details_title: Összegzés + details_text: "A szöveg el lesz rejtve" diff --git a/plugins/discourse-details/config/locales/server.hu.yml b/plugins/discourse-details/config/locales/server.hu.yml new file mode 100644 index 0000000000..97878c01e5 --- /dev/null +++ b/plugins/discourse-details/config/locales/server.hu.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/ + +hu: {} diff --git a/plugins/discourse-local-dates/config/locales/client.hu.yml b/plugins/discourse-local-dates/config/locales/client.hu.yml new file mode 100644 index 0000000000..6cf10ee4d4 --- /dev/null +++ b/plugins/discourse-local-dates/config/locales/client.hu.yml @@ -0,0 +1,25 @@ +# 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/ + +hu: + js: + discourse_local_dates: + title: Dátum beszúrása + create: + modal_title: Dátum beszúrása + modal_subtitle: "Automatikusan átkonvertáljuk a dátumot és az időt a látogató helyi időzónájára." + form: + insert: Beszúrás + advanced_mode: Haladó mód + simple_mode: Egyszerű mód + timezones_title: Megjelenített időzónák + recurring_title: Ismétlődés + recurring_none: Nincs ismétlődés + invalid_date: Érvénytelen dátum, győződj meg róla, hogy a dátum és idő helyes + date_title: Dátum + time_title: Idő + format_title: Dátumformátum diff --git a/plugins/discourse-local-dates/config/locales/server.hu.yml b/plugins/discourse-local-dates/config/locales/server.hu.yml new file mode 100644 index 0000000000..97878c01e5 --- /dev/null +++ b/plugins/discourse-local-dates/config/locales/server.hu.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/ + +hu: {} diff --git a/plugins/discourse-narrative-bot/config/locales/client.hu.yml b/plugins/discourse-narrative-bot/config/locales/client.hu.yml new file mode 100644 index 0000000000..8a2b6083df --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/client.hu.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/ + +hu: + js: + discourse_narrative_bot: + welcome_post_type: + new_user_track: "Indítsa el az új felhasználói oktatást minden új felhasználónak" + welcome_message: "Küldjön minden új felhasználónak egy üzenetet egy rövid gyorstalpalóval." diff --git a/plugins/discourse-narrative-bot/config/locales/server.hu.yml b/plugins/discourse-narrative-bot/config/locales/server.hu.yml new file mode 100644 index 0000000000..484c8355e7 --- /dev/null +++ b/plugins/discourse-narrative-bot/config/locales/server.hu.yml @@ -0,0 +1,42 @@ +# 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/ + +hu: + site_settings: + discourse_narrative_bot_enabled: 'Discourse alámondó bot bekapcsolása' + disable_discourse_narrative_bot_welcome_post: "A Discourse alámondó bot üdvözlő üzenetének kikapcsolása" + discourse_narrative_bot_ignored_usernames: "Felhasználónevek, amelyeket a Discourse alámondó bot figyelmen kívül hagyjon" + discourse_narrative_bot_disable_public_replies: "A Discourse alámondó bot nyilvános válaszainak kikapcsolása" + discourse_narrative_bot_welcome_post_type: "A Discourse mesélő bot üdvözlő üzeneteinek kiküldendő típusai" + discourse_narrative_bot_welcome_post_delay: "Várj (n) másodpercet a Discourse mesélő bot üdvözlő üzenetének kiküldése előtt." + badges: + certified: + name: Hitelesített + description: "Teljesítette az új felhasználók eligazítását" + long_description: | + Ezt a jelvényt az új felhasználók interaktív eligazításának sikeres teljesítéséért adjuk. Kezdeményező voltál és megtanultad a beszélgetés alapvető eszközeit, és ezzel hitelesített lettél! + licensed: + name: Licenszelt + discourse_narrative_bot: + quote: + trigger: "idézet" + '1': + quote: "Minden nehézség közepében lehetőség rejtőzik" + '3': + quote: "Ne azért sírj, mert vége van, mosolyogj azért, mert megtörtént." + magic_8_ball: + answers: + '3': "Kétségtelenül" + '7': "Leginkább" + '9': "Igen" + '12': "Kérdezd meg később" + '17': "A válaszom nem." + track_selector: + reset_trigger: 'start' + skip_trigger: 'átugrás' + new_user_narrative: + reset_trigger: "új felhasználó" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.hu.yml b/plugins/discourse-nginx-performance-report/config/locales/server.hu.yml new file mode 100644 index 0000000000..97878c01e5 --- /dev/null +++ b/plugins/discourse-nginx-performance-report/config/locales/server.hu.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/ + +hu: {} diff --git a/plugins/discourse-presence/config/locales/client.hu.yml b/plugins/discourse-presence/config/locales/client.hu.yml new file mode 100644 index 0000000000..97878c01e5 --- /dev/null +++ b/plugins/discourse-presence/config/locales/client.hu.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/ + +hu: {} diff --git a/plugins/discourse-presence/config/locales/server.hu.yml b/plugins/discourse-presence/config/locales/server.hu.yml new file mode 100644 index 0000000000..97878c01e5 --- /dev/null +++ b/plugins/discourse-presence/config/locales/server.hu.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/ + +hu: {} diff --git a/plugins/poll/config/locales/client.hu.yml b/plugins/poll/config/locales/client.hu.yml new file mode 100644 index 0000000000..2819c17851 --- /dev/null +++ b/plugins/poll/config/locales/client.hu.yml @@ -0,0 +1,57 @@ +# 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/ + +hu: + js: + poll: + voters: + one: "szavazó" + other: "szavazók" + total_votes: + one: "összes szavazat" + other: "összes szavazat" + average_rating: "Átlagos értékelés: %{average}." + public: + title: "A szavazatok nyilvánosak" + cast-votes: + title: "Szavazatod leadása" + label: "Szavazz!" + show-results: + title: "Eredmények megjelenítése" + label: "Eredmények mutatása" + hide-results: + title: "Vissza a szavazataidhoz" + label: "Eredmények elrejtése" + open: + title: "A szavazás megnyitása" + label: "Megnyitás" + confirm: "Biztosan megnyitod ezt a szavazást?" + close: + title: "A szavazás lezárása" + label: "Bezárás" + confirm: "Biztosan lezárod ezt a szavazást?" + error_while_toggling_status: "Elnézést, a szavazat állapotának vizsgálatakor hiba lépett fel." + error_while_casting_votes: "Sajnáljuk, hiba történt a szavazatod elküldésekor." + error_while_fetching_voters: "Elnézést, a szavazatok kiírásakor hiba lépett fel." + ui_builder: + title: Szavazás felépítése + insert: Szavazás beillesztése + help: + options_count: Legalább 2 opciót adj meg + invalid_values: A minimum értéknek alacsonyabbnak kell lennie a maximum értéknél. + poll_type: + label: Típus + regular: Egyszerű választás + multiple: Többszörös Választás + poll_config: + max: Max + min: Min + step: Lépés + poll_public: + label: Mutasd, ki szavazott + poll_options: + label: Soronként egy választási lehetőséget adj meg diff --git a/plugins/poll/config/locales/server.hu.yml b/plugins/poll/config/locales/server.hu.yml new file mode 100644 index 0000000000..ea78c1fa9c --- /dev/null +++ b/plugins/poll/config/locales/server.hu.yml @@ -0,0 +1,21 @@ +# 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/ + +hu: + site_settings: + poll_maximum_options: "Maximum megszavazható tétel a szavazásokban." + poll: + default_poll_must_have_at_least_2_options: "Legalább 2 választható tételt kell tartalmaznia a szavazásnak." + default_poll_must_have_different_options: "A szavazásnak több különböző megszavazható tételt kell tartalmaznia." + named_poll_must_have_different_options: "A %[name] nevű szavazásnak több különböző megszavazható tételt kell tartalmaznia." + requires_at_least_1_valid_option: "Legalább 1 érvényes lehetőséget kell kiválasztanod." + topic_must_be_open_to_vote: "A témának nyítottnak kell hogy legyen hogy szavazni lehessen." + poll_must_be_open_to_vote: "A szavazásnak engedélyezve kell lennie ahhoz, hogy a felhasználok tudjanak szavazni." + topic_must_be_open_to_toggle_status: "A témának nyitottnak kell lennie hogy bekapcsolni lehessen a státuszt." + only_staff_or_op_can_toggle_status: "Csak egy staff tag vagy az eredeti posztoló tudja bekapcsolni a szavazás státuszát." + email: + link_to_poll: "Klikk, hogy megnézd ezt a szavazást." From ba022234c6613c19ab9cde264c33f5b06ae62650 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 13 Aug 2018 10:44:08 +0800 Subject: [PATCH 057/179] Add onceoff job to fix incorrect upload extensions. --- app/jobs/onceoff/fix_invalid_upload_extensions.rb | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 app/jobs/onceoff/fix_invalid_upload_extensions.rb diff --git a/app/jobs/onceoff/fix_invalid_upload_extensions.rb b/app/jobs/onceoff/fix_invalid_upload_extensions.rb new file mode 100644 index 0000000000..11a00fe35e --- /dev/null +++ b/app/jobs/onceoff/fix_invalid_upload_extensions.rb @@ -0,0 +1,9 @@ +require_dependency "upload_fixer" + +module Jobs + class FixInvalidUploadExtensions < Jobs::Onceoff + def execute_onceoff(args) + UploadFixer.fix_all_extensions + end + end +end From d10c9d7d758fe8b9ced3042bb24bdf3fc009285a Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 13 Aug 2018 10:55:06 +0800 Subject: [PATCH 058/179] FIX: Missing extensions for non-image uploads due to https://github.com/tgxworld/discourse/commit/2b5723938920647c45f7fc526e1edd19e8fad09e. --- lib/upload_creator.rb | 2 +- spec/requests/uploads_controller_spec.rb | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/lib/upload_creator.rb b/lib/upload_creator.rb index a063bdb361..10b5902a00 100644 --- a/lib/upload_creator.rb +++ b/lib/upload_creator.rb @@ -79,7 +79,7 @@ class UploadCreator @upload.sha1 = sha1 @upload.url = "" @upload.origin = @opts[:origin][0...1000] if @opts[:origin] - @upload.extension = image_type + @upload.extension = image_type || File.extname(@filename)[1..10] if FileHelper.is_image?(@filename) @upload.width, @upload.height = ImageSizer.resize(*@image_info.size) diff --git a/spec/requests/uploads_controller_spec.rb b/spec/requests/uploads_controller_spec.rb index 6347554771..62ba256243 100644 --- a/spec/requests/uploads_controller_spec.rb +++ b/spec/requests/uploads_controller_spec.rb @@ -164,7 +164,29 @@ describe UploadsController do expect(message).to contain_exactly(I18n.t("upload.images.size_not_found")) end - describe 'when filename has the wrong extension' do + describe 'when upload is not an image' do + before do + SiteSetting.authorized_extensions = 'txt' + end + + let(:file) do + Rack::Test::UploadedFile.new(file_from_fixtures("utf-8.txt", "encodings")) + end + + it 'should store the upload with the right extension' do + expect do + post "/uploads.json", params: { file: file, type: "composer" } + end.to change { Upload.count }.by(1) + + upload = Upload.last + + expect(upload.extension).to eq('txt') + expect(File.extname(upload.url)).to eq('.txt') + expect(upload.original_filename).to eq('utf-8.txt') + end + end + + describe 'when image has the wrong extension' do let(:file) do Rack::Test::UploadedFile.new(file_from_fixtures("png_as.jpg")) end From 168ffd8384f12859036978a4f35c2aec3a6f433a Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 13 Aug 2018 13:14:34 +1000 Subject: [PATCH 059/179] FEATURE: group warnings about IP level rate limiting --- Gemfile.lock | 2 +- lib/discourse.rb | 35 +++++++++++++++++++++++++++++++ lib/middleware/request_tracker.rb | 4 ++-- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 521090e794..11f3f74bbd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -184,7 +184,7 @@ GEM logstash-event (1.2.02) logstash-logger (0.26.1) logstash-event (~> 1.2) - logster (1.2.9) + logster (1.2.11) loofah (2.2.2) crass (~> 1.0.2) nokogiri (>= 1.5.9) diff --git a/lib/discourse.rb b/lib/discourse.rb index 79be1a7464..de70d4834d 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -489,6 +489,41 @@ module Discourse nil end + # you can use Discourse.warn when you want to report custom environment + # with the error, this helps with grouping + def self.warn(message, env = nil) + append = env ? (+" ") << env.map { |k, v|"#{k}: #{v}" }.join(" ") : "" + + if !(Logster::Logger === Rails.logger) + Rails.logger.warn("#{message}#{append}") + return + end + + loggers = [Rails.logger] + if Rails.logger.chained + loggers.concat(Rails.logger.chained) + end + + if old_env = Thread.current[Logster::Logger::LOGSTER_ENV] + env = env.merge(old_env) + end + + loggers.each do |logger| + + if !(Logster::Logger === logger) + logger.warn("#{message} #{append}") + next + end + + logger.store.report( + ::Logger::Severity::WARN, + "discourse", + message, + env: env + ) + end + end + # report a warning maintaining backtrack for logster def self.warn_exception(e, message: "", env: nil) if Rails.logger.respond_to? :add_with_opts diff --git a/lib/middleware/request_tracker.rb b/lib/middleware/request_tracker.rb index 0c76610464..26680bf9c6 100644 --- a/lib/middleware/request_tracker.rb +++ b/lib/middleware/request_tracker.rb @@ -258,7 +258,7 @@ class Middleware::RequestTracker if !limiter_assets10.can_perform? if warn - Rails.logger.warn("Global asset IP rate limit exceeded for #{ip}: 10 second rate limit, uri: #{request.env["REQUEST_URI"]}") + Discourse.warn("Global asset IP rate limit exceeded for #{ip}: 10 second rate limit", uri: request.env["REQUEST_URI"]) end return !(GlobalSetting.max_reqs_per_ip_mode == "warn") @@ -272,7 +272,7 @@ class Middleware::RequestTracker false rescue RateLimiter::LimitExceeded if warn - Rails.logger.warn("Global IP rate limit exceeded for #{ip}: #{type} second rate limit, uri: #{request.env["REQUEST_URI"]}") + Discourse.warn("Global IP rate limit exceeded for #{ip}: #{type} second rate limit", uri: request.env["REQUEST_URI"]) !(GlobalSetting.max_reqs_per_ip_mode == "warn") else true From 83fd308963c83e33028a4c14ae1ddb41302d4dc6 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 13 Aug 2018 14:48:25 +1000 Subject: [PATCH 060/179] FEATURE: group error message regarding image optimization failures --- app/models/optimized_image.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/optimized_image.rb b/app/models/optimized_image.rb index 4b6c81aeb3..96da29e055 100644 --- a/app/models/optimized_image.rb +++ b/app/models/optimized_image.rb @@ -255,7 +255,7 @@ class OptimizedImage < ActiveRecord::Base if opts[:raise_on_error] raise e else - Rails.logger.error("Could not optimize image #{to}: #{e.message}") + Discourse.warn("Failed to optimize image", location: to, error_message: e.message) false end end From 7e68062a46cb6cff0458358b373219a6575e538e Mon Sep 17 00:00:00 2001 From: Joe <33972521+hnb-ku@users.noreply.github.com> Date: Mon, 13 Aug 2018 13:29:46 +0800 Subject: [PATCH 061/179] Topic meta-data flexbox fixes (#6263) Topic meta-data flexbox fixes --- app/assets/stylesheets/common/base/topic-post.scss | 8 +++++--- app/assets/stylesheets/mobile/topic-post.scss | 3 +-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index f54b992e21..e1626ab2a6 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -140,10 +140,12 @@ } .clearfix > .topic-meta-data > .names { span.user-title { - background-color: dark-light-choose($highlight-low, $highlight-medium); color: dark-light-choose($primary-high, $secondary-low); - padding-left: 4px; - padding-right: 4px; + a { + background-color: dark-light-choose($highlight-low, $highlight-medium); + padding-left: 4px; + padding-right: 4px; + } } } } diff --git a/app/assets/stylesheets/mobile/topic-post.scss b/app/assets/stylesheets/mobile/topic-post.scss index 6fbfd052a4..3612d0ff84 100644 --- a/app/assets/stylesheets/mobile/topic-post.scss +++ b/app/assets/stylesheets/mobile/topic-post.scss @@ -442,7 +442,7 @@ span.highlighted { } .user-title { order: 4; - margin-right: auto; + flex-basis: 100%; } span { margin-right: 4px; @@ -456,7 +456,6 @@ span.highlighted { .user-title { color: #aaa; - padding-top: 2px; overflow: hidden; margin-right: 50px; } From e26437f3343bbf3360d828fe059b42ba2bb7b242 Mon Sep 17 00:00:00 2001 From: Misaka 0x4e21 Date: Mon, 13 Aug 2018 13:35:56 +0800 Subject: [PATCH 062/179] FEATURE: Do encodeURI on share links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some Discourse-supported sharing platforms such as Twitter are unable to determine the end of a unicode URL. If you post a URL "https://example.org/t/测试/1" on Twitter, it will be a link of href="https://example.org/t/" If the URL contains any unicode character (usually in the slug part) , it must be urlencoded with encodeURI(url) before being passed to source.generateUrl(link, title), or it will be a malformed URL in the sharing tweet. --- app/assets/javascripts/discourse/components/share-popup.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/components/share-popup.js.es6 b/app/assets/javascripts/discourse/components/share-popup.js.es6 index 5f899f2bae..7980b2abe3 100644 --- a/app/assets/javascripts/discourse/components/share-popup.js.es6 +++ b/app/assets/javascripts/discourse/components/share-popup.js.es6 @@ -81,7 +81,7 @@ export default Ember.Component.extend({ if (!this.site.mobileView) { $this.css({ left: "" + x + "px" }); } - this.set("link", url); + this.set("link", encodeURI(url)); this.set("visible", true); Ember.run.scheduleOnce("afterRender", this, this._focusUrl); From 03010571f5bda849a9d2f2f4cf68e8d0a3d85878 Mon Sep 17 00:00:00 2001 From: tshenry Date: Sun, 12 Aug 2018 22:43:03 -0700 Subject: [PATCH 063/179] Remove unnecessary code (#6262) "border-bottom: none;" gets overridden a few lines below by "border-bottom: 1px solid $primary-low;" making it unnecessary. --- app/assets/stylesheets/common/d-editor.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/stylesheets/common/d-editor.scss b/app/assets/stylesheets/common/d-editor.scss index d8168a6e68..f60937da41 100644 --- a/app/assets/stylesheets/common/d-editor.scss +++ b/app/assets/stylesheets/common/d-editor.scss @@ -75,7 +75,6 @@ .d-editor-button-bar { display: flex; align-items: center; - border-bottom: none; min-height: 30px; padding-left: 3px; border-bottom: 1px solid $primary-low; From 075d80862f01265f6659a35cb41b189471443664 Mon Sep 17 00:00:00 2001 From: Joe <33972521+hnb-ku@users.noreply.github.com> Date: Mon, 13 Aug 2018 14:04:20 +0800 Subject: [PATCH 064/179] UX: topic-timer modal style fixes --- .../base/edit-topic-status-update-modal.scss | 116 ++++++++---------- 1 file changed, 48 insertions(+), 68 deletions(-) diff --git a/app/assets/stylesheets/common/base/edit-topic-status-update-modal.scss b/app/assets/stylesheets/common/base/edit-topic-status-update-modal.scss index 704e17f3c8..24edccbaa9 100644 --- a/app/assets/stylesheets/common/base/edit-topic-status-update-modal.scss +++ b/app/assets/stylesheets/common/base/edit-topic-status-update-modal.scss @@ -1,63 +1,8 @@ .edit-topic-timer-modal { .modal-body { max-height: none; + overflow: visible !important; /* inline JS styles */ } - - .select-kit { - width: 50%; - } - - input.date-picker, - input[type="time"] { - width: 150px; - text-align: left; - } - - .radios { - margin-bottom: 10px; - } - - label { - vertical-align: middle; - display: inline-block; - padding-right: 5px; - - input { - vertical-align: middle; - } - } - - .btn.pull-right { - margin-right: 10px; - } - - .future-date-input { - input { - margin: 0; - } - - .alert-info { - margin: 0 -15px -15px -15px; - } - - .pika-single { - position: relative !important; - } - - .topic-status-info { - border: none; - padding: 0; - - h3 { - font-weight: normal; - font-size: $font-up-1; - } - } - } -} - -// mobile styles -.mobile-view .edit-topic-timer-modal { .control-group { display: flex; align-items: center; @@ -66,20 +11,55 @@ margin-right: 5px; } } + .select-kit { + width: 50%; + } + input.date-picker, + input[type="time"] { + width: 150px; + text-align: left; + } + .radios { + margin-bottom: 10px; + } + label { + vertical-align: middle; + display: inline-block; + padding-right: 5px; + input { + vertical-align: middle; + } + } + .btn.pull-right { + margin-right: 10px; + } + .future-date-input { + input { + margin: 0; + } + .alert-info { + margin: 0 -15px -15px -15px; + } + .btn-clear { + display: none; + } + .topic-status-info { + border: none; + padding: 0; + h3 { + font-weight: normal; + font-size: $font-up-1; + } + } + } + .pika-single { + position: absolute !important; /* inline JS styles */ + } +} +// mobile styles +.mobile-view .edit-topic-timer-modal { .select-kit.combo-box { flex: 1; } - - .future-date-input-selector-header .btn-clear { - display: none; - } - - .modal-body { - overflow: visible !important; /* inline styles from JS */ - } - - .pika-single { - position: absolute !important; /* inline styles from JS */ - } } From 6cae47aa533976cbf15c4a2badff418788e9ca8e Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 13 Aug 2018 16:33:06 +1000 Subject: [PATCH 065/179] collect extra environment correctly --- lib/discourse.rb | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/discourse.rb b/lib/discourse.rb index de70d4834d..9054e79e4b 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -504,12 +504,18 @@ module Discourse loggers.concat(Rails.logger.chained) end + logster_env = env + if old_env = Thread.current[Logster::Logger::LOGSTER_ENV] - env = env.merge(old_env) + logster_env = Logster::Message.populate_from_env(old_env) + + # a bit awkward by try to keep the new params + env.each do |k, v| + logster_env[k] = v + end end loggers.each do |logger| - if !(Logster::Logger === logger) logger.warn("#{message} #{append}") next @@ -519,9 +525,18 @@ module Discourse ::Logger::Severity::WARN, "discourse", message, - env: env + env: logster_env ) end + + if old_env + env.each do |k, v| + # do not leak state + logster_env.delete(k) + end + end + + nil end # report a warning maintaining backtrack for logster From 664186a2a43c143ab65cae9422e8b180579d3d9c Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 13 Aug 2018 14:48:15 +0800 Subject: [PATCH 066/179] DEV: Remove stub to make test more reliable. --- spec/models/user_avatar_spec.rb | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/spec/models/user_avatar_spec.rb b/spec/models/user_avatar_spec.rb index 7bc27537a2..f67cd4896c 100644 --- a/spec/models/user_avatar_spec.rb +++ b/spec/models/user_avatar_spec.rb @@ -86,8 +86,14 @@ describe UserAvatar do user = Fabricate(:user, uploaded_avatar_id: 1) user.user_avatar.update_columns(gravatar_upload_id: 1) - FileHelper.stubs(:download).returns(file_from_fixtures("logo.png")) - UserAvatar.import_url_for_user("logo.png", user, override_gravatar: false) + stub_request(:get, "http://thisfakesomething.something.com/") + .to_return(status: 200, body: file_from_fixtures("logo.png"), headers: {}) + + url = "http://thisfakesomething.something.com/" + + expect do + UserAvatar.import_url_for_user(url, user, override_gravatar: false) + end.to change { Upload.count }.by(1) user.reload expect(user.uploaded_avatar_id).to eq(1) @@ -101,7 +107,9 @@ describe UserAvatar do url = "http://thisfakesomething.something.com/" - UserAvatar.import_url_for_user(url, user) + expect do + UserAvatar.import_url_for_user(url, user) + end.to_not change { Upload.count } user.reload From dadbf2edb441a14e6cefd75e82eb4c2ee1e5cdab Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 13 Aug 2018 17:05:42 +0800 Subject: [PATCH 067/179] DEV: Log to STDOUT in development like how `rails s` used to. --- config/initializers/100-logster.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/config/initializers/100-logster.rb b/config/initializers/100-logster.rb index a3ba1e860a..f5d5aa5122 100644 --- a/config/initializers/100-logster.rb +++ b/config/initializers/100-logster.rb @@ -1,3 +1,14 @@ +if Rails.env.development? && !Sidekiq.server? + console = ActiveSupport::Logger.new(STDOUT) + original_logger = Rails.logger.chained.first + console.formatter = original_logger.formatter + console.level = original_logger.level + + unless ActiveSupport::Logger.logger_outputs_to?(original_logger, STDOUT) + original_logger.extend(ActiveSupport::Logger.broadcast(console)) + end +end + if Rails.env.production? Logster.store.ignore = [ # honestly, Rails should not be logging this, its real noisy From 7f4ef3db9e1a2794083bf99f5f1f8e17b24de54c Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Mon, 13 Aug 2018 15:27:51 +0200 Subject: [PATCH 068/179] Improve Telligent importer * Try multiple filenames and do lots of guessing when searching for attachments * Unescape HTML in filenames and replace invalid characters in filenames * Existing permalinks prevented resuming of import * Prevent duplicate attachments in same post --- script/import_scripts/telligent.rb | 76 +++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/script/import_scripts/telligent.rb b/script/import_scripts/telligent.rb index c872138895..7594affcec 100644 --- a/script/import_scripts/telligent.rb +++ b/script/import_scripts/telligent.rb @@ -1,11 +1,18 @@ require_relative 'base' require 'tiny_tds' +# Import script for Telligent communities +# +# Users are currently imported from a temp table. This will need some +# work the next time this import script is used, because that table +# won't exist. Also, it's really hard to find all attachments, but +# the script tries to do it anyway. + class ImportScripts::Telligent < ImportScripts::Base BATCH_SIZE ||= 1000 LOCAL_AVATAR_REGEX ||= /\A~\/.*(?communityserver-components-(?:selectable)?avatars)\/(?[^\/]+)\/(?.+)/i REMOTE_AVATAR_REGEX ||= /\Ahttps?:\/\//i - EMBEDDED_ATTACHMENT_REGEX ||= /.*?<\/a>/i + EMBEDDED_ATTACHMENT_REGEX ||= /(?.*?)<\/a>/i CATEGORY_LINK_NORMALIZATION = '/.*?(f\/\d+)$/\1' TOPIC_LINK_NORMALIZATION = '/.*?(f\/\d+\/t\/\d+)$/\1' @@ -174,7 +181,8 @@ class ImportScripts::Telligent < ImportScripts::Base if category_id = replace_with_category_id(row, child_categories, parent_category_id) add_category(row['ForumId'], Category.find_by_id(category_id)) - Permalink.create(url: "f/#{row['ForumId']}", category_id: category_id) + url = "f/#{row['ForumId']}" + Permalink.create(url: url, category_id: category_id) unless Permalink.exists?(url: url) nil else { @@ -268,7 +276,8 @@ class ImportScripts::Telligent < ImportScripts::Base post_create_action: proc do |post| topic = post.topic Jobs.enqueue_at(topic.pinned_until, :unpin_topic, topic_id: topic.id) if topic.pinned_until - Permalink.create(url: "f/#{row['ForumId']}/t/#{row['ThreadId']}", topic_id: topic.id) + url = "f/#{row['ForumId']}/t/#{row['ThreadId']}" + Permalink.create(url: url, topic_id: topic.id) unless Permalink.exists?(url: url) end } @@ -345,7 +354,7 @@ class ImportScripts::Telligent < ImportScripts::Base end def raw_with_attachment(row, user_id) - raw, embedded_paths = replace_embedded_attachments(row["Body"], user_id) + raw, embedded_paths, upload_ids = replace_embedded_attachments(row["Body"], user_id) raw = html_to_markdown(raw) || "" filename = row["FileName"] @@ -358,13 +367,16 @@ class ImportScripts::Telligent < ImportScripts::Base "%02d" % row["ApplicationId"], "%02d" % row["ApplicationContentTypeId"], ("%010d" % row["ContentId"]).scan(/.{2}/), - filename + clean_filename(filename) ) unless embedded_paths.include?(path) if File.exists?(path) upload = @uploader.create_upload(user_id, path, filename) - raw << "\n" << @uploader.html_for_upload(upload, filename) if upload.present? && upload.persisted? + + if upload.present? && upload.persisted? && !upload_ids.include?(upload.id) + raw << "\n" << @uploader.html_for_upload(upload, filename) + end else STDERR.puts "Could not find file: #{path}" end @@ -375,23 +387,17 @@ class ImportScripts::Telligent < ImportScripts::Base def replace_embedded_attachments(raw, user_id) paths = [] + upload_ids = [] raw = raw.gsub(EMBEDDED_ATTACHMENT_REGEX) do - match_data = Regexp.last_match - filename = match_data[:filename] - - path = File.join( - ENV["FILE_BASE_DIR"], - match_data[:directory].gsub("-", "."), - match_data[:path].split("-"), - filename - ) + filename, path = attachment_path(Regexp.last_match) if File.exists?(path) upload = @uploader.create_upload(user_id, path, filename) if upload.present? && upload.persisted? paths << path + upload_ids << upload.id @uploader.html_for_upload(upload, filename) end else @@ -399,7 +405,45 @@ class ImportScripts::Telligent < ImportScripts::Base end end - [raw, paths] + [raw, paths, upload_ids] + end + + def clean_filename(filename) + CGI.unescapeHTML(filename) + .gsub(/[\x00\/\\:\*\?\"<>\|]/, '_') + .gsub(/_(?:2B00|2E00|2D00|5B00|5D00|5F00)/, '') + end + + def attachment_path(match_data) + filename, path = join_attachment_path(match_data, filename_index: 2) + filename, path = join_attachment_path(match_data, filename_index: 1) unless File.exists?(path) + [filename, path] + end + + # filenames are a total mess - try to guess the correct filename + # works for 70% of all files + def join_attachment_path(match_data, filename_index:) + filename = clean_filename(match_data[:"filename#{filename_index}"]) + base_path = File.join( + ENV["FILE_BASE_DIR"], + match_data[:directory].gsub("-", "."), + match_data[:path].split("-") + ) + + path = File.join(base_path, filename) + return [filename, path] if File.exists?(path) + + original_filename = filename.dup + + filename = filename.gsub("-", " ") + path = File.join(base_path, filename) + return [filename, path] if File.exists?(path) + + filename = filename.gsub("_", "-") + path = File.join(base_path, filename) + return [filename, path] if File.exists?(path) + + [original_filename, File.join(base_path, original_filename)] end def mark_topics_as_solved From 71b65be6f6e0a289dffce3f7d02df819d9482cc3 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Mon, 13 Aug 2018 16:44:35 -0400 Subject: [PATCH 069/179] SECURITY: prevent use of X-Forwarded-Host to perform XSS --- app/views/common/_special_font_face.html.erb | 2 +- app/views/exceptions/not_found.html.erb | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/views/common/_special_font_face.html.erb b/app/views/common/_special_font_face.html.erb index 6436768afd..50fc5d96a8 100644 --- a/app/views/common/_special_font_face.html.erb +++ b/app/views/common/_special_font_face.html.erb @@ -8,7 +8,7 @@ &1 was added last when the nginx sample config changed %> -<% font_domain = "#{request.protocol}#{request.host_with_port}&2" %> +<% font_domain = "#{Discourse.base_url_no_prefix}&2".html_safe %> <% woff2_url = "#{asset_path("fontawesome-webfont.woff2")}?#{font_domain}&v=4.7.0".html_safe %> diff --git a/app/views/exceptions/not_found.html.erb b/app/views/exceptions/not_found.html.erb index 899c231514..e06acfd090 100644 --- a/app/views/exceptions/not_found.html.erb +++ b/app/views/exceptions/not_found.html.erb @@ -26,15 +26,13 @@ <% end %> <%- unless @hide_google %> - <% local_domain = "#{request.protocol}#{request.host_with_port}" %> -