Merge branch 'master' into psr-7

Conflicts:
	src/Api/Actions/Discussions/IndexAction.php
	src/Api/Actions/SerializeAction.php
	src/Core/Formatter/FormatterManager.php
	src/Extend/ForumAssets.php
	src/Forum/Actions/IndexAction.php
	src/Forum/ForumServiceProvider.php
This commit is contained in:
Franz Liedke 2015-06-17 00:52:50 +02:00
commit 0262f45f57
91 changed files with 1381 additions and 523 deletions

View File

@ -1,51 +1,22 @@
var gulp = require('gulp');
var livereload = require('gulp-livereload');
var concat = require('gulp-concat');
var argv = require('yargs').argv;
var uglify = require('gulp-uglify');
var gulpif = require('gulp-if');
var merge = require('merge-stream');
var babel = require('gulp-babel');
var cached = require('gulp-cached');
var remember = require('gulp-remember');
var gulp = require('flarum-gulp');
var vendorFiles = [
'../bower_components/loader.js/loader.js',
'../bower_components/mithril/mithril.js',
'../bower_components/jquery/dist/jquery.js',
'../bower_components/moment/moment.js',
'../bower_components/bootstrap/dist/js/bootstrap.js',
'../bower_components/spin.js/spin.js',
'../bower_components/spin.js/jquery.spin.js'
];
var moduleFiles = [
'src/**/*.js',
'../lib/**/*.js'
];
var modulePrefix = 'flarum';
gulp.task('default', function() {
return merge(
gulp.src(vendorFiles),
gulp.src(moduleFiles)
.pipe(cached('scripts'))
.pipe(babel({ modules: 'amd', moduleIds: true, moduleRoot: modulePrefix }))
.pipe(remember('scripts'))
)
.pipe(concat('app.js'))
.pipe(gulpif(argv.production, uglify()))
.pipe(gulp.dest('dist'))
.pipe(livereload());
});
gulp.task('watch', ['default'], function () {
livereload.listen();
var watcher = gulp.watch(moduleFiles.concat(vendorFiles), ['default']);
watcher.on('change', function (event) {
if (event.type === 'deleted') {
delete cached.caches.scripts[event.path];
remember.forget('scripts', event.path);
}
});
gulp({
files: [
'node_modules/babel-core/external-helpers.js',
'../bower_components/loader.js/loader.js',
'../bower_components/mithril/mithril.js',
'../bower_components/jquery/dist/jquery.js',
'../bower_components/moment/moment.js',
'../bower_components/bootstrap/dist/js/bootstrap.js',
'../bower_components/spin.js/spin.js',
'../bower_components/spin.js/jquery.spin.js'
],
moduleFiles: [
'src/**/*.js',
'../lib/**/*.js'
],
bootstrapFiles: [],
modulePrefix: 'flarum',
externalHelpers: true,
outputFile: 'dist/app.js'
});

View File

@ -1,14 +1,8 @@
{
"private": true,
"devDependencies": {
"gulp": "^3.8.11",
"gulp-babel": "^5.1.0",
"gulp-cached": "^1.0.4",
"gulp-concat": "^2.5.2",
"gulp-if": "^1.2.5",
"gulp-livereload": "^3.8.0",
"gulp-remember": "^0.3.0",
"gulp-uglify": "^1.2.0",
"merge-stream": "^0.1.7",
"yargs": "^3.7.2"
"flarum-gulp": "git+https://github.com/flarum/gulp.git",
"babel-core": "^5.0.0"
}
}

View File

@ -1,53 +1,24 @@
var gulp = require('gulp');
var livereload = require('gulp-livereload');
var concat = require('gulp-concat');
var argv = require('yargs').argv;
var uglify = require('gulp-uglify');
var gulpif = require('gulp-if');
var merge = require('merge-stream');
var babel = require('gulp-babel');
var cached = require('gulp-cached');
var remember = require('gulp-remember');
var gulp = require('flarum-gulp');
var vendorFiles = [
'../bower_components/loader.js/loader.js',
'../bower_components/mithril/mithril.js',
'../bower_components/jquery/dist/jquery.js',
'../bower_components/jquery.hotkeys/jquery.hotkeys.js',
'../bower_components/color-thief/js/color-thief.js',
'../bower_components/moment/moment.js',
'../bower_components/bootstrap/dist/js/bootstrap.js',
'../bower_components/spin.js/spin.js',
'../bower_components/spin.js/jquery.spin.js'
];
var moduleFiles = [
'src/**/*.js',
'../lib/**/*.js'
];
var modulePrefix = 'flarum';
gulp.task('default', function() {
return merge(
gulp.src(vendorFiles),
gulp.src(moduleFiles)
.pipe(cached('scripts'))
.pipe(babel({ modules: 'amd', moduleIds: true, moduleRoot: modulePrefix }))
.pipe(remember('scripts'))
)
.pipe(concat('app.js'))
.pipe(gulpif(argv.production, uglify()))
.pipe(gulp.dest('dist'))
.pipe(livereload());
});
gulp.task('watch', ['default'], function () {
livereload.listen();
var watcher = gulp.watch(moduleFiles.concat(vendorFiles), ['default']);
watcher.on('change', function (event) {
if (event.type === 'deleted') {
delete cached.caches.scripts[event.path];
remember.forget('scripts', event.path);
}
});
gulp({
files: [
'node_modules/babel-core/external-helpers.js',
'../bower_components/loader.js/loader.js',
'../bower_components/mithril/mithril.js',
'../bower_components/jquery/dist/jquery.js',
'../bower_components/jquery.hotkeys/jquery.hotkeys.js',
'../bower_components/color-thief/js/color-thief.js',
'../bower_components/moment/moment.js',
'../bower_components/bootstrap/dist/js/bootstrap.js',
'../bower_components/spin.js/spin.js',
'../bower_components/spin.js/jquery.spin.js'
],
moduleFiles: [
'src/**/*.js',
'../lib/**/*.js'
],
bootstrapFiles: [],
modulePrefix: 'flarum',
externalHelpers: true,
outputFile: 'dist/app.js'
});

View File

@ -1,14 +1,8 @@
{
"private": true,
"devDependencies": {
"gulp": "^3.8.11",
"gulp-babel": "^5.1.0",
"gulp-cached": "^1.0.4",
"gulp-concat": "^2.5.2",
"gulp-if": "^1.2.5",
"gulp-livereload": "^3.8.0",
"gulp-remember": "^0.3.0",
"gulp-uglify": "^1.2.0",
"merge-stream": "^0.1.7",
"yargs": "^3.7.2"
"flarum-gulp": "git+https://github.com/flarum/gulp.git",
"babel-core": "^5.0.0"
}
}

View File

@ -18,7 +18,7 @@ export default class ChangeEmailModal extends FormModal {
return super.view({
className: 'modal-sm change-email-modal',
title: 'Change Email',
body: this.success()
body: m('div.form-centered', this.success()
? [
m('p.help-text', 'We\'ve sent a confirmation email to ', m('strong', this.email()), '. If it doesn\'t arrive soon, check your spam folder.'),
m('div.form-group', [
@ -37,7 +37,7 @@ export default class ChangeEmailModal extends FormModal {
m('div.form-group', [
m('button.btn.btn-primary.btn-block[type=submit]', {disabled}, 'Save Changes')
])
]
])
});
}

View File

@ -5,12 +5,12 @@ export default class ChangePasswordModal extends FormModal {
return super.view({
className: 'modal-sm change-password-modal',
title: 'Change Password',
body: [
body: m('div.form-centered', [
m('p.help-text', 'Click the button below and check your email for a link to change your password.'),
m('div.form-group', [
m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading()}, 'Send Password Reset Email')
])
]
])
});
}

View File

@ -117,7 +117,7 @@ class Composer extends Component {
this.updateHeight();
var scrollTop = $(window).scrollTop();
this.updateBodyPadding(false, scrollTop > 0 && scrollTop + $(window).height() >= $(document).height());
this.updateBodyPadding(scrollTop > 0 && scrollTop + $(window).height() >= $(document).height());
localStorage.setItem('composerHeight', height);
}
@ -136,9 +136,9 @@ class Composer extends Component {
var $composer = this.$().stop(true);
var oldHeight = $composer.is(':visible') ? $composer.outerHeight() : 0;
if (this.position() !== Composer.PositionEnum.HIDDEN) {
m.redraw(true);
}
var scrollTop = $(window).scrollTop();
m.redraw(true);
this.$().height(this.computedHeight());
var newHeight = $composer.outerHeight();
@ -167,7 +167,8 @@ class Composer extends Component {
}
if (this.position() !== Composer.PositionEnum.FULLSCREEN) {
this.updateBodyPadding(true, anchorToBottom);
this.updateBodyPadding();
$('html, body').scrollTop(anchorToBottom ? $(document).height() : scrollTop);
} else {
this.component.focus();
}
@ -182,18 +183,11 @@ class Composer extends Component {
// Update the amount of padding-bottom on the body so that the page's
// content will still be visible above the composer when the page is
// scrolled right to the bottom.
updateBodyPadding(animate, anchorToBottom) {
var func = animate ? 'animate' : 'css';
var paddingBottom = this.position() !== Composer.PositionEnum.HIDDEN ? this.computedHeight() - parseInt($('#page').css('padding-bottom')) : 0;
$('#content')[func]({paddingBottom}, 'fast');
if (anchorToBottom) {
if (animate) {
$('html, body').stop(true).animate({scrollTop: $(document).height()}, 'fast');
} else {
$('html, body').scrollTop($(document).height());
}
}
updateBodyPadding() {
var paddingBottom = this.position() !== Composer.PositionEnum.HIDDEN && this.position() !== Composer.PositionEnum.MINIMIZED
? this.computedHeight() - parseInt($('#page').css('padding-bottom'))
: 0;
$('#content').css({paddingBottom});
}
// Update the height of the stuff inside of the composer. There should be

View File

@ -11,7 +11,7 @@ export default class DeleteAccountModal extends FormModal {
return super.view({
className: 'modal-sm change-password-modal',
title: 'Delete Account',
body: [
body: m('div.form-centered', [
m('p.help-text', 'Hold up there skippy! If you delete your account, there\'s no going back. All of your posts will be kept, but no longer associated with your account.'),
m('div.form-group', [
m('input.form-control[name=confirm][placeholder=Type "DELETE" to proceed]', {oninput: m.withAttr('value', this.confirmation)})
@ -19,7 +19,7 @@ export default class DeleteAccountModal extends FormModal {
m('div.form-group', [
m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading() || this.confirmation() != 'DELETE'}, 'Delete Account')
])
]
])
});
}

View File

@ -41,12 +41,32 @@ export default class DiscussionComposer extends ComposerBody {
if (empty) { $this.val(''); }
});
setTimeout(() => $(element).trigger('input'));
},
onkeydown: (e) => {
if (e.which === 13) { // return
e.preventDefault();
this.editor.setSelectionRange(0, 0);
}
m.redraw.strategy('none');
}
})));
return items;
}
onload(element) {
super.onload(element);
this.editor.$('textarea').keydown((e) => {
if (e.which === 8 && e.target.selectionStart == 0 && e.target.selectionEnd == 0) { // Backspace
e.preventDefault();
var title = this.$(':input:enabled:visible:first')[0];
title.focus();
title.selectionStart = title.selectionEnd = title.value.length;
}
});
}
preventExit() {
return (this.title() || this.content()) && !confirm(this.props.confirmExit);
}

View File

@ -19,7 +19,7 @@ export default class ForgotPasswordModal extends FormModal {
return super.view({
className: 'modal-sm forgot-password',
title: 'Forgot Password',
body: this.success()
body: m('div.form-centered', this.success()
? [
m('p.help-text', 'We\'ve sent you an email containing a link to reset your password. Check your spam folder if you don\'t receive it within the next minute or two.'),
m('div.form-group', [
@ -34,7 +34,7 @@ export default class ForgotPasswordModal extends FormModal {
m('div.form-group', [
m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading()}, 'Recover Password')
])
]
])
});
}

View File

@ -23,9 +23,7 @@ export default class FormModal extends Component {
m('form', {onsubmit: this.onsubmit.bind(this)}, [
m('div.modal-header', m('h3.title-control', options.title)),
alert ? m('div.modal-alert', alert) : '',
m('div.modal-body', [
m('div.form-centered', options.body)
]),
m('div.modal-body', options.body),
options.footer ? m('div.modal-footer', options.footer) : ''
])
]),

View File

@ -160,7 +160,7 @@ export default class IndexPage extends Component {
items.add('sort',
SelectInput.component({
options: sortOptions,
value: this.params.sort,
value: this.params().sort,
onchange: this.reorder.bind(this)
})
);
@ -271,6 +271,7 @@ export default class IndexPage extends Component {
$('body').addClass('index-page');
context.onunload = function() {
$('body').removeClass('index-page');
$('.global-page').css('min-height', '');
};
app.setTitle('');
@ -339,16 +340,30 @@ export default class IndexPage extends Component {
/**
* Initialize the composer for a new discussion.
*
* @todo return a promise
* @return void
* @return {Promise}
*/
newDiscussion() {
var deferred = m.deferred();
if (app.session.user()) {
app.composer.load(new DiscussionComposer({ user: app.session.user() }));
app.composer.show();
return true;
this.composeNewDiscussion(deferred);
} else {
app.modal.show(
new LoginModal({ onlogin: this.composeNewDiscussion.bind(this, deferred) })
);
}
app.modal.show(new LoginModal({ onlogin: this.newDiscussion.bind(this) }));
return deferred.promise;
}
composeNewDiscussion(deferred) {
// @todo check global permissions
var component = new DiscussionComposer({ user: app.session.user() });
app.composer.load(component);
app.composer.show();
deferred.resolve(component);
return deferred.promise;
}
/**

View File

@ -18,7 +18,7 @@ export default class LoginModal extends FormModal {
return super.view({
className: 'modal-sm login-modal',
title: 'Log In',
body: [
body: m('div.form-centered', [
m('div.form-group', [
m('input.form-control[name=email][placeholder=Username or Email]', {value: this.email(), onchange: m.withAttr('value', this.email), disabled: this.loading()})
]),
@ -28,7 +28,7 @@ export default class LoginModal extends FormModal {
m('div.form-group', [
m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading()}, 'Log In')
])
],
]),
footer: [
m('p.forgot-password-link', m('a[href=javascript:;]', {onclick: () => {
var email = this.email();

View File

@ -12,14 +12,14 @@ export default class PostPreview extends Component {
var excerpt = post.contentPlain();
var start = 0;
if (highlight) {
if (this.props.highlight) {
var regexp = new RegExp(this.props.highlight, 'gi');
start = Math.max(0, excerpt.search(regexp) - 100);
}
excerpt = (start > 0 ? '...' : '')+excerpt.substring(start, start + 200)+(excerpt.length > start + 200 ? '...' : '');
if (highlight) {
if (this.props.highlight) {
excerpt = highlight(excerpt, regexp);
}

View File

@ -134,7 +134,7 @@ export default class PostScrubber extends Component {
// properties to a 'default' state. These values reflect what would be
// seen if the browser were scrolled right up to the top of the page,
// and the viewport had a height of 0.
var $items = stream.$('> .item');
var $items = stream.$('> .item[data-index]');
var index = $items.first().data('index');
var visible = 0;
var period = '';

View File

@ -88,7 +88,7 @@ class PostStream extends mixin(Component, evented) {
stream is not visible.
*/
pushPost(post) {
if (this.visibleEnd >= this.count() - 1) {
if (this.visibleEnd >= this.count() - 1 && this.posts.indexOf(post) === -1) {
this.posts.push(post);
this.visibleEnd++;
}
@ -295,7 +295,7 @@ class PostStream extends mixin(Component, evented) {
var redraw = () => {
if (start < this.visibleStart || end > this.visibleEnd) return;
var anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
var anchorIndex = backwards && $(window).scrollTop() > 0 ? this.visibleEnd - 1 : this.visibleStart;
anchorScroll(this.$('.item[data-index='+anchorIndex+']'), () => m.redraw(true));
this.unpause();
@ -422,7 +422,7 @@ class PostStream extends mixin(Component, evented) {
scrollToIndex(index, noAnimation, bottom) {
var $item = this.$('.item[data-index='+index+']');
return this.scrollToItem($item, noAnimation, true, true);
return this.scrollToItem($item, noAnimation, true, bottom);
}
/**

View File

@ -23,7 +23,7 @@ export default class PostedActivity extends Component {
near: post.number()
}), config: m.route}, [
m('ul.list-inline', listItems(this.headerItems().toArray())),
m('div.body', m.trust(post.excerpt()))
m('div.body', m.trust(post.contentPlain().substring(0, 200)))
])
]);
}

View File

@ -22,7 +22,7 @@ export default class SignupModal extends FormModal {
var vdom = super.view({
className: 'modal-sm signup-modal'+(welcomeUser ? ' signup-modal-success' : ''),
title: 'Sign Up',
body: [
body: m('div.form-centered', [
m('div.form-group', [
m('input.form-control[name=username][placeholder=Username]', {value: this.username(), onchange: m.withAttr('value', this.username), disabled: this.loading()})
]),
@ -35,7 +35,7 @@ export default class SignupModal extends FormModal {
m('div.form-group', [
m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading()}, 'Sign Up')
])
],
]),
footer: [
m('p.log-in-link', [
'Already have an account? ',

View File

@ -1,5 +1,5 @@
import Component from 'flarum/component';
import humanTime from 'flarum/utils/human-time';
import humanTime from 'flarum/helpers/human-time';
import username from 'flarum/helpers/username';
/**
@ -16,10 +16,13 @@ export default class TerminalPost extends Component {
var discussion = this.props.discussion;
var lastPost = this.props.lastPost && discussion.repliesCount();
var user = discussion[lastPost ? 'lastUser' : 'startUser']();
var time = discussion[lastPost ? 'lastTime' : 'startTime']();
return m('span', [
username(discussion[lastPost ? 'lastUser' : 'startUser']()),
username(user),
lastPost ? ' replied ' : ' started ',
m('time', humanTime(discussion[lastPost ? 'lastTime' : 'startTime']()))
humanTime(time)
])
}
}

View File

@ -87,6 +87,8 @@ export default function(app) {
}
if (this.canDelete()) {
items.add('separator', Separator.component());
items.add('delete', ActionButton.component({ icon: 'times', label: 'Delete', onclick: this.deleteAction.bind(this) }));
}

View File

@ -1,10 +1,12 @@
import Composer from 'flarum/components/composer';
import ReplyComposer from 'flarum/components/reply-composer';
import DiscussionPage from 'flarum/components/discussion-page';
export default function(app) {
app.composingReplyTo = function(discussion) {
return this.composer.component instanceof ReplyComposer &&
this.composer.component.props.discussion === discussion;
this.composer.component.props.discussion === discussion &&
this.composer.position() !== Composer.PositionEnum.HIDDEN;
};
app.viewingDiscussion = function(discussion) {

View File

@ -30,7 +30,14 @@ export default class Model {
if (data.links) {
for (var i in data.links) {
var model = data.links[i];
data.links[i] = {linkage: {type: model.data().type, id: model.data().id}};
var linkage = model => {
return {type: model.data().type, id: model.data().id};
};
if (model instanceof Array) {
data.links[i] = {linkage: model.map(linkage)};
} else {
data.links[i] = {linkage: linkage(model)};
}
}
}

View File

@ -17,7 +17,11 @@ class Discussion extends Model {
}
if (newData.links && newData.links.addedPosts) {
[].push.apply(posts.linkage, newData.links.addedPosts.linkage);
newData.links.addedPosts.linkage.forEach(linkage => {
if (posts.linkage[posts.linkage.length - 1].id != linkage.id) {
posts.linkage.push(linkage);
}
});
}
}
}

View File

@ -1,10 +1,12 @@
import ItemList from 'flarum/utils/item-list';
import Alert from 'flarum/components/alert';
import ServerError from 'flarum/utils/server-error';
import Translator from 'flarum/utils/translator';
class App {
constructor() {
this.initializers = new ItemList();
this.translator = new Translator();
this.cache = {};
this.serverError = null;
}
@ -55,6 +57,10 @@ class App {
var queryString = m.route.buildQueryString(params);
return url+(queryString ? '?'+queryString : '');
}
translate(key, input) {
return this.translator.translate(key, input);
}
}
export default App;

View File

@ -41,21 +41,17 @@ export default class ItemList {
addItems('push', false);
addItems('push', 'last');
items = items.filter(function(item) {
items.forEach(item => {
var key = item.position.before || item.position.after;
var type = item.position.before ? 'before' : 'after';
if (key) {
var index = array.indexOf(this[key]);
if (index === -1) {
console.log("Can't find item with key '"+key+"' to insert "+type+", inserting at end instead");
return true;
} else {
array.splice(array.indexOf(this[key]) + (type === 'after' ? 1 : 0), 0, item);
index = type === 'before' ? 0 : array.length;
}
array.splice(index + (type === 'after' ? 1 : 0), 0, item);
}
}.bind(this));
array = array.concat(items);
});
return array.map((item) => item.content);
}

View File

@ -0,0 +1,32 @@
export default class Translator {
constructor() {
this.translations = {};
}
plural(count) {
return count == 1 ? 'one' : 'other';
}
translate(key, input) {
var parts = key.split('.');
var translation = this.translations;
parts.forEach(function(part) {
translation = translation && translation[part];
});
if (typeof translation === 'object' && typeof input.count !== 'undefined') {
translation = translation[this.plural(input.count)];
}
if (typeof translation === 'string') {
for (var i in input) {
translation = translation.replace(new RegExp('{'+i+'}', 'gi'), input[i]);
}
return translation;
} else {
return key;
}
}
}

View File

@ -210,10 +210,9 @@
& .title {
margin: 0 0 6px;
line-height: 1.3;
color: @fl-secondary-color;
color: @fl-body-heading-color;
}
&.unread .title {
color: @fl-body-heading-color;
font-weight: bold;
}
& .info {

View File

@ -21,8 +21,8 @@
@fl-body-secondary-color: hsl(@fl-secondary-hue, min(50%, @fl-secondary-sat), 93%);
@fl-body-bg: #fff;
@fl-body-color: #444;
@fl-body-muted-color: hsl(@fl-secondary-hue, min(25%, @fl-secondary-sat), 66%);
@fl-body-color: #333;
@fl-body-muted-color: hsl(@fl-secondary-hue, min(25%, @fl-secondary-sat), 60%);
@fl-body-muted-more-color: #bbb;
@fl-shadow-color: rgba(0, 0, 0, 0.35);
}

View File

@ -0,0 +1,3 @@
app.translator.plural = function(count) {
return count == 1 ? 'one' : 'other';
};

View File

@ -0,0 +1,7 @@
<?php
return [
'plural' => function ($count) {
return $count == 1 ? 'one' : 'other';
}
];

View File

@ -0,0 +1,2 @@
core:

View File

@ -15,7 +15,6 @@ class CreateUsersDiscussionsTable extends Migration
public function up()
{
Schema::create('users_discussions', function (Blueprint $table) {
$table->integer('user_id')->unsigned();
$table->integer('discussion_id')->unsigned();
$table->dateTime('read_time')->nullable();

View File

@ -20,6 +20,7 @@ class CreateUsersTable extends Migration
$table->string('email', 150)->unique();
$table->boolean('is_activated')->default(0);
$table->string('password', 100);
$table->string('locale', 10)->default('en');
$table->text('bio')->nullable();
$table->text('bio_html')->nullable();
$table->string('avatar_path', 100)->nullable();

View File

@ -84,15 +84,11 @@ class IndexAction extends SerializeCollectionAction
$load = array_merge($request->include, ['state']);
$results = $this->searcher->search($criteria, $request->limit, $request->offset, $load);
if (($total = $results->getTotal()) !== null) {
$document->addMeta('total', $total);
}
static::addPaginationLinks(
$document,
$request,
$this->url->toRoute('flarum.api.discussions.index'),
$total ?: $results->areMoreResults()
$results->areMoreResults()
);
return $results->getDiscussions();

View File

@ -93,7 +93,7 @@ class ShowAction extends SerializeResourceAction
$discussion = $this->discussions->findOrFail($request->get('id'), $user);
$discussion->posts_ids = $discussion->posts()->whereCan($user, 'view')->get(['id'])->fetch('id')->all();
$discussion->posts_ids = $discussion->visiblePosts($user)->lists('id');
if (in_array('posts', $request->include)) {
$length = strlen($prefix = 'posts.');

View File

@ -0,0 +1,29 @@
<?php namespace Flarum\Api\Actions\Forum;
use Flarum\Core\Models\Forum;
use Flarum\Api\Actions\SerializeResourceAction;
use Flarum\Api\JsonApiRequest;
use Flarum\Api\JsonApiResponse;
class ShowAction extends SerializeResourceAction
{
/**
* The name of the serializer class to output results with.
*
* @var string
*/
public static $serializer = 'Flarum\Api\Serializers\ForumSerializer';
/**
* Get the forum, ready to be serialized and assigned to the JsonApi
* response.
*
* @param \Flarum\Api\JsonApiRequest $request
* @param \Flarum\Api\JsonApiResponse $response
* @return \Flarum\Core\Models\Forum
*/
protected function data(JsonApiRequest $request, JsonApiResponse $response)
{
return app('flarum.forum');
}
}

View File

@ -1,11 +1,12 @@
<?php namespace Flarum\Api\Actions;
use Flarum\Api\Events\WillRespond;
use Flarum\Api\Request;
use Flarum\Api\JsonApiRequest;
use Flarum\Api\JsonApiResponse;
use Tobscure\JsonApi\Criteria;
use Tobscure\JsonApi\Document;
use Tobscure\JsonApi\SerializerInterface;
use Tobscure\JsonApi\Criteria;
abstract class SerializeAction extends JsonApiAction
{
@ -68,15 +69,18 @@ abstract class SerializeAction extends JsonApiAction
public function respond(Request $request)
{
$request = static::buildJsonApiRequest($request);
$document = new Document();
$data = $this->data($request, $document);
$serializer = new static::$serializer($request->actor, $request->include, $request->link);
$document->setData($this->serialize($serializer, $data));
$response = new JsonApiResponse($document);
return new JsonApiResponse($document);
event(new WillRespond($this, $data, $request, $response));
return $response;
}
/**

View File

@ -29,6 +29,8 @@ class ShowAction extends SerializeResourceAction
'groups' => true
];
public static $link = [];
/**
* Instantiate the action.
*

View File

@ -0,0 +1,20 @@
<?php namespace Flarum\Api\Events;
class WillRespond
{
public $action;
public $data;
public $request;
public $response;
public function __construct($action, &$data, $request, $response)
{
$this->action = $action;
$this->data = &$data;
$this->request = $request;
$this->response = $response;
}
}

View File

@ -4,6 +4,7 @@ use Tobscure\JsonApi\SerializerAbstract;
use Flarum\Api\Events\SerializeAttributes;
use Flarum\Api\Events\SerializeRelationship;
use Flarum\Support\Actor;
use Illuminate\Database\Eloquent\Relations\Relation;
use Closure;
/**
@ -54,7 +55,7 @@ abstract class BaseSerializer extends SerializerAbstract
$data = $relation($model, $include);
} else {
if ($include) {
$data = !is_null($model->$relation) ? $model->$relation : $model->$relation()->getResults();
$data = $model->getRelation($relation);
} elseif ($many) {
$relationIds = $relation.'_ids';
$data = $model->$relationIds ?: $model->$relation()->get(['id'])->fetch('id')->all();

View File

@ -2,6 +2,8 @@
class DiscussionBasicSerializer extends BaseSerializer
{
protected static $relationships = [];
/**
* The resource type.
*

View File

@ -0,0 +1,31 @@
<?php namespace Flarum\Api\Serializers;
class ForumSerializer extends BaseSerializer
{
/**
* The resource type.
*
* @var string
*/
protected $type = 'forums';
protected function id($forum)
{
return 1;
}
/**
* Serialize attributes of a Forum model for JSON output.
*
* @param Forum $forum The Forum model to serialize.
* @return array
*/
protected function attributes($forum)
{
$attributes = [
'title' => $forum->title
];
return $this->extendAttributes($forum, $attributes);
}
}

View File

@ -2,6 +2,8 @@
class PostBasicSerializer extends BaseSerializer
{
protected static $relationships = [];
/**
* The resource type.
*

View File

@ -0,0 +1,60 @@
<?php namespace Flarum\Assets;
use RuntimeException;
class AssetManager
{
protected $less;
protected $js;
public function __construct(CompilerInterface $js, CompilerInterface $less)
{
$this->js = $js;
$this->less = $less;
}
public function addFile($file)
{
$ext = pathinfo($file, PATHINFO_EXTENSION);
switch ($ext) {
case 'js':
$this->js->addFile($file);
break;
case 'css':
case 'less':
$this->less->addFile($file);
break;
default:
throw new RuntimeException('Unsupported asset type: '.$ext);
}
}
public function addFiles(array $files)
{
array_walk($files, [$this, 'addFile']);
}
public function addLess($string)
{
$this->less->addString($string);
}
public function addJs($strings)
{
$this->js->addString($string);
}
public function getCssFile()
{
return $this->less->getFile();
}
public function getJsFile()
{
return $this->js->getFile();
}
}

View File

@ -0,0 +1,10 @@
<?php namespace Flarum\Assets;
interface CompilerInterface
{
public function addFile($file);
public function addString($string);
public function getFile();
}

View File

@ -0,0 +1,9 @@
<?php namespace Flarum\Assets;
class JsCompiler extends RevisionCompiler
{
public function format($string)
{
return $string.";\n";
}
}

View File

@ -0,0 +1,26 @@
<?php namespace Flarum\Assets;
use Less_Parser;
class LessCompiler extends RevisionCompiler
{
public function compile()
{
ini_set('xdebug.max_nesting_level', 200);
$parser = new Less_Parser([
'compress' => true,
'cache_dir' => storage_path().'/less'
]);
foreach ($this->files as $file) {
$parser->parseFile($file);
}
foreach ($this->strings as $string) {
$parser->parse($string);
}
return $parser->getCss();
}
}

View File

@ -0,0 +1,95 @@
<?php namespace Flarum\Assets;
use Illuminate\Support\Str;
class RevisionCompiler implements CompilerInterface
{
protected $files = [];
protected $strings = [];
public function __construct($path, $filename)
{
$this->path = $path;
$this->filename = $filename;
}
public function addFile($file)
{
$this->files[] = $file;
}
public function addString($string)
{
$this->strings[] = $string;
}
public function getFile()
{
if (! ($revision = $this->getRevision())) {
$revision = Str::quickRandom();
$this->putRevision($revision);
}
$lastModTime = 0;
foreach ($this->files as $file) {
$lastModTime = max($lastModTime, filemtime($file));
}
$ext = pathinfo($this->filename, PATHINFO_EXTENSION);
$file = $this->path.'/'.substr_replace($this->filename, '-'.$revision, -strlen($ext) - 1, 0);
if (! file_exists($file)
|| filemtime($file) < $lastModTime) {
file_put_contents($file, $this->compile());
}
return $file;
}
protected function format($string)
{
return $string;
}
protected function compile()
{
$output = '';
foreach ($this->files as $file) {
$output .= $this->format(file_get_contents($file));
}
foreach ($this->strings as $string) {
$output .= $this->format($string);
}
return $output;
}
protected function getRevisionFile()
{
return $this->path.'/rev-manifest.json';
}
protected function getRevision()
{
if (file_exists($file = $this->getRevisionFile())) {
$manifest = json_decode(file_get_contents($file), true);
return array_get($manifest, $this->filename);
}
}
protected function putRevision($revision)
{
if (file_exists($file = $this->getRevisionFile())) {
$manifest = json_decode(file_get_contents($file), true);
} else {
$manifest = [];
}
$manifest[$this->filename] = $revision;
return file_put_contents($this->getRevisionFile(), json_encode($manifest));
}
}

View File

@ -13,6 +13,7 @@ class ConsoleServiceProvider extends ServiceProvider
{
$this->commands('Flarum\Console\InstallCommand');
$this->commands('Flarum\Console\SeedCommand');
$this->commands('Flarum\Console\GenerateExtensionCommand');
}
public function register()

View File

@ -0,0 +1,127 @@
<?php namespace Flarum\Console;
use Illuminate\Console\Command;
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputArgument;
class GenerateExtensionCommand extends Command
{
/**
* The console command name.
*
* @var string
*/
protected $name = 'flarum:extension';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate a Flarum extension skeleton.';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct(Application $app)
{
parent::__construct();
$this->app = $app;
}
/**
* Execute the console command.
*
* @return mixed
*/
public function fire()
{
do {
$name = $this->ask('Extension name (<vendor>-<name>):');
} while (! preg_match('/^([a-z0-9]+)-([a-z0-9-]+)$/i', $name, $match));
list(, $vendor, $package) = $match;
do {
$title = $this->ask('Title:');
} while (! $title);
$description = $this->ask('Description:');
$authorName = $this->ask('Author name:');
$authorEmail = $this->ask('Author email:');
$license = $this->ask('License:');
$this->info('Generating extension skeleton for "'.$name.'"...');
$dir = public_path().'/extensions/'.$name;
$replacements = [
'{{namespace}}' => ucfirst($vendor).'\\'.ucfirst($package),
'{{escapedNamespace}}' => ucfirst($vendor).'\\\\'.ucfirst($package),
'{{classPrefix}}' => ucfirst($package),
'{{name}}' => $name
];
$this->copyStub($dir, $replacements);
rename($dir.'/src/ServiceProvider.php', $dir.'/src/'.ucfirst($package).'ServiceProvider.php');
$manifest = [
'name' => $name,
'title' => $title,
'description' => $description,
'tags' => [],
'version' => '0.1.0',
'author' => [
'name' => $authorName,
'email' => $authorEmail
],
'license' => $license,
'require' => [
'php' => '>=5.4.0',
'flarum' => '>0.1.0'
]
];
file_put_contents($dir.'/flarum.json', json_encode($manifest, JSON_PRETTY_PRINT));
passthru("cd $dir; composer install; cd js; npm install; gulp");
$this->info('Extension "'.$name.'" generated!');
}
protected function copyStub($destination, $replacements = [])
{
$this->recursiveCopy(__DIR__.'/../../stubs/extension', $destination, $replacements);
}
protected function recursiveCopy($src, $dst, $replacements = [])
{
$dir = opendir($src);
@mkdir($dst);
while (($file = readdir($dir)) !== false) {
if ($file != '.' && $file != '..') {
if (is_dir($src.'/'.$file)) {
$this->recursiveCopy($src.'/'.$file, $dst.'/'.$file, $replacements);
}
else {
$contents = file_get_contents($src.'/'.$file);
$contents = str_replace(array_keys($replacements), array_values($replacements), $contents);
file_put_contents($dst.'/'.$file, $contents);
}
}
}
closedir($dir);
}
}

View File

@ -16,6 +16,7 @@ use Flarum\Core\Events\RegisterUserGambits;
use Flarum\Extend\Permission;
use Flarum\Extend\ActivityType;
use Flarum\Extend\NotificationType;
use Flarum\Extend\Locale;
class CoreServiceProvider extends ServiceProvider
{
@ -54,6 +55,17 @@ class CoreServiceProvider extends ServiceProvider
(new ActivityType('Flarum\Core\Activity\StartedDiscussionActivity', 'Flarum\Api\Serializers\PostBasicSerializer')),
(new ActivityType('Flarum\Core\Activity\JoinedActivity', 'Flarum\Api\Serializers\UserBasicSerializer'))
);
foreach (['en'] as $locale) {
$dir = __DIR__.'/../../locale/'.$locale;
$this->extend(
(new Locale($locale))
->translations($dir.'/translations.yml')
->config($dir.'/config.php')
->js($dir.'/config.js')
);
}
}
/**
@ -70,6 +82,8 @@ class CoreServiceProvider extends ServiceProvider
$this->app->singleton('flarum.formatter', 'Flarum\Core\Formatter\FormatterManager');
$this->app->singleton('flarum.localeManager', 'Flarum\Locale\LocaleManager');
$this->app->bind(
'Flarum\Core\Repositories\DiscussionRepositoryInterface',
'Flarum\Core\Repositories\EloquentDiscussionRepository'
@ -169,71 +183,78 @@ class CoreServiceProvider extends ServiceProvider
$this->extend(
new Permission('forum.view'),
new Permission('forum.startDiscussion'),
new Permission('discussion.rename'),
new Permission('discussion.delete'),
new Permission('discussion.reply'),
new Permission('post.edit'),
new Permission('post.delete')
new Permission('discussion.editPosts'),
new Permission('discussion.deletePosts'),
new Permission('discussion.rename'),
new Permission('discussion.delete')
);
Forum::grantPermission(function ($grant, $user, $permission) {
return $user->hasPermission('forum.'.$permission);
Forum::allow('*', function ($forum, $user, $action) {
if ($user->hasPermission('forum.'.$action)) {
return true;
}
});
Post::grantPermission(function ($grant, $user, $permission) {
return $user->hasPermission('post'.$permission);
Post::allow('*', function ($post, $user, $action) {
if ($user->hasPermission('post.'.$action)) {
return true;
}
});
// Grant view access to a post only if the user can also view the
// discussion which the post is in. Also, the if the post is hidden,
// the user must have edit permissions too.
Post::grantPermission('view', function ($grant) {
$grant->whereCan('view', 'discussion');
});
Post::demandPermission('view', function ($demand) {
$demand->whereNull('hide_user_id')
->orWhereCan('edit');
});
// Allow a user to edit their own post, unless it has been hidden by
// someone else.
Post::grantPermission('edit', function ($grant, $user) {
$grant->where('user_id', $user->id)
->where(function ($query) use ($user) {
// When fetching a discussion's posts: if the user doesn't have permission
// to moderate the discussion, then they can't see posts that have been
// hidden by someone other than themself.
Discussion::scopeVisiblePosts(function ($query, User $user, Discussion $discussion) {
if (! $discussion->can($user, 'editPosts')) {
$query->where(function ($query) use ($user) {
$query->whereNull('hide_user_id')
->orWhere('hide_user_id', $user->id);
});
// @todo add limitations to time etc. according to a config setting
});
}
});
User::grantPermission(function ($grant, $user, $permission) {
return $user->hasPermission('user.'.$permission);
Post::allow('view', function ($post, $user) {
if (! $post->hide_user_id || $post->can($user, 'edit')) {
return true;
}
});
// Grant view access to a user if the user can view the forum.
User::grantPermission('view', function ($grant, $user) {
$grant->whereCan('view', 'forum');
// A post is allowed to be edited if the user has permission to moderate
// the discussion which it's in, or if they are the author and the post
// hasn't been deleted by someone else.
Post::allow('edit', function ($post, $user) {
if ($post->discussion->can($user, 'editPosts') ||
($post->user_id == $user->id && (! $post->hide_user_id || $post->hide_user_id == $user->id))
) {
return true;
}
});
// Allow a user to edit their own account.
User::grantPermission(['edit', 'delete'], function ($grant, $user) {
$grant->where('id', $user->id);
User::allow('*', function ($discussion, $user, $action) {
if ($user->hasPermission('user.'.$action)) {
return true;
}
});
Discussion::grantPermission(function ($grant, $user, $permission) {
return $user->hasPermission('discussion.'.$permission);
User::allow(['edit', 'delete'], function ($user, $actor) {
if ($user->id == $actor->id) {
return true;
}
});
// Grant view access to a discussion if the user can view the forum.
Discussion::grantPermission('view', function ($grant, $user) {
$grant->whereCan('view', 'forum');
Discussion::allow('*', function ($discussion, $user, $action) {
if ($user->hasPermission('discussion.'.$action)) {
return true;
}
});
// Allow a user to rename their own discussion.
Discussion::grantPermission('rename', function ($grant, $user) {
$grant->where('start_user_id', $user->id);
// @todo add limitations to time etc. according to a config setting
Discussion::allow('rename', function ($discussion, $user) {
if ($discussion->start_user_id == $user->id) {
return true;
// @todo add limitations to time etc. according to a config setting
}
});
}
}

View File

@ -15,6 +15,8 @@ class FormatterManager
*/
protected $container;
public $config;
/**
* Create a new formatter manager instance.
*
@ -23,6 +25,17 @@ class FormatterManager
public function __construct(Container $container)
{
$this->container = $container;
// Studio does not yet merge autoload_files...
// https://github.com/franzliedke/studio/commit/4f0f4314db4ed3e36c869a5f79b855c97bdd1be7
require __DIR__.'/../../../vendor/ezyang/htmlpurifier/library/HTMLPurifier.composer.php';
$this->config = HTMLPurifier_Config::createDefault();
$this->config->set('Core.Encoding', 'UTF-8');
$this->config->set('Core.EscapeInvalidTags', true);
$this->config->set('HTML.Doctype', 'HTML 4.01 Strict');
$this->config->set('HTML.Allowed', 'p,em,strong,a[href|title],ul,ol,li,code,pre,blockquote,h1,h2,h3,h4,h5,h6,br,hr,img[src|alt]');
$this->config->set('HTML.Nofollow', true);
}
public function add($name, $formatter, $priority = 0)
@ -66,18 +79,7 @@ class FormatterManager
$text = $formatter->beforePurification($text, $post);
}
// Studio does not yet merge autoload_files...
// https://github.com/franzliedke/studio/commit/4f0f4314db4ed3e36c869a5f79b855c97bdd1be7
require __DIR__.'/../../../vendor/ezyang/htmlpurifier/library/HTMLPurifier.composer.php';
$config = HTMLPurifier_Config::createDefault();
$config->set('Core.Encoding', 'UTF-8');
$config->set('Core.EscapeInvalidTags', true);
$config->set('HTML.Doctype', 'HTML 4.01 Strict');
$config->set('HTML.Allowed', 'p,em,strong,a[href|title],ul,ol,li,code,pre,blockquote,h1,h2,h3,h4,h5,h6,br,hr');
$config->set('HTML.Nofollow', true);
$purifier = new HTMLPurifier($config);
$purifier = new HTMLPurifier($this->config);
$text = $purifier->purify($text);

View File

@ -20,9 +20,8 @@ class EditDiscussionCommandHandler
$user = $command->user;
$discussion = $this->discussions->findOrFail($command->discussionId, $user);
$discussion->assertCan($user, 'edit');
if (isset($command->data['title'])) {
$discussion->assertCan($user, 'rename');
$discussion->rename($command->data['title'], $user);
}

View File

@ -1,7 +1,8 @@
<?php namespace Flarum\Core\Models;
use Tobscure\Permissible\Permissible;
use Flarum\Core\Support\EventGenerator;
use Flarum\Core\Support\Locked;
use Flarum\Core\Support\VisibleScope;
use Flarum\Core\Events\DiscussionWasDeleted;
use Flarum\Core\Events\DiscussionWasStarted;
use Flarum\Core\Events\DiscussionWasRenamed;
@ -9,7 +10,8 @@ use Flarum\Core\Models\User;
class Discussion extends Model
{
use Permissible;
use Locked;
use VisibleScope;
/**
* The validation rules for this model.
@ -28,6 +30,8 @@ class Discussion extends Model
'last_post_number' => 'integer'
];
protected static $relationships = [];
/**
* The table associated with the model.
*
@ -213,6 +217,24 @@ class Discussion extends Model
return $this->hasMany('Flarum\Core\Models\Post');
}
protected static $visiblePostsScopes = [];
public static function scopeVisiblePosts($scope)
{
static::$visiblePostsScopes[] = $scope;
}
public function visiblePosts(User $user)
{
$query = $this->posts();
foreach (static::$visiblePostsScopes as $scope) {
$scope($query, $user, $this);
}
return $query;
}
/**
* Define the relationship with the discussion's comments.
*
@ -295,9 +317,8 @@ class Discussion extends Model
*/
public function stateFor(User $user)
{
$loadedState = array_get($this->relations, 'state');
if ($loadedState && $loadedState->user_id === $user->id) {
return $loadedState;
if ($this->isRelationLoaded('state')) {
return $this->relations['state'];
}
$state = $this->state($user)->first();

View File

@ -1,11 +1,13 @@
<?php namespace Flarum\Core\Models;
use Tobscure\Permissible\Permissible;
use Flarum\Core\Support\Locked;
use Flarum\Core;
class Forum extends Model
{
use Permissible;
use Locked;
protected static $relationships = [];
public function getTitleAttribute()
{

View File

@ -169,37 +169,22 @@ class Model extends Eloquent
return $rules;
}
/**
* Assert that the user has permission to view this model, throwing an
* exception if they don't.
*
* @param \Flarum\Core\Models\User $user
* @return void
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public function assertVisibleTo(User $user)
public function isRelationLoaded($relation)
{
if (! $this->can($user, 'view')) {
throw new ModelNotFoundException;
}
return array_key_exists($relation, $this->relations);
}
/**
* Assert that the user has a certain permission for this model, throwing
* an exception if they don't.
*
* @param \Flarum\Core\Models\User $user
* @param string $permission
* @return void
*
* @throws \Flarum\Core\Exceptions\PermissionDeniedException
*/
public function assertCan(User $user, $permission)
public function getRelation($relation)
{
if (! $this->can($user, $permission)) {
throw new PermissionDeniedException;
if (isset($this->$relation)) {
return $this->$relation;
}
if (! $this->isRelationLoaded($relation)) {
$this->relations[$relation] = $this->$relation()->getResults();
}
return $this->relations[$relation];
}
/**

View File

@ -1,11 +1,11 @@
<?php namespace Flarum\Core\Models;
use Tobscure\Permissible\Permissible;
use Flarum\Core\Events\PostWasDeleted;
use Flarum\Core\Support\Locked;
class Post extends Model
{
use Permissible;
use Locked;
/**
* The validation rules for this model.

View File

@ -1,7 +1,6 @@
<?php namespace Flarum\Core\Models;
use Illuminate\Contracts\Hashing\Hasher;
use Tobscure\Permissible\Permissible;
use Flarum\Core\Formatter\FormatterManager;
use Flarum\Core\Events\UserWasDeleted;
use Flarum\Core\Events\UserWasRegistered;
@ -13,10 +12,13 @@ use Flarum\Core\Events\UserAvatarWasChanged;
use Flarum\Core\Events\UserWasActivated;
use Flarum\Core\Events\UserEmailWasConfirmed;
use Flarum\Core\Events\UserEmailChangeWasRequested;
use Flarum\Core\Support\Locked;
use Flarum\Core\Support\VisibleScope;
class User extends Model
{
use Permissible;
use Locked;
use VisibleScope;
/**
* The text formatter instance.
@ -185,6 +187,7 @@ class User extends Model
public function changeBio($bio)
{
$this->bio = $bio;
$this->bio_html = null;
$this->raise(new UserBioWasChanged($this));
@ -199,7 +202,7 @@ class User extends Model
*/
public function getBioHtmlAttribute($value)
{
if (! $value) {
if ($value === null) {
$this->bio_html = $value = static::formatBio($this->bio);
$this->save();
}
@ -309,9 +312,13 @@ class User extends Model
return true;
}
$count = $this->permissions()->where('permission', $permission)->count();
static $permissions;
return (bool) $count;
if (!$permissions) {
$permissions = $this->permissions()->get();
}
return (bool) $permissions->contains('permission', $permission);
}
public function getUnreadNotificationsCount()

View File

@ -30,7 +30,7 @@ class EloquentDiscussionRepository implements DiscussionRepositoryInterface
{
$query = Discussion::where('id', $id);
return $this->scopeVisibleForUser($query, $user)->firstOrFail();
return $this->scopeVisibleTo($query, $user)->firstOrFail();
}
/**
@ -54,10 +54,10 @@ class EloquentDiscussionRepository implements DiscussionRepositoryInterface
* @param \Flarum\Core\Models\User $user
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function scopeVisibleForUser(Builder $query, User $user = null)
protected function scopeVisibleTo(Builder $query, User $user = null)
{
if ($user !== null) {
$query->whereCan($user, 'view');
$query->whereVisibleTo($user);
}
return $query;

View File

@ -1,10 +1,32 @@
<?php namespace Flarum\Core\Repositories;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Flarum\Core\Models\Post;
use Flarum\Core\Models\User;
use Flarum\Core\Models\Discussion;
use Flarum\Core\Search\Discussions\Fulltext\DriverInterface;
// TODO: In some cases, the use of a post repository incurs extra query expense,
// because for every post retrieved we need to check if the discussion it's in
// is visible. Especially when retrieving a discussion's posts, we can end up
// with an inefficient chain of queries like this:
// 1. Api\Discussions\ShowAction: get discussion (will exit if not visible)
// 2. Discussion@visiblePosts: get discussion tags (for post visibility purposes)
// 3. Discussion@visiblePosts: get post IDs
// 4. EloquentPostRepository@getIndexForNumber: get discussion
// 5. EloquentPostRepository@getIndexForNumber: get discussion tags (for post visibility purposes)
// 6. EloquentPostRepository@getIndexForNumber: get post index for number
// 7. EloquentPostRepository@findWhere: get post IDs for discussion to check for discussion visibility
// 8. EloquentPostRepository@findWhere: get post IDs in visible discussions
// 9. EloquentPostRepository@findWhere: get posts
// 10. EloquentPostRepository@findWhere: eager load discussion onto posts
// 11. EloquentPostRepository@findWhere: get discussion tags to filter visible posts
// 12. Api\Discussions\ShowAction: eager load users
// 13. Api\Discussions\ShowAction: eager load groups
// 14. Api\Discussions\ShowAction: eager load mentions
// 14. Serializers\DiscussionSerializer: load discussion-user state
class EloquentPostRepository implements PostRepositoryInterface
{
protected $fulltext;
@ -26,9 +48,13 @@ class EloquentPostRepository implements PostRepositoryInterface
*/
public function findOrFail($id, User $user = null)
{
$query = Post::where('id', $id);
$posts = $this->findByIds([$id], $user);
return $this->scopeVisibleForUser($query, $user)->firstOrFail();
if (! count($posts)) {
throw new ModelNotFoundException;
}
return $posts->first();
}
/**
@ -52,7 +78,9 @@ class EloquentPostRepository implements PostRepositoryInterface
$query->orderBy($field, $order);
}
return $this->scopeVisibleForUser($query, $user)->get();
$ids = $query->lists('id');
return $this->findByIds($ids, $user);
}
/**
@ -65,9 +93,11 @@ class EloquentPostRepository implements PostRepositoryInterface
*/
public function findByIds(array $ids, User $user = null)
{
$query = Post::whereIn('id', (array) $ids);
$ids = $this->filterDiscussionVisibleTo($ids, $user);
return $this->scopeVisibleForUser($query, $user)->get();
$posts = Post::with('discussion')->whereIn('id', (array) $ids)->get();
return $this->filterVisibleTo($posts, $user);
}
/**
@ -82,13 +112,17 @@ class EloquentPostRepository implements PostRepositoryInterface
{
$ids = $this->fulltext->match($string);
$ids = $this->filterDiscussionVisibleTo($ids, $user);
$query = Post::select('id', 'discussion_id')->whereIn('id', $ids);
foreach ($ids as $id) {
$query->orderByRaw('id != ?', [$id]);
}
return $this->scopeVisibleForUser($query, $user)->get();
$posts = $query->get();
return $this->filterVisibleTo($posts, $user);
}
/**
@ -103,7 +137,8 @@ class EloquentPostRepository implements PostRepositoryInterface
*/
public function getIndexForNumber($discussionId, $number, User $user = null)
{
$query = Post::where('discussion_id', $discussionId)
$query = Discussion::find($discussionId)
->visiblePosts($user)
->where('time', '<', function ($query) use ($discussionId, $number) {
$query->select('time')
->from('posts')
@ -116,22 +151,32 @@ class EloquentPostRepository implements PostRepositoryInterface
->orderByRaw('ABS(CAST(number AS SIGNED) - '.(int) $number.')');
});
return $this->scopeVisibleForUser($query, $user)->count();
return $query->count();
}
/**
* Scope a query to only include records that are visible to a user.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param \Flarum\Core\Models\User $user
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function scopeVisibleForUser(Builder $query, User $user = null)
protected function filterDiscussionVisibleTo($ids, $user)
{
if ($user !== null) {
$query->whereCan($user, 'view');
// For each post ID, we need to make sure that the discussion it's in
// is visible to the user.
if ($user) {
$ids = Discussion::join('posts', 'discussions.id', '=', 'posts.discussion_id')
->whereIn('posts.id', $ids)
->whereVisibleTo($user)
->get(['posts.id'])
->lists('id');
}
return $query;
return $ids;
}
protected function filterVisibleTo($posts, $user)
{
if ($user) {
$posts = $posts->filter(function ($post) use ($user) {
return $post->can($user, 'view');
});
}
return $posts;
}
}

View File

@ -29,7 +29,7 @@ class EloquentUserRepository implements UserRepositoryInterface
{
$query = User::where('id', $id);
return $this->scopeVisibleForUser($query, $user)->firstOrFail();
return $this->scopeVisibleTo($query, $user)->firstOrFail();
}
/**
@ -67,7 +67,7 @@ class EloquentUserRepository implements UserRepositoryInterface
{
$query = User::where('username', 'like', $username);
return $this->scopeVisibleForUser($query, $user)->pluck('id');
return $this->scopeVisibleTo($query, $user)->pluck('id');
}
/**
@ -85,7 +85,7 @@ class EloquentUserRepository implements UserRepositoryInterface
->orderByRaw('username = ? desc', [$string])
->orderByRaw('username like ? desc', [$string.'%']);
return $this->scopeVisibleForUser($query, $user)->lists('id');
return $this->scopeVisibleTo($query, $user)->lists('id');
}
/**
@ -95,10 +95,10 @@ class EloquentUserRepository implements UserRepositoryInterface
* @param \Flarum\Core\Models\User $user
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function scopeVisibleForUser(Builder $query, User $user = null)
protected function scopeVisibleTo(Builder $query, User $user = null)
{
if ($user !== null) {
$query->whereCan($user, 'view');
$query->whereVisibleTo($user);
}
return $query;

View File

@ -6,13 +6,10 @@ class DiscussionSearchResults
protected $areMoreResults;
protected $total;
public function __construct($discussions, $areMoreResults, $total)
public function __construct($discussions, $areMoreResults)
{
$this->discussions = $discussions;
$this->areMoreResults = $areMoreResults;
$this->total = $total;
}
public function getDiscussions()
@ -20,11 +17,6 @@ class DiscussionSearchResults
return $this->discussions;
}
public function getTotal()
{
return $this->total;
}
public function areMoreResults()
{
return $this->areMoreResults;

View File

@ -59,12 +59,10 @@ class DiscussionSearcher implements SearcherInterface
public function search(DiscussionSearchCriteria $criteria, $limit = null, $offset = 0, $load = [])
{
$this->user = $criteria->user;
$this->query = $this->discussions->query()->whereCan($criteria->user, 'view');
$this->query = $this->discussions->query()->whereVisibleTo($criteria->user);
$this->gambits->apply($criteria->query, $this);
$total = $this->query->count();
$sort = $criteria->sort ?: $this->defaultSort;
foreach ($sort as $field => $order) {
@ -112,6 +110,6 @@ class DiscussionSearcher implements SearcherInterface
Discussion::setStateUser($this->user);
$discussions->load($load);
return new DiscussionSearchResults($discussions, $areMoreResults, $total);
return new DiscussionSearchResults($discussions, $areMoreResults);
}
}

View File

@ -19,6 +19,7 @@ class ConfigTableSeeder extends Seeder
'welcome_message' => 'Flarum is now at a point where you can have basic conversations, so here is a little demo for you to break.',
'welcome_title' => 'Welcome to Flarum Demo Forum',
'extensions_enabled' => '[]',
'locale' => 'en',
'theme_primary_color' => '#536F90',
'theme_secondary_color' => '#536F90',
'theme_dark_mode' => false,

View File

@ -27,8 +27,8 @@ class PermissionsTableSeeder extends Seeder
// Moderators can edit + delete stuff and suspend users
[4, 'discussion.delete'],
[4, 'discussion.rename'],
[4, 'post.delete'],
[4, 'post.edit'],
[4, 'discussion.editPosts'],
[4, 'discussion.deletePosts'],
[4, 'user.suspend'],
];

View File

@ -0,0 +1,57 @@
<?php namespace Flarum\Core\Support;
use Flarum\Core\Exceptions\PermissionDeniedException;
use Flarum\Core\Models\User;
use Closure;
trait Locked
{
protected static $conditions = [];
protected static function getConditions($action)
{
$conditions = isset(static::$conditions[$action]) ? static::$conditions[$action] : [];
$all = isset(static::$conditions['*']) ? static::$conditions['*'] : [];
return array_merge($conditions, $all);
}
public static function allow($action, Closure $condition)
{
foreach ((array) $action as $action) {
if (! isset(static::$conditions[$action])) {
static::$conditions[$action] = [];
}
static::$conditions[$action][] = $condition;
}
}
public function can(User $user, $action)
{
foreach ($this->getConditions($action) as $condition) {
$can = $condition($this, $user, $action);
if ($can !== null) {
return $can;
}
}
}
/**
* Assert that the user has a certain permission for this model, throwing
* an exception if they don't.
*
* @param \Flarum\Core\Models\User $user
* @param string $permission
* @return void
*
* @throws \Flarum\Core\Exceptions\PermissionDeniedException
*/
public function assertCan(User $user, $action)
{
if (! $this->can($user, $action)) {
throw new PermissionDeniedException;
}
}
}

View File

@ -0,0 +1,20 @@
<?php namespace Flarum\Core\Support;
use Flarum\Core\Models\User;
trait VisibleScope
{
protected static $visibleScopes = [];
public static function scopeVisible($scope)
{
static::$visibleScopes[] = $scope;
}
public function scopeWhereVisibleTo($query, User $user)
{
foreach (static::$visibleScopes as $scope) {
$scope($query, $user);
}
}
}

View File

@ -0,0 +1,18 @@
<?php namespace Flarum\Extend;
use Illuminate\Contracts\Container\Container;
class AdminTranslations implements ExtenderInterface
{
protected $keys;
public function __construct($keys)
{
$this->keys = $keys;
}
public function extend(Container $container)
{
}
}

View File

@ -0,0 +1,28 @@
<?php namespace Flarum\Extend;
use Illuminate\Foundation\Application;
class ApiLink implements ExtenderInterface
{
protected $actions;
protected $relationships;
public function __construct($actions, $relationships)
{
$this->actions = $actions;
$this->relationships = $relationships;
}
public function extend(Application $app)
{
foreach ((array) $this->actions as $action) {
$parts = explode('.', $action);
$class = 'Flarum\Api\Actions\\'.ucfirst($parts[0]).'\\'.ucfirst($parts[1]).'Action';
foreach ((array) $this->relationships as $relationship) {
$class::$link[] = $relationship;
}
}
}
}

View File

@ -14,7 +14,7 @@ class ForumAssets implements ExtenderInterface
public function extend(Container $container)
{
$container->make('events')->listen('Flarum\Forum\Events\RenderView', function ($event) {
$event->assets->addFile($this->files);
$event->assets->addFiles($this->files);
});
}
}

View File

@ -0,0 +1,19 @@
<?php namespace Flarum\Extend;
use Illuminate\Contracts\Container\Container;
use Flarum\Forum\Actions\IndexAction;
class ForumTranslations implements ExtenderInterface
{
protected $keys;
public function __construct($keys)
{
$this->keys = $keys;
}
public function extend(Container $container)
{
IndexAction::$translations = array_merge(IndexAction::$translations, $this->keys);
}
}

View File

@ -0,0 +1,57 @@
<?php namespace Flarum\Extend;
use Illuminate\Contracts\Container\Container;
class Locale implements ExtenderInterface
{
protected $locale;
protected $translations;
protected $config;
protected $js;
public function __construct($locale)
{
$this->locale = $locale;
}
public function translations($translations)
{
$this->translations = $translations;
return $this;
}
public function config($config)
{
$this->config = $config;
return $this;
}
public function js($js)
{
$this->js = $js;
return $this;
}
public function extend(Container $container)
{
$manager = $container->make('flarum.localeManager');
if ($this->translations) {
$manager->addTranslations($this->locale, $this->translations);
}
if ($this->config) {
$manager->addConfig($this->locale, $this->config);
}
if ($this->js) {
$manager->addJsFile($this->locale, $this->js);
}
}
}

View File

@ -7,17 +7,19 @@ class Relationship implements ExtenderInterface
{
protected $parent;
protected $type;
protected $name;
protected $type;
protected $child;
public function __construct($parent, $type, $name, $child = null)
protected $table;
public function __construct($parent, $name, $type, $child = null)
{
$this->parent = $parent;
$this->type = $type;
$this->name = $name;
$this->type = $type;
$this->child = $child;
}
@ -30,6 +32,8 @@ class Relationship implements ExtenderInterface
return call_user_func($this->type, $model);
} elseif ($this->type === 'belongsTo') {
return $model->belongsTo($this->child, null, null, $this->name);
} elseif ($this->type === 'belongsToMany') {
return $model->belongsToMany($this->child, $this->table, null, null, $this->name);
} else {
// @todo
}

View File

@ -1,10 +1,14 @@
<?php namespace Flarum\Forum\Actions;
use Flarum\Api\Client;
use Flarum\Assets\AssetManager;
use Flarum\Assets\JsCompiler;
use Flarum\Assets\LessCompiler;
use Flarum\Core;
use Flarum\Forum\Events\RenderView;
use Flarum\Locale\JsCompiler as LocaleJsCompiler;
use Flarum\Support\Actor;
use Flarum\Support\HtmlAction;
use Flarum\Forum\Events\RenderView;
use Illuminate\Database\DatabaseManager;
use Psr\Http\Message\ServerRequestInterface as Request;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
@ -19,6 +23,8 @@ class IndexAction extends HtmlAction
protected $database;
public static $translations = [];
// TODO: DatabaseManager should be ConnectionInterface
public function __construct(Client $apiClient, Actor $actor, DatabaseManager $database, SessionInterface $session)
{
@ -35,12 +41,23 @@ class IndexAction extends HtmlAction
$session = [];
$alert = $this->session->get('alert');
$response = $this->apiClient->send('Flarum\Api\Actions\Forum\ShowAction');
$data = [$response->data];
if (isset($response->included)) {
$data = array_merge($data, $response->included);
}
if (($user = $this->actor->getUser()) && $user->exists) {
$session = [
'userId' => $user->id,
'token' => $request->getCookieParams()['flarum_remember'],
];
// TODO: calling on the API here results in an extra query to get
// the user + their groups, when we already have this information on
// $this->actor. Can we simply run the CurrentUserSerializer
// manually?
$response = $this->apiClient->send('Flarum\Api\Actions\Users\ShowAction', ['id' => $user->id]);
$data = [$response->data];
@ -57,23 +74,63 @@ class IndexAction extends HtmlAction
->with('session', $session)
->with('alert', $alert);
$assetManager = app('flarum.forum.assetManager');
$root = __DIR__.'/../../..';
$assetManager->addFile([
$root.'/js/forum/dist/app.js',
$root.'/less/forum/app.less'
]);
$assetManager->addLess('
@fl-primary-color: '.Core::config('theme_primary_color').';
@fl-secondary-color: '.Core::config('theme_secondary_color').';
@fl-dark-mode: '.(Core::config('theme_dark_mode') ? 'true' : 'false').';
@fl-colored_header: '.(Core::config('theme_colored_header') ? 'true' : 'false').';
');
$public = public_path().'/assets';
event(new RenderView($view, $assetManager, $this));
$assets = new AssetManager(
new JsCompiler($public, 'forum.js'),
new LessCompiler($public, 'forum.css')
);
$assets->addFile($root.'/js/forum/dist/app.js');
$assets->addFile($root.'/less/forum/app.less');
$variables = [
'fl-primary-color' => Core::config('theme_primary_color', '#000'),
'fl-secondary-color' => Core::config('theme_secondary_color', '#000'),
'fl-dark-mode' => Core::config('theme_dark_mode') ? 'true' : 'false',
'fl-colored-header' => Core::config('theme_colored_header') ? 'true' : 'false'
];
foreach ($variables as $name => $value) {
$assets->addLess("@$name: $value;");
}
$locale = $user->locale ?: Core::config('locale', 'en');
$localeManager = app('flarum.localeManager');
$translations = $localeManager->getTranslations($locale);
$jsFiles = $localeManager->getJsFiles($locale);
$localeCompiler = new LocaleJsCompiler($public, 'locale-'.$locale.'.js');
$localeCompiler->setTranslations(static::filterTranslations($translations));
array_walk($jsFiles, [$localeCompiler, 'addFile']);
event(new RenderView($view, $assets, $this));
return $view
->with('styles', $assetManager->getCSSFiles())
->with('scripts', $assetManager->getJSFiles());
->with('styles', [$assets->getCssFile()])
->with('scripts', [$assets->getJsFile(), $localeCompiler->getFile()]);
}
protected static function filterTranslations($translations)
{
$filtered = [];
foreach (static::$translations as $key) {
$parts = explode('.', $key);
$level = &$filtered;
foreach ($parts as $part) {
if (! isset($level[$part])) {
$level[$part] = [];
}
$level = &$level[$part];
}
$level = array_get($translations, $key);
}
return $filtered;
}
}

View File

@ -1,9 +1,10 @@
<?php namespace Flarum\Forum;
use Flarum\Extend\ForumTranslations;
use Flarum\Http\RouteCollection;
use Flarum\Http\UrlGenerator;
use Flarum\Support\AssetManager;
use Illuminate\Support\ServiceProvider;
use Flarum\Support\ServiceProvider;
use Psr\Http\Message\ServerRequestInterface;
class ForumServiceProvider extends ServiceProvider
@ -43,6 +44,12 @@ class ForumServiceProvider extends ServiceProvider
]);
$this->routes();
$this->extend(
new ForumTranslations([
//
])
);
}
protected function routes()

View File

@ -0,0 +1,24 @@
<?php namespace Flarum\Locale;
use Flarum\Assets\RevisionCompiler;
class JsCompiler extends RevisionCompiler
{
protected $translations = [];
public function setTranslations(array $translations)
{
$this->translations = $translations;
}
public function compile()
{
$output = "var app = require('flarum/app')['default']; app.translator.translations = ".json_encode($this->translations).";";
foreach ($this->files as $filename) {
$output .= file_get_contents($filename);
}
return $output;
}
}

View File

@ -0,0 +1,65 @@
<?php namespace Flarum\Locale;
class LocaleManager
{
protected $translations = [];
protected $js = [];
protected $config = [];
public function addTranslations($locale, $translations)
{
if (! isset($this->translations[$locale])) {
$this->translations[$locale] = [];
}
$this->translations[$locale][] = $translations;
}
public function addJsFile($locale, $js)
{
if (! isset($this->js[$locale])) {
$this->js[$locale] = [];
}
$this->js[$locale][] = $js;
}
public function addConfig($locale, $config)
{
if (! isset($this->config[$locale])) {
$this->config[$locale] = [];
}
$this->config[$locale][] = $config;
}
public function getTranslations($locale)
{
$files = array_get($this->translations, $locale, []);
$parts = explode('-', $locale);
if (count($parts) > 1) {
$files = array_merge(array_get($this->translations, $parts[0], []), $files);
}
$compiler = new TranslationCompiler($locale, $files);
return $compiler->getTranslations();
}
public function getJsFiles($locale)
{
$files = array_get($this->js, $locale, []);
$parts = explode('-', $locale);
if (count($parts) > 1) {
$files = array_merge(array_get($this->js, $parts[0], []), $files);
}
return $files;
}
}

View File

@ -0,0 +1,29 @@
<?php namespace Flarum\Locale;
use Symfony\Component\Yaml\Yaml;
class TranslationCompiler
{
protected $locale;
protected $filenames;
public function __construct($locale, array $filenames)
{
$this->locale = $locale;
$this->filenames = $filenames;
}
public function getTranslations()
{
// @todo caching
$translations = [];
foreach ($this->filenames as $filename) {
$translations = array_replace_recursive($translations, Yaml::parse(file_get_contents($filename)));
}
return $translations;
}
}

View File

@ -0,0 +1,42 @@
<?php namespace Flarum\Locale;
use Closure;
class Translator
{
protected $translations;
protected $plural;
public function __construct(array $translations, Closure $plural)
{
$this->translations = $translations;
$this->plural = $plural;
}
public function plural($count)
{
$callback = $this->plural;
return $callback($count);
}
public function translate($key, array $input = [])
{
$translation = array_get($this->translations, $key);
if (is_array($translation) && isset($input['count'])) {
$translation = $translation[$this->plural($input['count'])];
}
if (is_string($translation)) {
foreach ($input as $k => $v) {
$translation = str_replace('{'.$k.'}', $v, $translation);
}
return $translation;
} else {
return $key;
}
}
}

View File

@ -1,166 +0,0 @@
<?php namespace Flarum\Support;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Str;
use Cache;
use Less_Parser;
use Closure;
class AssetManager
{
protected $files = [
'css' => [],
'js' => [],
'less' => []
];
protected $less = [];
protected $publicPath;
protected $name;
protected $storage;
public function __construct(Filesystem $storage, $publicPath, $name)
{
$this->storage = $storage;
$this->publicPath = $publicPath;
$this->name = $name;
}
public function addFile($files)
{
foreach ((array) $files as $file) {
$ext = pathinfo($file, PATHINFO_EXTENSION);
$this->files[$ext][] = $file;
}
}
public function addLess($strings)
{
foreach ((array) $strings as $string) {
$this->less[] = $string;
}
}
protected function getAssetDirectory()
{
$dir = $this->publicPath;
if (! $this->storage->isDirectory($dir)) {
$this->storage->makeDirectory($dir);
}
return $dir;
}
protected function getRevisionFile()
{
return $this->getAssetDirectory().'/'.$this->name;
}
protected function getRevision()
{
if (file_exists($file = $this->getRevisionFile())) {
return file_get_contents($file);
}
}
protected function putRevision($revision)
{
return file_put_contents($this->getRevisionFile(), $revision);
}
protected function getFiles($type, Closure $callback)
{
$dir = $this->getAssetDirectory();
if (! ($revision = $this->getRevision())) {
$revision = Str::quickRandom();
$this->putRevision($revision);
}
$lastModTime = 0;
foreach ($this->files[$type] as $file) {
$lastModTime = max($lastModTime, filemtime($file));
}
$debug = 0;
// $debug = 1;
if (! file_exists($file = $dir.'/'.$this->name.'-'.$revision.'.'.$type)
|| filemtime($file) < $lastModTime
|| $debug) {
$this->storage->put($file, $callback());
}
return [$file];
}
public function clearCache()
{
if ($revision = $this->getRevision()) {
$dir = $this->getAssetDirectory();
foreach (['css', 'js'] as $type) {
@unlink($dir.'/'.$this->name.'-'.$revision.'.'.$type);
}
}
}
public function getCSSFiles()
{
return $this->getFiles('css', function () {
return $this->compileCSS();
});
}
public function getJSFiles()
{
return $this->getFiles('js', function () {
return $this->compileJS();
});
}
public function compileLess()
{
ini_set('xdebug.max_nesting_level', 200);
$parser = new Less_Parser(['compress' => true, 'cache_dir' => storage_path().'/less']);
$css = [];
$dir = $this->getAssetDirectory();
foreach ($this->files['less'] as $file) {
$parser->parseFile($file);
}
foreach ($this->less as $less) {
$parser->parse($less);
}
return $parser->getCss();
}
public function compileCSS()
{
$css = $this->compileLess();
foreach ($this->files['css'] as $file) {
$css .= $this->storage->get($file);
}
// minify
return $css;
}
public function compileJS()
{
$js = '';
foreach ($this->files['js'] as $file) {
$js .= $this->storage->get($file).';';
}
// minify
return $js;
}
}

View File

@ -21,11 +21,12 @@ class ExtensionsServiceProvider extends ServiceProvider
$providers = [];
foreach ($extensions as $extension) {
if (file_exists($file = base_path().'/extensions/'.$extension.'/bootstrap.php')) {
if (file_exists($file = public_path().'/extensions/'.$extension.'/bootstrap.php') ||
file_exists($file = base_path().'/extensions/'.$extension.'/bootstrap.php')) {
$providers[$extension] = require $file;
}
}
// @todo store $providers somewhere so that extensions can talk to each other
// @todo store $providers somewhere (in Core?) so that extensions can talk to each other
}
}

View File

@ -17,8 +17,14 @@ class ServiceProvider extends IlluminateServiceProvider
public function extend()
{
foreach (func_get_args() as $extender) {
$extender->extend($this->app);
// @todo don't support func_get_args
foreach (func_get_args() as $extenders) {
if (! is_array($extenders)) {
$extenders = [$extenders];
}
foreach ($extenders as $extender) {
$extender->extend($this->app);
}
}
}
}

View File

@ -0,0 +1,9 @@
<?php
// Require the extension's composer autoload file. This will enable all of our
// classes in the src directory to be autoloaded.
require __DIR__.'/vendor/autoload.php';
// Register our service provider with the Flarum application. In here we can
// register bindings and execute code when the application boots.
return $this->app->register('{{namespace}}\{{classPrefix}}ServiceProvider');

View File

@ -0,0 +1,7 @@
{
"autoload": {
"psr-4": {
"{{escapedNamespace}}\\": "src/"
}
}
}

View File

@ -0,0 +1,3 @@
bower_components
node_modules
dist

View File

@ -0,0 +1,5 @@
var gulp = require('flarum-gulp');
gulp({
modulePrefix: '{{name}}'
});

View File

@ -0,0 +1,8 @@
import { extend, override } from 'flarum/extension-utils';
import app from 'flarum/app';
app.initializers.add('{{name}}', function() {
// @todo
});

View File

@ -0,0 +1,7 @@
{
"private": true,
"devDependencies": {
"gulp": "^3.8.11",
"flarum-gulp": "git+https://github.com/flarum/gulp.git"
}
}

View File

@ -0,0 +1,2 @@
{{name}}:
# hello_world: Hello, world!

View File

@ -0,0 +1,41 @@
<?php namespace {{namespace}};
use Flarum\Support\ServiceProvider;
use Flarum\Extend\ForumAssets;
use Flarum\Extend\Locale;
use Flarum\Extend\ForumTranslations;
class {{classPrefix}}ServiceProvider extends ServiceProvider
{
/**
* Bootstrap the application events.
*
* @return void
*/
public function boot()
{
$this->extend(
new ForumAssets([
__DIR__.'/../js/dist/extension.js',
__DIR__.'/../less/extension.less'
]),
(new Locale('en'))->translations(__DIR__.'/../locale/en.yml'),
new ForumTranslations([
// Add the keys of translations you would like to be available
// for use by the JS client application.
]),
);
}
/**
* Register the service provider.
*
* @return void
*/
public function register()
{
//
}
}