FEATURE: Can override any translation via an admin interface

This commit is contained in:
Robin Ward 2015-11-23 16:45:05 -05:00
parent 6354324f2f
commit 5e93140f85
57 changed files with 769 additions and 600 deletions

View File

@ -7,6 +7,7 @@ app/assets/javascripts/vendor.js
app/assets/javascripts/locales/i18n.js
app/assets/javascripts/defer/html-sanitizer-bundle.js
app/assets/javascripts/ember-addons/
app/assets/javascripts/admin/lib/autosize.js.es6
lib/javascripts/locale/
lib/javascripts/messageformat.js
lib/javascripts/moment.js

View File

@ -1,2 +0,0 @@
import CustomizationBase from 'admin/adapters/customization-base';
export default CustomizationBase;

View File

@ -0,0 +1,24 @@
import { on, observes } from 'ember-addons/ember-computed-decorators';
import autosize from 'admin/lib/autosize';
export default Ember.TextArea.extend({
@on('didInsertElement')
_startWatching() {
Ember.run.scheduleOnce('afterRender', () => {
this.$().focus();
autosize(this.element);
});
},
@observes('value')
_updateAutosize() {
const evt = document.createEvent('Event');
evt.initEvent('autosize:update', true, false);
this.element.dispatchEvent(evt);
},
@on('willDestroyElement')
_disableAutosize() {
autosize.destroy(this.$());
}
});

View File

@ -0,0 +1,24 @@
import { on } from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({
classNames: ['site-text'],
@on('didInsertElement')
highlightTerm() {
const term = this.get('term');
if (term) {
this.$('.site-text-id, .site-text-value').highlight(term, {className: 'text-highlight'});
}
this.$('.site-text-value').ellipsis();
},
click() {
this.send('edit');
},
actions: {
edit() {
this.sendAction('editAction', this.get('siteText'));
}
}
});

View File

@ -1,14 +1,29 @@
export default Ember.Controller.extend({
saved: false,
import { popupAjaxError } from 'discourse/lib/ajax-error';
import { bufferedProperty } from 'discourse/mixins/buffered-content';
saveDisabled: function() {
return ((!this.get('allow_blank')) && Ember.isEmpty(this.get('model.value')));
}.property('model.iSaving', 'model.value'),
export default Ember.Controller.extend(bufferedProperty('siteText'), {
saved: false,
actions: {
saveChanges() {
const model = this.get('model');
model.save(model.getProperties('value')).then(() => this.set('saved', true));
const buffered = this.get('buffered');
this.get('siteText').save(buffered.getProperties('value')).then(() => {
this.commitBuffer();
this.set('saved', true);
}).catch(popupAjaxError);
},
revertChanges() {
this.set('saved', false);
bootbox.confirm(I18n.t('admin.site_text.revert_confirm'), result => {
if (result) {
this.get('siteText').revert().then(props => {
const buffered = this.get('buffered');
buffered.setProperties(props);
this.commitBuffer();
}).catch(popupAjaxError);
}
});
}
}
});

View File

@ -0,0 +1,40 @@
import { default as computed } from 'ember-addons/ember-computed-decorators';
export default Ember.Controller.extend({
_q: null,
searching: false,
siteTexts: null,
preferred: false,
queryParams: ['q'],
@computed
q: {
set(value) {
if (Ember.isEmpty(value)) { value = null; }
this._q = value;
return value;
},
get() {
return this._q;
}
},
_performSearch() {
const q = this.get('q');
this.store.find('site-text', { q }).then(results => {
this.set('siteTexts', results);
}).finally(() => this.set('searching', false));
},
actions: {
edit(siteText) {
this.transitionToRoute('adminSiteText.edit', siteText.get('id'));
},
search() {
this.set('searching', true);
Ember.run.debounce(this, this._performSearch, 400);
}
}
});

View File

@ -1 +0,0 @@
export default Ember.ArrayController.extend();

View File

@ -0,0 +1,3 @@
Em.Handlebars.helper('preserve-newlines', str => {
return new Handlebars.SafeString(Discourse.Utilities.escapeExpression(str).replace(/\n/g, "<br>"));
});

View File

@ -0,0 +1,200 @@
const set = (typeof Set === "function") ? new Set() : (function () {
const list = [];
return {
has(key) {
return Boolean(list.indexOf(key) > -1);
},
add(key) {
list.push(key);
},
delete(key) {
list.splice(list.indexOf(key), 1);
},
};
})();
function assign(ta, {setOverflowX = true, setOverflowY = true} = {}) {
if (!ta || !ta.nodeName || ta.nodeName !== 'TEXTAREA' || set.has(ta)) return;
let heightOffset = null;
let overflowY = null;
let clientWidth = ta.clientWidth;
function init() {
const style = window.getComputedStyle(ta, null);
overflowY = style.overflowY;
if (style.resize === 'vertical') {
ta.style.resize = 'none';
} else if (style.resize === 'both') {
ta.style.resize = 'horizontal';
}
if (style.boxSizing === 'content-box') {
heightOffset = -(parseFloat(style.paddingTop)+parseFloat(style.paddingBottom));
} else {
heightOffset = parseFloat(style.borderTopWidth)+parseFloat(style.borderBottomWidth);
}
// Fix when a textarea is not on document body and heightOffset is Not a Number
if (isNaN(heightOffset)) {
heightOffset = 0;
}
update();
}
function changeOverflow(value) {
{
// Chrome/Safari-specific fix:
// When the textarea y-overflow is hidden, Chrome/Safari do not reflow the text to account for the space
// made available by removing the scrollbar. The following forces the necessary text reflow.
const width = ta.style.width;
ta.style.width = '0px';
// Force reflow:
/* jshint ignore:start */
ta.offsetWidth;
/* jshint ignore:end */
ta.style.width = width;
}
overflowY = value;
if (setOverflowY) {
ta.style.overflowY = value;
}
resize();
}
function resize() {
const htmlTop = window.pageYOffset;
const bodyTop = document.body.scrollTop;
const originalHeight = ta.style.height;
ta.style.height = 'auto';
let endHeight = ta.scrollHeight+heightOffset;
if (ta.scrollHeight === 0) {
// If the scrollHeight is 0, then the element probably has display:none or is detached from the DOM.
ta.style.height = originalHeight;
return;
}
ta.style.height = endHeight+'px';
// used to check if an update is actually necessary on window.resize
clientWidth = ta.clientWidth;
// prevents scroll-position jumping
document.documentElement.scrollTop = htmlTop;
document.body.scrollTop = bodyTop;
}
function update() {
const startHeight = ta.style.height;
resize();
const style = window.getComputedStyle(ta, null);
if (style.height !== ta.style.height) {
if (overflowY !== 'visible') {
changeOverflow('visible');
}
} else {
if (overflowY !== 'hidden') {
changeOverflow('hidden');
}
}
if (startHeight !== ta.style.height) {
const evt = document.createEvent('Event');
evt.initEvent('autosize:resized', true, false);
ta.dispatchEvent(evt);
}
}
const pageResize = () => {
if (ta.clientWidth !== clientWidth) {
update();
}
};
const destroy = style => {
window.removeEventListener('resize', pageResize, false);
ta.removeEventListener('input', update, false);
ta.removeEventListener('keyup', update, false);
ta.removeEventListener('autosize:destroy', destroy, false);
ta.removeEventListener('autosize:update', update, false);
set.delete(ta);
Object.keys(style).forEach(key => {
ta.style[key] = style[key];
});
}.bind(ta, {
height: ta.style.height,
resize: ta.style.resize,
overflowY: ta.style.overflowY,
overflowX: ta.style.overflowX,
wordWrap: ta.style.wordWrap,
});
ta.addEventListener('autosize:destroy', destroy, false);
// IE9 does not fire onpropertychange or oninput for deletions,
// so binding to onkeyup to catch most of those events.
// There is no way that I know of to detect something like 'cut' in IE9.
if ('onpropertychange' in ta && 'oninput' in ta) {
ta.addEventListener('keyup', update, false);
}
window.addEventListener('resize', pageResize, false);
ta.addEventListener('input', update, false);
ta.addEventListener('autosize:update', update, false);
set.add(ta);
if (setOverflowX) {
ta.style.overflowX = 'hidden';
ta.style.wordWrap = 'break-word';
}
init();
}
function exportDestroy(ta) {
if (!(ta && ta.nodeName && ta.nodeName === 'TEXTAREA')) return;
const evt = document.createEvent('Event');
evt.initEvent('autosize:destroy', true, false);
ta.dispatchEvent(evt);
}
function exportUpdate(ta) {
if (!(ta && ta.nodeName && ta.nodeName === 'TEXTAREA')) return;
const evt = document.createEvent('Event');
evt.initEvent('autosize:update', true, false);
ta.dispatchEvent(evt);
}
let autosize = (el, options) => {
if (el) {
Array.prototype.forEach.call(el.length ? el : [el], x => assign(x, options));
}
return el;
};
autosize.destroy = el => {
if (el) {
Array.prototype.forEach.call(el.length ? el : [el], exportDestroy);
}
return el;
};
autosize.update = el => {
if (el) {
Array.prototype.forEach.call(el.length ? el : [el], exportUpdate);
}
return el;
};
export default autosize;

View File

@ -1,2 +0,0 @@
import RestModel from 'discourse/models/rest';
export default RestModel.extend();

View File

@ -1,8 +1,10 @@
import RestModel from 'discourse/models/rest';
const { getProperties } = Ember;
export default RestModel.extend({
markdown: Em.computed.equal('format', 'markdown'),
plainText: Em.computed.equal('format', 'plain'),
html: Em.computed.equal('format', 'html'),
css: Em.computed.equal('format', 'css'),
revert() {
return Discourse.ajax(`/admin/customize/site_texts/${this.get('id')}`, {
method: 'DELETE'
}).then(result => getProperties(result.site_text, 'value', 'can_revert'));
}
});

View File

@ -22,8 +22,9 @@ export default {
});
this.resource('adminSiteText', { path: '/site_texts' }, function() {
this.route('edit', {path: '/:text_type'});
this.route('edit', { path: '/:id' });
});
this.resource('adminUserFields', { path: '/user_fields' });
this.resource('adminEmojis', { path: '/emojis' });
this.resource('adminPermalinks', { path: '/permalinks' });

View File

@ -1,5 +1,9 @@
export default Discourse.Route.extend({
export default Ember.Route.extend({
model(params) {
return this.store.find('site-text', params.text_type);
return this.store.find('site-text', params.id);
},
setupController(controller, siteText) {
controller.setProperties({ siteText, saved: false });
}
});

View File

@ -0,0 +1,13 @@
export default Ember.Route.extend({
queryParams: {
q: { replace: true }
},
model(params) {
return this.store.find('site-text', { q: params.q });
},
setupController(controller, model) {
controller.set('siteTexts', model);
}
});

View File

@ -1,5 +0,0 @@
export default Discourse.Route.extend({
model() {
return this.store.findAll('site-text-type');
}
});

View File

@ -1,5 +1,7 @@
{{d-button action="saveChanges" disabled=buttonDisabled label=savingText class="btn-primary"}}
{{d-button action="saveChanges" disabled=buttonDisabled label=savingText class="btn-primary save-changes"}}
{{yield}}
<div class='save-messages'>
{{#if saved}}{{i18n 'saved'}}{{/if}}
{{#if saved}}
<div class='saved'>{{i18n 'saved'}}</div>
{{/if}}
</div>

View File

@ -0,0 +1,5 @@
{{d-button label="admin.site_text.edit" class='edit' action="edit"}}
<h3 class='site-text-id'>{{siteText.id}}</h3>
<div class='site-text-value'>{{siteText.value}}</div>
<div class='clearfix'></div>

View File

@ -1,17 +1,20 @@
<h3>{{model.title}}</h3>
<p class='description'>{{model.description}}</p>
<div class='edit-site-text'>
{{#if model.markdown}}
{{d-editor value=model.value}}
{{/if}}
{{#if model.plainText}}
{{textarea value=model.value class="plain"}}
{{/if}}
{{#if model.html}}
{{ace-editor content=model.value mode="html"}}
{{/if}}
{{#if model.css}}
{{ace-editor content=model.value mode="css"}}
{{/if}}
<div class='title'>
<h3>{{siteText.id}}</h3>
</div>
{{save-controls model=model action="saveChanges" saveDisabled=saveDisabled saved=saved}}
{{expanding-text-area value=buffered.value rows="1" class="site-text-value"}}
{{#save-controls model=siteText action="saveChanges" saved=saved}}
{{#if siteText.can_revert}}
{{d-button action="revertChanges" label="admin.site_text.revert" class="revert-site-text"}}
{{/if}}
{{/save-controls}}
{{#link-to 'adminSiteText.index' class="go-back"}}
{{fa-icon 'arrow-left'}}
{{i18n 'admin.site_text.go_back'}}
{{/link-to}}
</div>

View File

@ -1 +1,19 @@
<p>{{i18n 'admin.site_text.none'}}</p>
<div class='search-area'>
<p>{{i18n "admin.site_text.description"}}</p>
{{text-field value=q
placeholderKey="admin.site_text.search"
class="no-blur site-text-search"
autofocus="true"
keyUpAction="search"}}
</div>
{{#conditional-loading-spinner condition=searching}}
{{#unless siteTexts.findArgs.q}}
<p><b>{{i18n "admin.site_text.recommended"}}</b></p>
{{/unless}}
{{#each siteTexts as |siteText|}}
{{site-text-summary siteText=siteText editAction="edit" term=q}}
{{/each}}
{{/conditional-loading-spinner}}

View File

@ -1,15 +1,3 @@
<div class='row'>
<div class='content-list span6'>
<ul>
{{#each c in model}}
<li>
{{#link-to 'adminSiteText.edit' c.text_type}}{{c.title}}{{/link-to}}
</li>
{{/each}}
</ul>
</div>
<div class='content-editor'>
{{outlet}}
</div>
<div class='row site-texts'>
{{outlet}}
</div>

View File

@ -1,12 +0,0 @@
import { on } from "ember-addons/ember-computed-decorators";
export default Ember.TextField.extend({
@on("didInsertElement")
becomeFocused() {
const input = this.get("element");
input.focus();
input.selectionStart = input.selectionEnd = input.value.length;
}
});

View File

@ -6,5 +6,12 @@ export default Ember.TextField.extend({
@computed("placeholderKey")
placeholder(placeholderKey) {
return placeholderKey ? I18n.t(placeholderKey) : "";
},
keyUp() {
const act = this.get('keyUpAction');
if (act) {
this.sendAction('keyUpAction');
}
}
});

View File

@ -12,6 +12,7 @@ export default {
const overrides = PreloadStore.get('translationOverrides') || {};
Object.keys(overrides).forEach(k => {
const v = overrides[k];
k = k.replace('admin_js', 'js');
const segs = k.split('.');
let node = I18n.translations[I18n.locale];

View File

@ -4,6 +4,13 @@ export default Ember.ArrayProxy.extend({
totalRows: 0,
refreshing: false,
content: null,
loadMoreUrl: null,
refreshUrl: null,
findArgs: null,
store: null,
__type: null,
canLoadMore: function() {
return this.get('length') < this.get('totalRows');
}.property('totalRows', 'length'),

View File

@ -63,7 +63,7 @@ export default Ember.Object.extend({
_hydrateFindResults(result, type, findArgs) {
if (typeof findArgs === "object") {
return this._resultSet(type, result);
return this._resultSet(type, result, findArgs);
} else {
return this._hydrate(type, result[Ember.String.underscore(type)], result);
}
@ -81,7 +81,7 @@ export default Ember.Object.extend({
},
find(type, findArgs, opts) {
return this.adapterFor(type).find(this, type, findArgs, opts).then((result) => {
return this.adapterFor(type).find(this, type, findArgs, opts).then(result => {
return this._hydrateFindResults(result, type, findArgs, opts);
});
},
@ -142,14 +142,14 @@ export default Ember.Object.extend({
});
},
_resultSet(type, result) {
_resultSet(type, result, findArgs) {
const typeName = Ember.String.underscore(this.pluralize(type)),
content = result[typeName].map(obj => this._hydrate(type, obj, result)),
totalRows = result["total_rows_" + typeName] || content.length,
loadMoreUrl = result["load_more_" + typeName],
refreshUrl = result['refresh_' + typeName];
return ResultSet.create({ content, totalRows, loadMoreUrl, refreshUrl, store: this, __type: type });
return ResultSet.create({ content, totalRows, loadMoreUrl, refreshUrl, findArgs, store: this, __type: type });
},
_build(type, obj) {

View File

@ -16,7 +16,8 @@
{{#if model.isPrivateMessage}}
<span class="private-message-glyph">{{fa-icon "envelope"}}</span>
{{/if}}
{{autofocus-text-field id="edit-title" value=buffered.title maxlength=siteSettings.max_topic_title_length}}
{{text-field id="edit-title" value=buffered.title maxlength=siteSettings.max_topic_title_length autofocus="true"}}
{{#if showCategoryChooser}}
<br>
{{category-chooser valueAttribute="id" value=buffered.category_id source=buffered.category_id}}

View File

@ -9,6 +9,7 @@
//= require admin/routes/admin-email-logs
//= require admin/controllers/admin-email-skipped
//= require discourse/lib/export-result
//= require_tree ./admin/lib
//= require_tree ./admin
//= require resumable.js

View File

@ -43,6 +43,59 @@ td.flaggers td {
border-top: none;
}
.site-texts {
.search-area {
margin-bottom: 2em;
p {
margin-top: 0;
}
input {
padding: 0.5em;
font-size: 1em;
width: 50%;
}
}
.text-highlight {
font-weight: bold;
}
.site-text {
cursor: pointer;
border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%);
margin-bottom: 0.5em;
h3 {
font-weight: normal;
font-size: 1.1em;
}
button.edit {
float: right;
}
.site-text-value {
margin: 0.5em 5em 0.5em 0;
max-height: 100px;
color: dark-light-diff($primary, $secondary, 40%, -10%);
}
}
.edit-site-text {
textarea {
width: 80%;
}
.save-messages, .title {
margin-bottom: 1em;
}
.go-back {
margin-top: 1em;
}
}
}
.content-list li a span.count {
font-size: 0.857em;
float: right;

View File

@ -1,7 +0,0 @@
class Admin::SiteTextTypesController < Admin::AdminController
def index
render_serialized(SiteText.text_types, SiteTextTypeSerializer, root: 'site_text_types')
end
end

View File

@ -1,21 +1,54 @@
class Admin::SiteTextsController < Admin::AdminController
def self.preferred_keys
['system_messages.usage_tips.text_body_template',
'education.new-topic',
'education.new-reply',
'login_required.welcome_message']
end
def index
if params[:q].blank?
results = self.class.preferred_keys.map {|k| {id: k, value: I18n.t(k) }}
else
results = []
translations = I18n.search(params[:q])
translations.each do |k, v|
results << {id: k, value: v}
end
results.sort! do |x, y|
(x[:id].size + x[:value].size) <=> (y[:id].size + y[:value].size)
end
end
render_serialized(results[0..50], SiteTextSerializer, root: 'site_texts', rest_serializer: true)
end
def show
site_text = SiteText.find_or_new(params[:id].to_s)
render_serialized(site_text, SiteTextSerializer, root: 'site_text')
site_text = find_site_text
render_serialized(site_text, SiteTextSerializer, root: 'site_text', rest_serializer: true)
end
def update
site_text = SiteText.find_or_new(params[:id].to_s)
site_text = find_site_text
site_text[:value] = params[:site_text][:value]
# Updating to nothing is the same as removing it
if params[:site_text][:value].present?
site_text.value = params[:site_text][:value]
site_text.save!
else
site_text.destroy
TranslationOverride.upsert!(I18n.locale, site_text[:id], site_text[:value])
render_serialized(site_text, SiteTextSerializer, root: 'site_text', rest_serializer: true)
end
def revert
site_text = find_site_text
TranslationOverride.revert!(I18n.locale, site_text[:id])
site_text = find_site_text
render_serialized(site_text, SiteTextSerializer, root: 'site_text', rest_serializer: true)
end
protected
def find_site_text
raise Discourse::NotFound unless I18n.exists?(params[:id])
{id: params[:id], value: I18n.t(params[:id]) }
end
render_serialized(site_text, SiteTextSerializer, root: 'site_text')
end
end

View File

@ -149,18 +149,6 @@ module ApplicationHelper
result.join("\n")
end
# Look up site content for a key. If the key is blank, you can supply a block and that
# will be rendered instead.
def markdown_content(key, replacements=nil)
result = PrettyText.cook(SiteText.text_for(key, replacements || {})).html_safe
if result.blank? && block_given?
yield
nil
else
result
end
end
def application_logo_url
@application_logo_url ||= (mobile_view? && SiteSetting.mobile_logo_url) || SiteSetting.logo_url
end

View File

@ -18,7 +18,7 @@ class UserNotifications < ActionMailer::Base
build_email(user.email,
template: 'user_notifications.signup_after_approval',
email_token: opts[:email_token],
new_user_tips: SiteText.text_for(:usage_tips, base_url: Discourse.base_url))
new_user_tips: I18n.t('system_messages.usage_tips.text_body_template', base_url: Discourse.base_url))
end
def authorize_email(user, opts={})

View File

@ -1,47 +0,0 @@
require_dependency 'site_text_type'
require_dependency 'site_text_class_methods'
require_dependency 'distributed_cache'
class SiteText < ActiveRecord::Base
extend SiteTextClassMethods
self.primary_key = 'text_type'
validates_presence_of :value
after_save do
SiteText.text_for_cache.clear
end
after_destroy do
SiteText.text_for_cache.clear
end
def self.formats
@formats ||= Enum.new(:plain, :markdown, :html, :css)
end
add_text_type :usage_tips, default_18n_key: 'system_messages.usage_tips.text_body_template'
add_text_type :education_new_topic, default_18n_key: 'education.new-topic'
add_text_type :education_new_reply, default_18n_key: 'education.new-reply'
add_text_type :login_required_welcome_message, default_18n_key: 'login_required.welcome_message'
def site_text_type
@site_text_type ||= SiteText.find_text_type(text_type)
end
end
# == Schema Information
#
# Table name: site_texts
#
# text_type :string(255) not null, primary key
# value :text not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_site_texts_on_text_type (text_type) UNIQUE
#

View File

@ -1,27 +0,0 @@
class SiteTextType
attr_accessor :text_type, :format
def initialize(text_type, format, opts=nil)
@opts = opts || {}
@text_type = text_type
@format = format
end
def title
I18n.t("content_types.#{text_type}.title")
end
def description
I18n.t("content_types.#{text_type}.description")
end
def allow_blank?
!!@opts[:allow_blank]
end
def default_text
@opts[:default_18n_key].present? ? I18n.t(@opts[:default_18n_key]) : ""
end
end

View File

@ -1,39 +1,20 @@
class SiteTextSerializer < ApplicationSerializer
attributes :id,
:text_type,
:title,
:description,
:value,
:format,
:allow_blank?
attributes :id, :value, :can_revert?
def id
text_type
end
def title
object.site_text_type.title
end
def text_type
object.text_type
end
def description
object.site_text_type.description
end
def format
object.site_text_type.format
object[:id]
end
def value
return object.value if object.value.present?
object.site_text_type.default_text
object[:value]
end
def allow_blank?
object.site_text_type.allow_blank?
def can_revert?
current_val = value
I18n.overrides_disabled do
return I18n.t(object[:id]) != current_val
end
end
end

View File

@ -1,17 +0,0 @@
class SiteTextTypeSerializer < ApplicationSerializer
attributes :id, :text_type, :title
def id
text_type
end
def text_type
object.text_type
end
def title
object.title
end
end

View File

@ -1,3 +1,3 @@
<% if SiteSetting.login_required %>
<%= markdown_content(:login_required_welcome_message) %>
<%= PrettyText.cook(I18n.t('login_required.welcome_message')).html_safe %>
<% end %>

View File

@ -3,6 +3,8 @@
require 'i18n/backend/discourse_i18n'
I18n.backend = I18n::Backend::DiscourseI18n.new
I18n.config.missing_interpolation_argument_handler = proc { throw(:exception) }
I18n.reload!
I18n.init_accelerator!
MessageBus.subscribe("/i18n-flush") { I18n.reload! }
unless Rails.env.test?
MessageBus.subscribe("/i18n-flush") { I18n.reload! }
end

View File

@ -2482,8 +2482,14 @@ en:
dropdown: "Dropdown"
site_text:
none: "Choose a type of content to begin editing."
description: "You can customize any of the text on your forum. Please start by searching below:"
search: "Search for the text you'd like to edit"
title: 'Text Content'
edit: 'edit'
revert: "Revert Changes"
revert_confirm: "Are you sure you want to revert your changes?"
go_back: "Back to Search"
recommended: "We recommend customizing the following text to suit your needs:"
site_settings:
show_overriden: 'Only show overridden'

View File

@ -748,38 +748,6 @@ en:
notification_email_warning: "Notification emails are not being sent from a valid email address on your domain; email delivery will be erratic and unreliable. Please set notification_email to a valid local email address in <a href='/admin/site_settings'>Site Settings</a>."
subfolder_ends_in_slash: "Your subfolder setup is incorrect; the DISCOURSE_RELATIVE_URL_ROOT ends in a slash."
content_types:
education_new_reply:
title: "New User Education: First Replies"
description: "Pop up just-in-time guidance automatically displayed above the composer when new users begin typing their first two new replies."
education_new_topic:
title: "New User Education: First Topics"
description: "Pop up just-in-time guidance automatically displayed above the composer when new users begin typing their first two new topics."
usage_tips:
title: "New User Guidance"
description: "Guidance and essential information for new users."
welcome_user:
title: "Welcome: New User"
description: "A message automatically sent to all new users when they sign up."
welcome_invite:
title: "Welcome: Invited User"
description: "A message automatically sent to all new invited users when they accept the invitation from another user to participate."
login_required_welcome_message:
title: "Login Required: Welcome Message"
description: "Welcome message that is displayed to logged out users when the 'login required' setting is enabled."
login_required:
title: "Login Required: Homepage"
description: "The text displayed for unauthorized users when login is required on the site."
head:
title: "HTML head"
description: "HTML that will be inserted inside the <head></head> tags."
top:
title: "Top of the pages"
description: "HTML that will be added at the top of every page (after the header, before the navigation or the topic title)."
bottom:
title: "Bottom of the pages"
description: "HTML that will be added before the </body> tag."
site_settings:
censored_words: "Words that will be automatically replaced with &#9632;&#9632;&#9632;&#9632;"
delete_old_hidden_posts: "Auto-delete any hidden posts that stay hidden for more than 30 days."

View File

@ -154,12 +154,15 @@ Discourse::Application.routes.draw do
post "flags/defer/:id" => "flags#defer"
resources :site_customizations, constraints: AdminConstraint.new
scope "/customize" do
resources :site_texts, constraints: AdminConstraint.new
resources :site_text_types, constraints: AdminConstraint.new
resources :user_fields, constraints: AdminConstraint.new
resources :emojis, constraints: AdminConstraint.new
# They have periods in their URLs often:
get 'site_texts' => 'site_texts#index'
match 'site_texts/(:id)' => 'site_texts#show', :constraints => { :id => /[0-9a-z\_\.\-]+/ }, via: :get
match 'site_texts/(:id)' => 'site_texts#update', :constraints => { :id => /[0-9a-z\_\.\-]+/ }, via: :put
match 'site_texts/(:id)' => 'site_texts#revert', :constraints => { :id => /[0-9a-z\_\.\-]+/ }, via: :delete
get 'email_templates' => 'email_templates#index'
match 'email_templates/(:id)' => 'email_templates#show', :constraints => { :id => /[0-9a-z\_\.]+/ }, via: :get
match 'email_templates/(:id)' => 'email_templates#update', :constraints => { :id => /[0-9a-z\_\.]+/ }, via: :put

View File

@ -32,11 +32,9 @@ unless Rails.env.test?
company_name: "company_short_name"
})
create_static_page_topic('guidelines_topic_id', 'guidelines_topic.title', "guidelines_topic.body",
(SiteText.text_for(:faq) rescue nil), staff, "guidelines")
create_static_page_topic('guidelines_topic_id', 'guidelines_topic.title', "guidelines_topic.body", nil, staff, "guidelines")
create_static_page_topic('privacy_topic_id', 'privacy_topic.title', "privacy_topic.body",
(SiteText.text_for(:privacy_policy) rescue nil), staff, "privacy policy")
create_static_page_topic('privacy_topic_id', 'privacy_topic.title', "privacy_topic.body", nil, staff, "privacy policy")
end
if seed_welcome_topics

View File

@ -0,0 +1,21 @@
class RemoveSiteText < ActiveRecord::Migration
def change
execute "INSERT INTO translation_overrides (locale, translation_key, value, created_at, updated_at)
SELECT '#{I18n.locale}',
CASE
WHEN text_type = 'usage_tips' THEN 'system_messages.usage_tips.text_body_template'
WHEN text_type = 'education_new_topic' THEN 'education.new-topic'
WHEN text_type = 'education_new_reply' THEN 'education.new-reply'
WHEN text_type = 'login_required_welcome_message' THEN 'login_required.welcome_message'
END,
value,
created_at,
updated_at
FROM site_texts
WHERE text_type in ('usage_tips',
'education_new_topic',
'education_new_reply',
'login_required_welcome_message')"
drop_table :site_texts
end
end

View File

@ -18,10 +18,10 @@ class ComposerMessagesFinder
def check_education_message
if creating_topic?
count = @user.created_topic_count
education_key = :education_new_topic
education_key = 'education.new_topic'
else
count = @user.post_count
education_key = :education_new_reply
education_key = 'education.new_reply'
end
if count < SiteSetting.educate_until_posts
@ -29,7 +29,7 @@ class ComposerMessagesFinder
return {
templateName: 'composer/education',
wait_for_typing: true,
body: PrettyText.cook(SiteText.text_for(education_key, education_posts_text: education_posts_text))
body: PrettyText.cook(I18n.t(education_key, education_posts_text: education_posts_text))
}
end

View File

@ -13,14 +13,18 @@ module I18n
# this accelerates translation a tiny bit (halves the time it takes)
class << self
alias_method :translate_no_cache, :translate
alias_method :exists_no_cache?, :exists?
alias_method :reload_no_cache!, :reload!
LRU_CACHE_SIZE = 300
def init_accelerator!
@overrides_enabled = true
reload!
end
def reload!
@loaded_locales = []
@cache = nil
@overrides_enabled = true
@overrides_by_site = {}
reload_no_cache!
@ -47,6 +51,21 @@ module I18n
backend.fallbacks(locale).each {|l| ensure_loaded!(l) }
end
def search(query, opts=nil)
load_locale(config.locale) unless @loaded_locales.include?(config.locale)
opts ||= {}
target = opts[:backend] || backend
results = target.search(config.locale, query)
regexp = /#{query}/i
(overrides_by_locale || {}).each do |k, v|
results.delete(k)
results[k] = v if (k =~ regexp || v =~ regexp)
end
results
end
def ensure_loaded!(locale)
@loaded_locales ||= []
load_locale(locale) unless @loaded_locales.include?(locale)
@ -94,7 +113,7 @@ module I18n
end
def client_overrides_json
client_json = (overrides_by_locale || {}).select {|k, _| k.starts_with?('js.')}
client_json = (overrides_by_locale || {}).select {|k, _| k.starts_with?('js.') || k.starts_with?('admin_js.')}
MultiJson.dump(client_json)
end
@ -118,5 +137,11 @@ module I18n
end
alias_method :t, :translate
def exists?(*args)
load_locale(config.locale) unless @loaded_locales.include?(config.locale)
exists_no_cache?(*args)
end
end
end

View File

@ -41,7 +41,25 @@ module I18n
false
end
def search(locale, query)
find_results(/#{query}/i, {}, translations[locale])
end
protected
def find_results(regexp, results, translations, path=nil)
return results if translations.blank?
translations.each do |k_sym, v|
k = k_sym.to_s
key_path = path ? "#{path}.#{k}" : k
if v.is_a?(String)
results[key_path] = v if key_path =~ regexp || v =~ regexp
elsif v.is_a?(Hash)
find_results(regexp, results, v, key_path)
end
end
results
end
def lookup(locale, key, scope = [], options = {})
# Support interpolation and pluralization of overrides

View File

@ -371,7 +371,6 @@ module SiteSettingExtension
protected
def clear_cache!
SiteText.text_for_cache.clear
Rails.cache.delete(SiteSettingExtension.client_settings_cache_key)
Site.clear_anon_cache!
end

View File

@ -1,66 +0,0 @@
module SiteTextClassMethods
def text_types
@types || []
end
def find_text_type(ct)
SiteText.text_types.find {|t| t.text_type == ct.to_sym}
end
def add_text_type(text_type, opts=nil)
opts ||= {}
@types ||= []
format = opts[:format] || :markdown
@types << SiteTextType.new(text_type, format, opts)
end
def text_for_cache
@text_for_cache ||= DistributedCache.new("text_for_cache")
end
def text_for(text_type, replacements=nil)
text = nil
text = text_for_cache[text_type] if replacements.blank?
text ||= uncached_text_for(text_type, replacements)
end
def uncached_text_for(text_type, replacements=nil)
store_cache = replacements.blank?
replacements ||= {}
replacements = {site_name: SiteSetting.title}.merge!(replacements)
replacements = SiteSetting.settings_hash.merge!(replacements)
site_text = SiteText.select(:value).find_by(text_type: text_type)
result = ""
if site_text.blank?
ct = find_text_type(text_type)
result = ct.default_text.dup if ct.present?
else
result = site_text.value.dup
end
result.gsub!(/\%\{[^}]+\}/) do |m|
replacements[m[2..-2].to_sym] || m
end
if store_cache
result.freeze
text_for_cache[text_type] = result
end
result
end
def find_or_new(text_type)
site_text = SiteText.find_by(text_type: text_type)
return site_text if site_text.present?
site_text = SiteText.new
site_text.text_type = text_type
site_text
end
end

View File

@ -50,7 +50,7 @@ class SystemMessage
site_name: SiteSetting.title,
username: @recipient.username,
user_preferences_url: "#{Discourse.base_url}/users/#{@recipient.username_lower}/preferences",
new_user_tips: SiteText.text_for(:usage_tips, base_url: Discourse.base_url),
new_user_tips: I18n.t('system_messages.usage_tips.text_body_template', base_url: Discourse.base_url),
site_password: "",
base_url: Discourse.base_url,
}

View File

@ -25,6 +25,21 @@ describe I18n::Backend::DiscourseI18n do
expect(backend.translate(:en, 'wat', count: 3)).to eq("Hello 3")
end
it 'can be searched by key or value' do
expect(backend.search(:en, 'fo')).to eq({'foo' => 'Foo in :en'})
expect(backend.search(:en, 'foo')).to eq({'foo' => 'Foo in :en' })
expect(backend.search(:en, 'Foo')).to eq({'foo' => 'Foo in :en' })
expect(backend.search(:en, 'hello')).to eq({'wat' => 'Hello %{count}' })
expect(backend.search(:en, 'items.one')).to eq({'items.one' => 'one item' })
end
it 'can return multiple results' do
results = backend.search(:en, 'item')
expect(results['items.one']).to eq('one item')
expect(results['items.other']).to eq('%{count} items')
end
describe '#exists?' do
it 'returns true when a key is given that exists' do
expect(backend.exists?(:de, :bar)).to eq(true)
@ -67,12 +82,21 @@ describe I18n::Backend::DiscourseI18n do
expect(I18n.translate('foo')).to eq('new value')
end
it "can be searched" do
TranslationOverride.upsert!('en', 'wat', 'Overwritten value')
expect(I18n.search('wat', backend: backend)).to eq({'wat' => 'Overwritten value'})
expect(I18n.search('Overwritten', backend: backend)).to eq({'wat' => 'Overwritten value'})
expect(I18n.search('Hello', backend: backend)).to eq({})
end
it 'supports disabling' do
TranslationOverride.upsert!('en', 'foo', 'meep')
orig_title = I18n.t('title')
TranslationOverride.upsert!('en', 'title', 'overridden title')
I18n.overrides_disabled do
expect(I18n.translate('foo')).to eq('meep')
expect(I18n.translate('title')).to eq(orig_title)
end
expect(I18n.translate('title')).to eq('overridden title')
end
it 'supports interpolation' do
@ -104,10 +128,12 @@ describe I18n::Backend::DiscourseI18n do
it "returns client overrides" do
TranslationOverride.upsert!('en', 'js.foo', 'bar')
TranslationOverride.upsert!('en', 'admin_js.beep', 'boop')
json = ::JSON.parse(I18n.client_overrides_json)
expect(json).to be_present
expect(json['js.foo']).to eq('bar')
expect(json['admin_js.beep']).to eq('boop')
end
end
end

View File

@ -1,27 +0,0 @@
require 'spec_helper'
describe Admin::SiteTextTypesController do
it "is a subclass of AdminController" do
expect(Admin::SiteTextTypesController < Admin::AdminController).to eq(true)
end
context 'while logged in as an admin' do
before do
@user = log_in(:admin)
end
context ' .index' do
it 'returns success' do
xhr :get, :index
expect(response).to be_success
end
it 'returns JSON' do
xhr :get, :index
expect(::JSON.parse(response.body)).to be_present
end
end
end
end

View File

@ -11,17 +11,69 @@ describe Admin::SiteTextsController do
@user = log_in(:admin)
end
context '.show' do
let(:text_type) { SiteText.text_types.first.text_type }
it 'returns success' do
xhr :get, :show, id: text_type
context '.index' do
it 'returns json' do
xhr :get, :index, q: 'title'
expect(response).to be_success
expect(::JSON.parse(response.body)).to be_present
end
end
context '.show' do
it 'returns a site text for a key that exists' do
xhr :get, :show, id: 'title'
expect(response).to be_success
json = ::JSON.parse(response.body)
expect(json).to be_present
site_text = json['site_text']
expect(site_text).to be_present
expect(site_text['id']).to eq('title')
expect(site_text['value']).to eq(I18n.t(:title))
end
it 'returns JSON' do
xhr :get, :show, id: text_type
expect(::JSON.parse(response.body)).to be_present
it 'returns not found for missing keys' do
xhr :get, :show, id: 'made_up_no_key_exists'
expect(response).not_to be_success
end
end
context '.update and .revert' do
it 'updates and reverts the key' do
orig_title = I18n.t(:title)
xhr :put, :update, id: 'title', site_text: {value: 'hello'}
expect(response).to be_success
json = ::JSON.parse(response.body)
expect(json).to be_present
site_text = json['site_text']
expect(site_text).to be_present
expect(site_text['id']).to eq('title')
expect(site_text['value']).to eq('hello')
# Revert
xhr :put, :revert, id: 'title'
expect(response).to be_success
json = ::JSON.parse(response.body)
expect(json).to be_present
site_text = json['site_text']
expect(site_text).to be_present
expect(site_text['id']).to eq('title')
expect(site_text['value']).to eq(orig_title)
end
it 'returns not found for missing keys' do
xhr :put, :update, id: 'made_up_no_key_exists', site_text: {value: 'hello'}
expect(response).not_to be_success
end
end
end

View File

@ -1,14 +0,0 @@
Fabricator(:site_text) do
text_type "great.poem"
value "%{flower} are red. %{food} are blue."
end
Fabricator(:site_text_basic, from: :site_text) do
text_type "breaking.bad"
value "best show ever"
end
Fabricator(:site_text_site_setting, from: :site_text) do
text_type "site.replacement"
value "%{title} is evil."
end

View File

@ -1,83 +0,0 @@
require 'spec_helper'
describe SiteText do
it { is_expected.to validate_presence_of :value }
describe "#text_for" do
it "returns an empty string for a missing text_type" do
expect(SiteText.text_for('something_random')).to eq("")
end
it "returns the default value for a text` type with a default" do
expect(SiteText.text_for("usage_tips")).to be_present
end
it "correctly expires and bypasses cache" do
SiteSetting.enable_sso = false
text = SiteText.create!(text_type: "got.sso", value: "got sso: %{enable_sso}")
expect(SiteText.text_for("got.sso")).to eq("got sso: false")
SiteText.text_for("got.sso").frozen? == true
SiteSetting.enable_sso = true
wait_for do
SiteText.text_for("got.sso") == "got sso: true"
end
text.value = "I gots sso: %{enable_sso}"
text.save!
wait_for do
SiteText.text_for("got.sso") == "I gots sso: true"
end
expect(SiteText.text_for("got.sso", enable_sso: "frog")).to eq("I gots sso: frog")
end
context "without replacements" do
let!(:site_text) { Fabricate(:site_text_basic) }
it "returns the simple string" do
expect(SiteText.text_for('breaking.bad')).to eq("best show ever")
end
end
context "with replacements" do
let!(:site_text) { Fabricate(:site_text) }
let(:replacements) { {flower: 'roses', food: 'grapes'} }
it "returns the correct string with replacements" do
expect(SiteText.text_for('great.poem', replacements)).to eq("roses are red. grapes are blue.")
end
it "doesn't mind extra keys in the replacements" do
expect(SiteText.text_for('great.poem', replacements.merge(extra: 'key'))).to eq("roses are red. grapes are blue.")
end
it "ignores missing keys" do
expect(SiteText.text_for('great.poem', flower: 'roses')).to eq("roses are red. %{food} are blue.")
end
end
context "replacing site_settings" do
let!(:site_text) { Fabricate(:site_text_site_setting) }
it "replaces site_settings by default" do
SiteSetting.title = "Evil Trout"
expect(SiteText.text_for('site.replacement')).to eq("Evil Trout is evil.")
end
it "allows us to override the default site settings" do
SiteSetting.title = "Evil Trout"
expect(SiteText.text_for('site.replacement', title: 'Good Tuna')).to eq("Good Tuna is evil.")
end
end
end
end

View File

@ -0,0 +1,41 @@
import { acceptance } from "helpers/qunit-helpers";
acceptance("Admin - Site Texts", { loggedIn: true });
test("search for a key", () => {
visit("/admin/customize/site_texts");
fillIn('.site-text-search', 'Test');
andThen(() => ok(exists('.site-text')));
});
test("edit and revert a site text by key", () => {
visit("/admin/customize/site_texts/site.test");
andThen(() => {
equal(find('.title h3').text(), 'site.test');
ok(!exists('.save-messages .saved'));
ok(!exists('.save-messages .saved'));
ok(!exists('.revert-site-text'));
});
// Change the value
fillIn('.site-text-value', 'New Test Value');
click(".save-changes");
andThen(() => {
ok(exists('.save-messages .saved'));
ok(exists('.revert-site-text'));
});
// Revert the changes
click('.revert-site-text');
andThen(() => {
ok(exists('.bootbox.modal'));
});
click('.bootbox.modal .btn-primary');
andThen(() => {
ok(!exists('.save-messages .saved'));
ok(!exists('.revert-site-text'));
});
});

View File

@ -1,75 +0,0 @@
import { acceptance } from "helpers/qunit-helpers";
acceptance("Queued Posts", { loggedIn: true });
test("approve a post", () => {
visit("/queued-posts");
click('.queued-post:eq(0) button.approve');
andThen(() => {
ok(!exists('.queued-post'), 'it removes the post');
});
});
test("reject a post", () => {
visit("/queued-posts");
click('.queued-post:eq(0) button.reject');
andThen(() => {
ok(!exists('.queued-post'), 'it removes the post');
});
});
test("delete user", () => {
visit("/queued-posts");
click('.queued-post:eq(0) button.delete-user');
andThen(() => {
ok(exists('.bootbox.modal'), 'it pops up a confirmation dialog');
});
click('.modal-footer a:eq(1)');
andThen(() => {
ok(!exists('.bootbox.modal'), 'it dismisses the modal');
ok(exists('.queued-post'), "it doesn't remove the post");
});
click('.queued-post:eq(0) button.delete-user');
click('.modal-footer a:eq(0)');
andThen(() => {
ok(!exists('.bootbox.modal'), 'it dismisses the modal');
ok(!exists('.queued-post'), "it removes the post");
});
});
test("edit a post - cancel", () => {
visit("/queued-posts");
click('.queued-post:eq(0) button.edit');
andThen(() => {
equal(find('.queued-post:eq(0) textarea').val(), 'queued post text', 'it shows an editor');
});
fillIn('.queued-post:eq(0) textarea', 'new post text');
click('.queued-post:eq(0) button.cancel');
andThen(() => {
ok(!exists('textarea'), 'it disables editing');
equal(find('.queued-post:eq(0) .body p').text(), 'queued post text', 'it reverts the new text');
});
});
test("edit a post - confirm", () => {
visit("/queued-posts");
click('.queued-post:eq(0) button.edit');
andThen(() => {
equal(find('.queued-post:eq(0) textarea').val(), 'queued post text', 'it shows an editor');
});
fillIn('.queued-post:eq(0) textarea', 'new post text');
click('.queued-post:eq(0) button.confirm');
andThen(() => {
ok(!exists('textarea'), 'it disables editing');
equal(find('.queued-post:eq(0) .body p').text(), 'new post text', 'it has the new text');
});
});

View File

@ -25,9 +25,7 @@ function response(code, obj) {
return [code, {"Content-Type": "application/json"}, obj];
}
function success() {
return response({ success: true });
}
const success = () => response({ success: true });
const _widgets = [
{id: 123, name: 'Trout Lure'},
@ -50,9 +48,7 @@ const colors = [{id: 1, name: 'Red'},
{id: 2, name: 'Green'},
{id: 3, name: 'Yellow'}];
function loggedIn() {
return !!Discourse.User.current();
}
const loggedIn = () => !!Discourse.User.current();
export default function() {
@ -77,9 +73,9 @@ export default function() {
}
});
this.get('/admin/plugins', () => { return response({ plugins: [] }); });
this.get('/admin/plugins', () => response({ plugins: [] }));
this.get('/composer-messages', () => { return response([]); });
this.get('/composer-messages', () => response([]));
this.get("/latest.json", () => {
const json = fixturesByUrl['/latest.json'];
@ -101,27 +97,19 @@ export default function() {
return response(json);
});
this.put('/users/eviltrout', () => {
return response({ user: {} });
});
this.put('/users/eviltrout', () => response({ user: {} }));
this.get("/t/280.json", function() {
return response(fixturesByUrl['/t/280/1.json']);
});
this.get("/t/280.json", () => response(fixturesByUrl['/t/280/1.json']));
this.get("/t/28830.json", function() {
return response(fixturesByUrl['/t/28830/1.json']);
});
this.get("/t/28830.json", () => response(fixturesByUrl['/t/28830/1.json']));
this.get("/t/9.json", function() {
return response(fixturesByUrl['/t/9/1.json']);
});
this.get("/t/9.json", () => response(fixturesByUrl['/t/9/1.json']));
this.get("/t/id_for/:slug", function() {
this.get("/t/id_for/:slug", () => {
return response({id: 280, slug: "internationalization-localization", url: "/t/internationalization-localization/280"});
});
this.get("/404-body", function() {
this.get("/404-body", () => {
return [200, {"Content-Type": "text/html"}, "<div class='page-not-found'>not found</div>"];
});
@ -135,9 +123,7 @@ export default function() {
return response({category});
});
this.get('/draft.json', function() {
return response({});
});
this.get('/draft.json', () => response({}));
this.put('/queued_posts/:queued_post_id', function(request) {
return response({ queued_post: {id: request.params.queued_post_id } });
@ -173,37 +159,25 @@ export default function() {
return response({available: true});
});
this.post('/users', function() {
return response({success: true});
});
this.post('/users', () => response({success: true}));
this.get('/login.html', function() {
return [200, {}, 'LOGIN PAGE'];
});
this.get('/login.html', () => [200, {}, 'LOGIN PAGE']);
this.delete('/posts/:post_id', success);
this.put('/posts/:post_id/recover', success);
this.put('/posts/:post_id', (request) => {
this.put('/posts/:post_id', request => {
const data = parsePostData(request.requestBody);
data.post.id = request.params.post_id;
data.post.version = 2;
return response(200, data.post);
});
this.get('/t/403.json', () => {
return response(403, {});
});
this.get('/t/403.json', () => response(403, {}));
this.get('/t/404.json', () => response(404, "not found"));
this.get('/t/500.json', () => response(502, {}));
this.get('/t/404.json', () => {
return response(404, "not found");
});
this.get('/t/500.json', () => {
return response(502, {});
});
this.put('/t/:slug/:id', (request) => {
this.put('/t/:slug/:id', request => {
const data = parsePostData(request.requestBody);
return response(200, { basic_topic: {id: request.params.id,
@ -264,7 +238,6 @@ export default function() {
return response({ cool_thing });
});
this.get('/widgets', function(request) {
let result = _widgets;
@ -286,8 +259,18 @@ export default function() {
this.delete('/widgets/:widget_id', success);
this.post('/topics/timings', function() {
return response(200, {});
this.post('/topics/timings', () => response(200, {}));
const siteText = {id: 'site.test', value: 'Test McTest'};
this.get('/admin/customize/site_texts', () => response(200, {site_texts: [siteText] }));
this.get('/admin/customize/site_texts/:key', () => response(200, {site_text: siteText }));
this.delete('/admin/customize/site_texts/:key', () => response(200, {site_text: siteText }));
this.put('/admin/customize/site_texts/:key', request => {
const result = parsePostData(request.requestBody);
result.id = request.params.key;
result.can_revert = true;
return response(200, {site_text: result});
});
});
@ -304,9 +287,6 @@ export default function() {
throw error;
};
server.checkPassthrough = function(request) {
return request.requestHeaders['Discourse-Script'];
};
server.checkPassthrough = request => request.requestHeaders['Discourse-Script'];
return server;
}