Version bump

This commit is contained in:
Neil Lalonde 2017-03-20 12:07:13 -04:00
commit 919f33f377
193 changed files with 4171 additions and 1367 deletions

View File

@ -120,6 +120,8 @@ group :test do
gem 'fakeweb', '~> 1.3.0', require: false
gem 'minitest', require: false
gem 'timecop'
# TODO: Remove once we upgrade to Rails 5.
gem 'test_after_commit'
end
group :test, :development do

View File

@ -138,7 +138,7 @@ GEM
json (1.8.6)
jwt (1.5.2)
kgio (2.10.0)
libv8 (5.3.332.38.3)
libv8 (5.3.332.38.5)
listen (0.7.3)
logster (1.2.7)
loofah (2.0.3)
@ -153,7 +153,7 @@ GEM
method_source (0.8.2)
mime-types (2.99.2)
mini_portile2 (2.1.0)
mini_racer (0.1.7)
mini_racer (0.1.9)
libv8 (~> 5.3)
minitest (5.9.1)
mocha (1.1.0)
@ -206,7 +206,7 @@ GEM
omniauth-twitter (1.3.0)
omniauth-oauth (~> 1.1)
rack
onebox (1.8.2)
onebox (1.8.3)
fast_blank (>= 1.0.0)
htmlentities (~> 4.3.4)
moneta (~> 0.8)
@ -276,7 +276,7 @@ GEM
ffi (>= 1.0.6)
msgpack (>= 0.4.3)
trollop (>= 1.16.2)
redis (3.3.1)
redis (3.3.3)
redis-namespace (1.5.2)
redis (~> 3.0, >= 3.0.4)
rest-client (1.8.0)
@ -359,6 +359,8 @@ GEM
activesupport (>= 4.0)
sprockets (>= 3.0.0)
stackprof (0.2.10)
test_after_commit (1.1.0)
activerecord (>= 3.2)
thor (0.19.1)
thread_safe (0.3.5)
tilt (2.0.5)
@ -470,6 +472,7 @@ DEPENDENCIES
sinatra
spork-rails
stackprof
test_after_commit
thor
timecop
uglifier
@ -477,4 +480,4 @@ DEPENDENCIES
unicorn
BUNDLED WITH
1.14.3
1.14.6

View File

@ -0,0 +1,33 @@
import { iconHTML } from 'discourse-common/helpers/fa-icon';
import { bufferedRender } from 'discourse-common/lib/buffered-render';
export default Ember.Component.extend(bufferedRender({
tagName: 'th',
classNames: ['sortable'],
rerenderTriggers: ['order', 'ascending'],
buildBuffer(buffer) {
const icon = this.get('icon');
if (icon) {
buffer.push(iconHTML(icon));
}
buffer.push(I18n.t(this.get('i18nKey')));
if (this.get('field') === this.get('order')) {
buffer.push(iconHTML(this.get('ascending') ? 'chevron-up' : 'chevron-down'));
}
},
click() {
const currentOrder = this.get('order');
const field = this.get('field');
if (currentOrder === field) {
this.set('ascending', this.get('ascending') ? null : true);
} else {
this.setProperties({ order: field, ascending: null });
}
}
}));

View File

@ -0,0 +1,11 @@
import computed from 'ember-addons/ember-computed-decorators';
export default Ember.Controller.extend({
adminGroupsType: Ember.inject.controller(),
sortedGroups: Ember.computed.alias("adminGroupsType.sortedGroups"),
@computed("sortedGroups")
messageKey(sortedGroups) {
return `admin.groups.${sortedGroups.length > 0 ? 'none_selected' : 'no_custom_groups'}`;
}
});

View File

@ -1,9 +1,14 @@
import debounce from 'discourse/lib/debounce';
import { i18n } from 'discourse/lib/computed';
import AdminUser from 'admin/models/admin-user';
import { observes } from 'ember-addons/ember-computed-decorators';
export default Ember.Controller.extend({
query: null,
queryParams: ['order', 'ascending'],
order: 'seen',
ascending: null,
showEmails: false,
refreshing: false,
listFilter: null,
@ -39,14 +44,15 @@ export default Ember.Controller.extend({
this._refreshUsers();
}, 250).observes('listFilter'),
@observes('order', 'ascending')
_refreshUsers: function() {
var self = this;
this.set('refreshing', true);
AdminUser.findAll(this.get('query'), { filter: this.get('listFilter'), show_emails: this.get('showEmails') }).then(function (result) {
self.set('model', result);
}).finally(function() {
self.set('refreshing', false);
AdminUser.findAll(this.get('query'), { filter: this.get('listFilter'), show_emails: this.get('showEmails'), order: this.get('order'), ascending: this.get('ascending') }).then( (result) => {
this.set('model', result);
}).finally( () => {
this.set('refreshing', false);
});
},

View File

@ -85,7 +85,7 @@ export default Discourse.Route.extend({
if (confirmed) {
Discourse.User.currentProp("hideReadOnlyAlert", true);
backup.restore().then(function() {
self.controllerFor("adminBackupsLogs").clear();
self.controllerFor("adminBackupsLogs").get("logs").clear();
self.controllerFor("adminBackups").set("model.isOperationRunning", true);
self.transitionTo("admin.backups.logs");
});

View File

@ -1,7 +1,7 @@
<div class="current-style {{if maximized 'maximized'}}">
<div class='wrapper'>
{{text-field class="style-name" value=model.name}}
<a class="btn export" download target="_blank" href={{downloadUrl}}>{{fa-icon "download"}} {{i18n 'admin.export_json.button_text'}}</a>
<a class="btn export" target="_blank" href={{downloadUrl}}>{{fa-icon "download"}} {{i18n 'admin.export_json.button_text'}}</a>
<div class='admin-controls'>
<ul class="nav nav-pills">

View File

@ -0,0 +1,9 @@
<div class="groups-type-index">
<p>{{i18n messageKey}}</p>
<div>
{{#link-to 'adminGroup' 'new' class="btn"}}
{{fa-icon "plus"}} {{i18n 'admin.groups.new'}}
{{/link-to}}
</div>
</div>

View File

@ -1,29 +1,31 @@
<div class='row groups'>
<div class='content-list span6'>
<h3>{{i18n 'admin.groups.edit'}}</h3>
<ul>
{{#each sortedGroups as |group|}}
<li>
{{#link-to "adminGroup" group.type group.name}}{{group.name}}
{{#if group.userCountDisplay}}
<span class="count">{{number group.userCountDisplay}}</span>
{{/if}}
{{#if sortedGroups}}
<div class='content-list span6'>
<h3>{{i18n 'admin.groups.edit'}}</h3>
<ul>
{{#each sortedGroups as |group|}}
<li>
{{#link-to "adminGroup" group.type group.name}}{{group.name}}
{{#if group.userCountDisplay}}
<span class="count">{{number group.userCountDisplay}}</span>
{{/if}}
{{/link-to}}
</li>
{{/each}}
</ul>
<div class='controls'>
{{#if isAuto}}
{{d-button action="refreshAutoGroups" icon="refresh" label="admin.groups.refresh" disabled=refreshingAutoGroups}}
{{else}}
{{#link-to 'adminGroup' 'new' class="btn"}}
{{fa-icon "plus"}} {{i18n 'admin.groups.new'}}
{{/link-to}}
</li>
{{/each}}
</ul>
<div class='controls'>
{{#if isAuto}}
{{d-button action="refreshAutoGroups" icon="refresh" label="admin.groups.refresh" disabled=refreshingAutoGroups}}
{{else}}
{{#link-to 'adminGroup' 'new' class="btn"}}
{{fa-icon "plus"}} {{i18n 'admin.groups.new'}}
{{/link-to}}
{{/if}}
{{/if}}
</div>
</div>
</div>
{{/if}}
<div class='content-editor'>
<div class="span13">
{{outlet}}
</div>
</div>

View File

@ -22,7 +22,7 @@
{{#conditional-loading-spinner condition=refreshing}}
{{#if model}}
<table class='table users-list'>
<tr>
<thead>
{{#if showApproval}}
<th>{{input type="checkbox" checked=selectAll}}</th>
{{/if}}
@ -30,54 +30,79 @@
<th>{{i18n 'username'}}</th>
<th>{{i18n 'email'}}</th>
<th>{{i18n 'admin.users.last_emailed'}}</th>
<th>{{i18n 'last_seen'}}</th>
<th>{{i18n 'admin.user.topics_entered'}}</th>
<th>{{i18n 'admin.user.posts_read_count'}}</th>
<th>{{i18n 'admin.user.time_read'}}</th>
<th>{{i18n 'created'}}</th>
{{admin-directory-toggle field="seen" i18nKey='last_seen' order=order ascending=ascending}}
{{admin-directory-toggle field="topics_viewed" i18nKey="admin.user.topics_entered" order=order ascending=ascending}}
{{admin-directory-toggle field="posts_read" i18nKey="admin.user.posts_read_count" order=order ascending=ascending}}
{{admin-directory-toggle field="read_time" i18nKey="admin.user.time_read" order=order ascending=ascending}}
{{admin-directory-toggle field="created" i18nKey="created" order=order ascending=ascending}}
{{#if showApproval}}
<th>{{i18n 'admin.users.approved'}}</th>
{{/if}}
<th>&nbsp;</th>
</tr>
{{#each model as |user|}}
<tr class="user {{user.selected}} {{unless user.active 'not-activated'}}">
{{#if showApproval}}
</thead>
<tbody>
{{#each model as |user|}}
<tr class="user {{user.selected}} {{unless user.active 'not-activated'}}">
{{#if showApproval}}
<td>
{{#if user.can_approve}}
{{input type="checkbox" checked=user.selected}}
{{/if}}
</td>
{{/if}}
<td><a href="{{unbound user.path}}" data-user-card="{{unbound user.username}}">{{avatar user imageSize="small"}}</a></td>
<td>{{#link-to 'adminUser' user}}{{unbound user.username}}{{/link-to}}</td>
<td class='email'>{{unbound user.email}}</td>
<td>{{{unbound user.last_emailed_age}}}</td>
<td>{{{unbound user.last_seen_age}}}</td>
<td>{{number user.topics_entered}}</td>
<td>{{number user.posts_read_count}}</td>
<td>{{{unbound user.time_read}}}</td>
<td>{{{unbound user.created_at_age}}}</td>
{{#if showApproval}}
<td>
{{#if user.approved}}
{{i18n 'yes_value'}}
{{else}}
{{i18n 'no_value'}}
{{/if}}
</td>
{{/if}}
<td>
{{#if user.admin}}<i class="fa fa-shield" title="{{i18n 'admin.title'}}"></i>{{/if}}
{{#if user.moderator}}<i class="fa fa-shield" title="{{i18n 'admin.moderator'}}"></i>{{/if}}
</td>
</tr>
{{/each}}
<td>
<a href="{{unbound user.path}}" data-user-card="{{unbound user.username}}">
{{avatar user imageSize="small"}}
</a>
</td>
<td>
{{#link-to 'adminUser' user}}{{unbound user.username}}{{/link-to}}
</td>
<td class='email'>
{{unbound user.email}}
</td>
<td>
{{{unbound user.last_emailed_age}}}
</td>
<td>
{{{unbound user.last_seen_age}}}
</td>
<td>
{{number user.topics_entered}}
</td>
<td>
{{number user.posts_read_count}}
</td>
<td>
{{{unbound user.time_read}}}
</td>
<td>
{{{unbound user.created_at_age}}}
</td>
{{#if showApproval}}
<td>
{{#if user.approved}}
{{i18n 'yes_value'}}
{{else}}
{{i18n 'no_value'}}
{{/if}}
</td>
{{/if}}
<td>
{{#if user.admin}}
{{fa-icon "shield" title="admin.title" }}
{{/if}}
{{#if user.moderator}}
{{fa-icon "shield" title="admin.moderator" }}
{{/if}}
</td>
</tr>
{{/each}}
</tbody>
</table>
{{else}}
<p>{{i18n 'search.no_results'}}</p>
{{/if}}

View File

@ -60,6 +60,8 @@
</div>
</div>
{{plugin-outlet name="web-hook-fields" args=(hash model=model)}}
<div>
{{input type="checkbox" name="verify_certificate" checked=model.verify_certificate}} {{i18n 'admin.web_hooks.verify_certificate'}}
</div>

View File

@ -0,0 +1,12 @@
import computed from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({
tagName: "section",
classNameBindings: [':category-boxes-with-topics', 'anyLogos:with-logos:no-logos'],
@computed('categories.[].uploaded_logo.url')
anyLogos() {
return this.get("categories").any((c) => { return !Ember.isEmpty(c.get('uploaded_logo.url')); });
return this.get("categories").any(c => !Ember.isEmpty(c.get('uploaded_logo.url')));
}
});

View File

@ -0,0 +1,12 @@
import computed from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({
tagName: "section",
classNameBindings: [':category-boxes', 'anyLogos:with-logos:no-logos'],
@computed('categories.[].uploaded_logo.url')
anyLogos() {
return this.get("categories").any((c) => { return !Ember.isEmpty(c.get('uploaded_logo.url')); });
return this.get("categories").any(c => !Ember.isEmpty(c.get('uploaded_logo.url')));
}
});

View File

@ -1,8 +1,10 @@
export default Ember.Component.extend({
tagName: 'img',
attributeBindings: ['cdnSrc:src'],
import computed from 'ember-addons/ember-computed-decorators';
cdnSrc: function() {
return Discourse.getURLWithCDN(this.get('src'));
}.property('src')
export default Ember.Component.extend({
tagName: '',
@computed('src')
cdnSrc(src) {
return Discourse.getURLWithCDN(src);
}
});

View File

@ -64,6 +64,24 @@ export default Ember.Component.extend(AddArchetypeClass, Scrolling, {
this.appEvents.on('post:highlight', postNumber => {
Ember.run.scheduleOnce('afterRender', null, highlight, postNumber);
});
this.appEvents.on('header:update-topic', topic => {
if (topic === null) {
this._lastShowTopic = false;
this.appEvents.trigger('header:hide-topic');
return;
}
const offset = window.pageYOffset || $('html').scrollTop();
this._lastShowTopic = this.showTopicInHeader(topic, offset);
if (this._lastShowTopic) {
this.appEvents.trigger('header:show-topic', topic);
} else {
this.appEvents.trigger('header:hide-topic');
}
});
},
willDestroyElement() {

View File

@ -1,11 +1,10 @@
const EditCategoryPanel = Ember.Component.extend({
classNameBindings: [':modal-tab', 'activeTab::invisible'],
});
const EditCategoryPanel = Ember.Component.extend({});
export default EditCategoryPanel;
export function buildCategoryPanel(tab, extras) {
return EditCategoryPanel.extend({
activeTab: Ember.computed.equal('selectedTab', tab)
activeTab: Ember.computed.equal('selectedTab', tab),
classNameBindings: [':modal-tab', 'activeTab::invisible', `:edit-category-tab-${tab}`]
}, extras || {});
}

View File

@ -5,9 +5,28 @@ import computed from "ember-addons/ember-computed-decorators";
export default buildCategoryPanel('settings', {
emailInEnabled: setting('email_in'),
showPositionInput: setting('fixed_category_positions'),
isParentCategory: Em.computed.empty('category.parent_category_id'),
showSubcategoryListStyle: Em.computed.and('category.show_subcategory_list', 'isParentCategory'),
isDefaultSortOrder: Em.computed.empty('category.sort_order'),
@computed
availableSubcategoryListStyles() {
return [
{name: I18n.t('category.subcategory_list_styles.rows'), value: 'rows'},
{name: I18n.t('category.subcategory_list_styles.rows_with_featured_topics'), value: 'rows_with_featured_topics'},
{name: I18n.t('category.subcategory_list_styles.boxes'), value: 'boxes'},
{name: I18n.t('category.subcategory_list_styles.boxes_with_featured_topics'), value: 'boxes_with_featured_topics'}
];
},
@computed
availableViews() {
return [
{name: I18n.t('filters.latest.title'), value: 'latest'},
{name: I18n.t('filters.top.title'), value: 'top'}
];
},
@computed
availableSorts() {
return ['likes', 'op_likes', 'views', 'posts', 'activity', 'posters', 'category', 'created']
@ -21,13 +40,5 @@ export default buildCategoryPanel('settings', {
{name: I18n.t('category.sort_ascending'), value: 'true'},
{name: I18n.t('category.sort_descending'), value: 'false'}
];
},
@computed
availableViews() {
return [
{name: I18n.t('filters.latest.title'), value: 'latest'},
{name: I18n.t('filters.top.title'), value: 'top'}
];
}
});

View File

@ -15,37 +15,32 @@ export default Ember.Component.extend({
@computed("model.is_group_user", "model.id", "groupUserIds")
userIsGroupUser(isGroupUser, groupId, groupUserIds) {
if (isGroupUser) {
if (isGroupUser !== undefined) {
return isGroupUser;
} else {
return !!groupUserIds && groupUserIds.includes(groupId);
}
},
@computed
joinGroupAction() {
return this.currentUser ? 'joinGroup' : 'showLogin';
},
@computed
requestMembershipAction() {
return this.currentUser ? 'requestMembership' : 'showLogin';
_showLoginModal() {
this.sendAction('showLogin');
$.cookie('destination_url', window.location.href);
},
actions: {
showLogin() {
this.sendAction('showLogin');
},
joinGroup() {
this.set('updatingMembership', true);
const model = this.get('model');
if (this.currentUser) {
this.set('updatingMembership', true);
const model = this.get('model');
model.addMembers(this.currentUser.get('username')).then(() => {
model.set('is_group_user', true);
}).catch(popupAjaxError).finally(() => {
this.set('updatingMembership', false);
});
model.addMembers(this.currentUser.get('username')).then(() => {
model.set('is_group_user', true);
}).catch(popupAjaxError).finally(() => {
this.set('updatingMembership', false);
});
} else {
this._showLoginModal();
}
},
leaveGroup() {
@ -60,14 +55,18 @@ export default Ember.Component.extend({
},
requestMembership() {
const groupName = this.get('model.name');
if (this.currentUser) {
const groupName = this.get('model.name');
Group.loadOwners(groupName).then(result => {
const names = result.map(owner => owner.username).join(",");
const title = I18n.t('groups.request_membership_pm.title');
const body = I18n.t('groups.request_membership_pm.body', { groupName });
this.sendAction("createNewMessageViaParams", names, title, body);
});
Group.loadOwners(groupName).then(result => {
const names = result.map(owner => owner.username).join(",");
const title = I18n.t('groups.request_membership_pm.title');
const body = I18n.t('groups.request_membership_pm.body', { groupName });
this.sendAction("createNewMessageViaParams", names, title, body);
});
} else {
this._showLoginModal();
}
}
}
});

View File

@ -14,11 +14,10 @@ const REGEXP_MIN_POST_COUNT_PREFIX = /^min_post_count:/ig;
const REGEXP_POST_TIME_PREFIX = /^(before|after):/ig;
const REGEXP_TAGS_REPLACE = /(^(tags?:|#(?=[a-z0-9\-]+::tag))|::tag\s?$)/ig;
const REGEXP_IN_MATCH = /^in:(posted|watching|tracking|bookmarks|first|pinned|unpinned)/ig;
const REGEXP_IN_MATCH = /^in:(posted|watching|tracking|bookmarks|first|pinned|unpinned|wiki|unseen)/ig;
const REGEXP_SPECIAL_IN_LIKES_MATCH = /^in:likes/ig;
const REGEXP_SPECIAL_IN_PRIVATE_MATCH = /^in:private/ig;
const REGEXP_SPECIAL_IN_WIKI_MATCH = /^in:wiki/ig;
const REGEXP_SPECIAL_IN_SEEN_MATCH = /^in:seen/ig;
const REGEXP_CATEGORY_SLUG = /^(\#[a-zA-Z0-9\-:]+)/ig;
const REGEXP_CATEGORY_ID = /^(category:[0-9]+)/ig;
@ -28,6 +27,7 @@ export default Em.Component.extend({
classNames: ['search-advanced-options'],
inOptions: [
{name: I18n.t('search.advanced.filters.unseen'), value: "unseen"},
{name: I18n.t('search.advanced.filters.posted'), value: "posted"},
{name: I18n.t('search.advanced.filters.watching'), value: "watching"},
{name: I18n.t('search.advanced.filters.tracking'), value: "tracking"},
@ -35,6 +35,7 @@ export default Em.Component.extend({
{name: I18n.t('search.advanced.filters.first'), value: "first"},
{name: I18n.t('search.advanced.filters.pinned'), value: "pinned"},
{name: I18n.t('search.advanced.filters.unpinned'), value: "unpinned"},
{name: I18n.t('search.advanced.filters.wiki'), value: "wiki"},
],
statusOptions: [
{name: I18n.t('search.advanced.statuses.open'), value: "open"},
@ -75,7 +76,7 @@ export default Em.Component.extend({
in: {
likes: false,
private: false,
wiki: false
seen: false
}
},
status: '',
@ -102,7 +103,7 @@ export default Em.Component.extend({
this.setSearchedTermValue('searchedTerms.in', REGEXP_IN_PREFIX, REGEXP_IN_MATCH);
this.setSearchedTermSpecialInValue('searchedTerms.special.in.likes', REGEXP_SPECIAL_IN_LIKES_MATCH);
this.setSearchedTermSpecialInValue('searchedTerms.special.in.private', REGEXP_SPECIAL_IN_PRIVATE_MATCH);
this.setSearchedTermSpecialInValue('searchedTerms.special.in.wiki', REGEXP_SPECIAL_IN_WIKI_MATCH);
this.setSearchedTermSpecialInValue('searchedTerms.special.in.seen', REGEXP_SPECIAL_IN_SEEN_MATCH);
this.setSearchedTermValue('searchedTerms.status', REGEXP_STATUS_PREFIX);
this.setSearchedTermValueForPostTime();
this.setSearchedTermValue('searchedTerms.min_post_count', REGEXP_MIN_POST_COUNT_PREFIX);
@ -438,15 +439,15 @@ export default Em.Component.extend({
}
},
@observes('searchedTerms.special.in.wiki')
updateSearchTermForSpecialInWiki() {
const match = this.filterBlocks(REGEXP_SPECIAL_IN_WIKI_MATCH);
const inFilter = this.get('searchedTerms.special.in.wiki');
@observes('searchedTerms.special.in.seen')
updateSearchTermForSpecialInSeen() {
const match = this.filterBlocks(REGEXP_SPECIAL_IN_SEEN_MATCH);
const inFilter = this.get('searchedTerms.special.in.seen');
let searchTerm = this.get('searchTerm') || '';
if (inFilter) {
if (match.length === 0) {
searchTerm += ` in:wiki`;
searchTerm += ` in:seen`;
this.set('searchTerm', searchTerm.trim());
}
} else if (match.length !== 0) {

View File

@ -116,7 +116,6 @@ export default Ember.Component.extend(CleansUp, {
const args = { stats: false };
args.include_post_count_for = this.get('topic.id');
args.skip_track_visit = true;
User.findByUsername(username, args).then(user => {
if (user.topic_post_count) {

View File

@ -1,6 +1,13 @@
import computed from 'ember-addons/ember-computed-decorators';
import DiscoveryController from 'discourse/controllers/discovery';
const subcategoryStyleComponentNames = {
'rows': 'categories_only',
'rows_with_featured_topics': 'categories_with_featured_topics',
'boxes': 'categories_boxes',
'boxes_with_featured_topics': 'categories_boxes_with_topics'
};
export default DiscoveryController.extend({
discovery: Ember.inject.controller(),
@ -19,7 +26,12 @@ export default DiscoveryController.extend({
@computed("model.parentCategory")
categoryPageStyle(parentCategory) {
const style = this.siteSettings.desktop_category_page_style;
let style = this.siteSettings.desktop_category_page_style;
if (parentCategory) {
style = subcategoryStyleComponentNames[parentCategory.get('subcategory_list_style')] || style;
}
const componentName = (parentCategory && style === "categories_and_latest_topics") ?
"categories_only" :
style;

View File

@ -8,6 +8,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
saving: false,
deleting: false,
panels: null,
hiddenTooltip: true,
_initPanels: function() {
this.set('panels', []);
@ -16,6 +17,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
onShow() {
this.changeSize();
this.titleChanged();
this.set('hiddenTooltip', true);
},
changeSize: function() {
@ -101,6 +103,10 @@ export default Ember.Controller.extend(ModalFunctionality, {
self.set('deleting', false);
}
});
},
toggleDeleteTooltip() {
this.toggleProperty('hiddenTooltip');
}
}

View File

@ -53,18 +53,18 @@ export default Ember.Controller.extend({
this.get('tabs')[0].set('count', this.get('model.user_count'));
},
@computed('model.is_group_user', 'model.is_group_owner', 'model.automatic')
getTabs(isGroupUser, isGroupOwner, automatic) {
@computed('model.is_group_owner', 'model.automatic')
getTabs() {
return this.get('tabs').filter(t => {
let display = true;
let canSee = true;
if (this.currentUser && t.get('requiresGroupAdmin')) {
display = automatic ? false : (this.currentUser.admin || isGroupOwner);
} else if (t.get('requiresGroupAdmin')) {
display = false;
if (this.currentUser && t.requiresGroupAdmin) {
canSee = this.currentUser.canManageGroup(this.get('model'));
} else if (t.requiresGroupAdmin) {
canSee = false;
}
return display;
return canSee;
});
}
});

View File

@ -5,15 +5,39 @@ import IncomingEmail from 'admin/models/incoming-email';
// This controller handles displaying of raw email
export default Ember.Controller.extend(ModalFunctionality, {
rawEmail: "",
textPart: "",
htmlPart: "",
tab: "raw",
showRawEmail: Ember.computed.equal("tab", "raw"),
showTextPart: Ember.computed.equal("tab", "text_part"),
showHtmlPart: Ember.computed.equal("tab", "html_part"),
onShow() { this.send("displayRaw"); },
loadRawEmail(postId) {
return Post.loadRawEmail(postId)
.then(result => this.set("rawEmail", result.raw_email));
.then(result => this.setProperties({
"rawEmail": result.raw_email,
"textPart": result.text_part,
"htmlPart": result.html_part,
}));
},
loadIncomingRawEmail(incomingEmailId) {
return IncomingEmail.loadRawEmail(incomingEmailId)
.then(result => this.set("rawEmail", result.raw_email));
.then(result => this.setProperties({
"rawEmail": result.raw_email,
"textPart": result.text_part,
"htmlPart": result.html_part,
}));
},
actions: {
displayRaw() { this.set("tab", "raw"); },
displayTextPart() { this.set("tab", "text_part"); },
displayHtmlPart() { this.set("tab", "html_part"); }
}
});

View File

@ -913,7 +913,7 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
if (data.reload_topic) {
topic.reload().then(() => {
this.send('postChangedRoute', topic.get('post_number') || 1);
this.appEvents.trigger('header:show-topic', topic);
this.appEvents.trigger('header:update-topic', topic);
});
} else {
if (topic.get('isPrivateMessage') &&

View File

@ -10,13 +10,9 @@ export default {
after: 'message-bus',
initialize(container) {
const user = container.lookup('current-user:main'),
site = container.lookup('site:main'),
siteSettings = container.lookup('site-settings:main'),
bus = container.lookup('message-bus:main'),
keyValueStore = container.lookup('key-value-store:main'),
store = container.lookup('store:main'),
appEvents = container.lookup('app-events:main');
const user = container.lookup('current-user:main');
const keyValueStore = container.lookup('key-value-store:main');
const bus = container.lookup('message-bus:main');
// clear old cached notifications, we used to store in local storage
// TODO 2017 delete this line
@ -36,12 +32,17 @@ export default {
}
bus.subscribe(`/notification/${user.get('id')}`, data => {
const store = container.lookup('store:main');
const appEvents = container.lookup('app-events:main');
const oldUnread = user.get('unread_notifications');
const oldPM = user.get('unread_private_messages');
user.set('unread_notifications', data.unread_notifications);
user.set('unread_private_messages', data.unread_private_messages);
user.set('read_first_notification', data.read_first_notification);
user.setProperties({
unread_notifications: data.unread_notifications,
unread_private_messages: data.unread_private_messages,
read_first_notification: data.read_first_notification
});
if (oldUnread !== data.unread_notifications || oldPM !== data.unread_private_messages) {
appEvents.trigger('notifications:changed');
@ -84,6 +85,9 @@ export default {
}
}, user.notification_channel_position);
const site = container.lookup('site:main');
const siteSettings = container.lookup('site-settings:main');
bus.subscribe("/categories", data => {
_.each(data.categories, c => site.updateCategory(c));
_.each(data.deleted_categories, id => site.removeCategory(id));

View File

@ -69,7 +69,7 @@ export function ajax() {
args.error = (xhr, textStatus, errorThrown) => {
// note: for bad CSRF we don't loop an extra request right away.
// this allows us to eliminate the possibility of having a loop.
if (xhr.status === 403 && xhr.responseText === "['BAD CSRF']") {
if (xhr.status === 403 && xhr.responseText === "[\"BAD CSRF\"]") {
Discourse.Session.current().set('csrfToken', null);
}

View File

@ -261,7 +261,11 @@ export default function(options) {
left: "-1000px"
});
me.parent().append(div);
if (options.appendSelector) {
me.parents(options.appendSelector).append(div);
} else {
me.parent().append(div);
}
if (!isInput && !options.treatAsTextarea) {
vOffset = div.height();

View File

@ -46,12 +46,16 @@ export default {
// Update badge clicks unless it's our own
if (!ownLink) {
var $badge = $('span.badge', $link);
const $badge = $('span.badge', $link);
if ($badge.length === 1) {
// don't update counts in category badge nor in oneboxes (except when we force it)
if (isValidLink($link)) {
var html = $badge.html();
if (/^\d+$/.test(html)) { $badge.html(parseInt(html, 10) + 1); }
const html = $badge.html();
const key = `${new Date().toLocaleDateString()}-${postId}-${href}`;
if (/^\d+$/.test(html) && !sessionStorage.getItem(key)) {
sessionStorage.setItem(key, true);
$badge.html(parseInt(html, 10) + 1);
}
}
}
}

View File

@ -130,14 +130,14 @@ export function isValidSearchTerm(searchTerm) {
}
};
export function applySearchAutocomplete($input, siteSettings, appEvents) {
export function applySearchAutocomplete($input, siteSettings, appEvents, options) {
const afterComplete = function() {
if (appEvents) {
appEvents.trigger("search-autocomplete:after-complete");
}
};
$input.autocomplete({
$input.autocomplete(_.merge({
template: findRawTemplate('category-tag-autocomplete'),
key: '#',
width: '100%',
@ -153,9 +153,9 @@ export function applySearchAutocomplete($input, siteSettings, appEvents) {
return searchCategoryTag(term, siteSettings);
},
afterComplete
});
}, options));
$input.autocomplete({
$input.autocomplete(_.merge({
template: findRawTemplate('user-selector-autocomplete'),
key: "@",
width: '100%',
@ -163,5 +163,5 @@ export function applySearchAutocomplete($input, siteSettings, appEvents) {
transformComplete: v => v.username || v.name,
dataSource: term => userSearch({ term, includeGroups: true }),
afterComplete
});
}, options));
};

View File

@ -105,7 +105,8 @@ const Category = RestModel.extend({
topic_featured_link_allowed: this.get('topic_featured_link_allowed'),
show_subcategory_list: this.get('show_subcategory_list'),
num_featured_topics: this.get('num_featured_topics'),
default_view: this.get('default_view')
default_view: this.get('default_view'),
subcategory_list_style: this.get('subcategory_list_style')
},
type: id ? 'PUT' : 'POST'
});

View File

@ -17,9 +17,10 @@ const Group = RestModel.extend({
return Em.isEmpty(value) ? "" : value;
},
type: function() {
return this.get("automatic") ? "automatic" : "custom";
}.property("automatic"),
@computed('automatic')
type(automatic) {
return automatic ? "automatic" : "custom";
},
@computed('user_count')
userCountDisplay(userCount) {
@ -93,6 +94,7 @@ const Group = RestModel.extend({
});
},
@computed('flair_bg_color')
flairBackgroundHexColor() {
return this.get('flair_bg_color') ? this.get('flair_bg_color').replace(new RegExp("[^0-9a-fA-F]", "g"), "") : null;
@ -224,7 +226,7 @@ Group.reopenClass({
mentionable(name) {
return ajax(`/groups/${name}/mentionable`, { data: { name } });
},
}
});
export default Group;

View File

@ -500,8 +500,11 @@ const User = RestModel.extend({
return summary;
});
}
},
canManageGroup(group) {
return group.get('automatic') ? false : (this.get('admin') || group.get('is_group_owner'));
}
});
User.reopenClass(Singleton, {

View File

@ -7,6 +7,12 @@ export default Ember.Route.extend({
return this.modelFor('group');
},
afterModel(group) {
if (!this.currentUser || !this.currentUser.canManageGroup(group)) {
this.transitionTo("group.members", group);
}
},
setupController(controller, model) {
this.controllerFor('group-edit').setProperties({ model });
this.controllerFor("group").set("showing", 'edit');

View File

@ -0,0 +1,29 @@
{{#each categories as |c|}}
<div class='category-box category-box-{{unbound c.slug}}' style={{border-color c.color}}>
<div class='category-box-inner'>
<div class='category-box-heading'>
<a href={{c.url}}>
{{#if c.uploaded_logo.url}}
{{cdn-img src=c.uploaded_logo.url class="logo"}}
{{/if}}
<h3>{{c.name}}</h3>
</a>
</div>
<div class='featured-topics'>
{{#if c.topics}}
<ul>
{{#each c.topics as |topic|}}
<li>
<a class='title' href="{{unbound topic.lastUnreadUrl}}">
{{text-overflow class="overflow" text=topic.fancyTitle}}
</a>
</li>
{{/each}}
</ul>
{{/if}}
</div>
</div>
</div>
{{/each}}

View File

@ -0,0 +1,16 @@
{{#each categories as |c|}}
<div class='category-box'>
<a href={{c.url}}>
{{#if c.uploaded_logo.url}}
{{cdn-img src=c.uploaded_logo.url class="logo"}}
{{/if}}
<div class='details'>
<h3>{{c.name}}</h3>
<div class='description'>
{{{c.description_excerpt}}}
</div>
</div>
</a>
</div>
{{/each}}

View File

@ -0,0 +1,3 @@
{{#if src}}
<img src={{cdnSrc}} class={{class}}>
{{/if}}

View File

@ -4,7 +4,7 @@
imageId=logoImageId
imageUrl=logoImageUrl
type="category_logo"
class="no-repeat"}}
class="no-repeat contain-image"}}
</section>
<section class='field'>

View File

@ -19,14 +19,51 @@
</label>
</section>
{{#unless category.parent_category_id}}
<section class="field">
{{#if isParentCategory}}
<section class="field show-subcategory-list-field">
<label>
{{input type="checkbox" checked=category.show_subcategory_list}}
{{i18n "category.show_subcategory_list"}}
</label>
</section>
{{/unless}}
{{/if}}
{{#if showSubcategoryListStyle}}
<section class="field subcategory-list-style-field">
<label>
{{i18n "category.subcategory_list_style"}}
{{combo-box valueAttribute="value" content=availableSubcategoryListStyles value=category.subcategory_list_style}}
</label>
</section>
{{/if}}
<section class="field default-view-field">
<label>
{{i18n "category.default_view"}}
{{combo-box valueAttribute="value" content=availableViews value=category.default_view}}
</label>
</section>
<section class="field">
<label>
{{i18n "category.sort_order"}}
{{combo-box valueAttribute="value" content=availableSorts value=category.sort_order none="category.sort_options.default"}}
{{#unless isDefaultSortOrder}}
{{combo-box valueAttribute="value" content=sortAscendingOptions value=category.sort_ascending none="category.sort_options.default"}}
{{/unless}}
</label>
</section>
<section class="field num-featured-topics-fields">
<label>
{{#if category.parent_category_id}}
{{i18n "category.subcategory_num_featured_topics"}}
{{else}}
{{i18n "category.num_featured_topics"}}
{{/if}}
{{text-field value=category.num_featured_topics}}
</label>
</section>
<section class="field">
<label>
@ -46,23 +83,6 @@
</section>
{{/if}}
<section class="field">
<label>
{{i18n "category.sort_order"}}
{{combo-box valueAttribute="value" content=availableSorts value=category.sort_order none="category.sort_options.default"}}
{{#unless isDefaultSortOrder}}
{{combo-box valueAttribute="value" content=sortAscendingOptions value=category.sort_ascending none="category.sort_options.default"}}
{{/unless}}
</label>
</section>
<section class="field default-view-field">
<label>
{{i18n "category.default_view"}}
{{combo-box valueAttribute="value" content=availableViews value=category.default_view}}
</label>
</section>
{{#if emailInEnabled}}
<section class='field'>
<label>
@ -82,17 +102,6 @@
{{plugin-outlet name="category-email-in" args=(hash category=category)}}
{{/if}}
<section class="field num-featured-topics-fields">
<label>
{{#if category.parent_category_id}}
{{i18n "category.subcategory_num_featured_topics"}}
{{else}}
{{i18n "category.num_featured_topics"}}
{{/if}}
{{text-field value=category.num_featured_topics}}
</label>
</section>
{{#if showPositionInput}}
<section class='field position-fields'>
<label>

View File

@ -6,7 +6,7 @@
label="groups.leave"
disabled=updatingMembership}}
{{else}}
{{d-button action=joinGroupAction
{{d-button action="joinGroup"
class="group-index-join"
icon="plus"
label="groups.join"
@ -22,7 +22,7 @@
disabled=true}}
{{/if}}
{{else}}
{{d-button action=requestMembershipAction
{{d-button action="requestMembership"
class="group-index-request"
icon="envelope"
label="groups.request"}}

View File

@ -53,7 +53,7 @@
<section class='field'>
<label>{{input type="checkbox" class="in-likes" checked=searchedTerms.special.in.likes}} {{i18n "search.advanced.filters.likes"}}</label>
<label>{{input type="checkbox" class="in-private" checked=searchedTerms.special.in.private}} {{i18n "search.advanced.filters.private"}}</label>
<label>{{input type="checkbox" class="in-wiki" checked=searchedTerms.special.in.wiki}} {{i18n "search.advanced.filters.wiki"}}</label>
<label>{{input type="checkbox" class="in-seen" checked=searchedTerms.special.in.seen}} {{i18n "search.advanced.filters.seen"}}</label>
</section>
{{combo-box id="in" valueAttribute="value" content=inOptions value=searchedTerms.in none="user.locale.any"}}
</div>

View File

@ -1,7 +1,3 @@
{{group-membership-button model=model
createNewMessageViaParams='createNewMessageViaParams'
showLogin='showLogin'}}
{{#if hasMembers}}
{{#load-more selector=".group-members tr" action="loadMore"}}
<table class='group-members'>

View File

@ -29,17 +29,25 @@
{{/if}}
</div>
{{#mobile-nav class='group-nav' desktopClass="nav nav-pills" currentPath=application.currentPath}}
{{#each getTabs as |tab|}}
<li>
{{#link-to tab.location model title=tab.message}}
{{#if tab.icon}}{{fa-icon tab.icon}}{{/if}}
{{tab.message}}
{{#if tab.count}}<span class='count'>({{tab.count}})</span>{{/if}}
{{/link-to}}
</li>
{{/each}}
{{/mobile-nav}}
<div class="list-controls">
<div class="container">
{{#mobile-nav class='group-nav' desktopClass="nav nav-pills" currentPath=application.currentPath}}
{{#each getTabs as |tab|}}
<li>
{{#link-to tab.location model title=tab.message}}
{{#if tab.icon}}{{fa-icon tab.icon}}{{/if}}
{{tab.message}}
{{#if tab.count}}<span class='count'>({{tab.count}})</span>{{/if}}
{{/link-to}}
</li>
{{/each}}
{{/mobile-nav}}
{{group-membership-button model=model
createNewMessageViaParams='createNewMessageViaParams'
showLogin='showLogin'}}
</div>
</div>
<div class='group-outlet'>
{{outlet}}

View File

@ -28,8 +28,16 @@
icon="trash-o"
label="category.delete"}}
{{else}}
<div class="cannot_delete_reason">
{{{model.cannot_delete_reason}}}
<div class="disable_info_wrap">
{{d-button disabled=deleteDisabled
class="disable-no-hover"
action="toggleDeleteTooltip"
icon="question-circle"
label="category.delete"}}
<div class="cannot_delete_reason {{if hiddenTooltip "hidden" ""}}">
{{{model.cannot_delete_reason}}}
</div>
</div>
{{/if}}
</div>

View File

@ -1,7 +1,45 @@
{{#d-modal-body title="raw_email.title" maxHeight="80%"}}
{{#if rawEmail}}
{{textarea value=rawEmail class="raw-email-textarea"}}
{{else}}
{{i18n 'raw_email.not_available'}}
{{/if}}
{{#d-modal-body title="raw_email.title" class="incoming-email-modal" maxHeight="80%"}}
<div class="incoming-email-tabs">
{{d-button action="displayRaw"
label="post.raw_email.displays.raw.button"
title="post.raw_email.displays.raw.title"
class=(if showRawEmail 'active')
}}
{{#if textPart}}
{{d-button action="displayTextPart"
label="post.raw_email.displays.text_part.button"
title="post.raw_email.displays.text_part.title"
class=(if showTextPart 'active')
}}
{{/if}}
{{#if htmlPart}}
{{d-button action="displayHtmlPart"
label="post.raw_email.displays.html_part.button"
title="post.raw_email.displays.html_part.title"
class=(if showHtmlPart 'active')
}}
{{/if}}
</div>
<div class="incoming-email-content">
{{#if showRawEmail}}
{{#if rawEmail}}
{{textarea value=rawEmail}}
{{else}}
{{i18n 'raw_email.not_available'}}
{{/if}}
{{/if}}
{{#if showTextPart}}
{{textarea value=textPart}}
{{/if}}
{{#if showHtmlPart}}
<div class="incoming-email-html-part">
{{{htmlPart}}}
</div>
{{/if}}
</div>
{{/d-modal-body}}

View File

@ -1,5 +1,7 @@
{{#d-section pageClass="user-preferences" class="user-content user-preferences"}}
{{plugin-outlet name="above-user-preferences"}}
<form class="form-horizontal">
<div class="control-group save-button" id='save-button-top'>

View File

@ -6,6 +6,12 @@ createWidget('hamburger-category', {
tagName: 'li.category-link',
html(c) {
if (c.parent_category_id) {
this.tagName += '.subcategory';
}
this.tagName += '.category-' + Discourse.Category.slugFor(c, '-');
const results = [ this.attach('category-link', { category: c, allowUncategorized: true }) ];
const unreadTotal = parseInt(c.get('unreadTopics'), 10) + parseInt(c.get('newTopics'), 10);

View File

@ -262,7 +262,10 @@ export default createWidget('header', {
Ember.run.schedule('afterRender', () => {
const $searchInput = $('#search-term');
$searchInput.focus().select();
applySearchAutocomplete($searchInput, this.siteSettings, this.appEvents);
applySearchAutocomplete($searchInput, this.siteSettings, this.appEvents, {
appendSelector: '.menu-panel'
});
});
}
},

View File

@ -16,6 +16,8 @@ const icons = {
'pinned.disabled': 'thumb-tack unpinned',
'pinned_globally.enabled': 'thumb-tack',
'pinned_globally.disabled': 'thumb-tack unpinned',
'banner.enabled': 'thumb-tack',
'banner.disabled': 'thumb-tack unpinned',
'visible.enabled': 'eye',
'visible.disabled': 'eye-slash',
'split_topic': 'sign-out',

View File

@ -137,10 +137,13 @@ export default createWidget('topic-admin-menu', {
icon: 'thumb-tack',
label: featured ? 'actions.unpin' : 'actions.pin' });
}
buttons.push({ className: 'topic-admin-change-timestamp',
action: 'showChangeTimestamp',
icon: 'calendar',
label: 'change_timestamp.title' });
if (this.currentUser.admin) {
buttons.push({ className: 'topic-admin-change-timestamp',
action: 'showChangeTimestamp',
icon: 'calendar',
label: 'change_timestamp.title' });
}
if (!isPrivateMessage) {
buttons.push({ className: 'topic-admin-archive',

View File

@ -31,8 +31,10 @@ $mobile-breakpoint: 700px;
width: 100%;
tr {text-align: left;}
td, th {padding: 8px;}
th {border-top: 1px solid dark-light-diff($primary, $secondary, 90%, -60%);}
th {border-top: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); text-align: left;}
td {border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); border-top: 1px solid dark-light-diff($primary, $secondary, 90%, -60%);}
th.sortable i.fa-chevron-down,
th.sortable i.fa-chevron-up {margin-left: 0.5em;}
tr:hover { background-color: darken($secondary, 2.5%); }
tr.selected { background-color: lighten($primary, 80%); }
.filters input { margin-bottom: 0; }
@ -696,6 +698,10 @@ section.details {
width: 100%;
border-color: dark-light-choose(scale-color($primary, $lightness: 75%), scale-color($secondary, $lightness: 25%));
}
.content-list {
margin-right: 20px;
}
}
// Customise area

View File

@ -73,9 +73,6 @@
max-width: 710px;
margin: 0 auto;
background-color: $secondary;
border: 1px solid dark-light-diff($primary, $secondary, 90%, -60%);
box-shadow: 0 3px 7px rgba(0,0,0, .8);
background-clip: padding-box;
}
@ -154,9 +151,6 @@
.warning {
color: $danger !important;
}
.raw-email-textarea {
height: 300px;
}
.json-uploader {
.jsfu-shade-container {
display: table-row;
@ -328,5 +322,79 @@
.auto-close-fields label {
font-size: .929em;
}
.subcategory-list-style-field {
margin-left: 16px;
}
.edit-category-tab-settings {
section.field {
margin-bottom: 10px;
}
}
.disable_info_wrap {
position: relative;
display: inline-block;
float: right;
.cannot_delete_reason {
position: absolute;
background: dark-light-choose(scale-color($primary, $lightness: 10%), scale-color($secondary, $lightness: 10%));
color: dark-light-choose(scale-color($primary, $lightness: 100%), scale-color($secondary, $lightness: 0%));
text-align: center;
border-radius: 2px;
padding: 12px 8px;
&::after {
top: 100%;
left: 57%;
border: solid transparent;
content: " ";
position: absolute;
border-top-color: dark-light-choose(scale-color($primary, $lightness: 10%), scale-color($secondary, $lightness: 10%));
border-width: 8px;
}
}
}
}
.incoming-email-modal {
.btn {
transition: none;
background-color: transparent;
margin-right: 5px;
&:hover, &.active {
color: $primary;
}
&.active {
font-weight: bold;
}
&:focus {
outline: 2px solid dark-light-diff($primary, $secondary, 90%, -60%);
}
}
.incoming-email-tabs {
margin-bottom: 15px;
}
.incoming-email-content {
height: 300px;
textarea, .incoming-email-html-part {
height: 95%;
border: none;
border-top: 1px solid dark-light-diff($primary, $secondary, 90%, -60%);
padding-top: 10px;
}
textarea {
font-family: monospace;
resize: none;
border-radius: 0px;
box-shadow: none;
}
.incoming-email-html-part {
padding: 10px 4px 4px 4px;
}
}
}

View File

@ -141,7 +141,7 @@ aside.quote {
color: dark-light-choose($secondary, $primary);
padding: 10px;
z-index: 401;
opacity: 0.8;
opacity: 0.9;
&:before {
font-family: "FontAwesome";

View File

@ -8,3 +8,9 @@
background-repeat: no-repeat;
}
}
.image-uploader.contain-image {
.uploaded-image-preview {
background-size: contain;
}
}

View File

@ -76,3 +76,11 @@
.clickable {
cursor: pointer;
}
// Buttons
// ---------------------------------------------------
.disable-no-hover:hover {
background: dark-light-choose(scale-color($primary, $lightness: 90%), scale-color($secondary, $lightness: 60%));;
color: $primary;
}

View File

@ -176,3 +176,126 @@
}
}
}
.category-boxes, .category-boxes-with-topics {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
margin-top: 1em;
margin-bottom: 1em;
width: 100%;
.category-box {
display: flex;
flex-direction: row;
align-content: flex-start;
box-sizing: border-box;
border: 2px solid blend-primary-secondary(20%);
.mobile-view & {
width: 100%;
}
.details {
h3 {
font-size: 1.5em;
margin-bottom: 0.5em;
margin-top: 0.25em;
}
.description {
font-size: 1.05em;
color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 40%));
}
}
.logo {
display: block;
height: 40px;
margin: 0 auto 1em auto;
}
}
&.no-logos {
.logo {
display: none;
}
}
}
.category-boxes {
.category-box {
width: 23%;
margin: 0 1% 1.5em 1%;
}
.details {
text-align: center;
}
a {
width: 100%;
padding: 1em;
}
&.no-logos {
.category-box a {
padding: 3em 1em;
}
}
}
.category-boxes-with-topics {
.category-box {
width: 31%;
margin: 0 1% 1.5em 1%;
padding: 0;
border-width: 0 0 0 6px;
border-style: solid;
border-color: blend-primary-secondary(20%);
}
.category-box-inner {
width: 100%;
padding: 0;
border-width: 2px 2px 2px 0;
border-style: solid;
border-color: blend-primary-secondary(20%);
}
h3 {
font-size: 1.2em;
text-align: center;
}
.category-box-heading {
padding: 1em 1em 0.5em 1em;
a[href] {
width: 100%;
color: $primary;
}
}
.featured-topics {
padding: 0.5em 1em 1em 1em;
ul {
color: blend-primary-secondary(70%);
list-style: none;
padding: 0;
margin: 0;
}
li {
padding: 0;
margin-left: 1.5em;
.overflow {
max-height: 3em;
overflow: hidden;
text-overflow: ellipsis;
}
}
li:before {
content: '\f0f6';
font-family: 'FontAwesome';
float: left;
margin-left: -1.5em;
}
}
}

View File

@ -117,11 +117,19 @@
}
}
.cannot_delete_reason {
float: right;
text-align: right;
max-width: 380px;
color: dark-light-choose(scale-color($primary, $lightness: 60%), scale-color($secondary, $lightness: 40%));
.disable_info_wrap {
margin-top: -70px;
padding-top: 70px;
@media all and (min-width: 550px) {
padding-left: 170px;
}
.cannot_delete_reason {
top: 4px;
right: 4px;
max-width: 380px;
min-width: 300px;
}
}
}

View File

@ -20,6 +20,7 @@
.group-nav.mobile-nav {
margin-bottom: 15px;
float: left;
}
.group-activity {
@ -29,7 +30,7 @@
.group-activity-nav.mobile-nav {
position: absolute;
right: 0px;
top: -50px;
top: -55px;
}
.group-activity-outlet {

View File

@ -38,7 +38,7 @@
width: 95%;
}
// we need a little extra room on mobile for the
// we need a little extra room on mobile for the
// stuff inside the footer to fit
.modal-footer {
padding-right: 0;
@ -133,6 +133,12 @@
}
}
}
.disable_info_wrap .cannot_delete_reason {
top: -114px;
right: 8px;
min-width: 200px;
}
}
.tabbed-modal {

View File

@ -78,7 +78,8 @@ class Admin::EmailController < Admin::AdminController
def raw_email
params.require(:id)
incoming_email = IncomingEmail.find(params[:id].to_i)
render json: { raw_email: incoming_email.raw }
text, html = Email.extract_parts(incoming_email.raw)
render json: { raw_email: incoming_email.raw, text_part: text, html_part: html }
end
def incoming

View File

@ -14,25 +14,50 @@ class Admin::EmojisController < Admin::AdminController
.gsub(/_{2,}/, '_')
.downcase
data = if Emoji.exists?(name)
failed_json.merge(errors: [I18n.t("emoji.errors.name_already_exists", name: name)])
elsif emoji = Emoji.create_for(file, name)
emoji
else
failed_json.merge(errors: [I18n.t("emoji.errors.error_while_storing_emoji")])
end
upload = Upload.create_for(
current_user.id,
file.tempfile,
file.original_filename,
File.size(file.tempfile.path),
image_type: 'custom_emoji'
)
data =
if upload.persisted?
custom_emoji = CustomEmoji.new(name: name, upload: upload)
if custom_emoji.save
Emoji.clear_cache
{ name: custom_emoji.name, url: custom_emoji.upload.url }
else
failed_json.merge(errors: custom_emoji.errors.full_messages)
end
else
failed_json.merge(errors: upload.errors.full_messages)
end
MessageBus.publish("/uploads/emoji", data.as_json, user_ids: [current_user.id])
end
render json: success_json
end
def destroy
name = params.require(:id)
Emoji[name].try(:remove)
render nothing: true
custom_emoji = CustomEmoji.find_by(name: name)
raise Discourse::InvalidParameters unless custom_emoji
CustomEmoji.transaction do
custom_emoji.upload.destroy!
custom_emoji.destroy!
end
Emoji.clear_cache
Jobs.enqueue(:rebake_custom_emoji_posts, name: name)
render json: success_json
end
end

View File

@ -29,7 +29,7 @@ class ApplicationController < ActionController::Base
unless is_api? || is_user_api?
super
clear_current_user
render text: "['BAD CSRF']", status: 403
render text: "[\"BAD CSRF\"]", status: 403
end
end
@ -230,8 +230,8 @@ class ApplicationController < ActionController::Base
preload_anonymous_data
if current_user
preload_current_user_data
current_user.sync_notification_channel_position
preload_current_user_data
end
end

View File

@ -16,10 +16,12 @@ class CategoriesController < ApplicationController
@description = SiteSetting.site_description
parent_category = Category.find_by(slug: params[:parent_category_id]) || Category.find_by(id: params[:parent_category_id].to_i)
category_options = {
is_homepage: current_homepage == "categories".freeze,
parent_category_id: params[:parent_category_id],
include_topics: include_topics
include_topics: include_topics(parent_category)
}
@category_list = CategoryList.new(guardian, category_options)
@ -57,7 +59,8 @@ class CategoriesController < ApplicationController
topic_options = {
per_page: SiteSetting.categories_topics,
no_definitions: true
no_definitions: true,
exclude_category_ids: Category.where(suppress_from_homepage: true).pluck(:id)
}
result = CategoryAndTopicLists.new
@ -245,6 +248,7 @@ class CategoriesController < ApplicationController
:show_subcategory_list,
:num_featured_topics,
:default_view,
:subcategory_list_style,
:custom_fields => [params[:custom_fields].try(:keys)],
:permissions => [*p.try(:keys)],
:allowed_tags => [],
@ -260,9 +264,10 @@ class CategoriesController < ApplicationController
@staff_action_logger = StaffActionLogger.new(current_user)
end
def include_topics
def include_topics(parent_category=nil)
view_context.mobile_view? ||
params[:include_topics] ||
(parent_category && parent_category.subcategory_list_includes_topics?) ||
SiteSetting.desktop_category_page_style == "categories_with_featured_topics".freeze
end
end

View File

@ -46,6 +46,7 @@ class ListController < ApplicationController
:parent_category_category_top,
# top pages (ie. with a period)
TopTopic.periods.map { |p| :"top_#{p}" },
TopTopic.periods.map { |p| :"top_#{p}_feed" },
TopTopic.periods.map { |p| :"category_top_#{p}" },
TopTopic.periods.map { |p| :"category_none_top_#{p}" },
TopTopic.periods.map { |p| :"parent_category_category_top_#{p}" },
@ -168,7 +169,7 @@ class ListController < ApplicationController
@link = "#{Discourse.base_url}/top"
@atom_link = "#{Discourse.base_url}/top.rss"
@description = I18n.t("rss_description.top")
@topic_list = TopicQuery.new(nil).list_top_for("monthly")
@topic_list = TopicQuery.new(nil).list_top_for(SiteSetting.top_page_default_timeframe.to_sym)
render 'list', formats: [:rss]
end
@ -232,7 +233,7 @@ class ListController < ApplicationController
list.for_period = period
list.more_topics_url = construct_url_with(:next, top_options)
list.prev_topics_url = construct_url_with(:prev, top_options)
@rss = "top"
@rss = "top_#{period}"
if use_crawler_layout?
@title = I18n.t("js.filters.top.#{period}.title")
@ -252,6 +253,19 @@ class ListController < ApplicationController
define_method("parent_category_category_top_#{period}") do
self.send("top_#{period}", category: @category.id)
end
# rss feed
define_method("top_#{period}_feed") do |options = nil|
discourse_expires_in 1.minute
@description = I18n.t("rss_description.top_#{period}")
@title = "#{SiteSetting.title} - #{@description}"
@link = "#{Discourse.base_url}/top/#{period}"
@atom_link = "#{Discourse.base_url}/top/#{period}.rss"
@topic_list = TopicQuery.new(nil).list_top_for(period)
render 'list', formats: [:rss]
end
end
protected

View File

@ -109,14 +109,15 @@ class PostsController < ApplicationController
end
def cooked
post = find_post_from_params
render json: {cooked: post.cooked}
render json: { cooked: find_post_from_params.cooked }
end
def raw_email
params.require(:id)
post = Post.unscoped.find(params[:id].to_i)
guardian.ensure_can_view_raw_email!(post)
render json: { raw_email: post.raw_email }
text, html = Email.extract_parts(post.raw_email)
render json: { raw_email: post.raw_email, text_part: text, html_part: html }
end
def short_link
@ -377,17 +378,19 @@ class PostsController < ApplicationController
end
def bookmark
post = find_post_from_params
if params[:bookmarked] == "true"
post = find_post_from_params
PostAction.act(current_user, post, PostActionType.types[:bookmark])
else
post_action = PostAction.find_by(post_id: params[:post_id], user_id: current_user.id)
post = post_action.post
raise Discourse::InvalidParameters unless post_action
PostAction.remove_act(current_user, post, PostActionType.types[:bookmark])
end
tu = TopicUser.get(post.topic, current_user)
render_json_dump(topic_bookmarked: tu.try(:bookmarked))
topic_user = TopicUser.get(post.topic, current_user)
render_json_dump(topic_bookmarked: topic_user.try(:bookmarked))
end
def wiki

View File

@ -3,7 +3,7 @@ require_dependency 'site_serializer'
class SiteController < ApplicationController
layout false
skip_before_filter :preload_json, :check_xhr
skip_before_filter :redirect_to_login_if_required, only: ['basic_info']
skip_before_filter :redirect_to_login_if_required, only: ['basic_info', 'statistics']
def site
render json: Site.json_for(guardian)
@ -42,4 +42,9 @@ class SiteController < ApplicationController
# this info is always available cause it can be scraped from a 404 page
render json: results
end
def statistics
return redirect_to path('/') unless SiteSetting.share_anonymized_statistics?
render json: About.fetch_cached_stats
end
end

View File

@ -81,7 +81,8 @@ class UploadsController < ApplicationController
if filename == "image.png" && SiteSetting.convert_pasted_images_to_hq_jpg
jpeg_path = "#{File.dirname(tempfile.path)}/image.jpg"
OptimizedImage.ensure_safe_paths!(tempfile.path, jpeg_path)
`convert #{tempfile.path} -quality #{SiteSetting.convert_pasted_images_quality} #{jpeg_path}`
Discourse::Utils.execute_command('convert', tempfile.path, '-quality', SiteSetting.convert_pasted_images_quality.to_s, jpeg_path)
# only change the format of the image when JPG is at least 5% smaller
if File.size(jpeg_path) < File.size(tempfile.path) * 0.95
filename = "image.jpg"

View File

@ -550,7 +550,13 @@ class UsersController < ApplicationController
if Guardian.new(@user).can_access_forum?
@user.enqueue_welcome_message('welcome_user') if @user.send_welcome_message
log_on_user(@user)
return redirect_to(wizard_path) if Wizard.user_requires_completion?(@user)
if Wizard.user_requires_completion?(@user)
return redirect_to(wizard_path)
elsif destination_url = cookies[:destination_url]
cookies[:destination_url] = nil
return redirect_to(destination_url)
end
else
@needs_approval = true
end
@ -581,7 +587,7 @@ class UsersController < ApplicationController
if @user.active
render_json_error(I18n.t('activation.activated'), status: 409)
else @user
else
@email_token = @user.email_tokens.unconfirmed.active.first
enqueue_activation_email
render nothing: true

View File

@ -0,0 +1,41 @@
module Jobs
class MigrateCustomEmojis < Jobs::Onceoff
def execute_onceoff(args)
return if Rails.env.test?
Dir["#{Rails.root}/#{Emoji.base_directory}/*.{png,gif}"].each do |path|
name = File.basename(path, File.extname(path))
File.open(path) do |file|
upload = Upload.create_for(
Discourse.system_user.id,
file,
File.basename(path),
file.size,
image_type: 'custom_emoji'
)
if upload.persisted?
custom_emoji = CustomEmoji.new(name: name, upload: upload)
if !custom_emoji.save
warn("Failed to create custom emoji '#{name}': #{custom_emoji.errors.full_messages}")
end
else
warn("Failed to create upload for '#{name}' custom emoji: #{upload.errors.full_messages}")
end
end
end
Emoji.clear_cache
Post.where("cooked LIKE '%#{Emoji.base_url}%'").find_each do |post|
post.rebake!
end
end
def warn(message)
Rails.logger.warn(message)
end
end
end

View File

@ -3,32 +3,24 @@ require 'excon'
module Jobs
class EmitWebHookEvent < Jobs::Base
def execute(args)
raise Discourse::InvalidParameters.new(:web_hook_id) unless args[:web_hook_id].present?
raise Discourse::InvalidParameters.new(:event_type) unless args[:event_type].present?
args = args.dup
if args[:topic_id]
args[:topic_view] = TopicView.new(args[:topic_id], Discourse.system_user)
[:web_hook_id, :event_type].each do |key|
raise Discourse::InvalidParameters.new(key) unless args[key].present?
end
if args[:post_id]
# deleted post so skip
return unless args[:post] = Post.find_by(id: args[:post_id])
end
web_hook = WebHook.find_by(id: args[:web_hook_id])
raise Discourse::InvalidParameters(:web_hook_id) if web_hook.blank?
if args[:user_id]
return unless args[:user] = User.find_by(id: args[:user_id])
end
web_hook = WebHook.find(args[:web_hook_id])
unless args[:event_type] == 'ping'
unless ping_event?(args[:event_type])
return unless web_hook.active?
return if web_hook.group_ids.present? && (args[:group_id].present? ||
!web_hook.group_ids.include?(args[:group_id]))
return if web_hook.category_ids.present? && (!args[:category_id].present? ||
!web_hook.category_ids.include?(args[:category_id]))
event_type = args[:event_type].to_s
return unless self.send("setup_#{event_type}", args)
end
web_hook_request(args, web_hook)
@ -36,12 +28,56 @@ module Jobs
private
def web_hook_request(args, web_hook)
def guardian
Guardian.new(Discourse.system_user)
end
def setup_post(args)
post = Post.find_by(id: args[:post_id])
return if post.blank?
args[:payload] = WebHookPostSerializer.new(post, scope: guardian, root: false).as_json
end
def setup_topic(args)
topic_view = (TopicView.new(args[:topic_id], Discourse.system_user) rescue nil)
return if topic_view.blank?
args[:payload] = WebHookTopicViewSerializer.new(topic_view, scope: guardian, root: false).as_json
end
def setup_user(args)
user = User.find_by(id: args[:user_id])
return if user.blank?
args[:payload] = WebHookUserSerializer.new(user, scope: guardian, root: false).as_json
end
def ping_event?(event_type)
event_type.to_s == 'ping'.freeze
end
def build_web_hook_body(args, web_hook)
body = {}
guardian = Guardian.new(Discourse.system_user)
event_type = args[:event_type].to_s
if ping_event?(event_type)
body[:ping] = 'OK'
else
body[event_type] = args[:payload]
end
new_body = Plugin::Filter.apply(:after_build_web_hook_body, self, body)
MultiJson.dump(new_body)
end
def web_hook_request(args, web_hook)
uri = URI(web_hook.payload_url)
conn = Excon.new(uri.to_s,
ssl_verify_peer: web_hook.verify_certificate,
retry_limit: 0)
conn = Excon.new(
uri.to_s,
ssl_verify_peer: web_hook.verify_certificate,
retry_limit: 0
)
body = build_web_hook_body(args, web_hook)
web_hook_event = WebHookEvent.create!(web_hook_id: web_hook.id)
@ -53,6 +89,7 @@ module Jobs
else
'application/json'
end
headers = {
'Accept' => '*/*',
'Connection' => 'close',
@ -64,6 +101,7 @@ module Jobs
'X-Discourse-Event-Id' => web_hook_event.id,
'X-Discourse-Event-Type' => args[:event_type]
}
headers['X-Discourse-Event'] = args[:event_name].to_s if args[:event_name].present?
if web_hook.secret.present?
@ -72,45 +110,23 @@ module Jobs
now = Time.zone.now
response = conn.post(headers: headers, body: body)
web_hook_event.update!(
headers: MultiJson.dump(headers),
payload: body,
status: response.status,
response_headers: MultiJson.dump(response.headers),
response_body: response.body,
duration: ((Time.zone.now - now) * 1000).to_i
)
MessageBus.publish("/web_hook_events/#{web_hook.id}", {
web_hook_event_id: web_hook_event.id,
event_type: args[:event_type]
}, user_ids: User.human_users.staff.pluck(:id))
rescue
web_hook_event.destroy!
end
web_hook_event.update_attributes!(headers: MultiJson.dump(headers),
payload: body,
status: response.status,
response_headers: MultiJson.dump(response.headers),
response_body: response.body,
duration: ((Time.zone.now - now) * 1000).to_i)
MessageBus.publish("/web_hook_events/#{web_hook.id}", {
web_hook_event_id: web_hook_event.id,
event_type: args[:event_type]
}, user_ids: User.staff.pluck(:id))
end
def build_web_hook_body(args, web_hook)
body = {}
guardian = Guardian.new(Discourse.system_user)
if topic_view = args[:topic_view]
body[:topic] = TopicViewSerializer.new(topic_view, scope: guardian, root: false).as_json
end
if post = args[:post]
body[:post] = PostSerializer.new(post, scope: guardian, root: false).as_json
end
if user = args[:user]
body[:user] = UserSerializer.new(user, scope: guardian, root: false).as_json
end
body[:ping] = 'OK' if args[:event_type] == 'ping'
raise Discourse::InvalidParameters.new if body.empty?
MultiJson.dump(body)
end
end
end

View File

@ -0,0 +1,8 @@
module Jobs
class RebakeCustomEmojiPosts < Jobs::Base
def execute(args)
name = args[:name]
Post.where("raw LIKE '%:#{name}:%'").find_each { |post| post.rebake! }
end
end
end

View File

@ -1,18 +0,0 @@
module Jobs
class ResizeEmoji < Jobs::Base
def execute(args)
path = args[:path]
return unless File.exists?(path)
opts = {
allow_animation: true,
force_aspect_ratio: SiteSetting.enforce_square_emoji
}
# make sure emoji aren't too big
OptimizedImage.downsize(path, path, "100x100", opts)
end
end
end

View File

@ -67,16 +67,13 @@ module Jobs
signup_after_approval
}
def message_for_email(user, post, type, notification,
notification_type=nil, notification_data_hash=nil,
email_token=nil, to_address=nil)
def message_for_email(user, post, type, notification, notification_type=nil, notification_data_hash=nil, email_token=nil, to_address=nil)
set_skip_context(type, user.id, to_address || user.email, post.try(:id))
return skip_message(I18n.t("email_log.anonymous_user")) if user.anonymous?
return skip_message(I18n.t("email_log.suspended_not_pm")) if user.suspended? && type.to_s != "user_private_message"
return if user.staged && type == :digest
return if user.staged && type.to_s == "digest"
seen_recently = (user.last_seen_at.present? && user.last_seen_at > SiteSetting.email_time_window_mins.minutes.ago)
seen_recently = false if user.user_option.email_always || user.staged
@ -87,9 +84,7 @@ module Jobs
return skip_message(I18n.t('email_log.seen_recently')) if seen_recently && !user.suspended?
end
if post
email_args[:post] = post
end
email_args[:post] = post if post
if notification || notification_type
email_args[:notification_type] ||= notification_type || notification.try(:notification_type)
@ -118,18 +113,13 @@ module Jobs
end
skip_reason = skip_email_for_post(post, user)
return skip_message(skip_reason) if skip_reason
return skip_message(skip_reason) if skip_reason.present?
# Make sure that mailer exists
raise Discourse::InvalidParameters.new("type=#{type}") unless UserNotifications.respond_to?(type)
if email_token.present?
email_args[:email_token] = email_token
end
if type.to_s == "notify_old_email"
email_args[:new_email] = user.email
end
email_args[:email_token] = email_token if email_token.present?
email_args[:new_email] = user.email if type.to_s == "notify_old_email"
if EmailLog.reached_max_emails?(user)
return skip_message(I18n.t('email_log.exceeded_emails_limit'))
@ -144,9 +134,7 @@ module Jobs
end
# Update the to address if we have a custom one
if message && to_address.present?
message.to = to_address
end
message.to = to_address if message && to_address.present?
[message, nil]
end
@ -179,12 +167,10 @@ module Jobs
return I18n.t('email_log.topic_nil') if post.topic.blank?
return I18n.t('email_log.post_user_deleted') if post.user.blank?
return I18n.t('email_log.post_deleted') if post.user_deleted?
return I18n.t('email_log.user_suspended') if (user.suspended? && !post.user.try(:staff?))
return I18n.t('email_log.user_suspended') if user.suspended? && !post.user&.staff?
if !user.user_option.email_always? &&
PostTiming.where(topic_id: post.topic_id, post_number: post.post_number, user_id: user.id).present?
return I18n.t('email_log.already_read')
end
already_read = !user.user_option.email_always? && PostTiming.exists?(topic_id: post.topic_id, post_number: post.post_number, user_id: user.id)
return I18n.t('email_log.already_read') if already_read
else
false
end

View File

@ -22,11 +22,13 @@ module Jobs
.joins("LEFT JOIN user_avatars ua ON (ua.gravatar_upload_id = uploads.id OR ua.custom_upload_id = uploads.id)")
.joins("LEFT JOIN user_profiles up ON up.profile_background = uploads.url OR up.card_background = uploads.url")
.joins("LEFT JOIN categories c ON c.uploaded_logo_id = uploads.id OR c.uploaded_background_id = uploads.id")
.joins("LEFT JOIN custom_emojis ce ON ce.upload_id = uploads.id")
.where("pu.upload_id IS NULL")
.where("u.uploaded_avatar_id IS NULL")
.where("ua.gravatar_upload_id IS NULL AND ua.custom_upload_id IS NULL")
.where("up.profile_background IS NULL AND up.card_background IS NULL")
.where("c.uploaded_logo_id IS NULL AND c.uploaded_background_id IS NULL")
.where("ce.upload_id IS NULL")
.where("uploads.url NOT IN (?)", ignore_urls)
result.find_each do |upload|

View File

@ -15,18 +15,18 @@ module Jobs
def target_user_ids
# Users who want to receive digest email within their chosen digest email frequency
query = User.real
.where(active: true, staged: false)
.joins(:user_option)
.not_suspended
.where(user_options: {email_digests: true})
.activated
.where(staged: false)
.joins(:user_option, :user_stat)
.where("user_options.email_digests")
.where("user_stats.bounce_score < #{SiteSetting.bounce_score_threshold}")
.where("COALESCE(last_emailed_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 MINUTE'::INTERVAL * user_options.digest_after_minutes)")
.where("COALESCE(last_seen_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 MINUTE'::INTERVAL * user_options.digest_after_minutes)")
.where("COALESCE(last_seen_at, '2010-01-01') >= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * #{SiteSetting.suppress_digest_email_after_days})")
# If the site requires approval, make sure the user is approved
if SiteSetting.must_approve_users?
query = query.where("approved OR moderator OR admin")
end
query = query.where("approved OR moderator OR admin") if SiteSetting.must_approve_users?
query.pluck(:id)
end

View File

@ -357,6 +357,12 @@ class UserNotifications < ActionMailer::Base
user = opts[:user]
locale = user_locale(user)
template = "user_notifications.user_#{notification_type}"
if post.topic.private_message?
template << "_pm"
template << "_staged" if user.staged?
end
# category name
category = Topic.find_by(id: post.topic_id).category
if opts[:show_category_in_subject] && post.topic_id && category && !category.uncategorized?
@ -384,10 +390,7 @@ class UserNotifications < ActionMailer::Base
end
end
reached_limit = SiteSetting.max_emails_per_day_per_user > 0
reached_limit &&= (EmailLog.where(user_id: user.id, skipped: false)
.where('created_at > ?', 1.day.ago)
.count) >= (SiteSetting.max_emails_per_day_per_user-1)
translation_override_exists = TranslationOverride.where(locale: SiteSetting.default_locale, translation_key: "#{template}.text_body_template").exists?
if opts[:use_invite_template]
if post.topic.private_message?
@ -397,36 +400,42 @@ class UserNotifications < ActionMailer::Base
end
topic_excerpt = post.excerpt.tr("\n", " ") if post.is_first_post? && post.excerpt
message = I18n.t(invite_template, username: username, topic_title: title, topic_excerpt: topic_excerpt, site_title: SiteSetting.title, site_description: SiteSetting.site_description)
html = UserNotificationRenderer.new(Rails.configuration.paths["app/views"]).render(
template: 'email/invite',
format: :html,
locals: { message: PrettyText.cook(message, sanitize: false).html_safe,
classes: RTL.new(user).css_class
}
)
else
in_reply_to_post = post.reply_to_post if user.user_option.email_in_reply_to
html = UserNotificationRenderer.new(Rails.configuration.paths["app/views"]).render(
template: 'email/notification',
format: :html,
locals: { context_posts: context_posts,
reached_limit: reached_limit,
post: post,
in_reply_to_post: in_reply_to_post,
classes: RTL.new(user).css_class
}
)
message = email_post_markdown(post) + (reached_limit ? "\n\n#{I18n.t "user_notifications.reached_limit", count: SiteSetting.max_emails_per_day_per_user}" : "");
end
template = "user_notifications.user_#{notification_type}"
if post.topic.private_message?
template << "_pm"
template << "_staged" if user.staged?
unless translation_override_exists
html = UserNotificationRenderer.new(Rails.configuration.paths["app/views"]).render(
template: 'email/invite',
format: :html,
locals: { message: PrettyText.cook(message, sanitize: false).html_safe,
classes: RTL.new(user).css_class
}
)
end
else
reached_limit = SiteSetting.max_emails_per_day_per_user > 0
reached_limit &&= (EmailLog.where(user_id: user.id, skipped: false)
.where('created_at > ?', 1.day.ago)
.count) >= (SiteSetting.max_emails_per_day_per_user-1)
in_reply_to_post = post.reply_to_post if user.user_option.email_in_reply_to
message = email_post_markdown(post) + (reached_limit ? "\n\n#{I18n.t "user_notifications.reached_limit", count: SiteSetting.max_emails_per_day_per_user}" : "");
unless translation_override_exists
html = UserNotificationRenderer.new(Rails.configuration.paths["app/views"]).render(
template: 'email/notification',
format: :html,
locals: { context_posts: context_posts,
reached_limit: reached_limit,
post: post,
in_reply_to_post: in_reply_to_post,
classes: RTL.new(user).css_class
}
)
end
end
email_opts = {
topic_title: title,
topic_title_url_encoded: title ? URI.encode(title) : title,
message: message,
url: post.url,
post_id: post.id,
@ -444,13 +453,17 @@ class UserNotifications < ActionMailer::Base
private_reply: post.topic.private_message?,
include_respond_instructions: !(user.suspended? || user.staged?),
template: template,
html_override: html,
site_description: SiteSetting.site_description,
site_title: SiteSetting.title,
site_title_url_encoded: URI.encode(SiteSetting.title),
style: :notification,
locale: locale
}
unless translation_override_exists
email_opts[:html_override] = html
end
# If we have a display name, change the from address
email_opts[:from_alias] = from_alias if from_alias.present?

View File

@ -35,14 +35,12 @@ class About
def moderators
@moderators ||= User.where(moderator: true, admin: false)
.where.not(id: Discourse::SYSTEM_USER_ID)
.human_users
.order(:username_lower)
end
def admins
@admins ||= User.where(admin: true)
.where.not(id: Discourse::SYSTEM_USER_ID)
.order(:username_lower)
@admins ||= User.where(admin: true).human_users.order(:username_lower)
end
def stats

View File

@ -507,6 +507,10 @@ SQL
self.where(slug: category_slug, parent_category_id: nil).first
end
end
def subcategory_list_includes_topics?
subcategory_list_style.end_with?("with_featured_topics")
end
end
# == Schema Information

View File

@ -0,0 +1,6 @@
class CustomEmoji < ActiveRecord::Base
belongs_to :upload
validates :name, presence: true, uniqueness: true
validates :upload_id, presence: true
end

View File

@ -40,7 +40,7 @@ class EmbeddableHost < ActiveRecord::Base
def host_must_be_valid
if host !~ /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,7}(:[0-9]{1,5})?(\/.*)?\Z/i &&
host !~ /\A(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\Z/ &&
host !~ /\A(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(:[0-9]{1,5})?(\/.*)?\Z/ &&
host !~ /\A([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.)?localhost(\:[0-9]{1,5})?(\/.*)?\Z/i
errors.add(:host, I18n.t('errors.messages.invalid'))
end

View File

@ -1,6 +1,6 @@
class Emoji
# update this to clear the cache
EMOJI_VERSION = "v2"
EMOJI_VERSION = "v3"
include ActiveModel::SerializerSupport
@ -14,14 +14,6 @@ class Emoji
@path = path
end
def remove
return if path.blank?
if File.exists?(path)
File.delete(path) rescue nil
Emoji.clear_cache
end
end
def self.all
Discourse.cache.fetch(cache_key("all_emojis")) { standard | custom }
end
@ -46,14 +38,6 @@ class Emoji
Emoji.custom.detect { |e| e.name == name }
end
def self.create_from_path(path)
extension = File.extname(path)
Emoji.new(path).tap do |e|
e.name = File.basename(path, ".*")
e.url = "#{base_url}/#{e.name}#{extension}"
end
end
def self.create_from_db_item(emoji)
name = emoji["name"]
filename = "#{emoji['filename'] || name}.png"
@ -63,22 +47,6 @@ class Emoji
end
end
def self.create_for(file, name)
extension = File.extname(file.original_filename)
path = "#{Emoji.base_directory}/#{name}#{extension}"
full_path = "#{Rails.root}/#{path}"
# store the emoji
FileUtils.mkdir_p(Pathname.new(path).dirname)
File.open(path, "wb") { |f| f << file.tempfile.read }
# clear the cache
Emoji.clear_cache
# launch resize job
Jobs.enqueue(:resize_emoji, path: full_path)
# return created emoji
Emoji[name]
end
def self.cache_key(name)
"#{name}:#{EMOJI_VERSION}:#{Plugin::CustomEmoji.cache_key}"
end
@ -124,9 +92,12 @@ class Emoji
def self.load_custom
result = []
Dir.glob(File.join(Emoji.base_directory, "*.{png,gif}"))
.sort
.each { |emoji| result << Emoji.create_from_path(emoji) }
CustomEmoji.all.each do |emoji|
result << Emoji.new.tap do |e|
e.name = emoji.name
e.url = emoji.upload.url
end
end
Plugin::CustomEmoji.emojis.each do |name, url|
result << Emoji.new.tap do |e|

View File

@ -11,6 +11,8 @@ class GlobalSetting
# for legacy reasons
REDIS_SECRET_KEY = 'SECRET_TOKEN'
REDIS_VALIDATE_SECONDS = 30
# In Rails secret_key_base is used to encrypt the cookie store
# the cookie store contains session data
# Discourse also uses this secret key to digest user auth tokens
@ -19,9 +21,22 @@ class GlobalSetting
# - generate a token on the fly if needed and cache in redis
# - enforce rules about token format falling back to redis if needed
def self.safe_secret_key_base
if @safe_secret_key_base && @token_in_redis && (@token_last_validated + REDIS_VALIDATE_SECONDS) < Time.now
@token_last_validated = Time.now
token = $redis.without_namespace.get(REDIS_SECRET_KEY)
if token.nil?
$redis.without_namespace.set(REDIS_SECRET_KEY, @safe_secret_key_base)
end
end
@safe_secret_key_base ||= begin
token = secret_key_base
if token.blank? || token !~ VALID_SECRET_KEY
@token_in_redis = true
@token_last_validated = Time.now
token = $redis.without_namespace.get(REDIS_SECRET_KEY)
unless token && token =~ VALID_SECRET_KEY
token = SecureRandom.hex(64)
@ -39,8 +54,21 @@ class GlobalSetting
default_provider = FileProvider.from(File.expand_path('../../../config/discourse_defaults.conf', __FILE__))
default_provider.keys.concat(@provider.keys).uniq.each do |key|
default = default_provider.lookup(key, nil)
instance_variable_set("@#{key}_cache", nil)
define_singleton_method(key) do
provider.lookup(key, default)
val = instance_variable_get("@#{key}_cache")
unless val.nil?
val == :missing ? nil : val
else
val = provider.lookup(key, default)
if val.nil?
val = :missing
end
instance_variable_set("@#{key}_cache", val)
val == :missing ? nil : val
end
end
end
end

View File

@ -62,6 +62,7 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password) do
send_welcome_message
notify_invitee
send_password_instructions
enqueue_activation_mail
delete_duplicate_invites
end
@ -126,6 +127,13 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password) do
end
end
def enqueue_activation_mail
if invited_user.has_password?
email_token = invited_user.email_tokens.create(email: invited_user.email)
Jobs.enqueue(:critical_user_email, type: :signup, user_id: invited_user.id, email_token: email_token.token)
end
end
def notify_invitee
if inviter = invite.invited_by
inviter.notifications.create(notification_type: Notification.types[:invitee_accepted],

View File

@ -12,10 +12,11 @@ class Notification < ActiveRecord::Base
scope :visible , lambda { joins('LEFT JOIN topics ON notifications.topic_id = topics.id')
.where('topics.id IS NULL OR topics.deleted_at IS NULL') }
after_save :refresh_notification_count
after_destroy :refresh_notification_count
after_commit :send_email
# This is super weird because the tests fail if we don't specify `on: :destroy`
# TODO: Revert back to default in Rails 5
after_commit :refresh_notification_count, on: :destroy
after_commit :refresh_notification_count, on: [:create, :update]
def self.ensure_consistency!
Notification.exec_sql("

View File

@ -215,8 +215,11 @@ class OptimizedImage < ActiveRecord::Base
end
def self.convert_with(instructions, to)
`#{instructions.join(" ")} &> /dev/null`
return false if $?.exitstatus != 0
begin
Discourse::Utils.execute_command(*instructions)
rescue
return false
end
ImageOptim.new.optimize_image!(to)
true

View File

@ -833,7 +833,7 @@ SQL
.update_all(dismissed_banner_key: nil)
self.archetype = Archetype.banner
self.add_moderator_post(user, I18n.t("archetypes.banner.message.make"))
self.add_small_action(user, "banner.enabled")
self.save
MessageBus.publish('/site/banner', banner)
@ -841,7 +841,7 @@ SQL
def remove_banner!(user)
self.archetype = Archetype.default
self.add_moderator_post(user, I18n.t("archetypes.banner.message.remove"))
self.add_small_action(user, "banner.disabled")
self.save
MessageBus.publish('/site/banner', nil)

View File

@ -57,7 +57,12 @@ class Upload < ActiveRecord::Base
end
# list of image types that will be cropped
CROPPED_IMAGE_TYPES ||= %w{avatar profile_background card_background}
CROPPED_IMAGE_TYPES ||= %w{
avatar
profile_background
card_background
custom_emoji
}
WHITELISTED_SVG_ELEMENTS ||= %w{
circle
@ -92,7 +97,7 @@ class Upload < ActiveRecord::Base
# options
# - content_type
# - origin (url)
# - image_type ("avatar", "profile_background", "card_background")
# - image_type ("avatar", "profile_background", "card_background", "custom_emoji")
# - is_attachment_for_group_message (boolean)
def self.create_for(user_id, file, filename, filesize, options = {})
upload = Upload.new
@ -145,6 +150,8 @@ class Upload < ActiveRecord::Base
max_width = 590 * max_pixel_ratio
width, height = ImageSizer.resize(w, h, max_width: max_width, max_height: max_width)
OptimizedImage.downsize(file.path, file.path, "#{width}x#{height}", filename: filename, allow_animation: allow_animation)
when "custom_emoji"
OptimizedImage.downsize(file.path, file.path, "100x100", filename: filename, allow_animation: allow_animation)
end
end
@ -246,7 +253,7 @@ class Upload < ActiveRecord::Base
end
def self.fix_image_orientation(path)
`convert #{path} -auto-orient #{path}`
Discourse::Utils.execute_command('convert', path, '-auto-orient', path)
end
def self.migrate_to_new_scheme(limit=nil)

View File

@ -93,7 +93,6 @@ class User < ActiveRecord::Base
after_create :create_user_profile
after_create :ensure_in_trust_level_group
after_create :set_default_categories_preferences
after_create :trigger_user_created_event
before_save :update_username_lower
before_save :ensure_password_is_hashed
@ -105,6 +104,7 @@ class User < ActiveRecord::Base
after_save :badge_grant
after_save :expire_old_email_tokens
after_save :index_search
after_commit :trigger_user_created_event, on: :create
before_destroy do
# These tables don't have primary keys, so destroying them with activerecord is tricky:
@ -124,8 +124,10 @@ class User < ActiveRecord::Base
# set to true to optimize creation and save for imports
attr_accessor :import_mode
scope :human_users, -> { where('users.id > 0') }
# excluding fake users like the system user or anonymous users
scope :real, -> { where('users.id > 0').where('NOT EXISTS(
scope :real, -> { human_users.where('NOT EXISTS(
SELECT 1
FROM user_custom_fields ucf
WHERE
@ -1062,11 +1064,6 @@ class User < ActiveRecord::Base
end
end
def trigger_user_created_event
DiscourseEvent.trigger(:user_created, self)
true
end
private
def previous_visit_at_update_required?(timestamp)
@ -1080,6 +1077,11 @@ class User < ActiveRecord::Base
end
end
def trigger_user_created_event
DiscourseEvent.trigger(:user_created, self)
true
end
end
# == Schema Information

View File

@ -41,11 +41,11 @@ class WebHook < ActiveRecord::Base
end
def self.enqueue_topic_hooks(event, topic, user=nil)
WebHook.enqueue_hooks(:topic, topic_id: topic.id, user_id: user&.id, category_id: topic&.category_id, event_name: event.to_s)
WebHook.enqueue_hooks(:topic, topic_id: topic.id, category_id: topic&.category_id, event_name: event.to_s)
end
def self.enqueue_post_hooks(event, post, user=nil)
WebHook.enqueue_hooks(:post, post_id: post.id, topic_id: post&.topic_id, user_id: user&.id, category_id: post&.topic&.category_id, event_name: event.to_s)
WebHook.enqueue_hooks(:post, post_id: post.id, category_id: post&.topic&.category_id, event_name: event.to_s)
end
%i(topic_destroyed topic_recovered).each do |event|
@ -61,6 +61,7 @@ class WebHook < ActiveRecord::Base
%i(post_created
post_destroyed
post_recovered).each do |event|
DiscourseEvent.on(event) do |post, _, user|
WebHook.enqueue_post_hooks(event, post, user)
end

View File

@ -91,4 +91,8 @@ class AdminDetailedUserSerializer < AdminUserSerializer
scope.can_view_action_logs?(object)
end
def post_count
object.posts.count
end
end

View File

@ -22,7 +22,8 @@ class BasicCategorySerializer < ApplicationSerializer
:sort_ascending,
:show_subcategory_list,
:num_featured_topics,
:default_view
:default_view,
:subcategory_list_style
has_one :uploaded_logo, embed: :object, serializer: CategoryUploadSerializer
has_one :uploaded_background, embed: :object, serializer: CategoryUploadSerializer

View File

@ -0,0 +1,17 @@
class WebHookPostSerializer < PostSerializer
def include_can_edit?
false
end
def can_delete
false
end
def can_recover
false
end
def can_wiki
false
end
end

View File

@ -0,0 +1,11 @@
require_dependency 'pinned_check'
class WebHookTopicViewSerializer < TopicViewSerializer
def include_post_stream?
false
end
def include_timeline_lookup?
false
end
end

View File

@ -0,0 +1,5 @@
class WebHookUserSerializer < UserSerializer
# remove staff attributes
def staff_attributes(*attrs)
end
end

View File

@ -20,7 +20,7 @@ class UserBlocker
message_type = @opts[:message] || :blocked_by_staff
post = SystemMessage.create(@user, message_type)
if post && @by_user
StaffActionLogger.new(@by_user).log_block_user(@user, {context: "#{message_type}: '#{post.topic&.title rescue ''}'"})
StaffActionLogger.new(@by_user).log_block_user(@user, {context: "#{message_type}: '#{post.topic&.title rescue ''}' #{@opts[:reason]}"})
end
end
else

View File

@ -15,8 +15,8 @@ class UserDestroyer
# Returns a frozen instance of the User if the delete succeeded.
def destroy(user, opts={})
raise Discourse::InvalidParameters.new('user is nil') unless user and user.is_a?(User)
@guardian.ensure_can_delete_user!(user)
raise PostsExistError if !opts[:delete_posts] && user.posts.count != 0
@guardian.ensure_can_delete_user!(user)
User.transaction do

View File

@ -96,15 +96,16 @@ class UserUpdater
user.custom_fields = user.custom_fields.merge(fields)
end
saved = nil
User.transaction do
if attributes.key?(:muted_usernames)
update_muted_users(attributes[:muted_usernames])
end
saved = (!save_options || user.user_option.save) && user_profile.save && user.save
if saved
DiscourseEvent.trigger(:user_updated, user)
if saved
# log name changes
if attributes[:name].present? && old_user_name.downcase != attributes.fetch(:name).downcase
StaffActionLogger.new(@actor).log_name_change(user.id, old_user_name, attributes.fetch(:name))
@ -112,8 +113,10 @@ class UserUpdater
StaffActionLogger.new(@actor).log_name_change(user.id, old_user_name, "")
end
end
saved
end
DiscourseEvent.trigger(:user_updated, user) if saved
saved
end
def update_muted_users(usernames)

View File

@ -185,6 +185,8 @@ module Discourse
require_dependency 'group'
require_dependency 'user_field'
require_dependency 'post_action_type'
# Ensure that Discourse event triggers for web hooks are loaded
require_dependency 'web_hook'
# So open id logs somewhere sane
OpenID::Util.logger = Rails.logger

View File

@ -12,4 +12,3 @@ end
ActiveSupport.on_load(:active_record) do
self.include_root_in_json = false
end

View File

@ -9,8 +9,8 @@ ar:
js:
number:
format:
separator: "."
delimiter: ","
separator: "٫"
delimiter: "٬"
human:
storage_units:
format: '%n% u'
@ -187,28 +187,28 @@ ar:
split_topic: "قسم هذا الموضوع في %{when}"
invited_user: "دعى %{who} %{when}"
invited_group: "دعى %{who} %{when}"
removed_user: "أزال %{who} في %{when}"
removed_group: "أزال %{who} في %{when}"
removed_user: "أزال %{who} %{when}"
removed_group: "أزال %{who} %{when}"
autoclosed:
enabled: 'أُغلق في %{when}'
disabled: 'فُتح في %{when}'
enabled: 'أُغلق %{when}'
disabled: 'فُتح %{when}'
closed:
enabled: 'أغلقه في %{when}'
disabled: 'فتحه في %{when}'
enabled: 'أغلقه %{when}'
disabled: 'فتحه %{when}'
archived:
enabled: 'أرشفه في %{when}'
disabled: 'أزال أرشفته في %{when}'
enabled: 'أرشفه %{when}'
disabled: 'أزال أرشفته %{when}'
pinned:
enabled: 'ثبّته في %{when}'
disabled: 'أزال تثبيته في %{when}'
enabled: 'ثبّته %{when}'
disabled: 'أزال تثبيته %{when}'
pinned_globally:
enabled: 'ثبّته عموميا في %{when}'
disabled: 'أزال تثبيته في %{when}'
enabled: 'ثبّته عموميا %{when}'
disabled: 'أزال تثبيته %{when}'
visible:
enabled: 'أدرجه في %{when}'
disabled: 'أزال إدراجه في %{when}'
enabled: 'أدرجه %{when}'
disabled: 'أزال إدراجه %{when}'
topic_admin_menu: "عمليات المدير"
wizard_required: "حان وقت اعداد منتداك! <a href='%{url}' data-auto-route='true'>شغل معالج الإعداد</a>!"
wizard_required: "مرحبًا في دسكورس الجديد! فلنبدأ مستخدمين <a href='%{url}' data-auto-route='true'>مرشد الإعداد</a> ✨"
emails_are_disabled: "لقد عطّل أحد المدراء الرّسائل الصادرة للجميع. لن تُرسل إشعارات عبر البريد الإلكتروني أيا كان نوعها."
bootstrap_mode_enabled: "الموقع اﻷن مفعل بالطريقة التمهيدية لكى تتمكن من اطلاق موقعك الجديد بسهولة. سيسجل كل الأعضاء الجدد بمستوى ثقة 1 وسيكون اختيار ارسال الملخص اليومى عن طريق البريد الالكترونى مفعل. سيتم الغاء تفعيل الطريقة التمهيدية تلقائيا عندما يتخطى عدد اﻷعضاء %{min_users} عضو."
bootstrap_mode_disabled: "سيتم الغاء تفعيل الطريقة التمهيدية خلال ال 24 ساعة القادمة"
@ -236,7 +236,7 @@ ar:
sign_up: "سجّل حسابا"
log_in: "تسجيل الدخول "
age: "العمر"
joined: "انضم"
joined: "انضم في"
admin_title: "المدير"
flags_title: "بلاغات"
show_more: "أظهر المزيد"
@ -334,7 +334,7 @@ ar:
other: "{{count}} موضوع جديد."
click_to_show: "انقر للعرض."
preview: "معاينة"
cancel: "ألغ"
cancel: "إلغاء"
save: "احفظ التعديلات"
saving: "يحفظ..."
saved: "حُفظت!"
@ -365,7 +365,7 @@ ar:
title: "تحتاج موافقة"
none: "لا مشاركات لمراجعتها."
edit: "حرر"
cancel: "ألغِ"
cancel: "إلغاء"
view_pending: "اعرض المشاركات المعلّقة"
has_pending_posts:
zero: "ليس في هذا الموضوع <b>أيّة</b> مشاركات تحتاج مراجعة"
@ -560,11 +560,11 @@ ar:
toggle_ordering: "تبديل التحكم في الترتيب"
subcategories: "أقسام فرعية"
topic_sentence:
zero: "%{count} موضوع"
one: "موضوع"
zero: "لا مواضيع"
one: "موضوع واحد"
two: "موضوعان"
few: "%{count} مواضيع"
many: "%{count} موضوع"
many: "%{count} موضوعًا"
other: "%{count} موضوع"
topic_stat_sentence:
zero: "لا مواضيع جديدة في ال%{unit} الماضي."
@ -597,7 +597,7 @@ ar:
edit: "تعديل التفضيلات"
download_archive:
button_text: "تحميل مواضيعي"
confirm: "هل أنت متأكد من رغبتك في تحميل جميع مشاركاتك ؟"
confirm: "أمتأكّد من تنزيل مشاركاتك؟"
success: "بدأ التحميل, سيتم إعلامك برسالة عند اكتمال العملية."
rate_limit_error: "المشاركات يمكن تحميلها لمرة واحدة في اليوم , الرجاء المحاولة غدا."
new_private_message: "رسالة جديدة"
@ -614,18 +614,18 @@ ar:
statistics: "احصائيات"
desktop_notifications:
label: "تنبيهات سطح المكتب"
not_supported: "عذراً , التنبيهات غير مدعومة على هذا المتصفح"
perm_default: "تفعيل التنبيهات"
not_supported: "نأسف، لا يدعم المتصفّح الإشعارات."
perm_default: "فعّل الإشعارات"
perm_denied_btn: "الوصول مرفوض"
perm_denied_expl: "لقد رفضت صلاحية عرض الإشعارات. اسمح بظهور الإشعارات وذلك من إعدادات المتصفح."
disable: "إيقاف التنبيهات "
enable: "تفعيل التنبيهات"
disable: "عطّل الإشعارات"
enable: "فعّل الإشعارات"
each_browser_note: "ملاحظة: عليك تغيير هذا الإعداد في كل متصفح تستخدمه."
dismiss_notifications: "تجاهل الكل"
dismiss_notifications_tooltip: "جعل جميع التنبيهات الغيرة مقروءة الى مقروءة. "
first_notification: "إشعارك الأول! قم بالضغط عليه للبدء."
disable_jump_reply: "لاتذهب إلى مشاركتي بعد الرد"
dynamic_favicon: "إعرض عدد المواضيع الجديدة والمحدثة في أيقونة المتصفح"
disable_jump_reply: "لا تنتقل إلى مشاركتي بعدما أردّ"
dynamic_favicon: "أظهر عدد المواضيع الجديدة/المحدّثة في أيقونة المتصفّح"
external_links_in_new_tab: "فتح الروابط الخارجية في ألسنة جديدة"
enable_quoting: "فعل خاصية إقتباس النصوص المظللة"
change: "تغيير"
@ -636,7 +636,7 @@ ar:
blocked_tooltip: "هذا المستخدم محظور"
suspended_notice: "هذا المستخدم موقوف حتى تاريخ {{date}}"
suspended_reason: "السبب:"
github_profile: "Github"
github_profile: "غِت‌هَب"
email_activity_summary: "ملخص النشاط"
mailing_list_mode:
label: "وضع القائمة البريدية"
@ -646,7 +646,7 @@ ar:
هذه الرسائل لا تشمل المواضيع والفئات المكتومة.
daily: "أرسل تحديثات يومية"
individual: "أرسل لي بريدا لكل مشاركة جديدة"
individual_no_echo: "ارسل بريد الكتروني لكل مشاركة جديدة ما عدا مواضيعي"
individual_no_echo: "أرسل بريدًا لكلّ مشاركة جديدة عدا مشاركاتي"
many_per_day: "أرسل لي بريدا لكل مشاركة جديدة (تقريبا {{dailyEmailEstimate}} يوميا)"
few_per_day: "أرسل لي بريدا لكل مشاركة جديدة (تقريبا إثنتان يوميا)"
tag_settings: "الوسوم"
@ -667,7 +667,7 @@ ar:
muted_categories: "المكتومة"
muted_categories_instructions: "لن يتم إشعارك بأي جديد عن المواضيع الجديدة في هذه التصنيفات، ولن تظهر مواضيع هذه التصنيفات في قائمة المواضيع المنشورة مؤخراً."
delete_account: "حذف الحساب"
delete_account_confirm: "هل انت متاكد من انك تريد حذف حسابك نهائيا؟ لايمكن التراجع عن هذا العمل!"
delete_account_confirm: "أمتأكّد من حذف حسابك للأبد؟ هذا إجراء لا عودة فيه!"
deleted_yourself: "تم حذف حسابك بنجاح"
delete_yourself_not_allowed: "لايمكنك حذف حسابك الان , تواصل مع المدير ليحذف حسابك "
unread_message_count: "الرسائل"
@ -709,17 +709,17 @@ ar:
choose: "ادخل رمز سري"
change_about:
title: "تعديل ملفي الشخصي"
error: "حدث خطأ عند تغيير هذه القيمة."
error: "حدث عطل في تغيير هذه القيمة."
change_username:
title: "تغيير اسم المستخدم"
confirm: "عند تغييرك أسم المستخدم, جميع الاقتباسات السابقة من مشاركاتك و ذكر اسم المستخدم الخاص بك ستصبح غير صحيحة ولا تشير الى ملفك الشخصي. هل انت متأكد تماماً من اختيارك؟"
taken: "عذراً، هذا الإسم مُستخدم مسبقاً."
error: "حدث خطأ عند تغيير اسم المستخدم."
invalid: "اسم المستخدم غير صالح. يجب ان يحتوي على ارقام وحروف فقط "
taken: "نأسف، اسم المستخدم مأخوذ."
error: "حدث عطل في تغيير اسم المستخدم."
invalid: "اسم المستخدم غير صالح. يمكنه احتواء الأرقام والأحرف فحسب"
change_email:
title: "تغيير البريد الالكتروني"
taken: "نأسف، البريد الالكتروني غير متاح."
error: "حدث خطأ عند تغيير البريد الالكتروني. ربما يكون هذا البريد مستخدم من قبل؟"
title: "غيّر البريد الإلكترونيّ"
taken: "نأسف، البريد الإلكترونيّ غير متوفّر."
error: "حدث عطل في تغيير البريد الإلكترونيّ. لربّما هناك من يستخدم هذا العنوان بالفعل؟"
success: "لقد أرسلنا بريدا إلى هذا البريد. من فضلك اتّبع تعليمات التأكيد."
change_avatar:
title: "غير صورتك الشخصية"
@ -741,7 +741,7 @@ ar:
instructions: "سيتم وضع الخلفية في المنتصف بعرض 590px"
email:
title: "البريد الإلكتروني"
instructions: "سيكون مخفي عن الجميع"
instructions: "لا يظهر للعموم"
ok: "سنرسل لك بريدا للتأكيد"
invalid: "من فضلك أدخل بريدا إلكترونيا صالحا"
authenticated: "تم توثيق بريدك الإلكتروني بواسطة {{provider}}"
@ -761,18 +761,18 @@ ar:
ok: "يبدو اسمك جيدا"
username:
title: "اسم المستخدم"
instructions: "غير مكرر , بدون مسافات , قصير"
instructions: "فريد دون مسافات وقصير"
short_instructions: "يمكن للغير الإشارة إليك ب‍@{{username}}"
available: "اسم المستخدم متاح."
not_available: "غير متاح. جرّب {{suggestion}} ؟"
not_available_no_suggestion: "غير متاح"
too_short: "اسم المستخدم قصير جداً"
too_long: "اسم المستخدم طويل جداً"
too_short: "اسم المستخدم قصير جدًّا"
too_long: "اسم المستخدم طويل جدًّا"
checking: "يتم التاكد من توفر اسم المستخدم..."
prefilled: "البريد الالكتروني مطابق لـ اسم المستخدم المسّجل."
locale:
title: "لغة الموقع."
instructions: "اللغة المستخدمة تغيرت. لتفعيل اللغة الجديدة قم بتحديث الصفحة. "
title: "لغة الواجهة"
instructions: "لغة الواجهة الرّسومية. ستتغيّر عندما تُنعش الصّفحة."
default: "(الافتراضية)"
any: "أي"
password_confirmation:
@ -808,14 +808,14 @@ ar:
every_two_weeks: "كل أسبوعين"
include_tl0_in_digests: "في إيميلات تلخيص المحتوى إرفق مشاركات الأعضاء الجدد."
email_in_reply_to: " بالإشعار الألكتروني إرفق مقتطفات من الردود."
email_direct: "تلقي رسالة إلكترونية عند اقتباس مشاركة لك أو الرد على عليها أو في حالة ذكر اسمك @username"
email_private_messages: "إرسل إشعار بالبريد الإلكتروني عندما يرسل لك شخصاً رسالة خاصة."
email_always: "إرسل التنبيهات على بريدي الإلكتروني حتى وإن كنت متواجد في الموقع ."
other_settings: "اخرى"
email_direct: "أرسل إليّ بريدًا عندما يقتبس أحد كلامي، أو يرّد على إحدى مشاركاتي، أو يذكر @اسمي أو يدعوني إلى أحد المواضيع"
email_private_messages: "أرسل إليّ بريدًا عندما يبعث أحدهم رسالة إليّ"
email_always: "أرسل إليّ الإخطارات عبر البريد حتّى ولو كنت متّصلًا"
other_settings: "أخرى"
categories_settings: "الأقسام"
new_topic_duration:
label: " \nإعتبر المواضيع جديدة في حال"
not_viewed: "لم أشاهدها بعد. "
not_viewed: "لم أطالعها بعد"
last_here: "تم إنشائها منذ اخر زيارة لي. "
after_1_day: "أُنشئت في اليوم الماضي"
after_2_days: "أُنشئت في اليومين الماضيين"
@ -826,13 +826,13 @@ ar:
never: "ابداً"
immediately: "حالاً"
after_30_seconds: "بعد 30 ثانية"
after_1_minute: "بعد 1 دقيقة"
after_2_minutes: "بعد 2 دقائق"
after_1_minute: "بعد دقيقة واحدة"
after_2_minutes: "بعد دقيقتين"
after_3_minutes: "بعد 3 دقائق"
after_4_minutes: "بعد 4 دقائق"
after_5_minutes: "بعد 5 دقائق"
after_10_minutes: "بعد 10 دقائق"
notification_level_when_replying: "عندما أشارك في موضوع، اجعل ذلك الموضوع"
notification_level_when_replying: "إن شاركت في موضوع ما، اجعله"
invited:
search: "نوع البحث عن الدعوات"
title: "دعوة"
@ -867,32 +867,32 @@ ar:
account_age_days: "عمر الحساب بالأيام"
create: "أرسل دعوة"
generate_link: "انسخ رابط الدعوة"
link_generated: "تم إنشاء رابط الدعوة بنجاح!"
valid_for: "رابط الدعوة صحيح فقط للبريد الإلكتروني %{email}"
link_generated: "وُلّد رابط الدّعوة بنجاح!"
valid_for: "رابط الدعوة صالح للبريد الإلكترونيّ هذا فقط: %{email}"
bulk_invite:
none: "لم تقم بدعوة احد الى هنا حتى الآن، يمكنك إرسال دعوه واحدة او مجموعة دعوات عن طريق ملف CSV."
text: "الدعوة من ملف"
success: "رُفع الملف بنجاح. سيصلك إشعارا عبر رسالة عند اكتمال العملية."
error: "عذراً، الملف يجب ان يكون على صيغة CSV."
error: "نأسف، يجب أن يكون الملفّ بنسق CSV."
password:
title: "الرمز السري. "
too_short: "الرمز السري قصير جداً."
common: "الرمز السري ضعيف!"
same_as_username: "الرمز السري مُطابق لاسم المستخدم."
same_as_email: "الرمز السري مطابق للبريد الإلكتروني."
ok: "الرمز السري جيّد. "
title: "كلمة السّرّ"
too_short: "كلمة السّرّ قصيرة جدًّا."
common: "كلمة السّرّ هذه شائعة."
same_as_username: "كلمة السّرّ تطابق اسم المستخدم."
same_as_email: "كلمة السّر تطابق البريد الإلكترونيّ."
ok: "تبدو كلمة السّر جيّدة."
instructions: "يجب أن تكون على الأقل %{count} أحرف او أرقام"
summary:
title: "ملخص"
stats: "إحصائيات العضو:"
stats: "إحصائيّات"
time_read: "< الوقت الذي امضاه في الموقع"
topic_count:
zero: "< إجمالي المواضيع"
one: "< إجمالي المواضيع"
two: "< إجمالي المواضيع"
few: "< إجمالي المواضيع"
many: "< إجمالي المواضيع"
other: "< إجمالي المواضيع"
zero: "عدد المواضيع المنشأة"
one: "عدد المواضيع المنشأة"
two: "عدد المواضيع المنشأة"
few: "عدد المواضيع المنشأة"
many: "عدد المواضيع المنشأة"
other: "عدد المواضيع المنشأة"
post_count:
zero: "< إجمالي المواضيع"
one: " < اجمالي المشاركات"
@ -936,20 +936,20 @@ ar:
many: "المفضلة"
other: "المفضلة"
top_replies: "أفضل الردود"
no_replies: َم ينشر أي رد بعد."
more_replies: "المزيد من الردود"
no_replies: ا ردود بعد."
more_replies: "ردود أخرى"
top_topics: "أفضل المواضيع"
no_topics: َم ينشر أي موضوع بعد. "
more_topics: "المزيد من المواضيع"
top_badges: "افضل الاوسمه"
no_badges: م يحصل على اي وسام بعد. "
no_topics: ا مواضيع بعد."
more_topics: "مواضيع أخرى"
top_badges: "أفضل الشّارات"
no_badges: ا شارات بعد."
more_badges: "أوسمة أخرى .. "
top_links: "أفضل الروابط التي اضافها:"
no_links: يس هناك اي روابط بعد."
top_links: "أفضل الرّوابط"
no_links: ا روابط بعد."
most_liked_by: "الأعضاء المُعجبين بمشاركاتِه:"
most_liked_users: "الأعضاء الذين تعجبهُ مشاركاتِهم:"
most_replied_to_users: "الأعضاء الذين يتفاعل معهم غالباً:"
no_likes: م يتلقى أي إعجاب بعد."
no_likes: ا إعجابات بعد."
associated_accounts: "الحسابات المرتبطة"
ip_address:
title: "عنوان IP الأخير"
@ -988,11 +988,11 @@ ar:
again: "أعد المحاولة"
fixed: "تحميل"
close: "اغلاق"
assets_changed_confirm: "لقد تم تحديث الموقع قبل قليل، هل تريد تحديث الصفحة لتصفح الموقع بآخر نسخة؟ "
assets_changed_confirm: "حُدّث الموقع لتوّه. أتريد إنعاش الصّفحة ورؤية أحدث إصدارة؟"
logout: "لقد تم تسجيل خروجك"
refresh: "تحديث"
read_only_mode:
enabled: "الموقع حالياً في وضع القراءة فقط، هذا يعني انه لا يمكنك الآن إنشاء مواضيع او إضافة ردود او تقديم إعجاب بمشاركة. "
enabled: "هذا الموقع في وضع القراءة فقط. نأمل أن تتابع تصفّحه، ولكنّ الرّدّ، والإعجاب وغيرها من إجراءات معطّلة حاليًّا."
login_disabled: "تسجيل الدخول معطّل عندما يكون الموقع في حالة القراءة فقط"
logout_disabled: "تسجيل الخروج معطّل عندما يكون الموقع في حاله القراءة فقط"
too_few_topics_and_posts_notice: "دعونا <a href='http://blog.discourse.org/2014/08/building-a-discourse-community/'>الحصول على هذه المناقشة بدأت!</a> يوجد حاليا<strong>%{currentTopics} / %{requiredTopics}</strong> المواضيع و <strong>%{currentPosts} / %{requiredPosts}</strong> المشاركات. الزوار الجدد بحاجة إلى بعض الأحاديث لقراءة والرد على."
@ -1001,7 +1001,7 @@ ar:
logs_error_rate_notice:
reached: "<b>%{relativeAge}</b> <a href='%{url}' target='_blank'>%{rate}</a> وصل الحد الأعلى لإعدادت الموقع %{siteSettingRate}."
exceeded: "<b>%{relativeAge}</b> <a href='%{url}' target='_blank'>%{rate}</a> يتجاوز الحد الأعلى لإعدادت الموقع %{siteSettingRate}."
learn_more: "اقرأ اكثر .."
learn_more: "اطّلع على المزيد..."
all_time: 'المجموع'
all_time_desc: 'عدد المواضيع المنشأة'
year: 'عام'
@ -1011,11 +1011,11 @@ ar:
week: 'أسبوع'
week_desc: ' المواضيع التي كتبت خلال 7 أيام الماضية'
day: 'يوم'
first_post: الموضوع الأول
first_post: أوّل مشاركة
mute: كتم
unmute: إلغاء الكتم
last_post: أخر مشاركة
last_reply_lowercase: آخر رد
last_post: آخر مشاركة
last_reply_lowercase: آخر ردّ
replies_lowercase:
zero: الردود
one: الردود
@ -1037,57 +1037,57 @@ ar:
enable: 'لخّص هذا الموضوع'
disable: 'أظهر كل المشاركات'
deleted_filter:
enabled_description: "هذا الموضوع يحوي على مشاركات محذوفة تم اخفائها "
disabled_description: "المشاركات المحذوفة في هذا الموضوع ممكن مشاهدتها "
enable: "إخفاء المشاركات المحذوفة"
disable: "عرض المشاركات المحذوفة"
enabled_description: "في هذا الموضوع مشاركات محذوفة قد أُخفيت."
disabled_description: "المشاركات المحذوفة في الموضوع معروضة."
enable: "أخفِ المشاركات المحذوفة"
disable: "أظهر المشاركات المحذوفة"
private_message_info:
title: " رسالة خاصة"
invite: " إدعو اخرين"
remove_allowed_user: "هل تريد حقا ازالة {{name}} من الرسائل الخاصة ؟"
remove_allowed_group: "هل تريد حقا ازالة {{name}} من هذه الرسالة ?"
remove_allowed_user: "أمتأكّد من إزالة {{name}} من هذه الرّسالة؟"
remove_allowed_group: "أمتأكّد من إزالة {{name}} من هذه الرّسالة؟"
email: 'البريد الإلكتروني'
username: 'إسم المستخدم'
last_seen: 'شوهدت'
created: 'مكتوبة'
created_lowercase: 'نُشر منذ'
created_lowercase: 'أُنشئ'
trust_level: 'مستوى التقة'
search_hint: 'اسم مستخدم او بريد الكتروني او عنوان ايبي'
search_hint: 'اسم المستخدم، أو بريد إلكترونيّ أو عنوان IP'
create_account:
disclaimer: "التّسجيل يعني موافقتك على <a href='{{privacy_link}}'>سياسة الخصوصيّة</a> و<a href='{{tos_link}}'>بنود الخدمة</a>."
title: "إنشاء حساب جديد"
failed: "حدث خطأ ما, ربما بريدك الالكتروني مسجل مسبقا, جرب رابط نسيان كلمة المرور "
forgot_password:
title: " إعادة تعيين كلمة المرور"
action: "لقد نسيت رمزي السري"
invite: "ادخل اسم مستخدمك او بريدك الالكتروني وسنقوم بإرسال اعاذة ضبط كلمة المرور على بريدك"
action: "نسيتُ كلمة السّرّ"
invite: "أدخل اسم المستخدم أو البريد الإلكترونيّ وسنرسل بريدًا لتصفير كلمة السّرّ."
reset: " إعادة تعين الرمز السري"
complete_username: "اذا كان اسم المسنخدم موجود <b>%{username}</b>, سيتم ارسال رسالة لبريدك لإعادة ضبط كلمة المرور "
complete_email: "اذا كان الحساب متطابق <b>%{email}</b>, سوف تستلم بريد الالكتروني يحوي على التعليمات لإعادة ضبط كلمة المرور"
complete_username_found: "وجدنا حساب متطابق مع المستخدم <b>%{username}</b>, سوف تستلم بريد الالكتروني يحوي على التعليمات لإعادة ضبط كلمة المرور"
complete_email_found: "وجدنا حساب متطابق مع <b>%{email}</b>, سوف تستلم بريد الالكتروني يحوي على التعليمات لإعادة ضبط كلمة المرور"
complete_username: "إن تطابق حساب ما مع اسم المستخدم <b>%{username}</b>، فيفترض أن تستلم قريبًا بريدًا به إرشادات تصفير كلمة السّرّ."
complete_email: "إن تطابق حساب ما مع <b>%{email}</b>، فيفترض أن تستلم قريبًا بريدًا به إرشادات تصفير كلمة السّرّ."
complete_username_found: "عثرنا على حساب يطابق اسم المستخدم <b>%{username}</b>. يفترض أن تستلم قريبًا بريدًا به إرشادات تصفير كلمة السر."
complete_email_found: "عثرنا على حساب يطابق <b>%{email}</b>. يفترض أن تستلم قريبًا بريدًا به إرشادات تصفير كلمة السر."
complete_username_not_found: "لايوجد حساب متطابق مع هذا المستخدم <b>%{username}</b>"
complete_email_not_found: "لايوجد حساب متطابق مع <b>%{email}</b>"
login:
title: "تسجيل الدخول"
username: "المستخدم"
password: "كلمة المرور"
password: "كلمة السّرّ"
email_placeholder: "البريد الإلكتروني أو اسم المستخدم"
caps_lock_warning: "حساسية الأحرف الإنقليزية Caps Lock في وضع التشغيل"
caps_lock_warning: "مفتاح Caps Lock مفعّل"
error: "خطأ مجهول"
rate_limit: "الرجاء اﻹنتظارقبل محاولة تسجيل الدخول مجدداً."
blank_username_or_password: "من فضلك أدخل اسم المستخدم أو البريد الإلكتروني وكلمة المرور."
reset_password: 'صفّر كلمة المرور'
blank_username_or_password: "رجاءً أدخل اسم المستخدم (أو البريد الإلكترونيّ) وكلمة السّرّ."
reset_password: 'صفّر كلمة السّرّ'
logging_in: "...تسجيل الدخول "
or: "أو "
authenticating: "جارِ التحقق .. "
awaiting_confirmation: "ما زال حسابك غير مفعّل، استخدم رابط نسيان كلمة المرور لإرسال بريد إلكتروني تفعيلي آخر."
awaiting_approval: "لم يوافق أحد أعضاء الطاقم على حسابك بعد. سيُرسل إليك بريد حالما يتم ذلك."
awaiting_approval: "لم يوافق أيّ من أعضاء الطّاقم على حسابك بعد. سيُرسل إليك بريد حالما يتمّ ذلك."
requires_invite: "المَعذرة، الوصول لهذا الموقع خاص بالمدعويين فقط."
not_activated: "لا يُمكنك تسجيل الدخول حتى تقوم بتفعيل الحساب. لقد سبق و أن أرسلنا بريد إلكتروني إلى <b>{{sentTo}}</b> لتفعيل حسابك. الرجاء اتباع التعليمات المرسلة لتفعيل الحساب."
not_allowed_from_ip_address: "لا يمكنك تسجيل الدخول من خلال هذا العنوان الرقمي - IP."
admin_not_allowed_from_ip_address: "لا يمكنك تسجيل الدخول كمدير من خلال هذا العنوان الرقمي - IP."
resend_activation_email: "اضغط هنا لإرسال رسالة إلكترونية أخرى لتفعيل الحساب."
sent_activation_email_again: "لقد قمنا بإرسال رسالة التفعيل مرة أُخرى إلى <b>{{currentEmail}}</b> قد يستغرق وصول الإيميل عدة دقائق. لاتنسى أن تتأكد من مجلد Spam أيضاً. "
resend_activation_email: "انقر هنا لإرسال بريد التّفعيل مرّة أخرى."
sent_activation_email_again: "لقد أرسلنا بريد تفعيل آخر إلى <b>{{currentEmail}}</b>. قد يستغرق وصوله بضعة دقائق. تحقّق من فحص مجلّد السّخام."
to_continue: "من فضلك قم بتسجيل دُخولك"
preferences: "يتوجب عليك تسجيل الدخول لتغيير إعداداتك الشخصية."
forgot: "لا أذكر معلومات حسابي"
@ -1153,14 +1153,21 @@ ar:
saved_local_draft_tip: "حُفظ محليا"
similar_topics: "موضوعك يشابه..."
drafts_offline: "مسودات محفوظة "
group_mentioned:
zero: "الإشارة إلى {{group}} تعني <a href='{{group_link}}'>عدم إخطار أحد</a>. أمتأكّد؟"
one: "الإشارة إلى {{group}} تعني إخطار <a href='{{group_link}}'>شخص واحد</a>. أمتأكّد؟"
two: "الإشارة إلى {{group}} تعني إخطار <a href='{{group_link}}'>شخصين</a>. أمتأكّد؟"
few: "الإشارة إلى {{group}} تعني إخطار <a href='{{group_link}}'>{{count}} أشخاص</a>. أمتأكّد؟"
many: "الإشارة إلى {{group}} تعني إخطار <a href='{{group_link}}'>{{count}} شخصًا</a>. أمتأكّد؟"
other: "الإشارة إلى {{group}} تعني إخطار <a href='{{group_link}}'>{{count}} شخص</a>. أمتأكّد؟"
duplicate_link: "يبدو أن الرابط المشير إلى <b>{{domain}}</b> قد نشره <b>@{{username}}</b> في الموضوع في <a href='{{post_url}}'>رد له {{ago}}</a> أمتأكد من نشره مجددا؟"
error:
title_missing: "العنوان مطلوب"
title_too_short: "العنوان يجب أن يكون اكثر {{min}} حرف"
title_too_long: "العنوان يجب أن لا يكون أكثر من {{max}} حرف"
post_missing: "لا يمكن للمشاركة أن تكون خالية"
post_missing: "لا يمكن أن تكون المشاركة خالية"
post_length: "التعليق يجب أن يكون أكثر {{min}} حرف"
try_like: 'هل جربت زر <i class="fa fa-heart"></i> ؟'
try_like: 'هل جرّبت زرّ <i class="fa fa-heart"></i>؟'
category_missing: "يجب اختيار احد الأقسام"
save_edit: "حفظ التحرير"
reply_original: "التعليق على الموضوع الاصلي"
@ -1169,17 +1176,17 @@ ar:
cancel: "إلغاء"
create_topic: "إنشاء موضوع"
create_pm: "رسالة"
title: "او اضغط على Ctrl+Enter"
title: "أو اضغط Ctrl+Enter"
users_placeholder: "أضف مستخدما"
title_placeholder: "بجملة واحدة، ما الذي تود النقاش عنه؟"
edit_reason_placeholder: "لماذا تعدّل النص؟"
show_edit_reason: "(أضف سبب التعديل)"
reply_placeholder: "اكتب ما تريد هنا. استخدم Markdown، أو BBCode، أو HTML للتنسيق. اسحب الصور أو ألصقها."
view_new_post: "الاطلاع على أحدث مشاركاتك"
view_new_post: "اعرض المشاركة الجديدة."
saving: "يحفظ"
saved: "حُفظ!"
saved_draft: "جاري إضافة المسودة. اضغط للاستئناف"
uploading: "جارِ التحميل..."
uploading: "يرفع..."
show_preview: 'أظهر المعاينة &raquo;'
hide_preview: '&laquo; أخفِ المعاينة'
quote_post_title: "اقتبس كامل المشاركة"
@ -1209,11 +1216,11 @@ ar:
toggler: "اخف او اظهر صندوق التحرير"
modal_ok: "حسنا"
modal_cancel: "ألغِ"
cant_send_pm: "آسفون، لا يمكنك إرسال الرسائل إلى %{username} ."
cant_send_pm: "نأسف، لا يمكنك إرسال الرّسائل إلى %{username}."
yourself_confirm:
title: "هل نسيت أن تضيف المرسل اليهم؟"
title: "أنسيت إضافة المستلمين؟"
body: "حاليا هذة الرسالة مرسلة اليك فقط!"
admin_options_title: "اختياري اضافة اعدادات الموضوع"
admin_options_title: "إعدادات الطّاقم الاختياريّة لهذا الموضوع"
auto_close:
label: "وقت الإغلاق التلقائي للموضوع"
error: "من فضلك أدخل قيمة صالحة."
@ -1268,10 +1275,10 @@ ar:
private_message: 'أرسل {{username}} إليك رسالة خاصة في "{{topic}}" - {{site_title}}'
linked: '{{username}} رتبط بمشاركتك من "{{topic}}" - {{site_title}}'
upload_selector:
title: "اضف صورة"
title_with_attachments: "اضف صورة او ملف"
from_my_computer: "عن طريق جهازي"
from_the_web: "عن طريق الويب"
title: "أضف صورة"
title_with_attachments: "أضف صورة أو ملفّ"
from_my_computer: "من جهازي"
from_the_web: "من الوبّ"
remote_tip: "رابط لصورة"
remote_tip_with_attachments: "رابط لصورة أو ملف {{authorized_extensions}}"
local_tip: "إختر صور من جهازك ."
@ -1289,7 +1296,7 @@ ar:
most_liked: "الأكثر إعجابا"
select_all: "أختر الكل"
clear_all: "إلغ إختيار الكل"
too_short: "مصطلح البحث قصير جدا."
too_short: "عبارة البحث قصيرة جدًّا."
result_count:
zero: "لا نتائج ل‍ <span class='term'>\"{{term}}\"</span>"
one: "نتيجة واحدة ل‍ <span class='term'>\"{{term}}\"</span>"
@ -1301,16 +1308,16 @@ ar:
no_results: "لا نتائج."
no_more_results: "لا نتائج أخرى."
searching: "يبحث..."
post_format: "#{{post_number}} بواسطة {{username}}"
post_format: "#{{post_number}} كتبها {{username}}"
context:
user: "البحث عن مواضيع @{{username}}"
user: "ابحث عن مواضيع @{{username}}"
category: "ابحث في فئة #{{category}}"
topic: "ابحث في هذا الموضوع"
private_messages: "البحث في الرسائل الخاصة"
advanced:
title: البحث التفصيلي
title: بحث متقدّم
posted_by:
label: نُشرت بواسطة
label: نشرها
in_category:
label: في الفئة
in_group:
@ -1320,26 +1327,28 @@ ar:
with_tags:
label: مع أوسمة
filters:
label: استرجع فقط المواضيع/المشاركات التي...
likes: أعجبني
posted: شاركت في
label: ابحث عن المواضيع والمنشورات التي...
likes: أعجبتني
posted: شاركت فيها
watching: التي أراقبها
tracking: التي اتابعها
private: من ضمن رسائلي
bookmarks: أضفت للمفضلات
private: موجودة في رسائلي
bookmarks: أضفتها للعلامات
first: تلك المشاركة الاولى
seen: قرأتها
unseen: لم أقرأها
statuses:
label: عندما المواضيع
label: بشرط أن تكون المواضيع
open: مفتوحة
closed: مغلقة
archived: محفوظة
noreplies: لا تحتوي على رد
single_user: تحتوي على مستخدم واحد
archived: مؤرشفة
noreplies: ليس فيها أيّ ردّ
single_user: تحتوي مستخدما واحدا
post:
count:
label: عدد المشاركات الأدنى
time:
label: ا’رْسِلت
label: المُرسَلة
before: قبل
after: بعد
hamburger_menu: "انتقل إلى قائمة مواضيع أو فئة أخرى"
@ -1375,7 +1384,6 @@ ar:
few: "حددت <b>{{count}}</b> مواضيع."
many: "حددت <b>{{count}}</b> موضوعا."
other: "حددت <b>{{count}}</b> موضوع."
change_tags: "غيّر الوسوم"
choose_new_tags: "اختر وسوم جديدة لهذه المواضيع:"
changed_tags: "تغيرت وسوم هذه المواضيع."
none:
@ -1408,12 +1416,12 @@ ar:
stop_notifications: "ستستقبل الأن إشعارات أقل لـ<strong>{{title}}</strong>"
change_notification_state: "حالة إشعارك الحالي هي "
filter_to:
zero: "عدد الردود في الموضوع"
one: "رد في الموضوع"
two: "الردود في الموضوع"
few: "الردود في الموضوع"
many: "الردود في الموضوع "
other: "الردود في الموضوع "
zero: "لا مشاركات في الموضوع"
one: "مشاركة واحدة في الموضوع"
two: "مشاركتان في الموضوع"
few: "{{count}} مشاركات في الموضوع"
many: "{{count}} مشاركة في الموضوع"
other: "{{count}} مشاركة في الموضوع"
create: 'موضوع جديد'
create_long: 'كتابة موضوع جديد'
private_message: 'أرسل رسالة خاصة'
@ -1449,7 +1457,7 @@ ar:
title: "فشل تحميل الموضوع"
description: "آسفون، تعذر علينا تحميل هذا الموضوع، قد يرجع ذلك إلى مشكلة بالاتصال. من فضلك حاول مجددا. أخبرنا بالمشكلة إن استمر حدوثها."
not_found:
title: "لم يتم العثور على الموضوع"
title: "لم يُعثر على الموضوع"
description: "آسفون، لم نجد هذا الموضوع. ربما أزاله أحد المشرفين؟"
total_unread_posts:
zero: "لا مشاركات غير مقروءة في هذا الموضوع"
@ -1485,14 +1493,14 @@ ar:
toggle_information: "أظهر/أخف تفاصيل الموضوع"
read_more_in_category: "أتريد قراءة المزيد؟ تصفح المواضيع الأخرى في {{catLink}} أو {{latestLink}}."
read_more: "أتريد قراءة المزيد؟ {{catLink}} أو {{latestLink}}."
read_more_MF: "{UNREAD, plural, zero {} one {تبقى {BOTH, select, true {<a href='/unread'>موضوع واحد غير مقروء</a> و} false {<a href='/unread'>موضوع واحد غير مقروء</a>} other {}}} two {تبقى {BOTH, select, true {<a href='/unread'>موضوعان غير مقروءان</a> و} false {<a href='/unread'>موضوعان غير مقروءان</a>} other {}}} few {تبقت {BOTH, select, true {<a href='/unread'># مواضيع غير مقروءة</a> و} false {<a href='/unread'># مواضيع غير مقروءة</a>} other {}}} many {تبقى {BOTH, select, true {<a href='/unread'># موضوعا غير مقروء</a> و} false {<a href='/unread'># موضوعا غير مقروء</a>} other {}}} other {تبقى {BOTH, select, true {<a href='/unread'># موضوع غير مقروء</a> و} false {<a href='/unread'># موضوع غير مقروء</a>} other {}}}}{NEW, plural, zero {} one {{UNREAD, plural, zero {تبقى } one {} two {} few {} many {} other {}}<a href='/new'>موضوع واحد جديد</a>} two {{UNREAD, plural, zero {تبقى } one {} two {} few {} many {} other {}}<a href='/new'>موضوعان جديدان</a>} few {{UNREAD, plural, zero {تبقت } one {} two {} few {} many {} other {}}<a href='/new'># مواضيع جديدة</a>} many {{UNREAD, plural, zero {تبقى } one {} two {} few {} many {} other {}}<a href='/new'># موضوعا جديدا</a>} other {{UNREAD, plural, zero {تبقى } one {} two {} few {} many {} other {}}<a href='/new'># موضوع جديد</a>}}، أو {CATEGORY, select, true {تصفّح المواضيع الأخرى في {catLink}} false {{latestLink}} other {}}"
read_more_MF: "{UNREAD, plural, zero {} one {تبقى <a href='/unread'>موضوع واحد غير مقروء</a>} two {تبقى <a href='/unread'>موضوعان غير مقروءان</a>} few {تبقت <a href='/unread'># مواضيع غير مقروءة</a>} many {تبقى <a href='/unread'># موضوعا غير مقروء</a>} other {تبقى <a href='/unread'># موضوع غير مقروء</a>}}{BOTH, select, true { و} false {} other {}}{NEW, plural, zero {} one {{UNREAD, plural, zero {تبقى } one {} two {} few {} many {} other {}}<a href='/new'>موضوع واحد جديد</a>} two {{UNREAD, plural, zero {تبقى } one {} two {} few {} many {} other {}}<a href='/new'>موضوعان جديدان</a>} few {{UNREAD, plural, zero {تبقت } one {} two {} few {} many {} other {}}<a href='/new'># مواضيع جديدة</a>} many {{UNREAD, plural, zero {تبقى } one {} two {} few {} many {} other {}}<a href='/new'># موضوعا جديدا</a>} other {{UNREAD, plural, zero {تبقى } one {} two {} few {} many {} other {}}<a href='/new'># موضوع جديد</a>}}، أو {CATEGORY, select, true {تصفّح المواضيع الأخرى في {catLink}} false {{latestLink}} other {}}"
browse_all_categories: تصفّح كل الأقسام
view_latest_topics: اعرض أحدث المواضيع
suggest_create_topic: لمَ لا تكتب موضوعًا؟
jump_reply_up: انتقل إلى أول رد
jump_reply_down: انتقل إلى آخر رد
deleted: "الموضوع محذوف"
auto_close_notice: "سيُغلق الموضوع آليا بعد %{timeLeft}."
auto_close_notice: "سيُغلق الموضوع آليًّا %{timeLeft}."
auto_close_notice_based_on_last_post: "سيُغلق الموضوع بعد %{duration} من آخر رد."
auto_close_title: 'إعدادات الإغلاق الآلي'
auto_close_save: "احفظ"
@ -1535,13 +1543,13 @@ ar:
title: "مُراقب"
description: "سيصلك إشعار لكل رد جديد لهذه الرسالة، وسيظهر عدد الردود الجديدة."
watching:
title: ُراقب"
title: راقَبًا"
description: "سيصلك إشعار لكل رد جديد لهذا الموضوع، وسيظهر عدد الردود الجديدة."
tracking_pm:
title: "مُتابع"
description: "سيظهر عدد الردود الجديدة لهذه الرسالة. سيصلك إشعار إن أشار أحد إلى @اسمك أو رد عليك."
tracking:
title: ُتابع"
title: تابَعًا"
description: "سيظهر عدد الردود الجديدة لهذا الموضوع. سيصلك إشعار إن أشار أحد إلى @اسمك أو رد عليك."
regular:
title: "الوضع العادي"
@ -1641,7 +1649,7 @@ ar:
title: 'دعوة'
username_placeholder: "اسم المستخدم"
action: 'أرسل دعوة'
help: 'دعوة المستخدمين لهذا الموضوع عن طرق البريد الإلكتروني أو الأشعارات'
help: 'ادعُ الغير إلى هذا الموضوع عبر البريد الإلكترونيّ أو الإشعارات'
to_forum: "سيتم ارسال رسالة بريد الكتروني ﻷصدقائك للمشاركة في الموقع , هذه العملية لا تتطلب تسجيل الدخول ."
sso_enabled: "أدخل اسم مَن تريد دعوته إلى هذا الموضوع."
to_topic_blank: "أدخل اسم مَن تريد دعوته إلى هذا الموضوع أو بريده."
@ -1686,7 +1694,7 @@ ar:
many: "الرجاء اختيار الموضوع الذي تود نقل الـ<b>{{count}}</b> مشاركة إليه."
other: "الرجاء اختيار الموضوع الذي تود نقل الـ<b>{{count}}</b> مشاركة إليه."
merge_posts:
title: "قم بدمج المشاركات المختارة"
title: "ادمج المشاركات المحدّدة"
action: "ادمج المشاركات المحددة"
error: "حدث خطأ في دمج المشاركات المحددة."
change_owner:
@ -1708,7 +1716,7 @@ ar:
action: "تغيير الطابع الزمني"
invalid_timestamp: "الطابع الزمني لا يمكن أن يكون في المستقبل."
error: "هناك خطأ في نغيير الطابع الزمني للموضوع."
instructions: "من فضلك اختر الطابع الزمني الجديد للموضوع. ستُحدث المشاركات فيه لألا يختلف فرق تاريخ نشرها ووقته."
instructions: "من فضلك اختر الطابع الزمني الجديد للموضوع. ستُحدّث المشاركات فيه لألا يختلف فرق تاريخ نشرها ووقته."
multi_select:
select: 'حددها'
selected: 'محددة ({{count}})'
@ -1733,7 +1741,7 @@ ar:
post_number: "المشاركة {{number}}"
last_edited_on: "آخر تعديل على المشاركة في "
reply_as_new_topic: "التعليق على الموضوع الاصلي"
continue_discussion: "إكمال النقاش على {{postLink}}"
continue_discussion: "إكمالا للنّقاش في {{postLink}}:"
follow_quote: "الذهاب إلى المشاركة المقتبسة"
show_full: "عرض كامل المشاركة"
show_hidden: 'عرض المحتوى المخفي.'
@ -1768,20 +1776,20 @@ ar:
many: "{{count}} إعجابا"
other: "{{count}} إعجاب"
has_likes_title:
zero: "لم تعجب هذه المشاركة أحد"
zero: "لم تُعجب هذه المشاركة أحد"
one: "أعجبت هذه المشاركة شخصا واحدا"
two: "أعجبت هذه المشاركة شخصين"
few: "أعجبت هذه المشاركة {{count}} أشخاص"
many: "أعجبت هذه المشاركة {{count}} شخصا"
other: "{{count}} أشخاص أعجبوا بهذه المشاركة"
has_likes_title_only_you: نت أعجبت بهذه المشاركة"
other: "أعجبت هذه المشاركة {{count}} شخص"
has_likes_title_only_you: عجبتك هذه المشاركة"
has_likes_title_you:
zero: "أعجبتك هذه المشاركة"
one: "أعجبت هذه المشاركة شخصا واحدا غيرك"
one: "أعجبت هذه المشاركة شخصًا واحدًا غيرك"
two: "أعجبت هذه المشاركة شخصين غيرك"
few: "أعجبت هذه المشاركة {{count}} أشخاص غيرك"
many: "أعجبت هذه المشاركة {{count}} شخصا غيرك"
other: نت و {{count}} شخص أخرون أعجبتم بهذه المشاركة ."
many: "أعجبت هذه المشاركة {{count}} شخصًا غيرك"
other: عجبت هذه المشاركة {{count}} شخص غيرك"
errors:
create: "آسفون، حدثت مشكلة في إنشاء المشاركة. من فضلك حاول مجددا."
edit: "آسفون، حدثت مشكلة في تعديل المشاركة. من فضلك حاول مجددا."
@ -2027,7 +2035,7 @@ ar:
settings: 'اعدادات'
topic_template: "إطار الموضوع"
tags: "الوسوم"
tags_allowed_tags: "العلامات الوصفية التي تستخدم فقط في هذا القسم:"
tags_allowed_tags: "ما يمكن استخدامه من وسوم في هذه الفئة فقط:"
tags_allowed_tag_groups: "مجموعات العلامات الوصفية التي تستخدم فقط في هذا القسم:"
tags_placeholder: "(اختياري) قائمة العلامات الوصفية المسموح بها"
tag_groups_placeholder: "(اختياري) قائمة مجموعات العلامات الوصفية المسموح بها"
@ -2062,7 +2070,7 @@ ar:
auto_close_units: "ساعات"
email_in: "تعيين بريد إلكتروني خاص:"
email_in_allow_strangers: "قبول بريد إلكتروني من مستخدمين لا يملكون حسابات"
email_in_disabled: "إضافة مواضيع جديدة من خلال البريد الإلكتروني موقوف في الوقت الحالي من خلال إعدادات الموقع. لتفعيل إضافة مواضيع جديدة من خلال البريد الإلكتروني,"
email_in_disabled: "عُطّل إرسال المشاركات عبر البريد الإلكترونيّ من إعدادات الموقع. لتفعيل نشر المشاركات الجديدة عبر البريد،"
email_in_disabled_click: 'قم بتفعيل خيار "email in" في الإعدادات'
suppress_from_homepage: "كتم هذه الفئة من الصفحة الرئيسية"
allow_badges_label: "السماح بالحصول على الأوسمة في هذا التصنيف"
@ -2086,7 +2094,7 @@ ar:
description: "ستتابع آليا كل مواضيع هذه الفئات. ستصلك إشعارات إن أشار أحدهم إلى @اسمك أو رد عليك، وسيظهر عدّاد للمشاركات الجديدة."
regular:
title: "منتظم"
description: وف تُنبه اذا قام أحد بالاشارة لاسمك \"@name\" أو الرد عليك."
description: تستقبل إشعارًا إن أشار أحد إلى @اسمك أو ردّ عليك."
muted:
title: "مكتومة"
description: "لن يتم إشعارك بأي مشاركات جديدة في هذه التصنيفات ولن يتم عرضها في قائمة المواضيع المنشورة مؤخراً."
@ -2123,12 +2131,12 @@ ar:
custom_placeholder_notify_moderators: "ممكن تزودنا بمعلومات أكثر عن سبب عدم ارتياحك حول هذه المشاركة؟ زودنا ببعض الروابط و الأمثلة قدر الإمكان."
custom_message:
at_least:
zero: "عدد الأحرف يجب ان لا يقل عن {{count}} حرف"
one: "عدد الأحرف يجب ان لا يقل عن {{count}} حرف"
two: "عدد الأحرف يجب ان لا يقل عن {{count}} حرف"
few: "عدد الأحرف يجب ان لا يقل عن {{count}} حروف"
many: "عدد الأحرف يجب ان لا يقل عن {{count}} حرف"
other: "عدد الأحرف يجب ان لا يقل عن {{count}} حرف"
zero: "لا تُدخل أيّ محرف"
one: "أدخل محرفًا واحدًا على الأقلّ"
two: "أدخل محرفين على الأقلّ"
few: "أدخل {{count}} محارف على الأقلّ"
many: "أدخل {{count}} محرفًا على الأقلّ"
other: "أدخل {{count}} محرف على الأقلّ"
flagging_topic:
title: "شكرا لمساعدتنا في ابقاء مجتمعنا نضيفا"
action: "التبليغ عن الموضوع"
@ -2231,13 +2239,13 @@ ar:
few: "الأخيرة ({{count}})"
many: "الأخيرة ({{count}})"
other: "الأخيرة ({{count}})"
help: "المواضيع التي أضيفت لها ردود مؤخراً"
help: "المواضيع التي فيها مشاركات حديثة"
hot:
title: "نَشط"
help: "مختارات من مواضيع ساخنة"
read:
title: "المقروءة"
help: "مواضيع قمت بقراءتها بترتيب آخر قراءة"
help: "المواضيع التي قرأتها بترتيب آخر قراءة لها"
search:
title: "بحث"
help: "بحث في كل المواضيع"
@ -2254,7 +2262,7 @@ ar:
few: "غير المقروءة ({{count}})"
many: "غير المقروءة ({{count}})"
other: "غير المقروءة ({{count}})"
help: "مواضيع أنت تشاهدها بمشاركات غير مقروءة "
help: "المواضيع التي تتابعها (أو تراقبها) والتي فيها مشاركات غير مقروءة"
lower_title_with_count:
zero: "1 غير مقررء "
one: "1 غير مقروء"
@ -2284,7 +2292,7 @@ ar:
title: "مشاركاتي"
help: "مواضيع شاركت بها "
bookmarks:
title: "المفضلة"
title: "العلامات"
help: "مواضيع قمت بتفضيلها"
category:
title: "{{categoryName}}"
@ -2451,7 +2459,7 @@ ar:
description: "ستتابع آليا كل مواضيع هذا الوسم. سيظهر عدّاد للمشاركات غير المقروءة والجديدة بجانب الموضوع."
regular:
title: "موضوع عادي"
description: ".سيتم إشعارك إذا ذكر أحد ما اسمك أو رد على مشاركاتك"
description: "ستستقبل إشعارًا إن أشار أحد إلى @اسمك أو ردّ على مشاركتك."
muted:
title: "مكتوم"
description: "لن يتم إشعارك بأي جديد يخص هذا الموضوع ولن يظهرهذا الموضوع في تبويب المواضيع الغير مقروءة."
@ -2833,7 +2841,7 @@ ar:
undo: "تراجع"
undo_title: "التراجع عن تغيير اللن الى اللون السابق"
revert: "تراجع"
revert_title: "اعادة ضبط اللون الى اللون الافتراضي للموقع"
revert_title: "صفّر هذا اللون إلى مخطّط ألوان دسكورس الافتراضيّ."
primary:
name: 'الأساسي'
secondary:
@ -3357,7 +3365,7 @@ ar:
enabled: تفعيل الشعار
icon: أيقونة
image: صورة
icon_help: "إستخدم فئة الخط او رابط الى صورة"
icon_help: "استخدم صنف Font Awesome أو عنوانًا إلى صورة"
query: علامة استفهام (SQL)
target_posts: إستعلام يستهدف المشاركات
auto_revoke: إلغاء الاستعلام اليومي
@ -3377,7 +3385,7 @@ ar:
sql_error_header: "كان هناك خطأ ما في الاستعلام."
error_help: "انظر الرابط التالي للمساعدة باستفسارات الوسام."
bad_count_warning:
header: "تحذير !!"
header: "تحذير!"
text: "هناك عينات ممنوحة ضائعة. حدث هذا عندما أعادت شارة الإستعلام user IDs أو post IDs التي لم تكن موجودة. هذا ربما بسبب نتيجة غير متوقعة في وقت لاحق - رجائا أنقر مرتين للتأكد من إستعلامك-"
no_grant_count: "لا توجد اوسمه لتمنح "
grant_count:
@ -3399,10 +3407,10 @@ ar:
add: "أضافة وجه تعبيري جديد ؟"
name: "الأسم"
image: "صورة"
delete_confirm: "هل أنت متأكد من انك تريد حذف هذا :%{name}: الوجه التعبيري ؟"
delete_confirm: "أمتأكّد من حذف الإيموجي :%{name}: هذا؟"
embedding:
get_started: "إذا أردت تضمين Discourse في موقع اخر، أبدأ بإضافة مضيف."
confirm_delete: "هل انت متأكد من انك تريد حذف هذا المضيف ؟"
confirm_delete: "أمتأكّد من حذف هذا المضيف؟"
sample: "استخدم كود HTML التالي لموقعك لإنشاء وتضمين موضوع discourse. استبدل <b>REPLACE_ME</b> مع canonical URL لصفحة قمت بتضمينها فيه."
title: "تضمين"
host: "أسمع بالمضيفين"
@ -3446,18 +3454,18 @@ ar:
next: "التالي"
step: "%{current} من %{total}"
upload: "رفع"
uploading: "جاري رفع الملفات.."
uploading: "يرفع..."
quit: "ربما لاحقا"
staff_count:
zero: "شركتك لديها %{count} من الموظفين"
one: "مجموعتك لديها 1 موظف فقط"
two: "مجموعتك لديها %{count} من الموظفين"
few: "مجموعتك لديها %{count} من الموظفين"
many: "مجموعتك لديها %{count} من الموظفين"
other: "مجموعتك لديها %{count} من الموظفين"
zero: "ليس في المجتمع أيّ أعضاء طاقم."
one: "في المجتمع عضو طاقم واحد."
two: "في المجتمع عضوا طاقم."
few: "في المجتمع %{count} أعضاء طاقم."
many: "في المجتمع %{count} عضو طاقم."
other: "في المجتمع %{count} عضو طاقم."
invites:
add_user: "أضف"
none_added: "لم تقم بدعوة أي موظفين. هل أنت متأكد انك تريد المتابعة؟"
none_added: "لم تدعو أيّ عضو طاقم. أمتأكّد من المتابعة؟"
roles:
admin: "مدير"
moderator: "مشرف"

Some files were not shown because too many files have changed in this diff Show More