Просмотр исходного кода

Merge pull request #106 from crowi/feature-delete-page

[Feature] Page Deletion
Sotaro KARASAWA 9 лет назад
Родитель
Сommit
106f0548df

+ 16 - 0
lib/models/attachment.js

@@ -88,5 +88,21 @@ module.exports = function(crowi) {
     return 'attachment/' + pageId + '/' + generateFileHash(fileName) + ext;
   };
 
+  attachmentSchema.statics.removeAttachmentsByPageId = function(pageId) {
+    var Attachment = this;
+
+    // todo
+    return Promise.resolve();
+    //return new Promise(function(resolve, reject) {
+    //  // target find
+    //  Attachment.getListByPageId(pageId)
+    //  .then(function(attachments) {
+    //  }).then(function(done) {
+    //  }).catch(function(err) {
+    //  });
+    //});
+
+  };
+
   return mongoose.model('Attachment', attachmentSchema);
 };

+ 16 - 1
lib/models/bookmark.js

@@ -116,7 +116,22 @@ module.exports = function(crowi) {
     });
   };
 
-  bookmarkSchema.statics.remove = function(page, user) {
+  bookmarkSchema.statics.removeBookmarksByPageId = function(pageId) {
+    var Bookmark = this;
+
+    return new Promise(function(resolve, reject) {
+      Bookmark.remove({page: pageId}, function(err, data) {
+        if (err) {
+          debug('Bookmark.remove failed (removeBookmarkByPage)', err);
+          return reject(err);
+        }
+
+        return resolve(data);
+      });
+    });
+  };
+
+  bookmarkSchema.statics.removeBookmark = function(page, user) {
     var Bookmark = this;
 
     return new Promise(function(resolve, reject) {

+ 14 - 0
lib/models/comment.js

@@ -100,6 +100,20 @@ module.exports = function(crowi) {
     });
   };
 
+  commentSchema.statics.removeCommentsByPageId = function(pageId) {
+    var Comment = this;
+
+    return new Promise(function(resolve, reject) {
+      Comment.remove({page: pageId}, function(err, done) {
+        if (err) {
+          return reject(err);
+        }
+
+        resolve(done);
+      });;
+    });
+  };
+
   /**
    * post save hook
    */

+ 172 - 6
lib/models/page.js

@@ -8,6 +8,11 @@ module.exports = function(crowi) {
     , GRANT_OWNER = 4
     , PAGE_GRANT_ERROR = 1
 
+    , STATUS_WIP        = 'wip'
+    , STATUS_PUBLISHED  = 'published'
+    , STATUS_DELETED    = 'deleted'
+    , STATUS_DEPRECATED = 'deprecated'
+
     , pageEvent = crowi.event('page')
 
     , pageSchema;
@@ -21,12 +26,16 @@ module.exports = function(crowi) {
   }
 
   pageSchema = new mongoose.Schema({
-    path: { type: String, required: true, index: true },
+    path: { type: String, required: true, index: true, unique: true },
     revision: { type: ObjectId, ref: 'Revision' },
     redirectTo: { type: String, index: true },
+    status: { type: String, default: STATUS_PUBLISHED, index: true },
     grant: { type: Number, default: GRANT_PUBLIC, index: true },
     grantedUsers: [{ type: ObjectId, ref: 'User' }],
     creator: { type: ObjectId, ref: 'User', index: true },
+    // lastUpdateUser: this schema is from 1.5.x (by deletion feature), and null is default.
+    // the last update user on the screen is by revesion.author for B.C.
+    lastUpdateUser: { type: ObjectId, ref: 'User', index: true },
     liker: [{ type: ObjectId, ref: 'User', index: true }],
     seenUsers: [{ type: ObjectId, ref: 'User', index: true }],
     commentCount: { type: Number, default: 0 },
@@ -54,6 +63,23 @@ module.exports = function(crowi) {
   pageEvent.on('create', pageEvent.onCreate);
   pageEvent.on('update', pageEvent.onUpdate);
 
+  pageSchema.methods.isWIP = function() {
+    return this.status === STATUS_WIP;
+  };
+
+  pageSchema.methods.isPublished = function() {
+    // null: this is for B.C.
+    return this.status === null || this.status === STATUS_PUBLISHED;
+  };
+
+  pageSchema.methods.isDeleted = function() {
+    return this.status === STATUS_DELETED;
+  };
+
+  pageSchema.methods.isDeprecated = function() {
+    return this.status === STATUS_DEPRECATED;
+  };
+
   pageSchema.methods.isPublic = function() {
     if (!this.grant || this.grant == GRANT_PUBLIC) {
       return true;
@@ -232,6 +258,7 @@ module.exports = function(crowi) {
 
     return new Promise(function(resolve, reject) {
       pageData.populate([
+        {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS},
         {path: 'creator', model: 'User', select: User.USER_PUBLIC_FIELDS},
         {path: 'revision', model: 'Revision'},
         //{path: 'liker', options: { limit: 11 }},
@@ -288,10 +315,10 @@ module.exports = function(crowi) {
     });
   };
 
-  pageSchema.statics.hasPortalPage = function (path, user) {
+  pageSchema.statics.hasPortalPage = function (path, user, revisionId) {
     var self = this;
     return new Promise(function(resolve, reject) {
-      self.findPage(path, user)
+      self.findPage(path, user, revisionId)
       .then(function(page) {
         resolve(page);
       }).catch(function(err) {
@@ -322,6 +349,32 @@ module.exports = function(crowi) {
     return '/user/' + user.username;
   };
 
+  pageSchema.statics.getDeletedPageName = function(path) {
+    if (path.match('\/')) {
+      path = path.substr(1);
+    }
+    return '/trash/' + path;
+  };
+
+  pageSchema.statics.getRevertDeletedPageName = function(path) {
+    return path.replace('\/trash', '');
+  };
+
+  pageSchema.statics.isDeletableName = function(path) {
+    var notDeletable = [
+      /^\/user\/[^\/]+$/, // user page
+    ];
+
+    for (var i = 0; i < notDeletable.length; i++) {
+      var pattern = notDeletable[i];
+      if (path.match(pattern)) {
+        return false;
+      }
+    }
+
+    return true;
+  };
+
   pageSchema.statics.isCreatableName = function(name) {
     var forbiddenPages = [
       /\^|\$|\*|\+|\#/,
@@ -533,6 +586,7 @@ module.exports = function(crowi) {
     var Page = this;
     var User = crowi.model('User');
     var pathCondition = [];
+    var includeDeletedPage = option.includeDeletedPage || false
 
     if (!option) {
       option = {sort: 'updatedAt', desc: -1, offset: 0, limit: 50};
@@ -573,6 +627,15 @@ module.exports = function(crowi) {
         .skip(opt.offset)
         .limit(opt.limit);
 
+      if (!includeDeletedPage) {
+        q.and({
+          $or: [
+            {status: null},
+            {status: STATUS_PUBLISHED},
+          ],
+        });
+      }
+
       q.exec()
       .then(function(pages) {
         Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS})
@@ -652,6 +715,7 @@ module.exports = function(crowi) {
 
         debug('Successfully saved new revision', newRevision);
         pageData.revision = newRevision;
+        pageData.lastUpdateUser = user;
         pageData.updatedAt = Date.now();
         pageData.save(function(err, data) {
           if (err) {
@@ -690,10 +754,12 @@ module.exports = function(crowi) {
           var newPage = new Page();
           newPage.path = path;
           newPage.creator = user;
+          newPage.lastUpdateUser = user;
           newPage.createdAt = Date.now();
           newPage.updatedAt = Date.now();
           newPage.redirectTo = redirectTo;
           newPage.grant = grant;
+          newPage.status = STATUS_PUBLISHED;
           newPage.grantedUsers = [];
           newPage.grantedUsers.push(user);
 
@@ -743,6 +809,108 @@ module.exports = function(crowi) {
     });
   };
 
+  pageSchema.statics.deletePage = function(pageData, user, options) {
+    var Page = this
+      , newPath = Page.getDeletedPageName(pageData.path)
+      ;
+    if (Page.isDeletableName(pageData.path)) {
+      return new Promise(function(resolve, reject) {
+        Page.updatePageProperty(pageData, {status: STATUS_DELETED, lastUpdateUser: user})
+        .then(function(data) {
+          pageData.status = STATUS_DELETED;
+
+          // ページ名が /trash/ 以下に存在する場合、おかしなことになる
+          // が、 /trash 以下にページが有るのは、個別に作っていたケースのみ。
+          // 一応しばらく前から uncreatable pages になっているのでこれでいいことにする
+          debug('Deleted the page, and rename it', pageData.path, newPath);
+          return Page.rename(pageData, newPath, user, {createRedirectPage: true})
+        }).then(function(pageData) {
+          resolve(pageData);
+        }).catch(reject);
+      });
+    } else {
+      return Promise.reject('Page is not deletable.');
+    }
+  };
+
+  pageSchema.statics.revertDeletedPage = function(pageData, user, options) {
+    var Page = this
+      , newPath = Page.getRevertDeletedPageName(pageData.path)
+      ;
+
+    // 削除時、元ページの path には必ず redirectTo 付きで、ページが作成される。
+    // そのため、そいつは削除してOK
+    // が、redirectTo ではないページが存在している場合それは何かがおかしい。(データ補正が必要)
+    return new Promise(function(resolve, reject) {
+      Page.findPageByPath(newPath)
+      .then(function(originPageData) {
+        if (originPageData.redirectTo !== pageData.path) {
+          throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
+        }
+
+        return Page.completelyDeletePage(originPageData);
+      }).then(function(done) {
+        return Page.updatePageProperty(pageData, {status: STATUS_PUBLISHED, lastUpdateUser: user})
+      }).then(function(done) {
+        pageData.status = STATUS_PUBLISHED;
+
+        debug('Revert deleted the page, and rename again it', pageData, newPath);
+        return Page.rename(pageData, newPath, user, {})
+      }).then(function(done) {
+        pageData.path = newPath;
+        resolve(pageData);
+      }).catch(reject);
+    });
+  };
+
+  /**
+   * This is danger.
+   */
+  pageSchema.statics.completelyDeletePage = function(pageData, user, options) {
+    // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
+    var Bookmark = crowi.model('Bookmark')
+      , Attachment = crowi.model('Attachment')
+      , Comment = crowi.model('Comment')
+      , Revision = crowi.model('Revision')
+      , Page = this
+      , pageId = pageData._id
+      ;
+
+    debug('Completely delete', pageData.path);
+
+    return new Promise(function(resolve, reject) {
+      Bookmark.removeBookmarksByPageId(pageId)
+      .then(function(done) {
+      }).then(function(done) {
+        return Attachment.removeAttachmentsByPageId(pageId);
+      }).then(function(done) {
+        return Comment.removeCommentsByPageId(pageId);
+      }).then(function(done) {
+        return Revision.removeRevisionsByPath(pageData.path);
+      }).then(function(done) {
+        return Page.removePageById(pageId);
+      }).then(function(done) {
+        pageEvent.emit('delete', pageData, user); // update as renamed page
+        resolve();
+      }).catch(reject);
+    });
+  };
+
+  pageSchema.statics.removePageById = function(pageId) {
+    var Page = this;
+
+    return new Promise(function(resolve, reject) {
+      Page.remove({_id: pageId}, function(err, done) {
+        debug('Remove phisiaclly, the page', pageId, err, done);
+        if (err) {
+          return reject(err);
+        }
+
+        resolve(done);
+      });
+    });
+  };
+
   pageSchema.statics.rename = function(pageData, newPagePath, user, options) {
     var Page = this
       , Revision = crowi.model('Revision')
@@ -752,13 +920,11 @@ module.exports = function(crowi) {
 
     return new Promise(function(resolve, reject) {
       // pageData の path を変更
-      Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath})
+      Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath, lastUpdateUser: user})
       .then(function(data) {
-        debug('Before ', pageData);
         // reivisions の path を変更
         return Revision.updateRevisionListByPath(path, {path: newPagePath}, {})
       }).then(function(data) {
-        debug('After ', pageData);
         pageData.path = newPagePath;
 
         if (createRedirectPage) {

+ 20 - 4
lib/models/revision.js

@@ -38,7 +38,8 @@ module.exports = function(crowi) {
   };
 
   revisionSchema.statics.findRevisions = function(ids) {
-    var Revision = this;
+    var Revision = this,
+        User = crowi.model('User');
 
     if (!Array.isArray(ids)) {
       return Promise.reject('The argument was not Array.');
@@ -48,7 +49,7 @@ module.exports = function(crowi) {
       Revision
         .find({ _id: { $in: ids }})
         .sort({createdAt: -1})
-        .populate('author')
+        .populate('author', User.USER_PUBLIC_FIELDS)
         .exec(function(err, revisions) {
           if (err) {
             return reject(err);
@@ -60,12 +61,13 @@ module.exports = function(crowi) {
   };
 
   revisionSchema.statics.findRevisionList = function(path, options) {
-    var Revision = this;
+    var Revision = this,
+        User = crowi.model('User');
 
     return new Promise(function(resolve, reject) {
       Revision.find({path: path})
         .sort({createdAt: -1})
-        .populate('author')
+        .populate('author', User.USER_PUBLIC_FIELDS)
         .exec(function(err, data) {
           if (err) {
             return reject(err);
@@ -112,6 +114,20 @@ module.exports = function(crowi) {
     return newRevision;
   };
 
+  revisionSchema.statics.removeRevisionsByPath = function(path) {
+    var Revision = this;
+
+    return new Promise(function(resolve, reject) {
+      Revision.remove({path: path}, function(err, data) {
+        if (err) {
+          return reject(err);
+        }
+
+        return resolve(data);
+      });
+    });
+  };
+
   revisionSchema.statics.updatePath = function(pathName) {
   };
 

+ 1 - 1
lib/routes/bookmark.js

@@ -75,7 +75,7 @@ module.exports = function(crowi, app) {
   actions.api.remove = function(req, res){
     var pageId = req.body.page_id;
 
-    Bookmark.remove(pageId, req.user)
+    Bookmark.removeBookmark(pageId, req.user)
     .then(function(data) {
       debug('Bookmark removed.', data); // if the bookmark is not exists, this 'data' is null
       return res.json(ApiResponse.success());

+ 4 - 0
lib/routes/index.js

@@ -98,6 +98,8 @@ module.exports = function(crowi, app) {
   app.get('/_api/pages.updatePost'    , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.getUpdatePost);
   app.post('/_api/pages.seen'         , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.seen);
   app.post('/_api/pages.rename'       , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.rename);
+  app.post('/_api/pages.remove'       , loginRequired(crowi, app) , page.api.remove); // (Avoid from API Token)
+  app.post('/_api/pages.revertRemove' , loginRequired(crowi, app) , page.api.revertRemove); // (Avoid from API Token)
   app.get('/_api/comments.get'        , accessTokenParser(crowi, app) , loginRequired(crowi, app) , comment.api.get);
   app.post('/_api/comments.add'       , form.comment, accessTokenParser(crowi, app) , loginRequired(crowi, app) , comment.api.add);
   app.get( '/_api/bookmarks.get'      , accessTokenParser(crowi, app) , loginRequired(crowi, app) , bookmark.api.get);
@@ -113,6 +115,8 @@ module.exports = function(crowi, app) {
   //app.get('/_api/r/:revisionId'    , user.useUserData()         , page.api.get);
 
   app.post('/_/edit'                 , form.revision             , loginRequired(crowi, app) , page.pageEdit);
+  app.get('/trash/$'                 , loginRequired(crowi, app) , page.deletedPageListShow);
+  app.get('/trash/*/$'               , loginRequired(crowi, app) , page.deletedPageListShow);
   app.get('/*/$'                     , loginRequired(crowi, app) , page.pageListShow);
   app.get('/*'                       , loginRequired(crowi, app) , page.pageShow);
   //app.get('/*/edit'                , routes.edit);

+ 122 - 11
lib/routes/page.js

@@ -62,6 +62,7 @@ module.exports = function(crowi, app) {
     var SEENER_THRESHOLD = 10;
     path = path + (path == '/' ? '' : '/');
 
+    debug('Page list show', path);
     // index page
     var pagerOptions = {
       offset: offset,
@@ -76,12 +77,21 @@ module.exports = function(crowi, app) {
       page: null,
       path: path,
       pages: [],
+      tree: [],
     };
 
-    Page.hasPortalPage(path, req.user)
+    Page.hasPortalPage(path, req.user, req.query.revision)
     .then(function(portalPage) {
       renderVars.page = portalPage;
 
+      if (portalPage) {
+        return Revision.findRevisionList(portalPage.path, {});
+      } else {
+        return Promise.resolve([]);
+      }
+    }).then(function(tree) {
+      renderVars.tree = tree;
+
       return Page.findListByStartWith(path, req.user, queryOptions);
     }).then(function(pageList) {
 
@@ -102,6 +112,45 @@ module.exports = function(crowi, app) {
     });
   };
 
+  actions.deletedPageListShow = function(req, res) {
+    var path = '/trash' + getPathFromRequest(req);
+    var limit = 50;
+    var offset = parseInt(req.query.offset)  || 0;
+
+    // index page
+    var pagerOptions = {
+      offset: offset,
+      limit : limit
+    };
+    var queryOptions = {
+      offset: offset,
+      limit : limit + 1,
+      includeDeletedPage: true,
+    };
+
+    var renderVars = {
+      page: null,
+      path: path,
+      pages: [],
+    };
+
+    Page.findListByStartWith(path, req.user, queryOptions)
+    .then(function(pageList) {
+
+      if (pageList.length > limit) {
+        pageList.pop();
+      }
+
+      pagerOptions.length = pageList.length;
+
+      renderVars.pager = generatePager(pagerOptions);
+      renderVars.pages = pageList;
+      res.render('page_list', renderVars);
+    }).catch(function(err) {
+      debug('Error on rendering deletedPageListShow', err);
+    });
+  };
+
   actions.search = function(req, res) {
     // spec: ?q=query&sort=sort_order&author=author_filter
     var query = req.query.q;
@@ -238,14 +287,6 @@ module.exports = function(crowi, app) {
 
     res.locals.path = path;
 
-    // pageShow は /* にマッチしてる最後の砦なので、creatableName でない routing は
-    // これ以前に定義されているはずなので、こうしてしまって問題ない。
-    if (!Page.isCreatableName(path)) {
-      debug('Page is not creatable name.', path);
-      res.redirect('/');
-      return ;
-    }
-
     Page.findPage(path, req.user, req.query.revision)
     .then(function(page) {
       debug('Page found', page._id, page.path);
@@ -257,6 +298,16 @@ module.exports = function(crowi, app) {
 
       return renderPage(page, req, res);
     }).catch(function(err) {
+
+      // pageShow は /* にマッチしてる最後の砦なので、creatableName でない routing は
+      // これ以前に定義されているはずなので、こうしてしまって問題ない。
+      if (!Page.isCreatableName(path)) {
+        // 削除済みページの場合 /trash 以下に移動しているので creatableName になっていないので、表示を許可
+        debug('Page is not creatable name.', path);
+        res.redirect('/');
+        return ;
+      }
+
       if (req.query.revision) {
         return res.redirect(encodeURI(path));
       }
@@ -300,6 +351,7 @@ module.exports = function(crowi, app) {
     // set to render
     res.locals.pageForm = pageForm;
 
+    // 削除済みページはここで編集不可判定される
     if (!Page.isCreatableName(path)) {
       res.redirect(redirectPath);
       return ;
@@ -585,9 +637,68 @@ module.exports = function(crowi, app) {
     });
   };
 
+  /**
+   * @api {post} /pages.remove Remove page
+   * @apiName RemovePage
+   * @apiGroup Page
+   *
+   * @apiParam {String} page_id Page Id.
+   * @apiParam {String} revision_id
+   */
+  api.remove = function(req, res){
+    var pageId = req.body.page_id;
+    var previousRevision = req.body.revision_id || null;
+
+    Page.findPageByIdAndGrantedUser(pageId, req.user)
+    .then(function(pageData) {
+      debug('Delete page', pageData._id, pageData.path);
+
+      if (!pageData.isUpdatable(previousRevision)) {
+        throw new Error('Someone could update this page, so couldn\'t delete.');
+      }
+      return Page.deletePage(pageData, req.user);
+    }).then(function(data) {
+      debug('Page deleted', data.path);
+      var result = {};
+      result.page = data;
+
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      debug('Error occured while get setting', err, err.stack);
+      return res.json(ApiResponse.error('Failed to delete page.'));
+    });
+  };
+
+  /**
+   * @api {post} /pages.revertRemove Revert removed page
+   * @apiName RevertRemovePage
+   * @apiGroup Page
+   *
+   * @apiParam {String} page_id Page Id.
+   */
+  api.revertRemove = function(req, res){
+    var pageId = req.body.page_id;
+
+    Page.findPageByIdAndGrantedUser(pageId, req.user)
+    .then(function(pageData) {
+
+      // TODO: これでいいんだっけ
+      return Page.revertDeletedPage(pageData, req.user);
+    }).then(function(data) {
+      debug('Complete to revert deleted page', data.path);
+      var result = {};
+      result.page = data;
+
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      debug('Error occured while get setting', err, err.stack);
+      return res.json(ApiResponse.error('Failed to revert deleted page.'));
+    });
+  };
+
   /**
    * @api {post} /pages.rename Rename page
-   * @apiName SeenPage
+   * @apiName RenamePage
    * @apiGroup Page
    *
    * @apiParam {String} page_id Page Id.
@@ -620,7 +731,7 @@ module.exports = function(crowi, app) {
       .then(function(pageData) {
         page = pageData;
         if (!pageData.isUpdatable(previousRevision)) {
-          return res.json(ApiResponse.error('誰かが更新している可能性があります。ページを更新できません。'));
+          throw new Error('Someone could update this page, so couldn\'t delete.');
         }
 
         return Page.rename(pageData, newPagePath, req.user, options);

+ 20 - 5
lib/routes/revision.js

@@ -2,6 +2,7 @@ module.exports = function(crowi, app) {
   'use strict';
 
   var debug = require('debug')('crowi:routes:revision')
+    , Page = crowi.model('Page')
     , Revision = crowi.model('Revision')
     , ApiResponse = require('../util/apiResponse')
     , actions = {}
@@ -34,18 +35,32 @@ module.exports = function(crowi, app) {
    * @apiGroup Revision
    *
    * @apiParam {String} revision_ids Revision Ids.
+   * @apiParam {String} page_id      Page Id.
    */
   actions.api.list = function(req, res) {
-    var revisionIds = req.query.revision_ids.split(',');
+    var revisionIds = (req.query.revision_ids || '').split(',');
+    var pageId = req.query.page_id || null;
 
-    Revision
-      .findRevisions(revisionIds)
+    if (pageId) {
+      Page.findPageByIdAndGrantedUser(pageId, req.user)
+      .then(function(pageData) {
+        debug('Page found', pageData._id, pageData.path);
+        return Revision.findRevisionList(pageData.path, {});
+      }).then(function(revisions) {
+        return res.json(ApiResponse.success(revisions));
+      }).catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
+    } else if (revisionIds.length > 0) {
+      Revision.findRevisions(revisionIds)
       .then(function(revisions) {
         return res.json(ApiResponse.success(revisions));
-      })
-      .catch(function(err) {
+      }).catch(function(err) {
         return res.json(ApiResponse.error(err));
       });
+    } else {
+      return res.json(ApiResponse.error('Parameter error.'));
+    }
   };
 
   return actions;

+ 1 - 1
lib/util/middlewares.js

@@ -25,7 +25,7 @@ exports.loginChecker = function(crowi, app) {
 
 exports.swigFunctions = function(crowi, app) {
   return function(req, res, next) {
-    require('../util/swigFunctions')(crowi, app, res.locals);
+    require('../util/swigFunctions')(crowi, app, req, res.locals);
     next();
   };
 };

+ 21 - 2
lib/util/search.js

@@ -34,6 +34,7 @@ SearchClient.prototype.registerUpdateEvent = function() {
   var pageEvent = this.crowi.event('page');
   pageEvent.on('create', this.syncPageCreated.bind(this))
   pageEvent.on('update', this.syncPageUpdated.bind(this))
+  pageEvent.on('delete', this.syncPageDeleted.bind(this))
 };
 
 SearchClient.prototype.shouldIndexed = function(page) {
@@ -46,6 +47,10 @@ SearchClient.prototype.shouldIndexed = function(page) {
     return false;
   }
 
+  if (page.isDeleted()) {
+    return false;
+  }
+
   return true;
 };
 
@@ -390,7 +395,7 @@ SearchClient.prototype.searchKeywordUnderPath = function(keyword, path, option)
 
 SearchClient.prototype.syncPageCreated = function(page, user)
 {
-  debug('SearchClient.syncPageCreated', page);
+  debug('SearchClient.syncPageCreated', page.path);
 
   if (!this.shouldIndexed(page)) {
     return ;
@@ -407,7 +412,7 @@ SearchClient.prototype.syncPageCreated = function(page, user)
 
 SearchClient.prototype.syncPageUpdated = function(page, user)
 {
-  debug('SearchClient.syncPageUpdated', page);
+  debug('SearchClient.syncPageUpdated', page.path);
   // TODO delete
   if (!this.shouldIndexed(page)) {
     this.deletePages([page])
@@ -430,5 +435,19 @@ SearchClient.prototype.syncPageUpdated = function(page, user)
   });
 };
 
+SearchClient.prototype.syncPageDeleted = function(page, user)
+{
+  debug('SearchClient.syncPageDeleted', page.path);
+
+  this.deletePages([page])
+  .then(function(res) {
+    debug('deletePages: ES Response', res);
+  })
+  .catch(function(err){
+    debug('deletePages:ES Error', err);
+  });
+
+  return ;
+};
 
 module.exports = SearchClient;

+ 19 - 1
lib/util/swigFunctions.js

@@ -1,4 +1,4 @@
-module.exports = function(crowi, app, locals) {
+module.exports = function(crowi, app, req, locals) {
   var debug = require('debug')('crowi:lib:swigFunctions')
     , Page = crowi.model('Page')
     , Config = crowi.model('Config')
@@ -43,6 +43,24 @@ module.exports = function(crowi, app, locals) {
     return false;
   };
 
+  locals.isTopPage = function() {
+    var path = req.path || '';
+    if (path === '/') {
+      return true;
+    }
+
+    return false;
+  };
+
+  locals.isTrashPage = function() {
+    var path = req.path || '';
+    if (path.match(/^\/trash\/.*/)) {
+      return true;
+    }
+
+    return false;
+  };
+
   locals.userPageRoot = function(user) {
     if (!user || !user.username) {
       return '';

+ 1 - 1
lib/views/layout/2column.html

@@ -20,7 +20,7 @@
     <footer class="">
       <p>
       <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"> ヘルプ</i></a>
-      &copy; {{ now|date('Y') }} {{ config.crowi['app:title'] }} <img src="/logo/100x11_g.png" alt="powered by Crowi"> </p>
+      &copy; {{ now|date('Y') }} {{ config.crowi['app:title']|default('Crowi') }} <img src="/logo/100x11_g.png" alt="powered by Crowi"> </p>
     </footer>
   </div>
 </aside>

+ 2 - 0
lib/views/layout/layout.html

@@ -104,6 +104,8 @@
           <li class="divider"></li>
           <li><a href="/me"><i class="fa fa-gears"></i> ユーザー設定</a></li>
           <li class="divider"></li>
+          <li><a href="/trash/"><i class="fa fa-trash-o"></i> 削除済みページ</a></li>
+          <li class="divider"></li>
           <li><a href="/logout"><i class="fa fa-sign-out"></i> ログアウト</a></li>
           {# <li><a href="#">今日の日報を作成</a></li> #}
           {# <li class="divider"></li> #}

+ 31 - 0
lib/views/modal/widget_delete.html

@@ -0,0 +1,31 @@
+  <div class="modal fade" id="deletePage">
+    <div class="modal-dialog">
+      <div class="modal-content">
+
+      <form role="form" id="delete-page-form" onsubmit="return false;">
+
+        <div class="modal-header">
+          <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+          <h4 class="modal-title"><i class="fa fa-trash-o"></i> Delete Page</h4>
+        </div>
+        <div class="modal-body">
+          <ul>
+            <li>This page will be moved to the <a href="/trash/">trash</a>.</li>
+          </ul>
+            <div class="form-group">
+              <label for="">This page:</label><br>
+              <code>{{ page.path }}</code>
+            </div>
+        </div>
+        <div class="modal-footer">
+          <p><small class="pull-left" id="delete-errors"></small></p>
+          <input type="hidden" name="path" value="{{ page.path }}">
+          <input type="hidden" name="page_id" value="{{ page._id.toString() }}">
+          <input type="hidden" name="revision_id" value="{{ page.revision._id.toString() }}">
+          <input type="submit" class="btn btn-danger" value="Delete!">
+        </div>
+
+      </form>
+      </div><!-- /.modal-content -->
+    </div><!-- /.modal-dialog -->
+  </div><!-- /.modal -->

+ 46 - 0
lib/views/modal/widget_unportalize.html

@@ -0,0 +1,46 @@
+{% if isTopPage() %}
+  {% set unportalizedPath = '/top-' + Date.now() %}
+{% else %}
+  {% set unportalizedPath = page.path|replace('(\/)$', '') %}
+{% endif %}
+  <div class="modal fade" id="unportalize">
+    <div class="modal-dialog">
+      <div class="modal-content">
+
+      <form role="form" id="unportalize-form" onsubmit="return false;">
+
+        <div class="modal-header">
+          <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+          <h4 class="modal-title">ポータル化を解除する</h4>
+        </div>
+        <div class="modal-body">
+          <ul>
+           <li>このポータル化を解除し、通常のページに戻します。</li>
+          </ul>
+            <div class="form-group">
+              <p>
+                <label for="">このページ</label><br><code>{{ page.path }}</code>
+              </p>
+              <p>
+                <label for="">解除後のページ</label><br><code>{{ unportalizedPath }}</code>
+              </p>
+              {% if isTopPage() %}
+              <p class="alert alert-info">
+              このポータルはトップページのポータルのため、特別なページに移動します。
+              </p>
+              {% endif %}
+            </div>
+        </div>
+        <div class="modal-footer">
+          <p><small class="pull-left" id="newPageNameCheck"></small></p>
+          <input type="hidden" name="path" value="{{ page.path }}">
+          <input type="hidden" class="form-control" name="new_path" id="newPageName" value="{{ unportalizedPath }}">
+          <input type="hidden" name="page_id" value="{{ page._id.toString() }}">
+          <input type="hidden" name="revision_id" value="{{ page.revision._id.toString() }}">
+          <input type="submit" class="btn btn-primary" value="Unportalize!">
+        </div>
+
+      </form>
+      </div><!-- /.modal-content -->
+    </div><!-- /.modal-dialog -->
+  </div><!-- /.modal -->

+ 47 - 6
lib/views/page.html

@@ -8,6 +8,7 @@
 {% endblock %}
 
 <div class="header-wrap">
+  {% if not page.isDeleted() %}
   <header id="page-header">
     <p class="stopper"><a href="#" data-affix-disable="#page-header"><i class="fa fa-chevron-up"></i></a></p>
 
@@ -17,6 +18,13 @@
     {% endif %}
     <h1 class="title" id="revision-path">{{ path|insertSpaceToEachSlashes }}</h1>
   </header>
+  {% else %}
+  {# trash/* #}
+  <header id="page-header">
+    <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+    <h1 class="title">{{ path|insertSpaceToEachSlashes }}</h1>
+  </header>
+  {% endif %}
 </div>
 
 {% block content_head_after %}
@@ -54,8 +62,25 @@
 
   {% else %}
 
-  <ul class="nav nav-tabs hidden-print">
+  {% if page.isDeleted() %}
+  <div class="alert alert-danger">
+    <form role="form" class="pull-right" id="revert-delete-page-form" onsubmit="return false;">
+      <input type="hidden" name="path" value="{{ page.path }}">
+      <input type="hidden" name="page_id" value="{{ page._id.toString() }}">
+      <input type="submit" class="btn btn-danger btn-inverse btn-sm" value="Put Back!">
+    </form>
+    <p>
+      <i class="fa fa-trash-o" aria-hidden="true"></i>
+      This page is in the trash.<br>
+    </p>
+    <p>
+    Deleted by <img src="{{ page.lastUpdateUser|picture }}" class="picture picture-sm picture-rounded"> {{ page.lastUpdateUser.name }} at {{ page.updatedAt|datetz('Y-m-d H:i:s') }}
+    </p>
+  </div>
+  {% endif %}
 
+  {% if not page.isDeleted() %}
+  <ul class="nav nav-tabs hidden-print">
     <li class=" {% if not req.body.pageForm %}active{% endif %}" data-toggle="tooltip" {# data-title="あなたの 確認待ち です" title="" data-placement="bottom" data-trigger="manual" data-tooltip-stay #}>
       <a href="#revision-body" data-toggle="tab">
       <i class="fa fa-magic"></i>
@@ -76,24 +101,28 @@
       <ul class="dropdown-menu">
        <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="fa fa-share"></i> 移動</a></li>
        <li><a href="?presentation=1" class="toggle-presentation"><i class="fa fa-arrows-alt"></i> プレゼンモード (beta)</a></li>
+       <li class="divider"></li>
+       <li class=""><a href="#" data-target="#deletePage" data-toggle="modal"><i class="fa fa-trash-o text-danger"></i> 削除</a></li>
       </ul>
     </li>
     {% if page %}
     <li class="pull-right"><a href="#revision-history" data-toggle="tab"><i class="fa fa-history"></i> History</a></li>
     {% endif %}
   </ul>
+  {% endif %}
 
   {% include 'modal/widget_rename.html' %}
+  {% include 'modal/widget_delete.html' %}
 
   <div class="tab-content wiki-content">
-  {% if req.query.renamed %}
+  {% if req.query.renamed and not page.isDeleted() %}
   <div class="alert alert-info">
     <strong>移動しました: </strong> このページは <code>{{ req.query.renamed }}</code> から移動しました。
   </div>
   {% endif %}
   {% if not page.isLatestRevision() %}
   <div class="alert alert-warning">
-    <strong>注意: </strong> これは現在の版ではありません。
+    <strong>注意: </strong> これは現在の版ではありません。 <i class="fa fa-magic"></i> <a href="{{ page.path }}">最新のページを表示</a>
   </div>
   {% endif %}
 
@@ -117,9 +146,11 @@
     </div>
 
     {# edit form #}
+    {% if not page.isDeleted() %}
     <div class="edit-form tab-pane {% if req.body.pageForm %}active{% endif %}" id="edit-form">
       {% include '_form.html' %}
     </div>
+    {% endif %}
 
     {# raw revision history #}
     <div class="tab-pane revision-history" id="revision-history">
@@ -175,18 +206,28 @@
 
 <p class="meta">
   Path: <span id="pagePath">{{ page.path }}</span><br>
-  Last updated at {{ page.revision.createdAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.revision.author|picture }}" class="picture picture-rounded"> {{ page.revision.author.name }}<br>
+  {# for BC #}
+  {% if page.lastUpdateUser %}
+    Last updated at {{ page.updatedAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.lastUpdateUser|picture }}" class="picture picture-rounded"> {{ page.lastUpdateUser.name }}<br>
+  {% else %}
+    Last updated at {{ page.revision.createdAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.revision.author|picture }}" class="picture picture-rounded"> {{ page.revision.author.name }}<br>
+  {% endif %}
+  {# /for BC #}
   Created at {{ page.createdAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.creator|default(page.creator)|picture }}" class="picture picture-rounded"> {{ page.creator.name }}<br>
 </p>
 
 {% endblock %}
 
 {% block side_header %}
-  {% include 'widget/page_side_header.html' %}
+  {% if not page.isDeleted() %}
+    {% include 'widget/page_side_header.html' %}
+  {% endif %}
 {% endblock %} {# side_header #}
 
 {% block side_content %}
-  {% include 'widget/page_side_content.html' %}
+  {% if not page.isDeleted() %}
+    {% include 'widget/page_side_content.html' %}
+  {% endif %}
 {% endblock %}
 
 {% block footer %}

+ 50 - 3
lib/views/page_list.html

@@ -20,7 +20,7 @@
     {% endif %}
     <h1 class="title">
       <span class="" id="revision-path">{{ path|insertSpaceToEachSlashes }}</span>
-      {% if searchConfigured() && path != '/' %}
+      {% if searchConfigured() && !isTopPage() && !isTrashPage() %}
       <form class="input-group search-input-group hidden-xs hidden-sm" data-toggle="tooltip" data-placement="bottom" title="{{ path }} 以下から検索" id="search-listpage-form">
         <input type="text" class="search-listpage-input form-control" data-path="{{ path }}" id="search-listpage-input">
         <span class="input-group-btn search-listpage-submit-group">
@@ -46,7 +46,7 @@
  # but now the header and page list content is rendered separately by the server,
  # so now bind the values through the hidden fields.
  #}
-{% if searchConfigured() && path != '/' %}
+{% if searchConfigured() && !isTopPage() && !isTrashPage() %}
 <div id="page-list-search">
 </div>
 {% endif %}
@@ -85,10 +85,26 @@
         <i class="fa fa-times"></i>
       </a>
     </li>
+    {% else %}
+    <li class="dropdown pull-right">
+      <a class="dropdown-toggle" data-toggle="dropdown" href="#">
+        <i class="fa fa-wrench"></i> <span class="caret"></span>
+      </a>
+      <ul class="dropdown-menu">
+       <li><a href="#" data-target="#unportalize" data-toggle="modal"><i class="fa fa-share"></i> ポータル解除</a></li>
+      </ul>
+    </li>
+    <li class="pull-right"><a href="#revision-history" data-toggle="tab"><i class="fa fa-history"></i> History</a></li>
     {% endif %}
   </ul>
+  {% include 'modal/widget_unportalize.html' %}
 
   <div class="tab-content">
+  {% if not page.isLatestRevision() %}
+  <div class="alert alert-warning">
+    <strong>注意: </strong> これは現在の版ではありません。 <i class="fa fa-magic"></i> <a href="{{ page.path }}">最新のポータルを表示</a>
+  </div>
+  {% endif %}
     <div class="wiki tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body-content">{{ page.revision.body|nl2br|safe }}</div>
 
     <script type="text/template" id="raw-text-original">{{ page.revision.body }}</script>
@@ -96,7 +112,38 @@
     <div class="tab-pane edit-form portal-form {% if req.body.pageForm %}active{% endif %}" id="edit-form">
       {% include '_form.html' with {forceGrant: 1} %}
     </div>
+
+    <div class="tab-pane revision-history" id="revision-history">
+      <h1><i class="fa fa-history"></i> History</h1>
+      {% if not page %}
+      {% else %}
+      <div class="revision-history-list">
+        {% for t in tree %}
+        <div class="revision-hisory-outer">
+          <img src="{{ t.author|picture }}" class="picture picture-rounded">
+          <div class="revision-history-main">
+            <div class="revision-history-author">
+              <strong>{% if t.author %}{{ t.author.username }}{% else %}-{% endif %}</strong>
+            </div>
+            <div class="revision-history-comment">
+            </div>
+            <div class="revision-history-meta">
+              {{ t.createdAt|datetz('Y-m-d H:i:s') }}
+              <br>
+              <a href="?revision={{ t._id.toString() }}"><i class="fa fa-history"></i> このバージョンを見る</a>
+              <a class="diff-view" data-revision-id="{{ t._id.toString() }}">
+                <i id="diff-icon-{{ t._id.toString() }}" class="fa fa-arrow-circle-right"></i> 差分を見る
+              </a>
+              <pre class="" id="diff-display-{{ t._id.toString()}}" style="display: none"></pre>
+            </div>
+          </div>
+        </div>
+        {% endfor %}
+      </div>
+      {% endif %}
+    </div>
   </div>
+
 </div> {# /.portal #}
 
 <div class="page-list-container">
@@ -163,7 +210,7 @@
 
 {% block side_header %}
 
-{% if not page and not isUserPageList(path) %}
+{% if not page and not isUserPageList(path) and !isTrashPage() %}
 <div class="portal-side">
   <div class="portal-form-button">
     <button class="btn btn-primary" id="create-portal-button">Create Portal</button>

+ 7 - 1
lib/views/widget/page_side_header.html

@@ -13,7 +13,13 @@
       </p>
       <p class="created-at">
         作成日: {{ page.createdAt|datetz('Y/m/d H:i:s') }}<br>
-        最終更新: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.revision.author.username }}"><img src="{{ page.revision.author|picture }}" class="picture picture-xs picture-rounded" alt="{{ page.revision.author.name }}"></a>
+
+        {% if page.lastUpdateUser %}
+          最終更新: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.lastUpdateUser.username }}"><img src="{{ page.lastUpdateUser|picture }}" class="picture picture-xs picture-rounded" alt="{{ page.lastUpdateUser.name }}"></a>
+        {% else %}
+          {# for BC 1.5.x #}
+          最終更新: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.revision.author.username }}"><img src="{{ page.revision.author|picture }}" class="picture picture-xs picture-rounded" alt="{{ page.revision.author.name }}"></a>
+        {% endif %}
       </p>
     </div>
   </div>

+ 1 - 0
package.json

@@ -48,6 +48,7 @@
     "connect-redis": "~2.1.0",
     "consolidate": "~0.11.0",
     "cookie-parser": "~1.3.4",
+    "csurf": "^1.9.0",
     "debug": "~2.2.0",
     "del": "~2.2.0",
     "diff": "~2.2.2",

+ 66 - 18
resource/js/crowi.js

@@ -298,14 +298,15 @@ $(function() {
     return false;
   });
 
+  // rename
   $('#renamePage').on('shown.bs.modal', function (e) {
     $('#newPageName').focus();
   });
-  $('#renamePageForm').submit(function(e) {
+  $('#renamePageForm, #unportalize-form').submit(function(e) {
     $.ajax({
       type: 'POST',
       url: '/_api/pages.rename',
-      data: $('#renamePageForm').serialize(),
+      data: $(this).serialize(),
       dataType: 'json'
     }).done(function(res) {
       if (!res.ok) {
@@ -313,13 +314,12 @@ $(function() {
         $('#newPageNameCheck').addClass('alert-danger');
       } else {
         var page = res.page;
-        var path = $('#pagePath').html();
 
         $('#newPageNameCheck').removeClass('alert-danger');
         $('#newPageNameCheck').html('<img src="/images/loading_s.gif"> 移動しました。移動先にジャンプします。');
 
         setTimeout(function() {
-          top.location.href = page.path + '?renamed=' + path;
+          top.location.href = page.path + '?renamed=' + pagePath;
         }, 1000);
       }
     });
@@ -327,6 +327,44 @@ $(function() {
     return false;
   });
 
+  // delete
+  $('#delete-page-form').submit(function(e) {
+    $.ajax({
+      type: 'POST',
+      url: '/_api/pages.remove',
+      data: $('#delete-page-form').serialize(),
+      dataType: 'json'
+    }).done(function(res) {
+      if (!res.ok) {
+        $('#delete-errors').html('<i class="fa fa-times-circle"></i> ' + res.error);
+        $('#delete-errors').addClass('alert-danger');
+      } else {
+        var page = res.page;
+        top.location.href = page.path;
+      }
+    });
+
+    return false;
+  });
+  $('#revert-delete-page-form').submit(function(e) {
+    $.ajax({
+      type: 'POST',
+      url: '/_api/pages.revertRemove',
+      data: $('#revert-delete-page-form').serialize(),
+      dataType: 'json'
+    }).done(function(res) {
+      if (!res.ok) {
+        $('#delete-errors').html('<i class="fa fa-times-circle"></i> ' + res.error);
+        $('#delete-errors').addClass('alert-danger');
+      } else {
+        var page = res.page;
+        top.location.href = page.path;
+      }
+    });
+
+    return false;
+  });
+
   $('#create-portal-button').on('click', function(e) {
     $('.portal').removeClass('hide');
     $('.content-main').addClass('on-edit');
@@ -705,16 +743,18 @@ $(function() {
     }
 
     var $seenUserList = $("#seen-user-list");
-    var seenUsers = $seenUserList.data('seen-users');
-    var seenUsersArray = seenUsers.split(',');
-    if (seenUsers && seenUsersArray.length > 0 && seenUsersArray.length <= 10) {
-      // FIXME: user data cache
-      $.get('/_api/users.list', {user_ids: seenUsers}, function(res) {
-        // ignore unless response has error
-        if (res.ok) {
-          AddToSeenUser(res.users);
-        }
-      });
+    if ($seenUserList && $seenUserList.length > 0) {
+      var seenUsers = $seenUserList.data('seen-users');
+      var seenUsersArray = seenUsers.split(',');
+      if (seenUsers && seenUsersArray.length > 0 && seenUsersArray.length <= 10) {
+        // FIXME: user data cache
+        $.get('/_api/users.list', {user_ids: seenUsers}, function(res) {
+          // ignore unless response has error
+          if (res.ok) {
+            AddToSeenUser(res.users);
+          }
+        });
+      }
     }
 
     function CreateUserLinkWithPicture (user) {
@@ -779,6 +819,11 @@ $(function() {
       } else {
         var revisionIds = revisionId + ',' + beforeRevisionId;
 
+        if ($diffDisplay.data('loaded')) {
+          $diffDisplay.slideToggle();
+          return true;
+        }
+
         $.ajax({
           type: 'GET',
           url: '/_api/revisions.list?revision_ids=' + revisionIds,
@@ -798,16 +843,19 @@ $(function() {
             $diffDisplay.append($span);
           });
 
+          $diffDisplay.data('loaded', 1);
           $diffDisplay.slideToggle();
         });
       }
     });
 
     // default open
-    $('.diff-view').each(function(i, diffView) {
-      if (i < 2) {
-        $(diffView).click();
-      }
+    $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
+      $('.diff-view').each(function(i, diffView) {
+        if (i < 2) {
+          $(diffView).click();
+        }
+      });
     });
 
     // presentation

+ 59 - 34
test/models/page.test.js

@@ -93,41 +93,66 @@ describe('Page', function () {
     });
   });
 
-  describe('.isCreatableName', function() {
+  describe('.getDeletedPageName', function() {
+    it('should return trash page name', function() {
+      expect(Page.getDeletedPageName('/hoge')).to.be.equal('/trash/hoge');
+      expect(Page.getDeletedPageName('hoge')).to.be.equal('/trash/hoge');
+    });
+  });
+  describe('.getRevertDeletedPageName', function() {
+    it('should return reverted trash page name', function() {
+      expect(Page.getRevertDeletedPageName('/hoge')).to.be.equal('/hoge');
+      expect(Page.getRevertDeletedPageName('/trash/hoge')).to.be.equal('/hoge');
+      expect(Page.getRevertDeletedPageName('/trash/hoge/trash')).to.be.equal('/hoge/trash');
+    });
+  });
 
-    expect(Page.isCreatableName('/hoge')).to.be.true;
-
-    // edge cases
-    expect(Page.isCreatableName('/me')).to.be.false;
-    expect(Page.isCreatableName('/me/')).to.be.false;
-    expect(Page.isCreatableName('/me/x')).to.be.false;
-    expect(Page.isCreatableName('/meeting')).to.be.true;
-    expect(Page.isCreatableName('/meeting/x')).to.be.true;
-
-    // under score
-    expect(Page.isCreatableName('/_')).to.be.false;
-    expect(Page.isCreatableName('/_r/x')).to.be.false;
-    expect(Page.isCreatableName('/_api')).to.be.false;
-    expect(Page.isCreatableName('/_apix')).to.be.false;
-    expect(Page.isCreatableName('/_api/x')).to.be.false;
-
-    expect(Page.isCreatableName('/hoge/xx.md')).to.be.false;
-
-
-    var forbidden = ['installer', 'register', 'login', 'logout', 'admin', 'files', 'trash', 'paste', 'comments'];
-    for (var i = 0; i < forbidden.length ; i++) {
-      var pn = forbidden[i];
-      expect(Page.isCreatableName('/' + pn + '')).to.be.false;
-      expect(Page.isCreatableName('/' + pn + '/')).to.be.false;
-      expect(Page.isCreatableName('/' + pn + '/abc')).to.be.false;
-    }
-
-    var forbidden = ['bookmarks', 'comments', 'activities', 'pages', 'recent-create', 'recent-edit'];
-    for (var i = 0; i < forbidden.length ; i++) {
-      var pn = forbidden[i];
-      expect(Page.isCreatableName('/user/aoi/' + pn)).to.be.false;
-      expect(Page.isCreatableName('/user/aoi/x/' + pn)).to.be.true;
-    }
+  describe('.isDeletableName', function() {
+    it('should decide deletable or not', function() {
+      expect(Page.isDeletableName('/hoge')).to.be.true;
+      expect(Page.isDeletableName('/user/xxx')).to.be.false;
+      expect(Page.isDeletableName('/user/xxx123')).to.be.false;
+      expect(Page.isDeletableName('/user/xxx/')).to.be.true;
+      expect(Page.isDeletableName('/user/xxx/hoge')).to.be.true;
+    });
+  });
+
+  describe('.isCreatableName', function() {
+    it('should decide creatable or not', function() {
+      expect(Page.isCreatableName('/hoge')).to.be.true;
+
+      // edge cases
+      expect(Page.isCreatableName('/me')).to.be.false;
+      expect(Page.isCreatableName('/me/')).to.be.false;
+      expect(Page.isCreatableName('/me/x')).to.be.false;
+      expect(Page.isCreatableName('/meeting')).to.be.true;
+      expect(Page.isCreatableName('/meeting/x')).to.be.true;
+
+      // under score
+      expect(Page.isCreatableName('/_')).to.be.false;
+      expect(Page.isCreatableName('/_r/x')).to.be.false;
+      expect(Page.isCreatableName('/_api')).to.be.false;
+      expect(Page.isCreatableName('/_apix')).to.be.false;
+      expect(Page.isCreatableName('/_api/x')).to.be.false;
+
+      expect(Page.isCreatableName('/hoge/xx.md')).to.be.false;
+
+
+      var forbidden = ['installer', 'register', 'login', 'logout', 'admin', 'files', 'trash', 'paste', 'comments'];
+      for (var i = 0; i < forbidden.length ; i++) {
+        var pn = forbidden[i];
+        expect(Page.isCreatableName('/' + pn + '')).to.be.false;
+        expect(Page.isCreatableName('/' + pn + '/')).to.be.false;
+        expect(Page.isCreatableName('/' + pn + '/abc')).to.be.false;
+      }
+
+      var forbidden = ['bookmarks', 'comments', 'activities', 'pages', 'recent-create', 'recent-edit'];
+      for (var i = 0; i < forbidden.length ; i++) {
+        var pn = forbidden[i];
+        expect(Page.isCreatableName('/user/aoi/' + pn)).to.be.false;
+        expect(Page.isCreatableName('/user/aoi/x/' + pn)).to.be.true;
+      }
+    });
   });
 
   describe('.isCreator', function() {