diff --git a/core/client/assets/sass/layouts/editor.scss b/core/client/assets/sass/layouts/editor.scss
index 44226f91fe..b24bb89b22 100644
--- a/core/client/assets/sass/layouts/editor.scss
+++ b/core/client/assets/sass/layouts/editor.scss
@@ -438,7 +438,7 @@ body.zen {
}
}
-#entry-categories {
+#entry-tags {
position: absolute;
top: 0;
left: 0;
@@ -448,7 +448,7 @@ body.zen {
padding: 10px 0 0 0;
}
-.category-label {
+.tag-label {
display: block;
float: left;
@include icon($i-tag);
@@ -461,7 +461,7 @@ body.zen {
}
}
-.category-input {
+.tag-input {
display: inline-block;
color: $lightgrey;
font-weight: 300;
@@ -471,7 +471,7 @@ body.zen {
&:focus {outline: none;}
}
-.category {
+.tag {
@include icon-after($i-x, 8px, $darkgrey) {
margin-left: 4px;
vertical-align: 5%;
@@ -489,6 +489,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;}
@@ -507,11 +509,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 {
diff --git a/core/client/models/post.js b/core/client/models/post.js
index 6028aab64f..97de7d5c7e 100644
--- a/core/client/models/post.js
+++ b/core/client/models/post.js
@@ -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 @@
}
});
-}());
\ No newline at end of file
+}());
diff --git a/core/client/models/tag.js b/core/client/models/tag.js
new file mode 100644
index 0000000000..9ab0f9098e
--- /dev/null
+++ b/core/client/models/tag.js
@@ -0,0 +1,8 @@
+/*global window, document, Ghost, $, _, Backbone */
+(function () {
+ "use strict";
+
+ Ghost.Collections.Tags = Backbone.Collection.extend({
+ url: Ghost.settings.apiRoot + '/tags'
+ });
+}());
diff --git a/core/client/tagui.js b/core/client/tagui.js
deleted file mode 100644
index fb1f824f9f..0000000000
--- a/core/client/tagui.js
+++ /dev/null
@@ -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("
" + results[i] + "");
- }
-
- 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, "$1");
- /*jslint regexp: true */ // - would like to remove this
- src_str = src_str.replace(/([^<>]*)((<[^>]+>)+)([^<>]*<\/mark>)/, "$1$2$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 = $('' + suggestions.children(".selected").text() + '');
- 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 = $('' + searchTerm + '');
- 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 = $('' + $(e.currentTarget).text() + ''),
- 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);
-
- });
-
-}());
\ No newline at end of file
diff --git a/core/client/views/editor-tag-widget.js b/core/client/views/editor-tag-widget.js
new file mode 100644
index 0000000000..430493e020
--- /dev/null
+++ b/core/client/views/editor-tag-widget.js
@@ -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 = $('' + tag.name + '');
+ $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("" + results[i].name + "");
+ }
+
+ 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, "$1");
+ /*jslint regexp: true */ // - would like to remove this
+ src_str = src_str.replace(/([^<>]*)((<[^>]+>)+)([^<>]*<\/mark>)/, "$1$2$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 = $('' + tag.name + '');
+ this.$('.tags').append($tag);
+ this.model.addTag(tag);
+
+ this.$('.tag-input').val('').focus();
+ this.$suggestions.hide();
+ }
+ });
+
+}());
diff --git a/core/client/views/editor.js b/core/client/views/editor.js
index d7872b21f7..e5bb1e3a61 100644
--- a/core/client/views/editor.js
+++ b/core/client/views/editor.js
@@ -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 () {
diff --git a/core/server/api.js b/core/server/api.js
index 6c9e38a658..6dcabed2be 100644
--- a/core/server/api.js
+++ b/core/server/api.js
@@ -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;
diff --git a/core/server/helpers/index.js b/core/server/helpers/index.js
index 92cf05908f..9a416c40c9 100644
--- a/core/server/helpers/index.js
+++ b/core/server/helpers/index.js
@@ -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;
\ No newline at end of file
+module.exports.loadCoreHelpers = coreHelpers;
diff --git a/core/server/models/base.js b/core/server/models/base.js
index 02a8af6d76..911a216541 100644
--- a/core/server/models/base.js
+++ b/core/server/models/base.js
@@ -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;
diff --git a/core/server/models/index.js b/core/server/models/index.js
index 4447d028c8..9f6f25b74e 100644
--- a/core/server/models/index.js
+++ b/core/server/models/index.js
@@ -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();
},
diff --git a/core/server/models/post.js b/core/server/models/post.js
index c4f61876aa..e418f1352c 100644
--- a/core/server/models/post.js
+++ b/core/server/models/post.js
@@ -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);
},
@@ -59,7 +61,9 @@ Post = GhostBookshelf.Model.extend({
}
this.set('updated_by', 1);
+
// refactoring of ghost required in order to make these details available here
+
},
creating: function () {
@@ -136,12 +140,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);
}
}, {
@@ -150,7 +219,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);
},
@@ -158,7 +227,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);
},
@@ -212,7 +281,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.)
diff --git a/core/server/models/tag.js b/core/server/models/tag.js
new file mode 100644
index 0000000000..a2356647d5
--- /dev/null
+++ b/core/server/models/tag.js
@@ -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
+};
diff --git a/core/server/views/default.hbs b/core/server/views/default.hbs
index 9100a93db5..849e3ce146 100644
--- a/core/server/views/default.hbs
+++ b/core/server/views/default.hbs
@@ -72,12 +72,12 @@
-
+
@@ -85,6 +85,7 @@
+
diff --git a/core/server/views/editor.hbs b/core/server/views/editor.hbs
index 61eb74d1a3..be0e2593bc 100644
--- a/core/server/views/editor.hbs
+++ b/core/server/views/editor.hbs
@@ -32,13 +32,12 @@
\ No newline at end of file
+
diff --git a/core/shared/lang/en.json b/core/shared/lang/en.json
index 24218f14b9..06e3467324 100644
--- a/core/shared/lang/en.json
+++ b/core/shared/lang/en.json
@@ -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"
-}
\ No newline at end of file
+}
diff --git a/core/test/unit/api_tags_spec.js b/core/test/unit/api_tags_spec.js
new file mode 100644
index 0000000000..b6ef14e4c6
--- /dev/null
+++ b/core/test/unit/api_tags_spec.js
@@ -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);
+ });
+ });
+
+ });
+
+});
diff --git a/index.js b/index.js
index 6248be0fd8..20d9d32ac5 100644
--- a/index.js
+++ b/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));