Sotaro KARASAWA 9 ani în urmă
părinte
comite
9fd88ba96e

+ 71 - 4
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_DELEDED    = 'deleted'
+    , STATUS_DEPRECATED = 'deprecated'
+
     , pageEvent = crowi.event('page')
 
     , pageSchema;
@@ -24,6 +29,7 @@ module.exports = function(crowi) {
     path: { type: String, required: true, index: 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 },
@@ -54,6 +60,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_DELEDED;
+  };
+
+  pageSchema.methods.isDeprecated = function() {
+    return this.status === STATUS_DEPRECATED;
+  };
+
   pageSchema.methods.isPublic = function() {
     if (!this.grant || this.grant == GRANT_PUBLIC) {
       return true;
@@ -322,6 +345,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 = [
       /\^|\$|\*|\+|\#/,
@@ -694,6 +743,7 @@ module.exports = function(crowi) {
           newPage.updatedAt = Date.now();
           newPage.redirectTo = redirectTo;
           newPage.grant = grant;
+          newPage.status = STATUS_PUBLISHED;
           newPage.grantedUsers = [];
           newPage.grantedUsers.push(user);
 
@@ -743,6 +793,27 @@ 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_DELEDED})
+        .then(function(pageData) {
+          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) {
+  };
+
   pageSchema.statics.rename = function(pageData, newPagePath, user, options) {
     var Page = this
       , Revision = crowi.model('Revision')
@@ -772,10 +843,6 @@ module.exports = function(crowi) {
     });
   };
 
-  pageSchema.statics.deletePage = function(pageData, options) {
-    var Page = this,
-  };
-
   pageSchema.statics.getHistories = function() {
     // TODO
     return;

+ 20 - 10
lib/routes/page.js

@@ -238,18 +238,19 @@ 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);
 
+      // pageShow は /* にマッチしてる最後の砦なので、creatableName でない routing は
+      // これ以前に定義されているはずなので、こうしてしまって問題ない。
+      if (!Page.isCreatableName(path) && page.isDeleted()) {
+        // 削除済みページの場合 /trash 以下に移動しているので creatableName になっていないので、表示を許可
+        debug('Page is not creatable name.', path);
+        res.redirect('/');
+        return ;
+      }
+
       if (isMarkdown) {
         res.set('Content-Type', 'text/plain');
         return res.send(page.revision.body);
@@ -300,6 +301,7 @@ module.exports = function(crowi, app) {
     // set to render
     res.locals.pageForm = pageForm;
 
+    // 削除済みページはここで編集不可判定される
     if (!Page.isCreatableName(path)) {
       res.redirect(redirectPath);
       return ;
@@ -597,10 +599,18 @@ module.exports = function(crowi, app) {
     var pageId = req.body.page_id;
     var previousRevision = req.body.revision_id || null;
 
-    Page.findPageByIdAndGrantedUser(id, req.user)
+    Page.findPageByIdAndGrantedUser(pageId, req.user)
     .then(function(pageData) {
-      return Page.removePage(pageData);
+      return Page.deletePage(pageData, req.user);
     }).then(function(data) {
+      debug('Page deleted', data);
+      var result = {};
+      result.page = pageData;
+
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      debug('Error occured while get setting', err);
+      return res.json(ApiResponse.error('Failed to delete page.'));
     });
   };
 

+ 4 - 0
lib/util/search.js

@@ -46,6 +46,10 @@ SearchClient.prototype.shouldIndexed = function(page) {
     return false;
   }
 
+  if (page.isDeleted()) {
+    return false;
+  }
+
   return true;
 };
 

+ 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> #}

+ 4 - 4
lib/views/modal/widget_delete.html

@@ -2,15 +2,15 @@
     <div class="modal-dialog">
       <div class="modal-content">
 
-      <form role="form" id="deletePageForm" onsubmit="return false;">
+      <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">Delete Page</h4>
+          <h4 class="modal-title"><i class="fa fa-trash-o"></i> Delete Page</h4>
         </div>
         <div class="modal-body">
           <ul>
-           <li>You can’t undo this action.</li>
+           <li>This page will be moved to the trash.</li>
           </ul>
             <div class="form-group">
               <label for="">This page:</label><br>
@@ -18,7 +18,7 @@
             </div>
         </div>
         <div class="modal-footer">
-          <p><small class="pull-left" id="newPageNameCheck"></small></p>
+          <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() }}">

+ 14 - 1
lib/views/page.html

@@ -56,6 +56,15 @@
 
   <ul class="nav nav-tabs hidden-print">
 
+  {% if page.isDeleted() %}
+    <li class="">
+      <a href="#revision-body" data-toggle="tab">
+        <i class="fa fa-trash-o" aria-hidden="true"></i> This page is in the trash.
+      </a>
+    </li>
+  {% endif %}
+
+  {% if not page.isDeleted() %}
     <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>
@@ -83,13 +92,15 @@
     {% if page %}
     <li class="pull-right"><a href="#revision-history" data-toggle="tab"><i class="fa fa-history"></i> History</a></li>
     {% endif %}
+
+  {% endif %}
   </ul>
 
   {% 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>
@@ -120,8 +131,10 @@
     </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' %}
+    {% endif %}
     </div>
 
     {# raw revision history #}

+ 21 - 0
resource/js/crowi.js

@@ -298,6 +298,7 @@ $(function() {
     return false;
   });
 
+  // rename
   $('#renamePage').on('shown.bs.modal', function (e) {
     $('#newPageName').focus();
   });
@@ -327,6 +328,26 @@ $(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;
+  });
+
   $('#create-portal-button').on('click', function(e) {
     $('.portal').removeClass('hide');
     $('.content-main').addClass('on-edit');

+ 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() {