mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-20 22:42:53 -05:00
Add post tagging functionality
closes #367 closes #368 - Adds Tag model with a many-to-many relationship with Post - Adds Tag API to retrieve all previously used Tags (needed for suggestions) - Allows setting and retrieval of Tags for a post through the Post's existing API endpoints. - Hooks up the editor's tag suggestion box to the Ghost install's previously used tags - Tidies the client code for adding tags, and encapsulates the functionality into a Backbone view
This commit is contained in:
parent
6c0434fc8f
commit
d90df55b75
17 changed files with 640 additions and 211 deletions
|
@ -452,7 +452,7 @@ body.zen {
|
|||
}
|
||||
}
|
||||
|
||||
#entry-categories {
|
||||
#entry-tags {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
@ -462,7 +462,7 @@ body.zen {
|
|||
padding: 10px 0 0 0;
|
||||
}
|
||||
|
||||
.category-label {
|
||||
.tag-label {
|
||||
display: block;
|
||||
float: left;
|
||||
@include icon($i-tag);
|
||||
|
@ -475,7 +475,7 @@ body.zen {
|
|||
}
|
||||
}
|
||||
|
||||
.category-input {
|
||||
.tag-input {
|
||||
display: inline-block;
|
||||
color: $lightgrey;
|
||||
font-weight: 300;
|
||||
|
@ -485,7 +485,7 @@ body.zen {
|
|||
&:focus {outline: none;}
|
||||
}
|
||||
|
||||
.category {
|
||||
.tag {
|
||||
@include icon-after($i-x, 8px, $darkgrey) {
|
||||
margin-left: 4px;
|
||||
vertical-align: 5%;
|
||||
|
@ -503,6 +503,8 @@ body.zen {
|
|||
rgba(255,255,255,0.2) 0 1px 0 inset,
|
||||
#000 0 1px 3px;
|
||||
|
||||
@include user-select(none);
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
@include icon-after($i-x, 8px, $lightgrey) {text-shadow: none;}
|
||||
|
@ -521,11 +523,16 @@ body.zen {
|
|||
rgba(0,0,0,0.5) 0 1px 5px;
|
||||
}
|
||||
|
||||
li a {
|
||||
padding-left: 25px;
|
||||
}
|
||||
|
||||
mark{
|
||||
background: none;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#entry-settings {
|
||||
|
|
|
@ -26,6 +26,20 @@
|
|||
if (_.isEmpty(attrs.title)) {
|
||||
return 'You must specify a title for the post.';
|
||||
}
|
||||
},
|
||||
|
||||
addTag: function (tagToAdd) {
|
||||
var tags = this.get('tags') || [];
|
||||
tags.push(tagToAdd);
|
||||
this.set('tags', tags);
|
||||
},
|
||||
|
||||
removeTag: function (tagToRemove) {
|
||||
var tags = this.get('tags') || [];
|
||||
tags = _.reject(tags, function (tag) {
|
||||
return tag.id === tagToRemove.id || tag.name === tagToRemove.name;
|
||||
});
|
||||
this.set('tags', tags);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -46,4 +60,4 @@
|
|||
}
|
||||
});
|
||||
|
||||
}());
|
||||
}());
|
||||
|
|
8
core/client/models/tag.js
Normal file
8
core/client/models/tag.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*global window, document, Ghost, $, _, Backbone */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
Ghost.Collections.Tags = Backbone.Collection.extend({
|
||||
url: Ghost.settings.apiRoot + '/tags'
|
||||
});
|
||||
}());
|
|
@ -1,181 +0,0 @@
|
|||
// ## Tag Selector UI
|
||||
|
||||
/*global window, document, $ */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var suggestions,
|
||||
categoryOffset,
|
||||
existingTags = [], // This will be replaced by an API return.
|
||||
keys = {
|
||||
UP: 38,
|
||||
DOWN: 40,
|
||||
ESC: 27,
|
||||
ENTER: 13,
|
||||
COMMA: 188,
|
||||
BACKSPACE: 8
|
||||
};
|
||||
|
||||
function findTerms(searchTerm, array) {
|
||||
searchTerm = searchTerm.toUpperCase();
|
||||
return $.map(array, function (item) {
|
||||
var match = item.toUpperCase().indexOf(searchTerm) !== -1;
|
||||
return match ? item : null;
|
||||
});
|
||||
}
|
||||
|
||||
function showSuggestions($target, searchTerm) {
|
||||
suggestions.show();
|
||||
var results = findTerms(searchTerm, existingTags),
|
||||
pos = $target.position(),
|
||||
styles = {
|
||||
left: pos.left
|
||||
},
|
||||
maxSuggestions = 5, // Limit the suggestions number
|
||||
results_length = results.length,
|
||||
i,
|
||||
suggest;
|
||||
|
||||
suggestions.css(styles);
|
||||
suggestions.html("");
|
||||
|
||||
if (results_length < maxSuggestions) {
|
||||
maxSuggestions = results_length;
|
||||
}
|
||||
for (i = 0; i < maxSuggestions; i += 1) {
|
||||
suggestions.append("<li><a href='#'>" + results[i] + "</a></li>");
|
||||
}
|
||||
|
||||
suggest = $('ul.suggestions li a:contains("' + searchTerm + '")');
|
||||
|
||||
suggest.each(function () {
|
||||
var src_str = $(this).html(),
|
||||
term = searchTerm,
|
||||
pattern;
|
||||
|
||||
term = term.replace(/(\s+)/, "(<[^>]+>)*$1(<[^>]+>)*");
|
||||
pattern = new RegExp("(" + term + ")", "i");
|
||||
|
||||
src_str = src_str.replace(pattern, "<mark>$1</mark>");
|
||||
/*jslint regexp: true */ // - would like to remove this
|
||||
src_str = src_str.replace(/(<mark>[^<>]*)((<[^>]+>)+)([^<>]*<\/mark>)/, "$1</mark>$2<mark>$4");
|
||||
|
||||
$(this).html(src_str);
|
||||
});
|
||||
}
|
||||
|
||||
function handleTagKeyup(e) {
|
||||
var $target = $(e.currentTarget),
|
||||
searchTerm = $.trim($target.val()).toLowerCase(),
|
||||
category,
|
||||
populator;
|
||||
|
||||
if (e.keyCode === keys.UP) {
|
||||
e.preventDefault();
|
||||
if (suggestions.is(":visible")) {
|
||||
if (suggestions.children(".selected").length === 0) {
|
||||
suggestions.find("li:last-child").addClass('selected');
|
||||
} else {
|
||||
suggestions.children(".selected").removeClass('selected').prev().addClass('selected');
|
||||
}
|
||||
}
|
||||
} else if (e.keyCode === keys.DOWN) {
|
||||
e.preventDefault();
|
||||
if (suggestions.is(":visible")) {
|
||||
if (suggestions.children(".selected").length === 0) {
|
||||
suggestions.find("li:first-child").addClass('selected');
|
||||
} else {
|
||||
suggestions.children(".selected").removeClass('selected').next().addClass('selected');
|
||||
}
|
||||
}
|
||||
} else if (e.keyCode === keys.ESC) {
|
||||
suggestions.hide();
|
||||
} else if ((e.keyCode === keys.ENTER || e.keyCode === keys.COMMA) && searchTerm) {
|
||||
// Submit tag using enter or comma key
|
||||
e.preventDefault();
|
||||
if (suggestions.is(":visible") && suggestions.children(".selected").length !== 0) {
|
||||
|
||||
if ($('.category:containsExact("' + suggestions.children(".selected").text() + '")').length === 0) {
|
||||
|
||||
category = $('<span class="category">' + suggestions.children(".selected").text() + '</span>');
|
||||
if ($target.data('populate')) {
|
||||
|
||||
populator = $($target.data('populate'));
|
||||
populator.append(category);
|
||||
}
|
||||
}
|
||||
suggestions.hide();
|
||||
} else {
|
||||
if (e.keyCode === keys.COMMA) {
|
||||
searchTerm = searchTerm.replace(",", "");
|
||||
} // Remove comma from string if comma is uses to submit.
|
||||
if ($('.category:containsExact("' + searchTerm + '")').length === 0) {
|
||||
category = $('<span class="category">' + searchTerm + '</span>');
|
||||
if ($target.data('populate')) {
|
||||
populator = $($target.data('populate'));
|
||||
populator.append(category);
|
||||
}
|
||||
}
|
||||
}
|
||||
$target.val('').focus();
|
||||
searchTerm = ""; // Used to reset search term
|
||||
suggestions.hide();
|
||||
}
|
||||
|
||||
if (e.keyCode === keys.UP || e.keyCode === keys.DOWN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
showSuggestions($target, searchTerm);
|
||||
} else {
|
||||
suggestions.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTagKeyDown(e) {
|
||||
var $target = $(e.currentTarget),
|
||||
populator,
|
||||
lastBlock;
|
||||
// Delete character tiggers on Keydown, so needed to check on that event rather than Keyup.
|
||||
if (e.keyCode === keys.BACKSPACE && !$target.val()) {
|
||||
populator = $($target.data('populate'));
|
||||
lastBlock = populator.find('.category').last();
|
||||
lastBlock.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSuggestionClick(e) {
|
||||
var $target = $(e.currentTarget),
|
||||
category = $('<span class="category">' + $(e.currentTarget).text() + '</span>'),
|
||||
populator;
|
||||
|
||||
if ($target.parent().data('populate')) {
|
||||
populator = $($target.parent().data('populate'));
|
||||
populator.append(category);
|
||||
suggestions.hide();
|
||||
$('[data-input-behaviour="tag"]').val('').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleCategoryClick(e) {
|
||||
$(e.currentTarget).remove();
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
suggestions = $("ul.suggestions").hide(); // Initnialise suggestions overlay
|
||||
|
||||
if ($('.category-input').length) {
|
||||
categoryOffset = $('.category-input').offset().left;
|
||||
$('.category-blocks').css({'left': categoryOffset + 'px'});
|
||||
}
|
||||
|
||||
$('[data-input-behaviour="tag"]')
|
||||
.on('keyup', handleTagKeyup)
|
||||
.on('keydown', handleTagKeyDown);
|
||||
$('ul.suggestions').on('click', "li", handleSuggestionClick);
|
||||
$('.categories').on('click', ".category", handleCategoryClick);
|
||||
|
||||
});
|
||||
|
||||
}());
|
238
core/client/views/editor-tag-widget.js
Normal file
238
core/client/views/editor-tag-widget.js
Normal file
|
@ -0,0 +1,238 @@
|
|||
// The Tag UI area associated with a post
|
||||
|
||||
/*global window, document, $, _, Backbone, Ghost */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
Ghost.View.EditorTagWidget = Ghost.View.extend({
|
||||
|
||||
events: {
|
||||
'keyup [data-input-behaviour="tag"]': 'handleKeyup',
|
||||
'keydown [data-input-behaviour="tag"]': 'handleKeydown',
|
||||
'click ul.suggestions li': 'handleSuggestionClick',
|
||||
'click .tags .tag': 'handleTagClick'
|
||||
},
|
||||
|
||||
keys: {
|
||||
UP: 38,
|
||||
DOWN: 40,
|
||||
ESC: 27,
|
||||
ENTER: 13,
|
||||
COMMA: 188,
|
||||
BACKSPACE: 8
|
||||
},
|
||||
|
||||
initialize: function () {
|
||||
var self = this,
|
||||
tagCollection = new Ghost.Collections.Tags();
|
||||
|
||||
tagCollection.fetch().then(function () {
|
||||
self.allGhostTags = tagCollection.toJSON();
|
||||
});
|
||||
|
||||
this.model.on('willSave', this.completeCurrentTag, this);
|
||||
},
|
||||
|
||||
render: function () {
|
||||
var tags = this.model.get('tags'),
|
||||
$tags = $('.tags'),
|
||||
tagOffset;
|
||||
|
||||
$tags.empty();
|
||||
|
||||
if (tags) {
|
||||
_.forEach(tags, function (tag) {
|
||||
var $tag = $('<span class="tag" data-tag-id="' + tag.id + '">' + tag.name + '</span>');
|
||||
$tags.append($tag);
|
||||
});
|
||||
}
|
||||
|
||||
this.$suggestions = $("ul.suggestions").hide(); // Initialise suggestions overlay
|
||||
|
||||
if ($tags.length) {
|
||||
tagOffset = $('.tag-input').offset().left;
|
||||
$('.tag-blocks').css({'left': tagOffset + 'px'});
|
||||
}
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
showSuggestions: function ($target, searchTerm) {
|
||||
this.$suggestions.show();
|
||||
var results = this.findMatchingTags(searchTerm),
|
||||
styles = {
|
||||
left: $target.position().left
|
||||
},
|
||||
maxSuggestions = 5, // Limit the suggestions number
|
||||
results_length = results.length,
|
||||
i,
|
||||
suggest;
|
||||
|
||||
this.$suggestions.css(styles);
|
||||
this.$suggestions.html("");
|
||||
|
||||
if (results_length < maxSuggestions) {
|
||||
maxSuggestions = results_length;
|
||||
}
|
||||
for (i = 0; i < maxSuggestions; i += 1) {
|
||||
this.$suggestions.append("<li data-tag-id='" + results[i].id + "' data-tag-name='" + results[i].name + "'><a href='#'>" + results[i].name + "</a></li>");
|
||||
}
|
||||
|
||||
suggest = $('ul.suggestions li a:contains("' + searchTerm + '")');
|
||||
|
||||
suggest.each(function () {
|
||||
var src_str = $(this).html(),
|
||||
term = searchTerm,
|
||||
pattern;
|
||||
|
||||
term = term.replace(/(\s+)/, "(<[^>]+>)*$1(<[^>]+>)*");
|
||||
pattern = new RegExp("(" + term + ")", "i");
|
||||
|
||||
src_str = src_str.replace(pattern, "<mark>$1</mark>");
|
||||
/*jslint regexp: true */ // - would like to remove this
|
||||
src_str = src_str.replace(/(<mark>[^<>]*)((<[^>]+>)+)([^<>]*<\/mark>)/, "$1</mark>$2<mark>$4");
|
||||
|
||||
$(this).html(src_str);
|
||||
});
|
||||
},
|
||||
|
||||
handleKeyup: function (e) {
|
||||
var $target = $(e.currentTarget),
|
||||
searchTerm = $.trim($target.val()).toLowerCase(),
|
||||
tag,
|
||||
$selectedSuggestion;
|
||||
|
||||
if (e.keyCode === this.keys.UP) {
|
||||
e.preventDefault();
|
||||
if (this.$suggestions.is(":visible")) {
|
||||
if (this.$suggestions.children(".selected").length === 0) {
|
||||
this.$suggestions.find("li:last-child").addClass('selected');
|
||||
} else {
|
||||
this.$suggestions.children(".selected").removeClass('selected').prev().addClass('selected');
|
||||
}
|
||||
}
|
||||
} else if (e.keyCode === this.keys.DOWN) {
|
||||
e.preventDefault();
|
||||
if (this.$suggestions.is(":visible")) {
|
||||
if (this.$suggestions.children(".selected").length === 0) {
|
||||
this.$suggestions.find("li:first-child").addClass('selected');
|
||||
} else {
|
||||
this.$suggestions.children(".selected").removeClass('selected').next().addClass('selected');
|
||||
}
|
||||
}
|
||||
} else if (e.keyCode === this.keys.ESC) {
|
||||
this.$suggestions.hide();
|
||||
} else if ((e.keyCode === this.keys.ENTER || e.keyCode === this.keys.COMMA) && searchTerm) {
|
||||
// Submit tag using enter or comma key
|
||||
e.preventDefault();
|
||||
|
||||
$selectedSuggestion = this.$suggestions.children(".selected");
|
||||
if (this.$suggestions.is(":visible") && $selectedSuggestion.length !== 0) {
|
||||
|
||||
if ($('.tag:containsExact("' + $selectedSuggestion.data('tag-name') + '")').length === 0) {
|
||||
tag = {id: $selectedSuggestion.data('tag-id'), name: $selectedSuggestion.data('tag-name')};
|
||||
this.addTag(tag);
|
||||
}
|
||||
} else {
|
||||
if (e.keyCode === this.keys.COMMA) {
|
||||
searchTerm = searchTerm.replace(/,/g, "");
|
||||
} // Remove comma from string if comma is used to submit.
|
||||
if ($('.tag:containsExact("' + searchTerm + '")').length === 0) {
|
||||
this.addTag({id: null, name: searchTerm});
|
||||
}
|
||||
}
|
||||
$target.val('').focus();
|
||||
searchTerm = ""; // Used to reset search term
|
||||
this.$suggestions.hide();
|
||||
}
|
||||
|
||||
if (e.keyCode === this.keys.UP || e.keyCode === this.keys.DOWN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
this.showSuggestions($target, searchTerm);
|
||||
} else {
|
||||
this.$suggestions.hide();
|
||||
}
|
||||
},
|
||||
|
||||
handleKeydown: function (e) {
|
||||
var $target = $(e.currentTarget),
|
||||
lastBlock,
|
||||
tag;
|
||||
// Delete character tiggers on Keydown, so needed to check on that event rather than Keyup.
|
||||
if (e.keyCode === this.keys.BACKSPACE && !$target.val()) {
|
||||
lastBlock = this.$('.tags').find('.tag').last();
|
||||
lastBlock.remove();
|
||||
tag = {id: lastBlock.data('tag-id'), name: lastBlock.text()};
|
||||
this.model.removeTag(tag);
|
||||
}
|
||||
},
|
||||
|
||||
completeCurrentTag: function () {
|
||||
var $target = this.$('.tag-input'),
|
||||
tagName = $target.val(),
|
||||
usedTagNames,
|
||||
hasAlreadyBeenAdded;
|
||||
|
||||
usedTagNames = _.map(this.model.get('tags'), function (tag) {
|
||||
return tag.name.toUpperCase();
|
||||
});
|
||||
hasAlreadyBeenAdded = usedTagNames.indexOf(tagName.toUpperCase()) !== -1;
|
||||
|
||||
if (tagName.length > 0 && !hasAlreadyBeenAdded) {
|
||||
this.addTag({id: null, name: tagName});
|
||||
}
|
||||
},
|
||||
|
||||
handleSuggestionClick: function (e) {
|
||||
var $target = $(e.currentTarget);
|
||||
if (e) { e.preventDefault(); }
|
||||
this.addTag({id: $target.data('tag-id'), name: $target.data('tag-name')});
|
||||
},
|
||||
|
||||
handleTagClick: function (e) {
|
||||
var $tag = $(e.currentTarget),
|
||||
tag = {id: $tag.data('tag-id'), name: $tag.text()};
|
||||
$tag.remove();
|
||||
|
||||
this.model.removeTag(tag);
|
||||
},
|
||||
|
||||
findMatchingTags: function (searchTerm) {
|
||||
var matchingTagModels,
|
||||
self = this;
|
||||
|
||||
if (!this.allGhostTags) {
|
||||
return [];
|
||||
}
|
||||
|
||||
searchTerm = searchTerm.toUpperCase();
|
||||
matchingTagModels = _.filter(this.allGhostTags, function (tag) {
|
||||
var tagNameMatches,
|
||||
hasAlreadyBeenAdded;
|
||||
|
||||
tagNameMatches = tag.name.toUpperCase().indexOf(searchTerm) !== -1;
|
||||
|
||||
hasAlreadyBeenAdded = _.some(self.model.get('tags'), function (usedTag) {
|
||||
return tag.name.toUpperCase() === usedTag.name.toUpperCase();
|
||||
});
|
||||
return tagNameMatches && !hasAlreadyBeenAdded;
|
||||
});
|
||||
|
||||
return matchingTagModels;
|
||||
},
|
||||
|
||||
addTag: function (tag) {
|
||||
var $tag = $('<span class="tag" data-tag-id="' + tag.id + '">' + tag.name + '</span>');
|
||||
this.$('.tags').append($tag);
|
||||
this.model.addTag(tag);
|
||||
|
||||
this.$('.tag-input').val('').focus();
|
||||
this.$suggestions.hide();
|
||||
}
|
||||
});
|
||||
|
||||
}());
|
|
@ -5,7 +5,6 @@
|
|||
"use strict";
|
||||
|
||||
var PublishBar,
|
||||
TagWidget,
|
||||
ActionsWidget,
|
||||
MarkdownShortcuts = [
|
||||
{'key': 'Ctrl+B', 'style': 'bold'},
|
||||
|
@ -40,7 +39,7 @@
|
|||
PublishBar = Ghost.View.extend({
|
||||
|
||||
initialize: function () {
|
||||
this.addSubview(new TagWidget({el: this.$('#entry-categories'), model: this.model})).render();
|
||||
this.addSubview(new Ghost.View.EditorTagWidget({el: this.$('#entry-tags'), model: this.model})).render();
|
||||
this.addSubview(new ActionsWidget({el: this.$('#entry-actions'), model: this.model})).render();
|
||||
},
|
||||
|
||||
|
@ -48,12 +47,6 @@
|
|||
|
||||
});
|
||||
|
||||
// The Tag UI area associated with a post
|
||||
// ----------------------------------------
|
||||
TagWidget = Ghost.View.extend({
|
||||
render: function () { return this; }
|
||||
});
|
||||
|
||||
// The Publish, Queue, Publish Now buttons
|
||||
// ----------------------------------------
|
||||
ActionsWidget = Ghost.View.extend({
|
||||
|
@ -167,6 +160,8 @@
|
|||
});
|
||||
}
|
||||
|
||||
this.model.trigger('willSave');
|
||||
|
||||
this.savePost({
|
||||
status: status
|
||||
}).then(function () {
|
||||
|
|
|
@ -12,6 +12,7 @@ var Ghost = require('../ghost'),
|
|||
dataProvider = ghost.dataProvider,
|
||||
posts,
|
||||
users,
|
||||
tags,
|
||||
notifications,
|
||||
settings,
|
||||
requestHandler,
|
||||
|
@ -144,6 +145,16 @@ users = {
|
|||
}
|
||||
};
|
||||
|
||||
tags = {
|
||||
// #### All
|
||||
|
||||
// **takes:** Nothing yet
|
||||
all: function browse() {
|
||||
// **returns:** a promise for all tags which have previously been used in a json object
|
||||
return dataProvider.Tag.findAll();
|
||||
}
|
||||
};
|
||||
|
||||
// ## Notifications
|
||||
notifications = {
|
||||
// #### Destroy
|
||||
|
@ -306,6 +317,7 @@ cachedSettingsRequestHandler = function (apiMethod) {
|
|||
// Public API
|
||||
module.exports.posts = posts;
|
||||
module.exports.users = users;
|
||||
module.exports.tags = tags;
|
||||
module.exports.notifications = notifications;
|
||||
module.exports.settings = settings;
|
||||
module.exports.requestHandler = requestHandler;
|
||||
|
|
|
@ -50,6 +50,29 @@ coreHelpers = function (ghost) {
|
|||
return this.author ? this.author.full_name : "";
|
||||
});
|
||||
|
||||
// ### Tags Helper
|
||||
//
|
||||
// *Usage example:*
|
||||
// `{{tags}}`
|
||||
// `{{tags separator=" - "}}`
|
||||
//
|
||||
// Returns a string of the tags on the post.
|
||||
// By default, tags are separated by commas.
|
||||
//
|
||||
// Note that the standard {{#each tags}} implementation is unaffected by this helper
|
||||
// and can be used for more complex templates.
|
||||
ghost.registerThemeHelper('tags', function (options) {
|
||||
var separator = ", ",
|
||||
tagNames;
|
||||
|
||||
if (typeof options.hash.separator === "string") {
|
||||
separator = options.hash.separator;
|
||||
}
|
||||
|
||||
tagNames = _.pluck(this.tags, 'name');
|
||||
return tagNames.join(separator);
|
||||
});
|
||||
|
||||
// ### Content Helper
|
||||
//
|
||||
// *Usage example:*
|
||||
|
@ -128,7 +151,10 @@ coreHelpers = function (ghost) {
|
|||
ghost.registerThemeHelper('post_class', function (options) {
|
||||
var classes = ['post'];
|
||||
|
||||
// TODO: add tag names once we have them
|
||||
if (this.tags) {
|
||||
classes = classes.concat(this.tags.map(function (tag) { return "tag-" + tag.name; }));
|
||||
}
|
||||
|
||||
return ghost.doFilter('post_class', classes, function (classes) {
|
||||
var classString = _.reduce(classes, function (memo, item) { return memo + ' ' + item; }, '');
|
||||
return new hbs.handlebars.SafeString(classString.trim());
|
||||
|
@ -305,4 +331,4 @@ coreHelpers = function (ghost) {
|
|||
);
|
||||
};
|
||||
|
||||
module.exports.loadCoreHelpers = coreHelpers;
|
||||
module.exports.loadCoreHelpers = coreHelpers;
|
||||
|
|
|
@ -39,7 +39,9 @@ GhostBookshelf.Model = GhostBookshelf.Model.extend({
|
|||
}
|
||||
|
||||
_.each(relations, function (relation, key) {
|
||||
attrs[key] = relation.toJSON ? relation.toJSON() : relation;
|
||||
if (key.substring(0, 7) !== "_pivot_") {
|
||||
attrs[key] = relation.toJSON ? relation.toJSON() : relation;
|
||||
}
|
||||
});
|
||||
|
||||
return attrs;
|
||||
|
|
|
@ -6,6 +6,7 @@ module.exports = {
|
|||
Role: require('./role').Role,
|
||||
Permission: require('./permission').Permission,
|
||||
Settings: require('./settings').Settings,
|
||||
Tag: require('./tag').Tag,
|
||||
init: function () {
|
||||
return migrations.init();
|
||||
},
|
||||
|
|
|
@ -8,6 +8,7 @@ var Post,
|
|||
github = require('../../shared/vendor/showdown/extensions/github'),
|
||||
converter = new Showdown.converter({extensions: [github]}),
|
||||
User = require('./user').User,
|
||||
Tag = require('./tag').Tag,
|
||||
GhostBookshelf = require('./base');
|
||||
|
||||
Post = GhostBookshelf.Model.extend({
|
||||
|
@ -32,6 +33,7 @@ Post = GhostBookshelf.Model.extend({
|
|||
|
||||
initialize: function () {
|
||||
this.on('creating', this.creating, this);
|
||||
this.on('saving', this.updateTags, this);
|
||||
this.on('saving', this.saving, this);
|
||||
this.on('saving', this.validate, this);
|
||||
},
|
||||
|
@ -57,7 +59,9 @@ Post = GhostBookshelf.Model.extend({
|
|||
}
|
||||
|
||||
this.set('updated_by', 1);
|
||||
|
||||
// refactoring of ghost required in order to make these details available here
|
||||
|
||||
},
|
||||
|
||||
creating: function () {
|
||||
|
@ -134,12 +138,77 @@ Post = GhostBookshelf.Model.extend({
|
|||
return checkIfSlugExists(slug);
|
||||
},
|
||||
|
||||
updateTags: function () {
|
||||
var self = this,
|
||||
tagOperations = [],
|
||||
newTags = this.get('tags'),
|
||||
tagsToDetach,
|
||||
existingTagIDs,
|
||||
tagsToCreateAndAdd,
|
||||
tagsToAddByID,
|
||||
fetchOperation;
|
||||
|
||||
if (!newTags) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchOperation = Post.forge({id: this.id}).fetch({withRelated: ['tags']});
|
||||
return fetchOperation.then(function (thisModelWithTags) {
|
||||
var existingTags = thisModelWithTags.related('tags').models;
|
||||
|
||||
tagsToDetach = existingTags.filter(function (existingTag) {
|
||||
var tagStillRemains = newTags.some(function (newTag) {
|
||||
return newTag.id === existingTag.id;
|
||||
});
|
||||
|
||||
return !tagStillRemains;
|
||||
});
|
||||
if (tagsToDetach.length > 0) {
|
||||
tagOperations.push(self.tags().detach(tagsToDetach));
|
||||
}
|
||||
|
||||
// Detect any tags that have been added by ID
|
||||
existingTagIDs = existingTags.map(function (existingTag) {
|
||||
return existingTag.id;
|
||||
});
|
||||
|
||||
tagsToAddByID = newTags.filter(function (newTag) {
|
||||
return existingTagIDs.indexOf(newTag.id) === -1;
|
||||
});
|
||||
|
||||
if (tagsToAddByID.length > 0) {
|
||||
tagsToAddByID = _.pluck(tagsToAddByID, 'id');
|
||||
tagOperations.push(self.tags().attach(tagsToAddByID));
|
||||
}
|
||||
|
||||
// Detect any tags that have been added, but don't already exist in the database
|
||||
tagsToCreateAndAdd = newTags.filter(function (newTag) {
|
||||
return newTag.id === null || newTag.id === undefined;
|
||||
});
|
||||
tagsToCreateAndAdd.forEach(function (tagToCreateAndAdd) {
|
||||
var createAndAddOperation = Tag.add({name: tagToCreateAndAdd.name}).then(function (createdTag) {
|
||||
return self.tags().attach(createdTag.id);
|
||||
});
|
||||
|
||||
tagOperations.push(createAndAddOperation);
|
||||
});
|
||||
|
||||
return when.all(tagOperations);
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
// Relations
|
||||
user: function () {
|
||||
return this.belongsTo(User, 'created_by');
|
||||
},
|
||||
|
||||
author: function () {
|
||||
return this.belongsTo(User, 'author_id');
|
||||
},
|
||||
|
||||
tags: function () {
|
||||
return this.belongsToMany(Tag);
|
||||
}
|
||||
|
||||
}, {
|
||||
|
@ -148,7 +217,7 @@ Post = GhostBookshelf.Model.extend({
|
|||
// Extends base model findAll to eager-fetch author and user relationships.
|
||||
findAll: function (options) {
|
||||
options = options || {};
|
||||
options.withRelated = [ "author", "user" ];
|
||||
options.withRelated = [ "author", "user", "tags" ];
|
||||
return GhostBookshelf.Model.findAll.call(this, options);
|
||||
},
|
||||
|
||||
|
@ -156,7 +225,7 @@ Post = GhostBookshelf.Model.extend({
|
|||
// Extends base model findOne to eager-fetch author and user relationships.
|
||||
findOne: function (args, options) {
|
||||
options = options || {};
|
||||
options.withRelated = [ "author", "user" ];
|
||||
options.withRelated = [ "author", "user", "tags" ];
|
||||
return GhostBookshelf.Model.findOne.call(this, args, options);
|
||||
},
|
||||
|
||||
|
@ -210,7 +279,7 @@ Post = GhostBookshelf.Model.extend({
|
|||
postCollection.query('where', opts.where);
|
||||
}
|
||||
|
||||
opts.withRelated = [ "author", "user" ];
|
||||
opts.withRelated = [ "author", "user", "tags" ];
|
||||
|
||||
// Set the limit & offset for the query, fetching
|
||||
// with the opts (to specify any eager relations, etc.)
|
||||
|
|
23
core/server/models/tag.js
Normal file
23
core/server/models/tag.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
var Tag,
|
||||
Tags,
|
||||
Posts = require('./post').Posts,
|
||||
GhostBookshelf = require('./base');
|
||||
|
||||
Tag = GhostBookshelf.Model.extend({
|
||||
tableName: 'tags',
|
||||
|
||||
posts: function () {
|
||||
return this.belongsToMany(Posts);
|
||||
}
|
||||
});
|
||||
|
||||
Tags = GhostBookshelf.Collection.extend({
|
||||
|
||||
model: Tag
|
||||
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
Tag: Tag,
|
||||
Tags: Tags
|
||||
};
|
|
@ -72,12 +72,12 @@
|
|||
<script src="/public/mobile-interactions.js"></script>
|
||||
<script src="/public/toggle.js"></script>
|
||||
<script src="/public/markdown-actions.js"></script>
|
||||
<script src="/public/tagui.js"></script>
|
||||
<script src="/public/helpers/index.js"></script>
|
||||
|
||||
<!-- // require '/public/models/*' -->
|
||||
<script src="/public/models/post.js"></script>
|
||||
<script src="/public/models/user.js"></script>
|
||||
<script src="/public/models/tag.js"></script>
|
||||
<script src="/public/models/widget.js"></script>
|
||||
<script src="/public/models/settings.js"></script>
|
||||
<!-- // require '/public/views/*' -->
|
||||
|
@ -85,6 +85,7 @@
|
|||
<script src="/public/views/dashboard.js"></script>
|
||||
<script src="/public/views/blog.js"></script>
|
||||
<script src="/public/views/editor.js"></script>
|
||||
<script src="/public/views/editor-tag-widget.js"></script>
|
||||
<script src="/public/views/login.js"></script>
|
||||
<script src="/public/views/settings.js"></script>
|
||||
<script src="/public/views/debug.js"></script>
|
||||
|
|
|
@ -32,13 +32,12 @@
|
|||
</section>
|
||||
<footer id="publish-bar">
|
||||
<nav>
|
||||
<section id="entry-categories" href="#" class="left">
|
||||
<label class="category-label" for="categories"><span class="hidden">Categories</span></label>
|
||||
<div class="categories"></div>
|
||||
<input type="hidden" class="category-holder" id="category-holder">
|
||||
<input class="category-input" id="categories" type="text"
|
||||
data-populate-hidden="#category-holder" data-input-behaviour="tag" data-populate=".categories" />
|
||||
<ul class="suggestions overlay" data-populate=".categories"></ul>
|
||||
<section id="entry-tags" href="#" class="left">
|
||||
<label class="tag-label" for="tags"><span class="hidden">Tags</span></label>
|
||||
<div class="tags"></div>
|
||||
<input type="hidden" class="tags-holder" id="tags-holder">
|
||||
<input class="tag-input" id="tags" type="text" data-input-behaviour="tag" />
|
||||
<ul class="suggestions overlay"></ul>
|
||||
</section>
|
||||
<div class="right">
|
||||
<section id="entry-actions" class="splitbutton-save">
|
||||
|
@ -53,4 +52,4 @@
|
|||
</section>
|
||||
</div>
|
||||
</nav>
|
||||
</footer>
|
||||
</footer>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
"admin.navbar.settings": "Settings",
|
||||
|
||||
"__SECTION__": "icons",
|
||||
"icon.category.label": "Category",
|
||||
"icon.tag.label": "Tag",
|
||||
"icon.faq.label": "?",
|
||||
"icon.faq.markdown.title": "What is Markdown?",
|
||||
"icon.full_screen.label": "Full Screen",
|
||||
|
@ -23,4 +23,4 @@
|
|||
"editor.actions.save_draft": "Save Draft",
|
||||
"editor.actions.publish": "Publish"
|
||||
|
||||
}
|
||||
}
|
||||
|
|
213
core/test/unit/api_tags_spec.js
Normal file
213
core/test/unit/api_tags_spec.js
Normal file
|
@ -0,0 +1,213 @@
|
|||
/*globals describe, beforeEach, it */
|
||||
var _ = require("underscore"),
|
||||
when = require('when'),
|
||||
sequence = require('when/sequence'),
|
||||
should = require('should'),
|
||||
helpers = require('./helpers'),
|
||||
Models = require('../../server/models');
|
||||
|
||||
describe('Tag Model', function () {
|
||||
|
||||
var TagModel = Models.Tag;
|
||||
|
||||
before(function (done) {
|
||||
helpers.clearData().then(function () {
|
||||
done();
|
||||
}, done);
|
||||
});
|
||||
|
||||
beforeEach(function (done) {
|
||||
this.timeout(5000);
|
||||
helpers.initData()
|
||||
.then(function () {
|
||||
})
|
||||
.then(function () {
|
||||
done();
|
||||
}, done);
|
||||
});
|
||||
|
||||
afterEach(function (done) {
|
||||
helpers.clearData().then(function () {
|
||||
done();
|
||||
}, done);
|
||||
});
|
||||
|
||||
describe('a Post', function () {
|
||||
var PostModel = Models.Post;
|
||||
|
||||
it('can add a tag', function (done) {
|
||||
var newPost = {title: 'Test Title 1', content_raw: 'Test Content 1'},
|
||||
newTag = {name: 'tag1'},
|
||||
createdPostID;
|
||||
|
||||
when.all([
|
||||
PostModel.add(newPost),
|
||||
TagModel.add(newTag)
|
||||
]).then(function (models) {
|
||||
var createdPost = models[0],
|
||||
createdTag = models[1];
|
||||
|
||||
createdPostID = createdPost.id;
|
||||
return createdPost.tags().attach(createdTag);
|
||||
}).then(function () {
|
||||
return PostModel.read({id: createdPostID}, { withRelated: ['tags']});
|
||||
}).then(function (postWithTag) {
|
||||
postWithTag.related('tags').length.should.equal(1);
|
||||
done();
|
||||
}).then(null, done);
|
||||
|
||||
});
|
||||
|
||||
it('can remove a tag', function (done) {
|
||||
// The majority of this test is ripped from above, which is obviously a Bad Thing.
|
||||
// Would be nice to find a way to seed data with relations for cases like this,
|
||||
// because there are more DB hits than needed
|
||||
var newPost = {title: 'Test Title 1', content_raw: 'Test Content 1'},
|
||||
newTag = {name: 'tag1'},
|
||||
createdTagID,
|
||||
createdPostID;
|
||||
|
||||
when.all([
|
||||
PostModel.add(newPost),
|
||||
TagModel.add(newTag)
|
||||
]).then(function (models) {
|
||||
var createdPost = models[0],
|
||||
createdTag = models[1];
|
||||
|
||||
createdPostID = createdPost.id;
|
||||
createdTagID = createdTag.id;
|
||||
return createdPost.tags().attach(createdTag);
|
||||
}).then(function () {
|
||||
return PostModel.read({id: createdPostID}, { withRelated: ['tags']});
|
||||
}).then(function (postWithTag) {
|
||||
return postWithTag.tags().detach(createdTagID);
|
||||
}).then(function () {
|
||||
return PostModel.read({id: createdPostID}, { withRelated: ['tags']});
|
||||
}).then(function (postWithoutTag) {
|
||||
postWithoutTag.related('tags').should.be.empty;
|
||||
done();
|
||||
}).then(null, done);
|
||||
});
|
||||
|
||||
describe('setting tags from an array on update', function () {
|
||||
// When a Post is updated, iterate through the existing tags, and detach any that have since been removed.
|
||||
// It can be assumed that any remaining tags in the update data are newly added.
|
||||
// Create new tags if needed, and attach them to the Post
|
||||
|
||||
function seedTags (tagNames) {
|
||||
var createOperations = [
|
||||
PostModel.add({title: 'title', content_raw: 'content'})
|
||||
];
|
||||
|
||||
var tagModels = tagNames.map(function (tagName) { return TagModel.add({name: tagName}) });
|
||||
createOperations = createOperations.concat(tagModels);
|
||||
|
||||
|
||||
return when.all(createOperations).then(function (models) {
|
||||
var postModel = models[0],
|
||||
attachOperations;
|
||||
|
||||
attachOperations = [];
|
||||
for (var i = 1; i < models.length; i++) {
|
||||
attachOperations.push(postModel.tags().attach(models[i]))
|
||||
};
|
||||
|
||||
return when.all(attachOperations).then(function () {
|
||||
return postModel;
|
||||
});
|
||||
}).then(function (postModel) {
|
||||
return PostModel.read({id: postModel.id}, { withRelated: ['tags']});
|
||||
});
|
||||
}
|
||||
|
||||
it('does nothing if tags havent changed', function (done) {
|
||||
var seededTagNames = ['tag1', 'tag2', 'tag3'];
|
||||
|
||||
seedTags(seededTagNames).then(function (postModel) {
|
||||
// the tag API expects tags to be provided like {id: 1, name: 'draft'}
|
||||
var existingTagData = seededTagNames.map(function (tagName, i) { return {id: i+1, name: tagName} });
|
||||
postModel.set('tags', existingTagData);
|
||||
return postModel.save();
|
||||
}).then(function (postModel) {
|
||||
var tagNames = postModel.related('tags').models.map(function (t) { return t.attributes.name });
|
||||
tagNames.should.eql(seededTagNames);
|
||||
|
||||
return TagModel.findAll();
|
||||
}).then(function (tagsFromDB) {
|
||||
tagsFromDB.length.should.eql(seededTagNames.length);
|
||||
|
||||
done();
|
||||
}).then(null, done);
|
||||
|
||||
});
|
||||
|
||||
it('detaches tags that have been removed', function (done) {
|
||||
var seededTagNames = ['tag1', 'tag2', 'tag3'];
|
||||
|
||||
seedTags(seededTagNames).then(function (postModel) {
|
||||
// the tag API expects tags to be provided like {id: 1, name: 'draft'}
|
||||
var tagData = seededTagNames.map(function (tagName, i) { return {id: i+1, name: tagName} });
|
||||
|
||||
// remove the second tag, and save
|
||||
tagData.splice(1, 1);
|
||||
return postModel.set('tags', tagData).save();
|
||||
}).then(function (postModel) {
|
||||
return PostModel.read({id: postModel.id}, { withRelated: ['tags']});
|
||||
}).then(function (reloadedPost) {
|
||||
var tagNames = reloadedPost.related('tags').models.map( function (t) { return t.attributes.name });
|
||||
tagNames.should.eql(['tag1', 'tag3']);
|
||||
|
||||
done();
|
||||
}).then(null, done);
|
||||
});
|
||||
|
||||
it('attaches tags that are new to the post, but aleady exist in the database', function (done) {
|
||||
var seededTagNames = ['tag1', 'tag2'],
|
||||
postModel;
|
||||
|
||||
seedTags(seededTagNames).then(function (_postModel) {
|
||||
postModel = _postModel;
|
||||
return TagModel.add({name: 'tag3'})
|
||||
}).then(function() {
|
||||
// the tag API expects tags to be provided like {id: 1, name: 'draft'}
|
||||
var tagData = seededTagNames.map(function (tagName, i) { return {id: i+1, name: tagName} });
|
||||
|
||||
// add the additional tag, and save
|
||||
tagData.push({id: 3, name: 'tag3'});
|
||||
return postModel.set('tags', tagData).save();
|
||||
}).then(function () {
|
||||
return PostModel.read({id: postModel.id}, { withRelated: ['tags']});
|
||||
}).then(function (reloadedPost) {
|
||||
var tagModels = reloadedPost.related('tags').models,
|
||||
tagNames = tagModels.map( function (t) { return t.attributes.name });
|
||||
tagNames.should.eql(['tag1', 'tag2', 'tag3']);
|
||||
tagModels[2].id.should.eql(3); // make sure it hasn't just added a new tag with the same name
|
||||
|
||||
done();
|
||||
}).then(null, done);
|
||||
});
|
||||
|
||||
it('creates and attaches tags that are new to the Tags table', function (done) {
|
||||
var seededTagNames = ['tag1', 'tag2'];
|
||||
|
||||
seedTags(seededTagNames).then(function (postModel) {
|
||||
// the tag API expects tags to be provided like {id: 1, name: 'draft'}
|
||||
var tagData = seededTagNames.map(function (tagName, i) { return {id: i+1, name: tagName} });
|
||||
|
||||
// add the additional tag, and save
|
||||
tagData.push({id: null, name: 'tag3'});
|
||||
return postModel.set('tags', tagData).save();
|
||||
}).then(function (postModel) {
|
||||
return PostModel.read({id: postModel.id}, { withRelated: ['tags']});
|
||||
}).then(function (reloadedPost) {
|
||||
var tagNames = reloadedPost.related('tags').models.map( function (t) { return t.attributes.name });
|
||||
tagNames.should.eql(['tag1', 'tag2', 'tag3']);
|
||||
|
||||
done();
|
||||
}).then(null, done);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
2
index.js
2
index.js
|
@ -203,6 +203,8 @@ when.all([ghost.init(), filters.loadCoreFilters(ghost), helpers.loadCoreHelpers(
|
|||
ghost.app().get('/api/v0.1/users', authAPI, disableCachedResult, api.requestHandler(api.users.browse));
|
||||
ghost.app().get('/api/v0.1/users/:id', authAPI, disableCachedResult, api.requestHandler(api.users.read));
|
||||
ghost.app().put('/api/v0.1/users/:id', authAPI, disableCachedResult, api.requestHandler(api.users.edit));
|
||||
// #### Tags
|
||||
ghost.app().get('/api/v0.1/tags', authAPI, disableCachedResult, api.requestHandler(api.tags.all));
|
||||
// #### Notifications
|
||||
ghost.app().del('/api/v0.1/notifications/:id', authAPI, disableCachedResult, api.requestHandler(api.notifications.destroy));
|
||||
ghost.app().post('/api/v0.1/notifications/', authAPI, disableCachedResult, api.requestHandler(api.notifications.add));
|
||||
|
|
Loading…
Add table
Reference in a new issue