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

Merge branch 'feature-search' of github.com:crowi/crowi into feature-search

Sotaro KARASAWA 10 лет назад
Родитель
Сommit
06f45b8e84
67 измененных файлов с 3761 добавлено и 1373 удалено
  1. 1 0
      .gitignore
  2. 8 1
      README.md
  3. 1 0
      gulpfile.js
  4. 45 7
      lib/crowi/index.js
  5. 34 0
      lib/events/user.js
  6. 11 0
      lib/form/comment.js
  7. 1 0
      lib/form/index.js
  8. 4 2
      lib/form/revision.js
  9. 9 0
      lib/models/attachment.js
  10. 103 24
      lib/models/bookmark.js
  11. 121 0
      lib/models/comment.js
  12. 6 3
      lib/models/config.js
  13. 3 0
      lib/models/index.js
  14. 426 196
      lib/models/page.js
  15. 61 15
      lib/models/revision.js
  16. 65 8
      lib/models/user.js
  17. 9 18
      lib/routes/attachment.js
  18. 89 0
      lib/routes/bookmark.js
  19. 75 0
      lib/routes/comment.js
  20. 21 6
      lib/routes/index.js
  21. 1 1
      lib/routes/me.js
  22. 379 192
      lib/routes/page.js
  23. 52 0
      lib/routes/revision.js
  24. 32 7
      lib/routes/user.js
  25. 29 0
      lib/util/apiResponse.js
  26. 4 61
      lib/util/fileUploader.js
  27. 34 2
      lib/util/middlewares.js
  28. 8 0
      lib/util/swigFunctions.js
  29. 8 4
      lib/views/_form.html
  30. 5 3
      lib/views/admin/app.html
  31. 5 3
      lib/views/admin/index.html
  32. 5 3
      lib/views/admin/users.html
  33. 6 14
      lib/views/layout/2column.html
  34. 1 1
      lib/views/layout/layout.html
  35. 1 1
      lib/views/modal/widget_help.html
  36. 9 4
      lib/views/modal/widget_rename.html
  37. 57 0
      lib/views/modal/widget_what_is_portal.html
  38. 78 207
      lib/views/page.html
  39. 155 25
      lib/views/page_list.html
  40. 32 0
      lib/views/user/bookmarks.html
  41. 32 0
      lib/views/user/recent-create.html
  42. 77 0
      lib/views/user_page.html
  43. 46 0
      lib/views/widget/page_list.html
  44. 42 0
      lib/views/widget/page_side_content.html
  45. 55 0
      lib/views/widget/page_side_header.html
  46. 0 100
      lib/views/widget/searcher.html
  47. 68 0
      local_modules/crowi-fileupload-aws/index.js
  48. 61 0
      local_modules/crowi-fileupload-local/index.js
  49. 25 0
      local_modules/crowi-fileupload-none/index.js
  50. 4 1
      package.json
  51. 1 0
      public/js/reveal.js
  52. 90 0
      resource/css/_comment.scss
  53. 138 4
      resource/css/_form.scss
  54. 31 416
      resource/css/_layout.scss
  55. 301 0
      resource/css/_page.scss
  56. 56 0
      resource/css/_page_list.scss
  57. 36 0
      resource/css/_portal.scss
  58. 11 0
      resource/css/_search.scss
  59. 60 0
      resource/css/_user.scss
  60. 2 2
      resource/css/_wiki.scss
  61. 18 11
      resource/css/crowi.scss
  62. 32 2
      resource/js/crowi-form.js
  63. 464 11
      resource/js/crowi.js
  64. 79 11
      resource/search/mappings.json
  65. 7 2
      test/crowi/crowi.test.js
  66. 25 2
      test/models/user.test.js
  67. 6 3
      test/utils.js

+ 1 - 0
.gitignore

@@ -3,3 +3,4 @@ node_modules/
 bower_components/
 public/js/*
 public/css/*
+public/uploads/*

+ 8 - 1
README.md

@@ -27,14 +27,19 @@ Install dependencies and build CSS and JavaScript:
 
 More info are [here](https://github.com/crowi/crowi/wiki/Install-and-Configuration).
 
+### WARNING
+
+Don't use `master` branch because it is unstable but use released tag version expect when you want to contribute the project.
+
 
 Dependencies
 -------------
 
 * Node.js (4.2.x)
 * MongoDB
+* Elasticsearch (optional)
 * Redis (optional)
-* Amazon S3
+* Amazon S3 (optional)
 * Facebook Application (optional)
 * Google Project (optional)
 
@@ -55,8 +60,10 @@ $ PASSWORD_SEED=somesecretstring MONGO_URI=mongodb://username:password@localhost
 * `NODE_ENV`: `production` OR `development`.
 * `MONGO_URI`: URI to connect MongoDB. This parameter is also by `MONGOHQ_URL` OR `MONGOLAB_URI`.
 * `REDIS_URL`: URI to connect Redis (to session store). This parameter is also by `REDISTOGO_URL`.
+* `ELASTICSEARCH_URI`: URI to connect Elasticearch.
 * `PASSWORD_SEED`: A password seed is used by password hash generator.
 * `SECRET_TOKEN`: A secret key for verifying the integrity of signed cookies.
+* `FILE_UPLOAD`: `aws` (default), `local`, `none`
 
 
 License

+ 1 - 0
gulpfile.js

@@ -47,6 +47,7 @@ var js = {
     'node_modules/inline-attachment/src/inline-attachment.js',
     'node_modules/socket.io-client/socket.io.js',
     'node_modules/jquery.cookie/jquery.cookie.js',
+    'node_modules/diff/dist/diff.js',
     'resource/thirdparty-js/jquery.selection.js',
     dirs.jsDist + '/crowi-bundled.js',
   ],

+ 45 - 7
lib/crowi/index.js

@@ -18,23 +18,30 @@ function Crowi (rootdir, env)
 
   this.version = pkg.version;
 
-  this.rootDir     = rootdir;
-  this.pluginDir   = path.join(this.rootDir, 'node_modules') + sep;
-  this.publicDir   = path.join(this.rootDir, 'public') + sep;
-  this.libDir      = path.join(this.rootDir, 'lib') + sep;
+  this.rootDir   = rootdir;
+  this.pluginDir = path.join(this.rootDir, 'node_modules') + sep;
+  this.publicDir = path.join(this.rootDir, 'public') + sep;
+  this.libDir    = path.join(this.rootDir, 'lib') + sep;
+  this.eventsDir = path.join(this.libDir, 'events') + sep;
   this.resourceDir = path.join(this.rootDir, 'resource') + sep;
-  this.viewsDir    = path.join(this.libDir, 'views') + sep;
-  this.mailDir     = path.join(this.viewsDir, 'mail') + sep;
+  this.viewsDir  = path.join(this.libDir, 'views') + sep;
+  this.mailDir   = path.join(this.viewsDir, 'mail') + sep;
 
   this.config = {};
+  this.searcher = {};
   this.mailer = {};
 
+
   this.models = {};
 
   this.env = env;
   this.node_env = this.env.NODE_ENV || 'development';
   this.port = this.env.PORT || 3000;
 
+  this.events = {
+    user: new (require(self.eventsDir + 'user'))(this),
+  };
+
   if (this.node_env == 'development') {
     Promise.longStackTraces();
   }
@@ -67,6 +74,8 @@ Crowi.prototype.init = function() {
         return resolve();
       });
     });
+  }).then(function() {
+    return self.setupSearcher();
   }).then(function() {
     return self.setupMailer();
   });
@@ -90,9 +99,19 @@ Crowi.prototype.model = function(name, model) {
   return this.models[name];
 };
 
+// getter/setter of event instance
+Crowi.prototype.event = function(name, event) {
+  if (event) {
+    return this.events[name] = event;
+  }
+
+  return this.events[name];
+};
+
 Crowi.prototype.setupDatabase = function() {
   // mongoUri = mongodb://user:password@host/dbname
-  var mongoUri = this.env.MONGOLAB_URI ||
+  var mongoUri = this.env.MONGOLAB_URI || // for B.C.
+    this.env.MONGODB_URI || // MONGOLAB changes their env name
     this.env.MONGOHQ_URL ||
     this.env.MONGO_URI ||
     'mongodb://localhost/crowi'
@@ -101,6 +120,7 @@ Crowi.prototype.setupDatabase = function() {
   return new Promise(function(resolve, reject) {
     mongoose.connect(mongoUri, function(e) {
       if (e) {
+        debug('DB Connect Error: ', e);
         debug('DB Connect Error: ', mongoUri);
         return reject(new Error('Cann\'t connect to Database Server.'));
       }
@@ -165,10 +185,28 @@ Crowi.prototype.getIo = function() {
   return this.io;
 };
 
+Crowi.prototype.getSearcher = function() {
+  return this.searcher;
+};
+
 Crowi.prototype.getMailer = function() {
   return this.mailer;
 };
 
+Crowi.prototype.setupSearcher = function() {
+  var self = this;
+  var searcherUri = this.env.ELASTICSEARCH_URI
+    || null
+    ;
+
+  return new Promise(function(resolve, reject) {
+    if (searcherUri) {
+      self.searcher = require('../util/searcher')(self);
+    }
+    resolve();
+  });
+};
+
 Crowi.prototype.setupMailer = function() {
   var self = this;
   return new Promise(function(resolve, reject) {

+ 34 - 0
lib/events/user.js

@@ -0,0 +1,34 @@
+var debug = require('debug')('crowi:events:user');
+var util = require('util');
+var events = require('events');
+var sprintf = require('sprintf');
+
+function UserEvent(crowi) {
+  this.crowi = crowi;
+
+  events.EventEmitter.call(this);
+}
+util.inherits(UserEvent, events.EventEmitter);
+
+UserEvent.prototype.onActivated = function(user) {
+  var User = this.crowi.model('User');
+  var Page = this.crowi.model('Page');
+
+  var userPagePath = Page.getUserPagePath(user);
+  Page.findPage(userPagePath, user, {}, false)
+  .then(function(page) {
+    // do nothing because user page is already exists.
+  }).catch(function(err) {
+    var body = sprintf('# %s\nThis is %s\'s page', user.username, user.username)
+    // create user page
+    Page.create(userPagePath, body, user, {})
+    .then(function(page) {
+      // page created
+      debug('User page created', page);
+    }).catch(function(err) {
+      debug('Failed to create user page', err);
+    });
+  });
+};
+
+module.exports = UserEvent;

+ 11 - 0
lib/form/comment.js

@@ -0,0 +1,11 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field;
+
+module.exports = form(
+  field('commentForm.page_id').trim().required(),
+  field('commentForm.revision_id').trim().required(),
+  field('commentForm.comment').trim().required(),
+  field('commentForm.comment_position').trim().toInt()
+);

+ 1 - 0
lib/form/index.js

@@ -2,6 +2,7 @@ exports.login = require('./login');
 exports.register = require('./register');
 exports.invited = require('./invited');
 exports.revision = require('./revision');
+exports.comment = require('./comment');
 exports.me = {
   user: require('./me/user'),
   password: require('./me/password'),

+ 4 - 2
lib/form/revision.js

@@ -4,6 +4,8 @@ var form = require('express-form')
   , field = form.field;
 
 module.exports = form(
-  field('pageForm.body').required()
-  //field('pageForm.hoge').required()
+  field('pageForm.path').required(),
+  field('pageForm.body').required().custom(function(value) { return value.replace(/\r/g, '\n'); }),
+  field('pageForm.currentRevision'),
+  field('pageForm.grant').toInt().required()
 );

+ 9 - 0
lib/models/attachment.js

@@ -3,6 +3,7 @@ module.exports = function(crowi) {
     , mongoose = require('mongoose')
     , ObjectId = mongoose.Schema.Types.ObjectId
     , Promise = require('bluebird')
+    , fileUploader = require('../util/fileUploader')(crowi)
   ;
 
   function generateFileHash (fileName) {
@@ -21,6 +22,14 @@ module.exports = function(crowi) {
     fileFormat: { type: String, required: true },
     fileSize: { type: Number, default: 0 },
     createdAt: { type: Date, default: Date.now }
+  }, {
+    toJSON: {
+      virtuals: true
+    }
+  });
+
+  attachmentSchema.virtual('fileUrl').get(function() {
+    return fileUploader.generateUrl(this.filePath);
   });
 
   attachmentSchema.statics.getListByPageId = function(id) {

+ 103 - 24
lib/models/bookmark.js

@@ -10,46 +10,125 @@ module.exports = function(crowi) {
     user: { type: ObjectId, ref: 'User', index: true },
     createdAt: { type: Date, default: Date.now() }
   });
+  bookmarkSchema.index({page: 1, user: 1}, {unique: true});
+
+  bookmarkSchema.statics.populatePage = function(bookmarks, requestUser) {
+    var Bookmark = this;
+    var User = crowi.model('User');
+    var Page = crowi.model('Page');
+
+    requestUser = requestUser || null;
+
+    // mongoose promise に置き換えてみたものの、こいつは not native promise but original promise だったので
+    // これ以上は置き換えないことにする ...
+    // @see http://eddywashere.com/blog/switching-out-callbacks-with-promises-in-mongoose/
+    return Bookmark.populate(bookmarks, {path: 'page'})
+      .then(function(bookmarks) {
+        return Bookmark.populate(bookmarks, {path: 'page.revision', model: 'Revision'});
+      }).then(function(bookmarks) {
+        // hmm...
+        bookmarks = bookmarks.filter(function(bookmark) {
+          // requestUser を指定しない場合 public のみを返す
+          if (requestUser === null) {
+            return bookmark.page.isPublic();
+          }
+
+          return bookmark.page.isGrantedFor(requestUser);
+        });
+
+        return Bookmark.populate(bookmarks, {path: 'page.revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS});
+      });
+  };
 
   // bookmark チェック用
-  bookmarkSchema.statics.findByPageIdAndUser = function(pageId, user, callback) {
+  bookmarkSchema.statics.findByPageIdAndUserId = function(pageId, userId) {
     var Bookmark = this;
 
-    Bookmark.findOne({ page: pageId, user: user._id }, callback);
+    return new Promise(function(resolve, reject) {
+      return Bookmark.findOne({ page: pageId, user: userId }, function(err, doc) {
+        if (err) {
+          return reject(err);
+        }
+
+        return resolve(doc);
+      });
+    });
   };
 
-  bookmarkSchema.statics.findByUser = function(user, option, callback) {
+  /**
+   * option = {
+   *  limit: Int
+   *  offset: Int
+   *  requestUser: User
+   * }
+   */
+  bookmarkSchema.statics.findByUser = function(user, option) {
+    var User = crowi.model('User');
     var Bookmark = this;
+    var requestUser = option.requestUser || null;
+
+    debug('Finding bookmark with requesting user:', requestUser);
 
     var limit = option.limit || 50;
-    var offset = option.skip || 0;
-
-    Bookmark
-      .find({ user: user._id })
-      //.sort('createdAt', -1)
-      .skip(offset)
-      .limit(limit)
-      .exec(function(err, bookmarks) {
-        debug ('bookmarks', bookmarks);
-        callback(err, bookmarks);
-      });
+    var offset = option.offset || 0;
+    var populatePage = option.populatePage || false;
+
+    return new Promise(function(resolve, reject) {
+      Bookmark
+        .find({ user: user._id })
+        .sort({createdAt: -1})
+        .skip(offset)
+        .limit(limit)
+        .exec(function(err, bookmarks) {
+          if (err) {
+            return reject(err);
+          }
+
+          if (!populatePage) {
+            return resolve(bookmarks);
+          }
+
+          return Bookmark.populatePage(bookmarks, requestUser).then(resolve);
+        });
+    });
   };
 
-  bookmarkSchema.statics.add = function(page, user, callback) {
+  bookmarkSchema.statics.add = function(page, user) {
     var Bookmark = this;
 
-    Bookmark.findOneAndUpdate(
-      { page: page._id, user: user._id },
-      { page: page._id, user: user._id, createdAt: Date.now() },
-      { upsert: true, },
-      function (err, bookmark) {
-        debug('Bookmark.findOneAndUpdate', err, bookmark);
-        callback(err, bookmark);
+    return new Promise(function(resolve, reject) {
+      var newBookmark = new Bookmark;
+
+      newBookmark.page = page;
+      newBookmark.user = user;
+      newBookmark.createdAt = Date.now();
+      newBookmark.save(function(err, bookmark) {
+        debug('Bookmark.save', err, bookmark);
+        if (err) {
+          if (err.code === 11000) { // duplicate key (dummy reesponse of new object)
+            return resolve(newBookmark);
+          }
+          return reject(err);
+        }
+
+        resolve(bookmark);
+      });
     });
   };
 
-  bookmarkSchema.statics.remove = function(page, user, callback) {
-    // To be implemented ...
+  bookmarkSchema.statics.remove = function(page, user) {
+    var Bookmark = this;
+
+    return new Promise(function(resolve, reject) {
+      Bookmark.findOneAndRemove({page: page, user: user}, function(err, data) {
+        if (err) {
+          debug('Bookmark.findOneAndRemove failed', err);
+          return reject(err);
+        }
+
+        return resolve(data);
+      });
+    });
   };
 
   return mongoose.model('Bookmark', bookmarkSchema);

+ 121 - 0
lib/models/comment.js

@@ -0,0 +1,121 @@
+module.exports = function(crowi) {
+  var debug = require('debug')('crowi:models:comment')
+    , mongoose = require('mongoose')
+    , ObjectId = mongoose.Schema.Types.ObjectId
+    , USER_PUBLIC_FIELDS = '_id fbId image googleId name username email status createdAt' // TODO: どこか別の場所へ...
+    , commentSchema
+  ;
+
+  commentSchema = new mongoose.Schema({
+    page: { type: ObjectId, ref: 'Page', index: true },
+    creator: { type: ObjectId, ref: 'User', index: true  },
+    revision: { type: ObjectId, ref: 'Revision', index: true },
+    comment: { type: String, required: true },
+    commentPosition: { type: Number, default: -1 },
+    createdAt: { type: Date, default: Date.now }
+  });
+
+  commentSchema.statics.create = function(pageId, creatorId, revisionId, comment, position) {
+    var Comment = this,
+      commentPosition = position || -1;
+
+
+    return new Promise(function(resolve, reject) {
+      var newComment = new Comment();
+
+      newComment.page = pageId;
+      newComment.creator = creatorId;
+      newComment.revision = revisionId;
+      newComment.comment = comment;
+      newComment.commentPosition = position;
+
+      newComment.save(function(err, data) {
+        if (err) {
+          debug('Error on saving comment.', err);
+          return reject(err);
+        }
+        debug('Comment saved.', data);
+        return resolve(data);
+      });
+    });
+  };
+
+  commentSchema.statics.getCommentsByPageId = function(id) {
+    var self = this;
+
+    return new Promise(function(resolve, reject) {
+      self
+        .find({page: id})
+        .sort({'createdAt': -1})
+        .populate('creator', USER_PUBLIC_FIELDS)
+        .exec(function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+
+          if (data.length < 1) {
+            return resolve([]);
+          }
+
+          //debug('Comment loaded', data);
+          return resolve(data);
+        });
+    });
+  };
+
+  commentSchema.statics.getCommentsByRevisionId = function(id) {
+    var self = this;
+
+    return new Promise(function(resolve, reject) {
+      self
+        .find({revision: id})
+        .sort({'createdAt': -1})
+        .populate('creator', USER_PUBLIC_FIELDS)
+        .exec(function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+
+          if (data.length < 1) {
+            return resolve([]);
+          }
+
+          debug('Comment loaded', data);
+          return resolve(data);
+        });
+    });
+  };
+
+  commentSchema.statics.countCommentByPageId = function(page) {
+    var self = this;
+
+    return new Promise(function(resolve, reject) {
+      self.count({page: page}, function(err, data) {
+        if (err) {
+          return reject(err);
+        }
+
+        return resolve(data);
+      });
+    });
+  };
+
+  /**
+   * post save hook
+   */
+  commentSchema.post('save', function(savedComment) {
+    var Page = crowi.model('Page')
+      , Comment = crowi.model('Comment')
+    ;
+
+    Comment.countCommentByPageId(savedComment.page)
+    .then(function(count) {
+      return Page.updateCommentCount(savedComment.page, count);
+    }).then(function(page) {
+      debug('CommentCount Updated', page);
+    }).catch(function() {
+    });
+  });
+
+  return mongoose.model('Comment', commentSchema);
+};

+ 6 - 3
lib/models/config.js

@@ -166,14 +166,17 @@ module.exports = function(crowi) {
 
   configSchema.statics.isUploadable = function(config)
   {
-    if (!config.crowi['aws:accessKeyId'] ||
+    var method = crowi.env.FILE_UPLOAD || 'aws';
+
+    if (method == 'aws' && (
+        !config.crowi['aws:accessKeyId'] ||
         !config.crowi['aws:secretAccessKey'] ||
         !config.crowi['aws:region'] ||
-        !config.crowi['aws:bucket']) {
+        !config.crowi['aws:bucket'])) {
       return false;
     }
 
-    return true;
+    return method != 'none';
   };
 
   /*

+ 3 - 0
lib/models/index.js

@@ -1,7 +1,10 @@
+'use strict';
+
 module.exports = {
   Page: require('./page'),
   User: require('./user'),
   Revision: require('./revision'),
   Bookmark: require('./bookmark'),
+  Comment: require('./comment'),
   Attachment: require('./attachment'),
 };

+ 426 - 196
lib/models/page.js

@@ -9,24 +9,12 @@ module.exports = function(crowi) {
     , PAGE_GRANT_ERROR = 1
     , pageSchema;
 
-  function populatePageData(pageData, revisionId, callback) {
-    var Page = crowi.model('Page');
-
-    pageData.latestRevision = pageData.revision;
-    if (revisionId) {
-      pageData.revision = revisionId;
+  function isPortalPath(path) {
+    if (path.match(/.*\/$/)) {
+      return true;
     }
-    pageData.likerCount = pageData.liker.length || 0;
-    pageData.seenUsersCount = pageData.seenUsers.length || 0;
 
-    pageData.populate([
-      {path: 'creator', model: 'User'},
-      {path: 'revision', model: 'Revision'},
-      {path: 'liker', options: { limit: 11 }},
-      {path: 'seenUsers', options: { limit: 11 }},
-    ], function (err, pageData) {
-      Page.populate(pageData, {path: 'revision.author', model: 'User'}, callback);
-    });
+    return false;
   }
 
   pageSchema = new mongoose.Schema({
@@ -38,6 +26,7 @@ module.exports = function(crowi) {
     creator: { 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 },
     createdAt: { type: Date, default: Date.now },
     updatedAt: Date
   });
@@ -50,6 +39,10 @@ module.exports = function(crowi) {
     return false;
   };
 
+  pageSchema.methods.isPortal = function() {
+    return isPortalPath(this.path);
+  };
+
   pageSchema.methods.isCreator = function(userData) {
     if (this.populated('creator') && this.creator._id.toString() === userData._id.toString()) {
       return true;
@@ -90,48 +83,53 @@ module.exports = function(crowi) {
   };
 
   pageSchema.methods.isLiked = function(userData) {
-    if (undefined === this.populated('liker')) {
-      if (this.liker.indexOf(userData._id.toString()) != -1) {
-        return true;
-      }
-      return true;
-    } else {
-      return this.liker.some(function(likedUser) {
-        return likedUser._id.equals(userData._id);
-      });
-    }
+    return this.liker.some(function(likedUser) {
+      return likedUser == userData._id.toString();
+    });
   };
 
-  pageSchema.methods.like = function(userData, callback) {
+  pageSchema.methods.like = function(userData) {
     var self = this,
       Page = self;
 
-    var added = this.liker.addToSet(userData._id);
-    if (added.length > 0) {
-      this.save(function(err, data) {
-        debug('liker updated!', added);
-        return callback(err, data);
-      });
-    } else {
-      debug('liker not updated');
-      return callback(null, this);
-    }
+    return new Promise(function(resolve, reject) {
+      var added = self.liker.addToSet(userData._id);
+      if (added.length > 0) {
+        self.save(function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+          debug('liker updated!', added);
+          return resolve(data);
+        });
+      } else {
+        debug('liker not updated');
+        return reject(self);
+      }
+    });
+
   };
 
   pageSchema.methods.unlike = function(userData, callback) {
     var self = this,
       Page = self;
 
-    var removed = this.liker.pull(userData._id);
-    if (removed.length > 0) {
-      this.save(function(err, data) {
-        debug('unlike updated!', removed);
-        return callback(err, data);
-      });
-    } else {
-      debug('unlike not updated');
-      callback(null, this);
-    }
+    return new Promise(function(resolve, reject) {
+      var beforeCount = self.liker.length;
+      self.liker.pull(userData._id);
+      if (self.liker.length != beforeCount) {
+        self.save(function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+          return resolve(data);
+        });
+      } else {
+        debug('liker not updated');
+        return reject(self);
+      }
+    });
+
   };
 
   pageSchema.methods.isSeenUser = function(userData) {
@@ -139,27 +137,119 @@ module.exports = function(crowi) {
       Page = self;
 
     return this.seenUsers.some(function(seenUser) {
-      return seenUser._id.equals(userData._id);
+      return seenUser.equals(userData._id);
     });
   };
 
-  pageSchema.methods.seen = function(userData, callback) {
+  pageSchema.methods.seen = function(userData) {
     var self = this,
       Page = self;
 
-    if (!userData || !userData._id) {
-      callback(new Error('User data is not valid'), null);
-    }
-
     if (this.isSeenUser(userData)) {
       debug('seenUsers not updated');
-      return callback(null, self);
+      return Promise.resolve(this);
     }
 
-    var added = self.seenUsers.addToSet(userData);
-    this.save(function(err, data) {
-      debug('seenUsers updated!', added);
-      return callback(err, self);
+    return new Promise(function(resolve, reject) {
+      if (!userData || !userData._id) {
+        reject(new Error('User data is not valid'));
+      }
+
+      var added = self.seenUsers.addToSet(userData);
+      self.save(function(err, data) {
+        if (err) {
+          return reject(err);
+        }
+
+        debug('seenUsers updated!', added);
+        return resolve(self);
+      });
+    });
+  };
+
+  pageSchema.statics.populatePageData = function(pageData, revisionId) {
+    var Page = crowi.model('Page');
+    var User = crowi.model('User');
+
+    pageData.latestRevision = pageData.revision;
+    if (revisionId) {
+      pageData.revision = revisionId;
+    }
+    pageData.likerCount = pageData.liker.length || 0;
+    pageData.seenUsersCount = pageData.seenUsers.length || 0;
+
+    return new Promise(function(resolve, reject) {
+      pageData.populate([
+        {path: 'creator', model: 'User', select: User.USER_PUBLIC_FIELDS},
+        {path: 'revision', model: 'Revision'},
+        //{path: 'liker', options: { limit: 11 }},
+        //{path: 'seenUsers', options: { limit: 11 }},
+      ], function (err, pageData) {
+        Page.populate(pageData, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+
+          return resolve(data);
+        });
+      });
+    });
+  };
+
+  pageSchema.statics.populatePageList = function(pageList) {
+    var Page = self;
+    var User = crowi.model('User');
+
+    return new Promise(function(resolve, reject) {
+      Page.populate(
+        pageList,
+        [
+          {path: 'creator', model: 'User', select: User.USER_PUBLIC_FIELDS},
+          {path: 'revision', model: 'Revision'}
+        ],
+        function(err, pageList) {
+          if (err) {
+            return reject(err);
+          }
+
+          Page.populate(pageList, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
+            if (err) {
+              return reject(err);
+            }
+
+            resolve(data);
+          });
+        }
+      );
+    });
+  };
+
+
+  pageSchema.statics.updateCommentCount = function (page, num)
+  {
+    var self = this;
+
+    return new Promise(function(resolve, reject) {
+      self.update({_id: page}, {commentCount: num}, {}, function(err, data) {
+        if (err) {
+          debug('Update commentCount Error', err);
+          return reject(err);
+        }
+
+        return resolve(data);
+      });
+    });
+  };
+
+  pageSchema.statics.hasPortalPage = function (path, user) {
+    var self = this;
+    return new Promise(function(resolve, reject) {
+      self.findPage(path, user)
+      .then(function(page) {
+        resolve(page);
+      }).catch(function(err) {
+        resolve(null); // check only has portal page, through error
+      });
     });
   };
 
@@ -181,14 +271,20 @@ module.exports = function(crowi) {
     return path;
   };
 
+  pageSchema.statics.getUserPagePath = function(user) {
+    return '/user/' + user.username;
+  };
+
   pageSchema.statics.isCreatableName = function(name) {
     var forbiddenPages = [
       /\^|\$|\*|\+|\#/,
       /^\/_api\/.*/,
       /^\/\-\/.*/,
       /^\/_r\/.*/,
+      /^\/user\/[^\/]+\/(bookmarks|comments|activities|pages|recent-create|recent-edit)/, // reserved
+      /^http:\/\/.+$/, // avoid miss in renaming
       /.+\/edit$/,
-      /\/$/,
+      /.+\.md$/,
       /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments).+/,
     ];
 
@@ -213,7 +309,7 @@ module.exports = function(crowi) {
   pageSchema.statics.findUpdatedList = function(offset, limit, cb) {
     this
       .find({})
-      .sort('updatedAt', -1)
+      .sort({updatedAt: -1})
       .skip(offset)
       .limit(limit)
       .exec(function(err, data) {
@@ -221,70 +317,145 @@ module.exports = function(crowi) {
       });
   };
 
-  pageSchema.statics.findPageById = function(id, cb) {
+  pageSchema.statics.findPageById = function(id) {
     var Page = this;
 
-    Page.findOne({_id: id}, function(err, pageData) {
-      if (pageData === null) {
-        return cb(new Error('Page Not Found'), null);
-      }
+    return new Promise(function(resolve, reject) {
+      Page.findOne({_id: id}, function(err, pageData) {
+        if (err) {
+          return reject(err);
+        }
 
-      return populatePageData(pageData, null, cb);
+        if (pageData == null) {
+          return reject(new Error('Page not found'));
+        }
+        return Page.populatePageData(pageData, null).then(resolve);
+      });
     });
   };
 
-  pageSchema.statics.findListByPageIds = function(ids) {
+  pageSchema.statics.findPageByIdAndGrantedUser = function(id, userData) {
     var Page = this;
 
-    debug('findPageByIds', ids);
-
     return new Promise(function(resolve, reject) {
-      Page.find({
-        _id: {$in: ids},
-      }).populate([
-        {path: 'creator', model: 'User'},
-        {path: 'revision', model: 'Revision'},
-      ]).exec(function(err, docs) {
-        debug('findPagesByIds', err, docs);
+      Page.findPageById(id)
+      .then(function(pageData) {
+        if (userData && !pageData.isGrantedFor(userData)) {
+          return reject(new Error('Page is not granted for the user')); //PAGE_GRANT_ERROR, null);
+        }
 
+        return resolve(pageData);
+      }).catch(function(err) {
+        return reject(err);
+      });
+    });
+  };
+
+  // find page and check if granted user
+  pageSchema.statics.findPage = function(path, userData, revisionId, ignoreNotFound) {
+    var self = this;
+
+    return new Promise(function(resolve, reject) {
+      self.findOne({path: path}, function(err, pageData) {
         if (err) {
           return reject(err);
         }
 
-        return resolve(docs);
+        if (pageData === null) {
+          if (ignoreNotFound) {
+            return resolve(null);
+          }
+
+          var pageNotFoundError = new Error('Page Not Found')
+          pageNotFoundError.name = 'Crowi:Page:NotFound';
+          return reject(pageNotFoundError);
+        }
+
+        if (!pageData.isGrantedFor(userData)) {
+          return reject(new Error('Page is not granted for the user')); //PAGE_GRANT_ERROR, null);
+        }
+
+        self.populatePageData(pageData, revisionId || null).then(resolve).catch(reject);
       });
     });
   };
 
-  pageSchema.statics.findPageByIdAndGrantedUser = function(id, userData, cb) {
+  // find page by path
+  pageSchema.statics.findPageByPath = function(path) {
     var Page = this;
 
-    Page.findPageById(id, function(err, pageData) {
-      if (pageData === null) {
-        return cb(new Error('Page Not Found'), null);
-      }
+    return new Promise(function(resolve, reject) {
+      Page.findOne({path: path}, function(err, pageData) {
+        if (err || pageData === null) {
+          return reject(err);
+        }
 
-      if (userData && !pageData.isGrantedFor(userData)) {
-        return cb(PAGE_GRANT_ERROR, null);
-      }
+        return resolve(pageData);
+      });
+    });
+  };
+
+  pageSchema.statics.findListByPageIds = function(ids, option) {
+    var Page = this;
+    var User = crowi.model('User');
+    var limit = option.limit || 50;
+    var offset = option.skip || 0;
+
+    return new Promise(function(resolve, reject) {
+      Page
+        .find({ _id: { $in: ids }, grant: GRANT_PUBLIC })
+        //.sort({createdAt: -1}) // TODO optionize
+        .skip(offset)
+        .limit(limit)
+        .populate([
+          {path: 'creator', model: 'User'},
+          {path: 'revision', model: 'Revision'},
+        ])
+        .exec(function(err, pages) {
+          if (err) {
+            return reject(err);
+          }
 
-      return cb(null,pageData);
+          Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
+            if (err) {
+              return reject(err);
+            }
+
+            return resolve(data);
+          });
+        });
     });
   };
 
-  pageSchema.statics.findPage = function(path, userData, revisionId, options, cb) {
+  /**
+   * とりあえず、公開ページであり、redirectTo が無いものだけを出すためだけのAPI
+   */
+  pageSchema.statics.findListByCreator = function(user, option) {
     var Page = this;
+    var User = crowi.model('User');
+    var limit = option.limit || 50;
+    var offset = option.offset || 0;
 
-    this.findOne({path: path}, function(err, pageData) {
-      if (pageData === null) {
-        return cb(new Error('Page Not Found'), null);
-      }
+    return new Promise(function(resolve, reject) {
+      Page
+        .find({ creator: user._id, grant: GRANT_PUBLIC, redirectTo: null })
+        .sort({createdAt: -1})
+        .skip(offset)
+        .limit(limit)
+        .populate('revision')
+        .exec(function(err, pages) {
+          if (err) {
+            return reject(err);
+          }
 
-      if (!pageData.isGrantedFor(userData)) {
-        return cb(PAGE_GRANT_ERROR, null);
-      }
+          Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
+            if (err) {
+              return reject(err);
+            }
 
-      return populatePageData(pageData, revisionId, cb);
+            return resolve(data);
+          });
+        });
     });
   };
 
@@ -311,158 +482,217 @@ module.exports = function(crowi) {
       .stream();
   };
 
-  pageSchema.statics.findListByStartWith = function(path, userData, options, cb) {
-    if (!options) {
-      options = {sort: 'updatedAt', desc: -1, offset: 0, limit: 50};
+  /**
+   * findListByStartWith
+   *
+   * If `path` has `/` at the end, returns '{path}/*' and '{path}' self.
+   * If `path` doesn't have `/` at the end, returns '{path}*'
+   * e.g.
+   */
+  pageSchema.statics.findListByStartWith = function(path, userData, option) {
+    var Page = this;
+    var User = crowi.model('User');
+    var pathCondition = [];
+
+    if (!option) {
+      option = {sort: 'updatedAt', desc: -1, offset: 0, limit: 50};
     }
     var opt = {
-      sort: options.sort || 'updatedAt',
-      desc: options.desc || -1,
-      offset: options.offset || 0,
-      limit: options.limit || 50
+      sort: option.sort || 'updatedAt',
+      desc: option.desc || -1,
+      offset: option.offset || 0,
+      limit: option.limit || 50
     };
     var sortOpt = {};
     sortOpt[opt.sort] = opt.desc;
     var queryReg = new RegExp('^' + path);
-    var sliceOption = options.revisionSlice || {$slice: 1};
-
-    var q = this.find({
-        path: queryReg,
-        redirectTo: null,
-        $or: [
-          {grant: null},
-          {grant: GRANT_PUBLIC},
-          {grant: GRANT_RESTRICTED, grantedUsers: userData._id},
-          {grant: GRANT_SPECIFIED, grantedUsers: userData._id},
-          {grant: GRANT_OWNER, grantedUsers: userData._id},
-        ],
-      })
-      .populate('revision')
-      .sort(sortOpt)
-      .skip(opt.offset)
-      .limit(opt.limit);
+    var sliceOption = option.revisionSlice || {$slice: 1};
 
-    q.exec(function(err, data) {
-      cb(err, data);
+    pathCondition.push({path: queryReg});
+    if (path.match(/\/$/)) {
+      debug('Page list by ending with /, so find also upper level page');
+      pathCondition.push({path: path.substr(0, path.length -1)});
+    }
+
+    return new Promise(function(resolve, reject) {
+      // FIXME: might be heavy
+      var q = Page.find({
+          redirectTo: null,
+          $or: [
+            {grant: null},
+            {grant: GRANT_PUBLIC},
+            {grant: GRANT_RESTRICTED, grantedUsers: userData._id},
+            {grant: GRANT_SPECIFIED, grantedUsers: userData._id},
+            {grant: GRANT_OWNER, grantedUsers: userData._id},
+          ],
+        })
+        .populate('revision')
+        .and({
+          $or: pathCondition
+        })
+        .sort(sortOpt)
+        .skip(opt.offset)
+        .limit(opt.limit);
+
+      q.exec()
+      .then(function(pages) {
+        Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS})
+        .then(resolve)
+        .catch(reject);
+      })
     });
   };
 
-  pageSchema.statics.updatePage = function(page, updateData, cb) {
-    // TODO foreach して save
-    this.update({_id: page._id}, {$set: updateData}, function(err, data) {
-      return cb(err, data);
+  pageSchema.statics.updatePage = function(page, updateData) {
+    var Page = this;
+    return new Promise(function(resolve, reject) {
+      // TODO foreach して save
+      Page.update({_id: page._id}, {$set: updateData}, function(err, data) {
+        if (err) {
+          return reject(err);
+        }
+
+        return resolve(data);
+      });
     });
   };
 
-  pageSchema.statics.updateGrant = function(page, grant, userData, cb) {
-    this.update({_id: page._id}, {$set: {grant: grant}}, function(err, data) {
-      if (grant == GRANT_PUBLIC) {
-        page.grantedUsers = [];
-      } else {
-        page.grantedUsers = [];
-        page.grantedUsers.push(userData._id);
-      }
-      page.save(function(err, data) {
-        return cb(err, data);
+  pageSchema.statics.updateGrant = function(page, grant, userData) {
+    var self = this;
+
+    return new Promise(function(resolve, reject) {
+      self.update({_id: page._id}, {$set: {grant: grant}}, function(err, data) {
+        if (err) {
+          return reject(err);
+        }
+
+        if (grant == GRANT_PUBLIC) {
+          page.grantedUsers = [];
+        } else {
+          page.grantedUsers = [];
+          page.grantedUsers.push(userData._id);
+        }
+        page.save(function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+
+          return resolve(data);
+        });
       });
     });
   };
 
   // Instance method でいいのでは
-  pageSchema.statics.pushToGrantedUsers = function(page, userData, cb) {
-    if (!page.grantedUsers || !Array.isArray(page.grantedUsers)) {
-      page.grantedUsers = [];
-    }
-    page.grantedUsers.push(userData._id);
-    page.save(function(err, data) {
-      return cb(err, data);
+  pageSchema.statics.pushToGrantedUsers = function(page, userData) {
+
+    return new Promise(function(resolve, reject) {
+      if (!page.grantedUsers || !Array.isArray(page.grantedUsers)) {
+        page.grantedUsers = [];
+      }
+      page.grantedUsers.push(userData);
+      page.save(function(err, data) {
+        if (err) {
+          return reject(err);
+        }
+        return resolve(data);
+      });
     });
   };
 
-  pageSchema.statics.pushRevision = function(pageData, newRevision, user, cb) {
-    newRevision.save(function(err, newRevision) {
-      if (err) {
-        debug('Error on saving revision', err);
-        return cb(err, null);
-      }
+  pageSchema.statics.pushRevision = function(pageData, newRevision, user) {
 
-      debug('Successfully saved new revision', newRevision);
-      pageData.revision = newRevision._id;
-      pageData.updatedAt = Date.now();
-      pageData.save(function(err, data) {
+    return new Promise(function(resolve, reject) {
+      newRevision.save(function(err, newRevision) {
         if (err) {
-          debug('Error on save page data (after push revision)', err);
-          cb(err, null);
-          return;
+          debug('Error on saving revision', err);
+          return reject(err);
         }
-        cb(err, data);
+
+        debug('Successfully saved new revision', newRevision);
+        pageData.revision = newRevision;
+        pageData.updatedAt = Date.now();
+        pageData.save(function(err, data) {
+          if (err) {
+            // todo: remove new revision?
+            debug('Error on save page data (after push revision)', err);
+            return reject(err);
+          }
+
+          resolve(data);
+        });
       });
     });
   };
 
-  pageSchema.statics.create = function(path, body, user, options, cb) {
+  pageSchema.statics.create = function(path, body, user, options) {
     var Page = this
       , Revision = crowi.model('Revision')
       , format = options.format || 'markdown'
       , grant = options.grant || GRANT_PUBLIC
       , redirectTo = options.redirectTo || null;
 
-    this.findOne({path: path}, function(err, pageData) {
-      if (pageData) {
-        cb(new Error('Cannot create new page to existed path'), null);
-        return;
-      }
+    // force public
+    if (isPortalPath(path)) {
+      grant = GRANT_PUBLIC;
+    }
 
-      var newPage = new Page();
-      newPage.path = path;
-      newPage.creator = user;
-      newPage.createdAt = Date.now();
-      newPage.updatedAt = Date.now();
-      newPage.redirectTo = redirectTo;
-      newPage.grant = grant;
-      newPage.grantedUsers = [];
-      newPage.grantedUsers.push(user);
-      newPage.save(function (err, newPage) {
-
-        var newRevision = Revision.prepareRevision(newPage, body, user, {format: format});
-        Page.pushRevision(newPage, newRevision, user, function(err, data) {
+    return new Promise(function(resolve, reject) {
+      Page.findOne({path: path}, function(err, pageData) {
+        if (pageData) {
+          return reject(new Error('Cannot create new page to existed path'));
+        }
+
+        var newPage = new Page();
+        newPage.path = path;
+        newPage.creator = user;
+        newPage.createdAt = Date.now();
+        newPage.updatedAt = Date.now();
+        newPage.redirectTo = redirectTo;
+        newPage.grant = grant;
+        newPage.grantedUsers = [];
+        newPage.grantedUsers.push(user);
+
+        newPage.save(function (err, newPage) {
           if (err) {
-            console.log('Push Revision Error on create page', err);
+            return reject(err);
           }
-          cb(err, data);
-          return;
+
+          var newRevision = Revision.prepareRevision(newPage, body, user, {format: format});
+          Page.pushRevision(newPage, newRevision, user).then(function(data) {
+            resolve(data);
+          }).catch(function(err) {
+            debug('Push Revision Error on create page', err);
+            return reject(err);
+          });
         });
       });
     });
   };
 
-  pageSchema.statics.rename = function(pageData, newPageName, user, options, cb) {
+  pageSchema.statics.rename = function(pageData, newPagePath, user, options) {
     var Page = this
       , Revision = crowi.model('Revision')
       , path = pageData.path
       , createRedirectPage = options.createRedirectPage || 0
       , moveUnderTrees     = options.moveUnderTrees || 0;
 
-    // pageData の path を変更
-    this.updatePage(pageData, {updatedAt: Date.now(), path: newPageName}, function(err, data) {
-      if (err) {
-        return cb(err, null);
-      }
-
-      // reivisions の path を変更
-      Revision.updateRevisionListByPath(path, {path: newPageName}, {}, function(err, data) {
-        if (err) {
-          return cb(err, null);
-        }
+    return new Promise(function(resolve, reject) {
+      // pageData の path を変更
+      Page.updatePage(pageData, {updatedAt: Date.now(), path: newPagePath})
+      .then(function(data) {
+        debug('Before ', pageData);
+        // reivisions の path を変更
+        return Revision.updateRevisionListByPath(path, {path: newPagePath}, {})
+      }).then(function(data) {
+        debug('After ', pageData);
+        pageData.path = newPagePath;
 
-        pageData.path = newPageName;
         if (createRedirectPage) {
-          Page.create(path, 'redirect ' + newPageName, user, {redirectTo: newPageName}, function(err, data) {
-            // @TODO error handling
-            return cb(err, pageData);
-          });
+          var body = 'redirect ' + newPagePath;
+          return Page.create(path, body, user, {redirectTo: newPagePath}).then(resolve).catch(reject);
         } else {
-          return cb(err, pageData);
+          return resolve(data);
         }
       });
     });

+ 61 - 15
lib/models/revision.js

@@ -14,34 +14,80 @@ module.exports = function(crowi) {
 
   revisionSchema.statics.findLatestRevision = function(path, cb) {
     this.find({path: path})
-      .sort({'createdAt': -1})
+      .sort({createdAt: -1})
       .limit(1)
       .exec(function(err, data) {
         cb(err, data.shift());
       });
   };
 
-  revisionSchema.statics.findRevisionList = function(path, options, cb) {
-    this.find({path: path})
-      .sort({'createdAt': -1})
-      .populate('author')
-      .exec(function(err, data) {
-        cb(err, data);
+  revisionSchema.statics.findRevision = function(id) {
+    var Revision = this;
+
+    return new Promise(function(resolve, reject) {
+      Revision.findById(id)
+        .populate('author')
+        .exec(function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+
+          return resolve(data);
+        });
       });
   };
 
-  revisionSchema.statics.updateRevisionListByPath = function(path, updateData, options, cb) {
-    this.update({path: path}, {$set: updateData}, {multi: true}, function(err, data) {
-      cb(err, data);
+  revisionSchema.statics.findRevisions = function(ids) {
+    var Revision = this;
+
+    if (!Array.isArray(ids)) {
+      return Promise.reject('The argument was not Array.');
+    }
+
+    return new Promise(function(resolve, reject) {
+      Revision
+        .find({ _id: { $in: ids }})
+        .sort({createdAt: -1})
+        .populate('author')
+        .exec(function(err, revisions) {
+          if (err) {
+            return reject(err);
+          }
+
+          return resolve(revisions);
+        });
     });
   };
 
-  revisionSchema.statics.findRevision = function(id, cb) {
-    this.findById(id)
-      .populate('author')
-      .exec(function(err, data) {
-        cb(err, data);
+  revisionSchema.statics.findRevisionList = function(path, options) {
+    var Revision = this;
+
+    return new Promise(function(resolve, reject) {
+      Revision.find({path: path})
+        .sort({createdAt: -1})
+        .populate('author')
+        .exec(function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+
+          return resolve(data);
+        });
+    });
+  };
+
+  revisionSchema.statics.updateRevisionListByPath = function(path, updateData, options) {
+    var Revision = this;
+
+    return new Promise(function(resolve, reject) {
+      Revision.update({path: path}, {$set: updateData}, {multi: true}, function(err, data) {
+        if (err) {
+          return reject(err);
+        }
+
+        return resolve(data);
       });
+    });
   };
 
   revisionSchema.statics.prepareRevision = function(pageData, body, user, options) {

+ 65 - 8
lib/models/user.js

@@ -11,9 +11,12 @@ module.exports = function(crowi) {
     , STATUS_SUSPENDED  = 3
     , STATUS_DELETED    = 4
     , STATUS_INVITED    = 5
+    , USER_PUBLIC_FIELDS = '_id fbId image googleId name username email status createdAt' // TODO: どこか別の場所へ...
 
     , PAGE_ITEMS        = 20
 
+    , userEvent = crowi.event('user')
+
     , userSchema;
 
   userSchema = new mongoose.Schema({
@@ -22,16 +25,19 @@ module.exports = function(crowi) {
     image: String,
     googleId: String,
     name: { type: String },
-    username: { type: String },
-    email: { type: String, required: true },
+    username: { type: String, index: true },
+    email: { type: String, required: true, index: true  },
+    introduction: { type: String },
     password: String,
     apiToken: String,
-    status: { type: Number, required: true, default: STATUS_ACTIVE },
+    status: { type: Number, required: true, default: STATUS_ACTIVE, index: true  },
     createdAt: { type: Date, default: Date.now },
-    admin: { type: Boolean, default: 0 }
+    admin: { type: Boolean, default: 0, index: true  }
   });
   userSchema.plugin(mongoosePaginate);
 
+  userEvent.on('activated', userEvent.onActivated);
+
   function decideUserStatusOnRegistration () {
     var Config = crowi.model('Config'),
       config = crowi.getConfig();
@@ -54,7 +60,7 @@ module.exports = function(crowi) {
 
   function generatePassword (password) {
     var hasher = crypto.createHash('sha256');
-    hasher.update(process.env.PASSWORD_SEED + password);
+    hasher.update(crowi.env.PASSWORD_SEED + password);
 
     return hasher.digest('hex');
   }
@@ -159,6 +165,7 @@ module.exports = function(crowi) {
     this.username = username;
     this.status = STATUS_ACTIVE;
     this.save(function(err, userData) {
+      userEvent.emit('activated', userData);
       return callback(err, userData);
     });
   };
@@ -183,6 +190,7 @@ module.exports = function(crowi) {
     debug('Activate User', this);
     this.status = STATUS_ACTIVE;
     this.save(function(err, userData) {
+      userEvent.emit('activated', userData);
       return callback(err, userData);
     });
   };
@@ -252,6 +260,7 @@ module.exports = function(crowi) {
 
   userSchema.statics.findUsers = function(options, callback) {
     var sort = options.sort || {status: 1, createdAt: 1};
+
     this.find()
       .sort(sort)
       .skip(options.skip || 0)
@@ -262,6 +271,30 @@ module.exports = function(crowi) {
 
   };
 
+  userSchema.statics.findUsersByIds = function(ids, option) {
+    var User = this;
+    var option = option || {}
+      , sort = option.sort || {createdAt: -1}
+      , status = option.status || STATUS_ACTIVE
+      , fields = option.fields || USER_PUBLIC_FIELDS
+      ;
+
+
+    return new Promise(function(resolve, reject) {
+      User
+        .find({ _id: { $in: ids }, status: status })
+        .select(fields)
+        .sort(sort)
+        .exec(function (err, userData) {
+          if (err) {
+            return reject(err);
+          }
+
+          return resolve(userData);
+        });
+    });
+  };
+
   userSchema.statics.findAdmins = function(callback) {
     var User = this;
     this.find({admin: true})
@@ -284,9 +317,16 @@ module.exports = function(crowi) {
     }, { sortBy : sort });
   };
 
-  userSchema.statics.findUserByUsername = function(username, callback) {
-    this.findOne({username: username}, function (err, userData) {
-      callback(err, userData);
+  userSchema.statics.findUserByUsername = function(username) {
+    var User = this;
+    return new Promise(function(resolve, reject) {
+      User.findOne({username: username}, function (err, userData) {
+        if (err) {
+          return reject(err);
+        }
+
+        return resolve(userData);
+      });
     });
   };
 
@@ -501,6 +541,9 @@ module.exports = function(crowi) {
     newUser.status = decideUserStatusOnRegistration();
 
     newUser.save(function(err, userData) {
+      if (userData.status == STATUS_ACTIVE) {
+        userEvent.emit('activated', userData);
+      }
       return callback(err, userData);
     });
   };
@@ -518,22 +561,36 @@ module.exports = function(crowi) {
     newUser.status = decideUserStatusOnRegistration();
 
     newUser.save(function(err, userData) {
+      if (userData.status == STATUS_ACTIVE) {
+        userEvent.emit('activated', userData);
+      }
       return callback(err, userData);
     });
   };
 
+
   userSchema.statics.createUserPictureFilePath = function(user, name) {
     var ext = '.' + name.match(/(.*)(?:\.([^.]+$))/)[2];
 
     return 'user/' + user._id + ext;
   };
 
+  userSchema.statics.getUsernameByPath = function(path) {
+    var username = null;
+    if (m = path.match(/^\/user\/([^\/]+)\/?/)) {
+      username = m[1];
+    }
+
+    return username;
+  };
+
 
   userSchema.statics.STATUS_REGISTERED = STATUS_REGISTERED;
   userSchema.statics.STATUS_ACTIVE = STATUS_ACTIVE;
   userSchema.statics.STATUS_SUSPENDED = STATUS_SUSPENDED;
   userSchema.statics.STATUS_DELETED = STATUS_DELETED;
   userSchema.statics.STATUS_INVITED = STATUS_INVITED;
+  userSchema.statics.USER_PUBLIC_FIELDS = USER_PUBLIC_FIELDS;
 
   return mongoose.model('User', userSchema);
 };

+ 9 - 18
lib/routes/attachment.js

@@ -8,6 +8,7 @@ module.exports = function(crowi, app) {
     , Promise = require('bluebird')
     , config = crowi.getConfig()
     , fs = require('fs')
+    , fileUploader = require('../util/fileUploader')(crowi, app)
     , actions = {}
     , api = {};
 
@@ -21,7 +22,6 @@ module.exports = function(crowi, app) {
       res.json({
         status: true,
         data: {
-          fileBaseUrl: 'https://' + config.crowi['aws:bucket'] +'.s3.amazonaws.com/', // FIXME: ベタ書きよくない
           attachments: attachments
         }
       });
@@ -39,7 +39,6 @@ module.exports = function(crowi, app) {
 
     debug('id and path are: ', id, path);
 
-    var fileUploader = require('../util/fileUploader')(crowi, app);
     var tmpFile = req.files.file || null;
     debug('Uploaded tmpFile: ', tmpFile);
     if (!tmpFile) {
@@ -52,22 +51,14 @@ module.exports = function(crowi, app) {
     new Promise(function(resolve, reject) {
       if (id == 0) {
         debug('Create page before file upload');
-        Page.create(path, '# '  + path, req.user, {grant: Page.GRANT_OWNER}, function(err, pageData) {
-          if (err) {
-            debug('Page create error', err);
-            return reject(err);
-          }
-          pageCreated = true;
-          return resolve(pageData);
-        });
+        Page.create(path, '# '  + path, req.user, {grant: Page.GRANT_OWNER})
+          .then(function(page) {
+            pageCreated = true;
+            resolve(page);
+          })
+          .catch(reject);
       } else {
-        Page.findPageById(id, function(err, pageData){
-          if (err) {
-            debug('Page find error', err);
-            return reject(err);
-          }
-          return resolve(pageData);
-        });
+        Page.findPageById(id).then(resolve).catch(reject);
       }
     }).then(function(pageData) {
       page = pageData;
@@ -88,7 +79,7 @@ module.exports = function(crowi, app) {
           // TODO size
           return Attachment.create(id, req.user, filePath, originalName, fileName, fileType, fileSize);
         }).then(function(data) {
-          var imageUrl = fileUploader.generateS3FileUrl(data.filePath);
+          var imageUrl = fileUploader.generateUrl(data.filePath);
           return res.json({
             status: true,
             filename: imageUrl,

+ 89 - 0
lib/routes/bookmark.js

@@ -0,0 +1,89 @@
+module.exports = function(crowi, app) {
+  'use strict';
+
+  var debug = require('debug')('crowi:routes:bookmark')
+    , Bookmark = crowi.model('Bookmark')
+    , Page = crowi.model('Page')
+    , User = crowi.model('User')
+    , Revision = crowi.model('Revision')
+    , Bookmark = crowi.model('Bookmark')
+    , ApiResponse = require('../util/apiResponse')
+    , actions = {}
+  ;
+  actions.api = {};
+
+  /**
+   * @api {get} /bookmarks.get Get bookmark of the page with the user
+   * @apiName GetBookmarks
+   * @apiGroup Bookmark
+   *
+   * @apiParam {String} page_id Page Id.
+   */
+  actions.api.get = function (req, res) {
+    var pageId = req.query.page_id;
+
+    Bookmark.findByPageIdAndUserId(pageId, req.user)
+    .then(function(data) {
+      debug('bookmark found', pageId, data);
+      var result = {};
+      if (data) {
+      }
+
+      result.bookmark = data;
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      return res.json(ApiResponse.error(err));
+    });
+  };
+
+  /**
+   * @api {post} /bookmarks.add Add bookmark of the page
+   * @apiName AddBookmark
+   * @apiGroup Bookmark
+   *
+   * @apiParam {String} page_id Page Id.
+   */
+  actions.api.add = function(req, res) {
+    var pageId = req.body.page_id;
+
+    Page.findPageByIdAndGrantedUser(pageId, req.user)
+    .then(function(pageData) {
+      if (pageData) {
+        return Bookmark.add(pageData, req.user);
+      } else {
+        return res.json(ApiResponse.success({bookmark: null}));
+      }
+    }).then(function(data) {
+      var result = {};
+      data.depopulate('page');
+      data.depopulate('user');
+
+      result.bookmark = data;
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      return res.json(ApiResponse.error(err));
+    });
+  };
+
+  /**
+   * @api {post} /bookmarks.remove Remove bookmark of the page
+   * @apiName RemoveBookmark
+   * @apiGroup Bookmark
+   *
+   * @apiParam {String} page_id Page Id.
+   */
+  actions.api.remove = function(req, res){
+    var pageId = req.body.page_id;
+
+    Bookmark.remove(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());
+    }).catch(function(err) {
+      return res.json(ApiResponse.error(err));
+    });
+  };
+
+
+  return actions;
+};

+ 75 - 0
lib/routes/comment.js

@@ -0,0 +1,75 @@
+module.exports = function(crowi, app) {
+  'use strict';
+
+  var debug = require('debug')('crowi:routs:comment')
+    , Comment = crowi.model('Comment')
+    , User = crowi.model('User')
+    , Page = crowi.model('Page')
+    , ApiResponse = require('../util/apiResponse')
+    , actions = {}
+    , api = {};
+
+  actions.api = api;
+
+  /**
+   * @api {get} /comments.get Get comments of the page of the revision
+   * @apiName GetComments
+   * @apiGroup Comment
+   *
+   * @apiParam {String} page_id Page Id.
+   * @apiParam {String} revision_id Revision Id.
+   */
+  api.get = function(req, res){
+    var pageId = req.query.page_id;
+    var revisionId = req.query.revision_id;
+
+    if (revisionId) {
+      return Comment.getCommentsByRevisionId(revisionId)
+        .then(function(comments) {
+          res.json(ApiResponse.success({comments}));
+        }).catch(function(err) {
+          res.json(ApiResponse.error(err));
+        });
+    }
+
+    return Comment.getCommentsByPageId(pageId)
+      .then(function(comments) {
+        res.json(ApiResponse.success({comments}));
+      }).catch(function(err) {
+        res.json(ApiResponse.error(err));
+      });
+  };
+
+  /**
+   * @api {post} /comments.add Post comment for the page
+   * @apiName PostComment
+   * @apiGroup Comment
+   *
+   * @apiParam {String} page_id Page Id.
+   * @apiParam {String} revision_id Revision Id.
+   * @apiParam {String} comment Comment body
+   * @apiParam {Number} comment_position=-1 Line number of the comment
+   */
+  api.add = function(req, res){
+    var form = req.form.commentForm;
+
+    if (!req.form.isValid) {
+      return res.json(ApiResponse.error('Invalid comment.'));
+    }
+
+    var pageId = form.page_id;
+    var revisionId = form.revision_id;
+    var comment = form.comment;
+    var position = form.comment_position || -1;
+
+    return Comment.create(pageId, req.user._id, revisionId, comment, position)
+      .then(function(createdComment) {
+        createdComment.creator = req.user;
+        return res.json(ApiResponse.success({comment: createdComment}));
+      }).catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
+  };
+
+  return actions;
+};

+ 21 - 6
lib/routes/index.js

@@ -9,6 +9,9 @@ module.exports = function(crowi, app) {
     , installer = require('./installer')(crowi, app)
     , user      = require('./user')(crowi, app)
     , attachment= require('./attachment')(crowi, app)
+    , comment   = require('./comment')(crowi, app)
+    , bookmark  = require('./bookmark')(crowi, app)
+    , revision  = require('./revision')(crowi, app)
     , loginRequired = middleware.loginRequired
     , accessTokenParser = middleware.accessTokenParser
     ;
@@ -69,21 +72,33 @@ module.exports = function(crowi, app) {
   app.get( '/_api/check_username'     , user.api.checkUsername);
   app.post('/_api/me/picture/upload'  , loginRequired(crowi, app) , me.api.uploadPicture);
   app.get( '/_api/user/bookmarks'     , loginRequired(crowi, app) , user.api.bookmarks);
-  app.post('/_api/page_rename/*'      , loginRequired(crowi, app) , page.api.rename);
   app.get( '/_api/attachment/page/:pageId', loginRequired(crowi, app) , attachment.api.list);
   app.post('/_api/attachment/page/:pageId', loginRequired(crowi, app) , attachment.api.add);
   app.post('/_api/attachment/:id/remove',loginRequired(crowi, app), attachment.api.remove);
-  app.post('/_api/page/:id/like'      , loginRequired(crowi, app) , page.api.like);
-  app.post('/_api/page/:id/unlike'    , loginRequired(crowi, app) , page.api.unlike);
-  app.get( '/_api/page/:id/bookmark'  , loginRequired(crowi, app) , page.api.isBookmarked);
-  app.post('/_api/page/:id/bookmark'  , loginRequired(crowi, app) , page.api.bookmark);
+
+  app.get( '/user/:username([^/]+)/bookmarks'      , loginRequired(crowi, app) , page.userBookmarkList);
+  app.get( '/user/:username([^/]+)/recent-create'  , loginRequired(crowi, app) , page.userRecentCreatedList);
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
+  app.get('/_api/users.list'          , accessTokenParser(crowi, app) , loginRequired(crowi, app) , user.api.list);
   app.get('/_api/pages.get'           , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.get);
+  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.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);
+  app.post('/_api/bookmarks.add'      , accessTokenParser(crowi, app) , loginRequired(crowi, app) , bookmark.api.add);
+  app.post('/_api/bookmarks.remove'   , accessTokenParser(crowi, app) , loginRequired(crowi, app) , bookmark.api.remove);
+  app.post('/_api/likes.add'          , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.like);
+  app.post('/_api/likes.remove'       , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.unlike);
+
+  app.get( '/_api/revisions.get'      , accessTokenParser(crowi, app) , loginRequired(crowi, app) , revision.api.get);
+  app.get( '/_api/revisions.list'     , accessTokenParser(crowi, app) , loginRequired(crowi, app) ,revision.api.list);
+
   //app.get('/_api/revision/:id'     , user.useUserData()         , revision.api.get);
   //app.get('/_api/r/:revisionId'    , user.useUserData()         , page.api.get);
 
-  app.post('/*/edit'                 , form.revision              , loginRequired(crowi, app) , page.pageEdit);
+  app.post('/_/edit'                 , form.revision             , loginRequired(crowi, app) , page.pageEdit);
   app.get('/*/$'                     , loginRequired(crowi, app) , page.pageListShow);
   app.get('/*'                       , loginRequired(crowi, app) , page.pageShow);
   //app.get('/*/edit'                , routes.edit);

+ 1 - 1
lib/routes/me.js

@@ -50,7 +50,7 @@ module.exports = function(crowi, app) {
 
     fileUploader.uploadFile(filePath, tmpFile.mimetype, tmpFileStream, {})
     .then(function(data) {
-      var imageUrl = fileUploader.generateS3FileUrl(filePath);
+      var imageUrl = fileUploader.generateUrl(filePath);
       req.user.updateImage(imageUrl, function(err, data) {
         fs.unlink(tmpPath, function (err) {
           // エラー自体は無視

+ 379 - 192
lib/routes/page.js

@@ -6,18 +6,33 @@ module.exports = function(crowi, app) {
     , User = crowi.model('User')
     , Revision = crowi.model('Revision')
     , Bookmark = crowi.model('Bookmark')
+    , ApiResponse = require('../util/apiResponse')
+
+    , sprintf = require('sprintf')
+
     , actions = {};
 
   function getPathFromRequest(req) {
     var path = '/' + (req.params[0] || '');
-    return path;
+    return path.replace(/\.md$/, '');
+  }
+
+  function isUserPage(path) {
+    if (path.match(/^\/user\/[^\/]+\/?$/)) {
+      return true;
+    }
+
+    return false;
   }
 
   // TODO: total とかでちゃんと計算する
   function generatePager(options) {
-    var next = null, prev = null,
-        offset = parseInt(options.offset, 10),
-        limit  = parseInt(options.limit, 10);
+    var next = null,
+      prev = null,
+      offset = parseInt(options.offset, 10),
+      limit  = parseInt(options.limit, 10),
+      length = options.length || 0;
+
 
     if (offset > 0) {
       prev = offset - limit;
@@ -26,35 +41,88 @@ module.exports = function(crowi, app) {
       }
     }
 
-    next = offset + limit;
+    if (length < limit) {
+      next = null;
+    } else {
+      next = offset + limit;
+    }
 
     return {
       prev: prev,
       next: next,
       offset: offset,
-      limit: limit
     };
   }
 
   // routing
   actions.pageListShow = function(req, res) {
     var path = getPathFromRequest(req);
-    var options = {};
+    var limit = 50;
+    var offset = parseInt(req.query.offset)  || 0;
+    path = path + (path == '/' ? '' : '/');
 
     // index page
-    options = {
-      offset: req.query.offset || 0,
-      limit : req.query.limit  || 50
+    var pagerOptions = {
+      offset: offset,
+      limit : limit
     };
-    var q = Page.findListByStartWith(path, req.user, options, function(err, doc) {
-      if (err) {
-        // TODO : check
+    var queryOptions = {
+      offset: offset,
+      limit : limit + 1
+    };
+
+    var renderVars = {
+      page: null,
+      path: path,
+      pages: [],
+    };
+
+    Page.hasPortalPage(path, req.user)
+    .then(function(portalPage) {
+      renderVars.page = portalPage;
+
+      return 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);
+    });
+  };
+
+  actions.search = function(req, res) {
+    // spec: ?q=query&sort=sort_order&author=author_filter
+    var query = req.query.q;
+    var search = require('../util/search')(crowi);
+
+    search.searchPageByKeyword(query)
+    .then(function(pages) {
+      debug('pages', pages);
+
+      if (pages.hits.total <= 0) {
+        return Promise.resolve([]);
+      }
+
+      var ids = pages.hits.hits.map(function(page) {
+        return page._id;
+      });
+
+      return Page.findListByPageIds(ids);
+    }).then(function(pages) {
+
       res.render('page_list', {
-        path: path + (path == '/' ? '' : '/'),
-        pages: doc,
-        pager: generatePager(options)
+        path: '/',
+        pages: pages,
+        pager: generatePager({offset: 0, limit: 50})
       });
+    }).catch(function(err) {
+      debug('search error', err);
     });
   };
 
@@ -92,7 +160,6 @@ module.exports = function(crowi, app) {
     // create page
     if (!pageData) {
       return res.render('page', {
-        revision: {},
         author: {},
         page: false,
       });
@@ -102,15 +169,58 @@ module.exports = function(crowi, app) {
       return res.redirect(encodeURI(pageData.redirectTo + '?renamed=' + pageData.path));
     }
 
-    Revision.findRevisionList(pageData.path, {}, function(err, tree) {
-      var revision = pageData.revision || {};
-      res.render(req.query.presentation ? 'page_presentation' : 'page', {
-        path: pageData.path,
-        revision: revision,
-        author: revision.author || false,
-        page: pageData,
-        tree: tree || [],
-      });
+    var renderVars = {
+      path: pageData.path,
+      page: pageData,
+      revision: pageData.revision || {},
+      author: pageData.revision.author || false,
+    };
+    var userPage = isUserPage(pageData.path);
+    var userData = null;
+
+    Revision.findRevisionList(pageData.path, {})
+    .then(function(tree) {
+      renderVars.tree = tree;
+
+      return Promise.resolve();
+    }).then(function() {
+      if (userPage) {
+        return User.findUserByUsername(User.getUsernameByPath(pageData.path))
+        .then(function(data) {
+          if (data === null) {
+            throw new Error('The user not found.');
+          }
+          userData = data;
+          renderVars.pageUser = userData;
+
+          return Bookmark.findByUser(userData, {limit: 10, populatePage: true, requestUser: req.user});
+        }).then(function(bookmarkList) {
+          debug(bookmarkList);
+          renderVars.bookmarkList = bookmarkList;
+
+          return Page.findListByCreator(userData, {limit: 10});
+        }).then(function(createdList) {
+          renderVars.createdList = createdList;
+          return Promise.resolve();
+        }).catch(function(err) {
+          debug('Error on finding user related entities', err);
+          // pass
+        });
+      } else {
+        return Promise.resolve();
+      }
+    }).then(function() {
+      var defaultPageTeamplate = 'page';
+      if (userData) {
+        defaultPageTeamplate = 'user_page';
+      }
+
+      res.render(req.query.presentation ? 'page_presentation' : defaultPageTeamplate, renderVars);
+    }).catch(function(err) {
+      debug('Error: renderPage()', err);
+      if (err) {
+        res.redirect('/');
+      }
     });
   }
 
@@ -118,6 +228,9 @@ module.exports = function(crowi, app) {
     var path = path || getPathFromRequest(req);
     var options = {};
 
+    // FIXME: せっかく getPathFromRequest になってるのにここが生 params[0] だとダサイ
+    var isMarkdown = req.params[0].match(/.+\.md$/) || false;
+
     res.locals.path = path;
 
     // pageShow は /* にマッチしてる最後の砦なので、creatableName でない routing は
@@ -128,80 +241,163 @@ module.exports = function(crowi, app) {
       return ;
     }
 
-    // single page
-    var parentPath = path.split('/').slice(0, -1).join('/'); // TODO : limitation
-    options = {
-    };
+    Page.findPage(path, req.user, req.query.revision)
+    .then(function(page) {
+      debug('Page found', page._id, page.path);
 
-    Page.findPage(path, req.user, req.query.revision, options, function(err, pageData) {
-      if (req.query.revision && err) {
-        res.redirect(encodeURI(path));
-        return ;
+      if (isMarkdown) {
+        res.set('Content-Type', 'text/plain');
+        return res.send(page.revision.body);
+      }
+
+      return renderPage(page, req, res);
+    }).catch(function(err) {
+      if (req.query.revision) {
+        return res.redirect(encodeURI(path));
       }
 
-      if (err == Page.PAGE_GRANT_ERROR) {
-        debug('PAGE_GRANT_ERROR');
+      if (isMarkdown) {
         return res.redirect('/');
       }
 
-      if (pageData) {
-        debug('Page found', pageData._id, pageData.path);
-        pageData.seen(req.user, function(err, data) {
-          return renderPage(data, req, res);
-        });
-      } else {
+      Page.hasPortalPage(path + '/', req.user)
+      .then(function(page) {
+        if (page) {
+          return res.redirect(path + '/');
+        } else {
+          debug('Catch pageShow', err);
           return renderPage(null, req, res);
-      }
+        }
+      });
     });
   };
 
   actions.pageEdit = function(req, res) {
-    var io = module.parent.exports.io;
-    var path = getPathFromRequest(req);
 
     var pageForm = req.body.pageForm;
     var body = pageForm.body;
-    var format = pageForm.format;
     var currentRevision = pageForm.currentRevision;
     var grant = pageForm.grant;
+    var path = pageForm.path;
+
+    var redirectPath = encodeURI(path);
+
+    // set to render
+    res.locals.pageForm = pageForm;
 
     if (!Page.isCreatableName(path)) {
-      res.redirect(encodeURI(path));
+      res.redirect(redirectPath);
       return ;
     }
 
-    Page.findPage(path, req.user, null, {}, function(err, pageData){
+    var ignoreNotFound = true;
+    Page.findPage(path, req.user, null, ignoreNotFound)
+    .then(function(pageData) {
       if (!req.form.isValid) {
-        renderPage(pageData, req, res);
-        return;
+        return renderPage(pageData, req, res);
       }
+
       if (pageData && !pageData.isUpdatable(currentRevision)) {
         req.form.errors.push('すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。');
-        renderPage(pageData, req, res);
-        return;
+        return renderPage(pageData, req, res);
       }
 
-      var cb = function(err, data) {
-        if (err) {
-          console.log('Page save error:', err);
-        }
-        crowi.getIo().sockets.emit('page edited', {page: data, user: req.user});
-
-        var redirectPath = encodeURI(path);
-        if (grant != data.grant) {
-          Page.updateGrant(data, grant, req.user, function (err, data) {
-            return res.redirect(redirectPath);
-          });
-        } else {
-          return res.redirect(redirectPath);
-        }
-      };
       if (pageData) {
+        // update existing page
         var newRevision = Revision.prepareRevision(pageData, body, req.user);
-        Page.pushRevision(pageData, newRevision, req.user, cb);
+        return Page.pushRevision(pageData, newRevision, req.user);
+      } else {
+        // new page
+        return Page.create(path, body, req.user, {grant: grant});
+      }
+    }).then(function(data) {
+      crowi.getIo().sockets.emit('page edited', {page: data, user: req.user});
+
+      if (grant != data.grant) {
+        return Page.updateGrant(data, grant, req.user).then(function(data) {
+          return res.redirect(redirectPath);
+        });
       } else {
-        Page.create(path, body, req.user, {format: format, grant: grant}, cb);
+        return res.redirect(redirectPath);
       }
+    }).catch(function(err) {
+      debug('Create or edit page error', err);
+      return res.redirect(redirectPath);
+    });
+  };
+
+  // app.get( '/users/:username([^/]+)/bookmarks'      , loginRequired(crowi, app) , page.userBookmarkList);
+  actions.userBookmarkList = function(req, res) {
+    var username = req.params.username;
+    var limit = 50;
+    var offset = parseInt(req.query.offset)  || 0;
+
+    var user;
+    var renderVars = {};
+
+    var pagerOptions = { offset: offset, limit : limit };
+    var queryOptions = { offset: offset, limit : limit + 1, populatePage: true, requestUser: req.user};
+
+    User.findUserByUsername(username)
+    .then(function(user) {
+      if (user === null) {
+        throw new Error('The user not found.');
+      }
+      renderVars.user = user;
+
+      return Bookmark.findByUser(user, queryOptions);
+    }).then(function(bookmarks) {
+
+      if (bookmarks.length > limit) {
+        bookmarks.pop();
+      }
+      pagerOptions.length = bookmarks.length;
+
+      renderVars.pager = generatePager(pagerOptions);
+      renderVars.bookmarks = bookmarks;
+
+      return res.render('user/bookmarks', renderVars);
+    }).catch(function(err) {
+      debug('Error on rendereing bookmark', err);
+      res.redirect('/');
+    });
+  };
+
+  // app.get( '/users/:username([^/]+)/recent-create' , loginRequired(crowi, app) , page.userRecentCreatedList);
+  actions.userRecentCreatedList = function(req, res) {
+    var username = req.params.username;
+    var limit = 50;
+    var offset = parseInt(req.query.offset) || 0;
+
+    var user;
+    var renderVars = {};
+
+    var pagerOptions = { offset: offset, limit : limit };
+    var queryOptions = { offset: offset, limit : limit + 1};
+
+
+    User.findUserByUsername(username)
+    .then(function(user) {
+      if (user === null) {
+        throw new Error('The user not found.');
+      }
+      renderVars.user = user;
+
+      return Page.findListByCreator(user, queryOptions);
+    }).then(function(pages) {
+
+      if (pages.length > limit) {
+        pages.pop();
+      }
+      pagerOptions.length = pages.length;
+
+      renderVars.pager = generatePager(pagerOptions);
+      renderVars.pages = pages;
+
+      return res.render('user/recent-create', renderVars);
+    }).catch(function(err) {
+      debug('Error on rendereing recent-created', err);
+      res.redirect('/');
     });
   };
 
@@ -213,173 +409,164 @@ module.exports = function(crowi, app) {
   api.redirector = function(req, res){
     var id = req.params.id;
 
-    var cb = function(err, d) {
-      if (err) {
-        return res.redirect('/');
-      }
-      return res.redirect(encodeURI(d.path));
-    };
+    Page.findPageById(id)
+    .then(function(pageData) {
 
-    Page.findPageById(id, function(err, pageData) {
-      if (pageData) {
-        if (pageData.grant == Page.GRANT_RESTRICTED && !pageData.isGrantedFor(req.user)) {
-          return Page.pushToGrantedUsers(pageData, req.user, cb);
-        } else {
-          return cb(null, pageData);
-        }
-      } else {
-        // 共有用URLにrevisionのidを使っていた頃の互換性のため
-        Revision.findRevision(id, cb);
+      if (pageData.grant == Page.GRANT_RESTRICTED && !pageData.isGrantedFor(req.user)) {
+        return Page.pushToGrantedUsers(pageData, req.user);
       }
+
+      return Promise.resolve(pageData);
+    }).then(function(page) {
+
+      return res.redirect(encodeURI(page.path));
+    }).catch(function(err) {
+      return res.redirect('/');
     });
   };
 
   /**
-   * @api pages.get
-   * @param page /page/path
-   * @param page_id XXXXX
+   * @api {get} /pages.get Get page data
+   * @apiName GetPage
+   * @apiGroup Page
+   *
+   * @apiParam {String} page_id
+   * @apiParam {String} path
+   * @apiParam {String} revision_id
    */
   api.get = function(req, res){
-    var pagePath = req.query.page;
-    var revision = req.query.revision;
-    var options = {};
+    var pagePath = req.query.path || null;
+    var pageId = req.query.page_id || null; // TODO: handling
+    var revisionId = req.query.revision_id || null;
 
-    Page.findPage(pagePath, req.user, revision, options, function(err, pageData) {
+    Page.findPage(pagePath, req.user, revisionId)
+    .then(function(pageData) {
       var result = {};
-      if (err) {
-        result = {
-          ok: false,
-          message: err.toString()
-        };
-      }
-      if (pageData) {
-        result = {
-          ok: true,
-          page: pageData
-        };
-      }
+      result.page = pageData;
 
-      return res.json(result);
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      return res.json(ApiResponse.error(err));
     });
   };
 
   /**
-   * page bookmark
+   * @api {post} /pages.seen Mark as seen user
+   * @apiName SeenPage
+   * @apiGroup Page
+   *
+   * @apiParam {String} page_id Page Id.
    */
-  api.isBookmarked = function(req, res){
-    var id = req.params.id;
-    Bookmark.findByPageIdAndUser(id, req.user, function(err, bookmark) {
-      debug('isBookmarked', id, req.user._id, err, bookmark);
-      if (err === null && bookmark) {
-        return res.json({bookmarked: true});
-      } else {
-        return res.json({bookmarked: false});
-      }
-    });
-  };
+  api.seen = function(req, res){
+    var pageId = req.body.page_id;
+    if (!pageId) {
+      return res.json(ApiResponse.error('page_id required'));
+    }
 
-  api.bookmark = function(req, res){
-    var id = req.params.id;
-    Page.findPageByIdAndGrantedUser(id, req.user, function(err, pageData) {
-      if (pageData) {
-        Bookmark.add(pageData, req.user, function(err, data) {
-          return res.json({status: true});
-        });
-      } else {
-        return res.json({status: false});
-      }
+    Page.findPageByIdAndGrantedUser(pageId, req.user)
+    .then(function(page) {
+      return page.seen(req.user);
+    }).then(function(user) {
+      var result = {};
+      result.seenUser = user;
+
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      debug('Seen user update error', err);
+      return res.json(ApiResponse.error(err));
     });
   };
 
   /**
-   * page like
+   * @api {post} /likes.add Like page
+   * @apiName LikePage
+   * @apiGroup Page
+   *
+   * @apiParam {String} page_id Page Id.
    */
   api.like = function(req, res){
-    var id = req.params.id;
-    Page.findPageByIdAndGrantedUser(id, req.user, function(err, pageData) {
-      if (pageData) {
-        pageData.like(req.user, function(err, data) {
-          return res.json({status: true});
-        });
-      } else {
-        return res.json({status: false});
-      }
+    var id = req.body.page_id;
+
+    Page.findPageByIdAndGrantedUser(id, req.user)
+    .then(function(pageData) {
+      return pageData.like(req.user);
+    }).then(function(data) {
+      var result = {page: data};
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      debug('Like failed', err);
+      return res.json(ApiResponse.error({}));
     });
   };
 
   /**
-   * page like
+   * @api {post} /likes.remove Unlike page
+   * @apiName UnlikePage
+   * @apiGroup Page
+   *
+   * @apiParam {String} page_id Page Id.
    */
   api.unlike = function(req, res){
-    var id = req.params.id;
-
-    Page.findPageByIdAndGrantedUser(id, req.user, function(err, pageData) {
-      if (pageData) {
-        pageData.unlike(req.user, function(err, data) {
-          return res.json({status: true});
-        });
-      } else {
-        return res.json({status: false});
-      }
+    var id = req.body.page_id;
+
+    Page.findPageByIdAndGrantedUser(id, req.user)
+    .then(function(pageData) {
+      return pageData.unlike(req.user);
+    }).then(function(data) {
+      var result = {page: data};
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      debug('Unlike failed', err);
+      return res.json(ApiResponse.error({}));
     });
   };
 
   /**
-   * page rename
+   * @api {post} /pages.rename Rename page
+   * @apiName SeenPage
+   * @apiGroup Page
+   *
+   * @apiParam {String} page_id Page Id.
+   * @apiParam {String} path
+   * @apiParam {String} revision_id
+   * @apiParam {String} new_path
+   * @apiParam {Bool} create_redirect
    */
   api.rename = function(req, res){
-    var path = Page.normalizePath(getPathFromRequest(req));
-
-    var val = req.body;
-    var previousRevision = val.previousRevision;
-    var newPageName = Page.normalizePath(val.newPageName);
+    var pageId = req.body.page_id;
+    var previousRevision = req.body.revision_id || null;
+    var newPagePath = Page.normalizePath(req.body.new_path);
     var options = {
-      createRedirectPage: val.createRedirectPage || 0,
-      moveUnderTrees: val.moveUnderTrees || 0,
+      createRedirectPage: req.body.create_redirect || 0,
+      moveUnderTrees: req.body.move_trees || 0,
     };
+    var page = {};
 
-    if (!Page.isCreatableName(newPageName)) {
-      return res.json({
-        message: 'このページ名は作成できません (' + newPageName + ')',
-        status: false,
-      });
+    if (!Page.isCreatableName(newPagePath)) {
+      return res.json(ApiResponse.error(sprintf('このページ名は作成できません (%s)', newPagePath)));
     }
-    Page.findPage(newPageName, req.user, null, {}, function(err, checkPageData){
-      if (checkPageData) {
-        return res.json({
-          message: 'このページ名は作成できません (' + newPageName + ')。ページが存在します。',
-          status: false,
-        });
-      }
 
-      Page.findPage(path, req.user, null, {}, function(err, pageData){
+    Page.findPageByPath(newPagePath)
+    .then(function(page) {
+      // if page found, cannot cannot rename to that path
+      return res.json(ApiResponse.error(sprintf('このページ名は作成できません (%s)。ページが存在します。', newPagePath)));
+    }).catch(function(err) {
+
+      Page.findPageById(pageId)
+      .then(function(pageData) {
+        page = pageData;
         if (!pageData.isUpdatable(previousRevision)) {
-          return res.json({
-            message: '誰かが更新している可能性があります。ページを更新できません。',
-            status: false,
-          });
-        }
-        if (err) {
-          return res.json({
-            message: 'エラーが発生しました。ページを更新できません。',
-            status: false,
-          });
+          return res.json(ApiResponse.error('誰かが更新している可能性があります。ページを更新できません。'));
         }
 
-        Page.rename(pageData, newPageName, req.user, options, function(err, pageData) {
-          if (err) {
-            return res.json({
-              message: 'ページの移動に失敗しました',
-              status: false,
-            });
-          }
+        return Page.rename(pageData, newPagePath, req.user, options);
+      }).then(function() {
+        var result = {};
+        result.page = page;
 
-          return res.json({
-            message: '移動しました',
-            page: pageData,
-            status: true,
-          });
-        });
+        return res.json(ApiResponse.success(result));
+      }).catch(function(err) {
+        return res.json(ApiResponse.error('エラーが発生しました。ページを更新できません。'));
       });
     });
   };

+ 52 - 0
lib/routes/revision.js

@@ -0,0 +1,52 @@
+module.exports = function(crowi, app) {
+  'use strict';
+
+  var debug = require('debug')('crowi:routes:revision')
+    , Revision = crowi.model('Revision')
+    , ApiResponse = require('../util/apiResponse')
+    , actions = {}
+  ;
+  actions.api = {};
+
+  /**
+   * @api {get} /revisions.get Get revision
+   * @apiName GetRevision
+   * @apiGroup Revision
+   *
+   * @apiParam {String} revision_id Revision Id.
+   */
+  actions.api.get = function(req, res) {
+    var revisionId = req.query.revision_id;
+
+    Revision
+      .findRevision(revisionId)
+      .then(function(revisionData) {
+        return res.json(ApiResponse.success(revisionData));
+      })
+      .catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
+  };
+
+  /**
+   * @api {get} /revisions.list Get revisions
+   * @apiName ListRevision
+   * @apiGroup Revision
+   *
+   * @apiParam {String} revision_ids Revision Ids.
+   */
+  actions.api.list = function(req, res) {
+    var revisionIds = req.query.revision_ids.split(',');
+
+    Revision
+      .findRevisions(revisionIds)
+      .then(function(revisions) {
+        return res.json(ApiResponse.success(revisions));
+      })
+      .catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
+  };
+
+  return actions;
+};

+ 32 - 7
lib/routes/user.js

@@ -5,6 +5,7 @@ module.exports = function(crowi, app) {
     , User = crowi.model('User')
     , Revision = crowi.model('Revision')
     , Bookmark = crowi.model('Bookmark')
+    , ApiResponse = require('../util/apiResponse')
     , actions = {}
     , api = {};
 
@@ -23,16 +24,40 @@ module.exports = function(crowi, app) {
   api.checkUsername = function(req, res) {
     var username = req.query.username;
 
-    User.findUserByUsername(username, function(err, userData) {
+    User.findUserByUsername(username)
+    .then(function(userData) {
       if (userData) {
-        return res.json({
-          valid: false
-        });
+        return res.json({ valid: false });
       } else {
-        return res.json({
-          valid: true
-        });
+        return res.json({ valid: true });
       }
+    }).catch(function(err) {
+      return res.json({ valid: true });
+    });
+  };
+
+  /**
+   * @api {get} /users.list Get user list
+   * @apiName GetUserList
+   * @apiGroup User
+   *
+   * @apiParam {String} user_ids
+   */
+  api.list = function(req, res) {
+    var userIds = req.query.user_ids || null; // TODO: handling
+    if (!userIds || userIds.split(',').length <= 0) {
+      return res.json(ApiResponse.error('user_ids param is required'));
+    }
+
+    User.findUsersByIds(userIds.split(','))
+    .then(function(userList) {
+      var result = {
+        users: userList,
+      };
+
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      return res.json(ApiResponse.error(err));
     });
   };
 

+ 29 - 0
lib/util/apiResponse.js

@@ -0,0 +1,29 @@
+'use strict';
+
+function ApiResponse () {
+};
+
+ApiResponse.error = function (err) {
+  var result = {};
+
+  result = {
+    ok: false
+  };
+
+  if (err instanceof Error) {
+    result.error = err.toString();
+  } else {
+    result.error = err;
+  }
+
+  return result;
+};
+
+ApiResponse.success = function (data) {
+  var result = data || {};
+
+  result.ok = true;
+  return result;
+};
+
+module.exports = ApiResponse;

+ 4 - 61
lib/util/fileUploader.js

@@ -2,69 +2,12 @@
  * fileUploader
  */
 
-
 module.exports = function(crowi) {
   'use strict';
 
-  var aws = require('aws-sdk')
-    , debug = require('debug')('crowi:lib:fileUploader')
-    , Promise = require('bluebird')
-    , Config = crowi.model('Config')
-    , config = crowi.getConfig()
-    , lib = {}
-    ;
-
-  lib.getAwsConfig = function()
-  {
-    return {
-      accessKeyId: config.crowi['aws:accessKeyId'],
-      secretAccessKey: config.crowi['aws:secretAccessKey'],
-      region: config.crowi['aws:region'],
-      bucket: config.crowi['aws:bucket']
-    };
-  };
-
-  // lib.deleteFile = function(filePath, callback) {
-  //   // TODO 実装する
-  // };
-  //
-
-  lib.uploadFile = function(filePath, contentType, fileStream, options) {
-    var awsConfig = lib.getAwsConfig();
-    if (!Config.isUploadable(config)) {
-      return new Promise.reject(new Error('AWS is not configured.'));
-    }
-
-    aws.config.update({
-      accessKeyId: awsConfig.accessKeyId,
-      secretAccessKey: awsConfig.secretAccessKey,
-      region: awsConfig.region
-    });
-    var s3 = new aws.S3();
-
-    var params = {Bucket: awsConfig.bucket};
-    params.ContentType = contentType;
-    params.Key = filePath;
-    params.Body = fileStream;
-    params.ACL = 'public-read';
-
-    return new Promise(function(resolve, reject) {
-      s3.putObject(params, function(err, data) {
-        if (err) {
-          return reject(err);
-        }
-
-        return resolve(data);
-      });
-    });
-  };
-
-  lib.generateS3FileUrl = function(filePath) {
-    var awsConfig = lib.getAwsConfig();
-    var url = 'https://' + awsConfig.bucket +'.s3.amazonaws.com/' + filePath;
-
-    return url;
-  };
+  var debug = require('debug')('crowi:lib:fileUploader')
+    , method = crowi.env.FILE_UPLOAD || 'aws'
+    , lib = '../../local_modules/crowi-fileupload-' + method;
 
-  return lib;
+  return require(lib)(crowi);
 };

+ 34 - 2
lib/util/middlewares.js

@@ -32,9 +32,20 @@ exports.swigFunctions = function(crowi, app) {
 
 exports.swigFilters = function(app, swig) {
   return function(req, res, next) {
-
     swig.setFilter('path2name', function(string) {
-      return string.replace(/.+\/(.+)?$/, '$1');
+      var name = string.replace(/(\/)$/, '');
+
+      if (name.match(/.+\/([^/]+\/\d{4}\/\d{2}\/\d{2})$/)) { // /.../hoge/YYYY/MM/DD 形式のページ
+        return name.replace(/.+\/([^/]+\/\d{4}\/\d{2}\/\d{2})$/, '$1');
+      }
+      if (name.match(/.+\/([^/]+\/\d{4}\/\d{2})$/)) { // /.../hoge/YYYY/MM 形式のページ
+        return name.replace(/.+\/([^/]+\/\d{4}\/\d{2})$/, '$1');
+      }
+      if (name.match(/.+\/([^/]+\/\d{4})$/)) { // /.../hoge/YYYY 形式のページ
+        return name.replace(/.+\/([^/]+\/\d{4})$/, '$1');
+      }
+
+      return name.replace(/.+\/(.+)?$/, '$1'); // ページの末尾を拾う
     });
 
     swig.setFilter('datetz', function(input, format) {
@@ -43,6 +54,27 @@ exports.swigFilters = function(app, swig) {
       return swigFilters.date(input, format, app.get('tzoffset'));
     });
 
+    swig.setFilter('nl2br', function(string) {
+      return string
+        .replace(/\n/g, '<br>');
+    });
+
+    swig.setFilter('insertSpaceToEachSlashes', function(string) {
+      if (string == '/') {
+        return string;
+      }
+
+      return string.replace(/\//g, ' / ');
+    });
+
+    swig.setFilter('removeLastSlash', function(string) {
+      if (string == '/') {
+        return string;
+      }
+
+      return string.substr(0, string.length - 1);
+    });
+
     swig.setFilter('presentation', function(string) {
       // 手抜き
       return string

+ 8 - 0
lib/util/swigFunctions.js

@@ -20,6 +20,14 @@ module.exports = function(crowi, app, locals) {
     return Config.isUploadable(config);
   };
 
+  locals.isUserPageList = function(path) {
+    if (path.match(/^\/user\/[^\/]+\/$/)) {
+      return true;
+    }
+
+    return false;
+  };
+
   locals.user_page_root = function(user) {
     if (!user) {
       return '';

+ 8 - 4
lib/views/_form.html

@@ -9,11 +9,11 @@
 </div>
 {% endif %}
 <div id="form-box" class="row">
-  <form action="{{ path }}/edit" id="page-form" method="post" class="col-md-6 {% if isUploadable() %}uploadable{% endif %}">
-    <textarea name="pageForm[body]" class="form-control form-body-height" id="form-body">{% if pageForm.body %}{{ pageForm.body }}{% elseif not revision.body %}# {{ path|path2name }}{% else %}{{ revision.body }}{% endif %}</textarea>
+  <form action="/_/edit" id="page-form" method="post" class="col-md-6 {% if isUploadable() %}uploadable{% endif %}">
+    <textarea name="pageForm[body]" class="form-control" id="form-body">{% if pageForm.body %}{{ pageForm.body }}{% elseif not page.revision.body %}# {{ path|path2name }}{% else %}{{ page.revision.body }}{% endif %}</textarea>
 
-    <input type="hidden" name="pageForm[format]" value="markdown" id="form-format">
-    <input type="hidden" name="pageForm[currentRevision]" value="{{ pageForm.currentRevision|default(revision._id.toString()) }}">
+    <input type="hidden" name="pageForm[path]" value="{{ path }}">
+    <input type="hidden" name="pageForm[currentRevision]" value="{{ pageForm.currentRevision|default(page.revision._id.toString()) }}">
     <div class="form-submit-group form-group form-inline">
       {#<button class="btn btn-default">
         <i class="fa fa-file-text"></i>
@@ -21,11 +21,15 @@
       </button>#}
 
       <div class="pull-right form-inline">
+        {% if forceGrant %}
+        <input type="hidden" name="pageForm[grant]" value="{{ forceGrant }}">
+        {% else %}
         <select name="pageForm[grant]" class="form-control">
           {% for grantId, grantLabel in consts.pageGrants %}
           <option value="{{ grantId }}" {% if pageForm.grant|default(page.grant) == grantId %}selected{% endif %}>{{ grantLabel }}</option>
           {% endfor %}
         </select>
+        {% endif %}
         <input type="submit" class="btn btn-primary" id="edit-form-submit" value="ページを更新" />
       </div>
     </div>

+ 5 - 3
lib/views/admin/app.html

@@ -3,9 +3,11 @@
 {% block html_title %}アプリ設定 · {% endblock %}
 
 {% block content_head %}
-<header id="page-header">
-  <h1 class="title" id="">アプリ設定</h1>
-</header>
+<div class="header-wrap">
+  <header id="page-header">
+    <h1 class="title" id="">アプリ設定</h1>
+  </header>
+</div>
 {% endblock %}
 
 {% block content_main %}

+ 5 - 3
lib/views/admin/index.html

@@ -3,9 +3,11 @@
 {% block html_title %}Wiki管理 · {{ path }}{% endblock %}
 
 {% block content_head %}
-<header id="page-header">
-  <h1 class="title" id="">Wiki管理</h1>
-</header>
+<div class="header-wrap">
+  <header id="page-header">
+    <h1 class="title" id="">Wiki管理</h1>
+  </header>
+</div>
 {% endblock %}
 
 {% block content_main %}

+ 5 - 3
lib/views/admin/users.html

@@ -3,9 +3,11 @@
 {% block html_title %}ユーザー管理 · {% endblock %}
 
 {% block content_head %}
-<header id="page-header">
-  <h1 class="title" id="">ユーザー管理</h1>
-</header>
+<div class="header-wrap">
+  <header id="page-header">
+    <h1 class="title" id="">ユーザー管理</h1>
+  </header>
+</div>
 {% endblock %}
 
 {% block content_main %}

+ 6 - 14
lib/views/layout/2column.html

@@ -4,7 +4,10 @@
 <nav class="crowi-header navbar navbar-default" role="navigation">
   <!-- Brand and toggle get grouped for better mobile display -->
   <div class="navbar-header">
-    <a class="navbar-brand" href="/">{% block title %}{{ config.crowi['app:title'] }}{% endblock %}</a>
+    <a class="navbar-brand" href="/">
+      <img alt="Crowi" src="/logo/32x32.png" width="16">
+      {% block title %}{{ config.crowi['app:title'] }}{% endblock %}
+    </a>
   </div>
 
   <button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#navbarCollapse">
@@ -15,9 +18,6 @@
   </button>
   <!-- Collect the nav links, forms, and other content for toggling -->
   <div class="collapse navbar-collapse" id="navbarCollapse">
-    <ul class="nav navbar-nav">
-      <li class=""><a href="/INDEX">INDEX</a></li>
-    </ul>
     <form id="headerSearch" class="navbar-form navbar-left form-inline" role="search" action="/_search">
       <input id="searchQuery" name="q" type="text" class="form-control" placeholder="検索文字...">
       <button type="submit" class="btn btn-default">検索</button>
@@ -127,7 +127,7 @@
   <div id="footer-container" class="footer">
     <footer class="">
     <p>
-    <a href="" data-target="#helpModal" data-toggle="modal"><i class="fa fa-question-circle"> ヘルプ</i></a>
+    <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>
     </footer>
   </div>
@@ -144,7 +144,7 @@
 {% endblock %} {# layout_sidebar #}
 
 {% block layout_main %}
-<div id="main" class="main col-md-9 {% if page %}{{ css.grant(page) }}{% endif %}">
+<div id="main" class="main col-md-9 {% if page %}{{ css.grant(page) }}{% endif %} {% block main_css_class %}{% endblock %}">
   {% if page && page.grant != 1 %}
   <p class="page-grant">
     <i class="fa fa-lock"></i> {{ consts.pageGrants[page.grant] }} (このページの閲覧は制限されています)
@@ -152,10 +152,6 @@
   {% endif %}
   <article>
   {% block content_head %}
-    <header>
-    <h2>-</h2>
-    <p>-</p>
-    </header>
   {% endblock %}
 
   {% block content_main %}
@@ -163,10 +159,6 @@
   {% endblock content_main %}
 
   {% block content_footer %}
-    <footer>
-    <h3>-</h3>
-    <p>-</p>
-    </footer>
   {% endblock %}
   </article>
 </div>

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

@@ -13,7 +13,7 @@
 
   <link rel="stylesheet" href="/css/crowi{% if env  == 'production' %}.min{% endif %}.css">
   <script src="/js/crowi{% if env  == 'production' %}.min{% endif %}.js"></script>
-  <link href='//fonts.googleapis.com/css?family=Maven+Pro:400,700' rel='stylesheet' type='text/css'>
+  <link href='//fonts.googleapis.com/css?family=Open+Sans:400,700' rel='stylesheet' type='text/css'>
 </head>
 {% endblock %}
 

+ 1 - 1
lib/views/modal/widget_help.html

@@ -1,4 +1,4 @@
-<div class="modal fade" id="helpModal">
+<div class="modal fade" id="help-modal">
   <div class="modal-dialog">
     <div class="modal-content">
 

+ 9 - 4
lib/views/modal/widget_rename.html

@@ -19,11 +19,14 @@
             </div>
             <div class="form-group">
               <label for="newPageName">移動先のページ名</label><br>
-              <input type="text" class="form-control" name="newPageName" id="newPageName" value="{{ page.path }}">
+              <div class="input-group">
+                <span class="input-group-addon">{{ config.crowi['app:url'] }}</span>
+                <input type="text" class="form-control" name="new_path" id="newPageName" value="{{ page.path }}">
+              </div>
             </div>
             <div class="checkbox">
                <label>
-                 <input name="createRedirectPage" value="1"  type="checkbox"> リダイレクトページを作成
+                 <input name="create_redirect" value="1"  type="checkbox"> リダイレクトページを作成
                </label>
                <p class="help-block">チェックを入れると、<code>{{ page.path }}</code>にアクセスされた際に自動的に新しいページにジャンプします。</p>
             </div>
@@ -37,8 +40,10 @@
         </div>
         <div class="modal-footer">
           <p><small class="pull-left" id="newPageNameCheck"></small></p>
-          <input type="hidden" name="previousRevision" value="{{ page.revision._id.toString() }}">
-          <input type="submit" class="btn btn-primary" value="実行">
+          <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-primary" value="Rename!">
         </div>
 
       </form>

+ 57 - 0
lib/views/modal/widget_what_is_portal.html

@@ -0,0 +1,57 @@
+<div class="modal fade" id="help-portal">
+  <div class="modal-dialog">
+    <div class="modal-content">
+
+      <div class="modal-header">
+        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+        <h4 class="modal-title">What is Portal?</h4>
+      </div>
+
+      <div class="modal-body">
+        <h4>Portal とは</h4>
+        <br>
+        <ul>
+          <li>すべての、スラッシュ <code>/</code> で終わるページは、その階層の一覧ページとなります。</li>
+          <li>Portal 機能を使うと、その一覧ページに対して、任意の編集コンテンツを配置することができるようになります (つまり、一般的なページと同様に、編集したコンテンツを作成でき、その内容は常にページ一覧の上部に表示されるようになります)</li>
+        </ul>
+        <br>
+
+        <hr>
+
+        <h4>想定される使われ方</h4>
+        <br>
+        <p>
+        例えば、以下のようなページの階層があったとします。
+        </p>
+        <ul>
+          <li><code>/projects</code>
+            <ul>
+              <li><code>/projects/homepage-renewal</code>
+                <ul>
+                  <li><code>/projects/homepage-renewal/...</code></li>
+                </ul>
+              </li>
+              <li><code>/projects/...</code></li>
+            </ul>
+          </li>
+        </ul>
+
+        <p>
+        こういったケースでは、<code>/projects/homepage-renewal</code> には homepage-renewal プロジェクトについてのイントロや各ページへのリンク、関係者の紹介など、homepage-renewal に関する情報を掲載しておきたいと思うはずです。
+        </p>
+        <p>
+        Poral機能を使うと、こうしたときに、<code>/projects/homepage-renewal/</code> この <strong>"一覧ページ" を、ページ化することができ、そこに、通常のページと同じように Markdown で編集したコンテンツを配置することができるようになります</strong>。
+        </p>
+
+        <p>
+        まさにそのプロジェクトのポータルページを用意したい場合などに活用してください。
+        </p>
+
+        </div>
+
+      </div>
+
+    </div><!-- /.modal-content -->
+  </div><!-- /.modal-dialog -->
+</div><!-- /.modal -->
+

+ 78 - 207
lib/views/page.html

@@ -3,21 +3,45 @@
 {% block html_title %}{{ path|path2name }} · {{ path }}{% endblock %}
 
 {% block content_head %}
+
+{% block content_head_before %}
+{% endblock %}
+
 <div class="header-wrap">
   <header id="page-header">
     <p class="stopper"><a href="#" data-affix-disable="#page-header"><i class="fa fa-chevron-up"></i></a></p>
 
-    <h1 class="title" id="revision-path">{{ path }}</h1>
+
+    {% if page %}
+      <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+    {% endif %}
+    <h1 class="title" id="revision-path">{{ path|insertSpaceToEachSlashes }}</h1>
   </header>
 </div>
+
+{% block content_head_after %}
+{% endblock %}
+
 {% endblock %}
 
 {% block content_main %}
-<div id="content-main" class="content-main {% if not page %}on-edit{% endif %}" data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}">
+
+{% block content_main_before %}
+{% endblock %}
+
+<div id="content-main" class="content-main {% if not page or req.body.pageForm %}on-edit{% endif %}"
+  data-path="{{ path }}"
+  data-path-shortname="{{ path|path2name }}"
+  data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}"
+  data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
+  data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
+  data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
+  data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
+  >
 
   {% if not page %}
   <ul class="nav nav-tabs hidden-print">
-    <li><a>ページ作成: {{ path|path2name }}</a></li>
+    <li><a>Create: {{ path }}</a></li>
     <li class="dropdown pull-right">
       <a href="/"><i class="fa fa-times"></i> キャンセル</a>
     </li>
@@ -44,6 +68,7 @@
 
     <li {% if req.body.pageForm %}class="active"{% endif %}><a href="#edit-form" data-toggle="tab"><i class="fa fa-pencil-square-o"></i> 編集</a></li>
 
+
     <li class="dropdown pull-right">
       <a class="dropdown-toggle" data-toggle="dropdown" href="#">
         <i class="fa fa-wrench"></i> <span class="caret"></span>
@@ -53,7 +78,9 @@
        <li><a href="?presentation=1" class="toggle-presentation"><i class="fa fa-arrows-alt"></i> プレゼンモード (beta)</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>
 
   {% include 'modal/widget_rename.html' %}
@@ -89,238 +116,82 @@
       <div class="wiki {{ revision.format }}" id="revision-body-content"></div>
     </div>
 
-    {# raw text #}
-    <div class="tab-pane" id="raw-text">
-      <pre id="">{{ revision.body }}</pre>
-    </div>
-
     {# edit form #}
     <div class="edit-form tab-pane {% if req.body.pageForm %}active{% endif %}" id="edit-form">
       {% include '_form.html' %}
     </div>
+
+    {# raw revision history #}
+    <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="fk-hide" id="diff-display-{{ t._id.toString()}}"></pre>
+            </div>
+          </div>
+        </div>
+        {% endfor %}
+      </div>
+      {% endif %}
+
+    </div>
+
   </div>
-  <script type="text/javascript">
-    $(function(){
-        var renderer = new Crowi.renderer($('#raw-text-original').html());
-        renderer.render();
-        Crowi.correctHeaders('#revision-body-content');
-        Crowi.revisionToc('#revision-body-content', '#revision-toc');
-
-        $('a[data-toggle="tab"][href="#edit-form"]').on('show.bs.tab', function() {
-          $('.content-main').addClass('on-edit');
-        });
-        $('a[data-toggle="tab"][href="#edit-form"]').on('hide.bs.tab', function() {
-          $('.content-main').removeClass('on-edit');
-        });
-
-        $('#edit-form').submit(function()
-        {
-          //console.log('save');
-          //return false;
-        });
-
-        //data-spy="affix" data-offset-top="80"
-        var headerHeight = $('#page-header').outerHeight(true);
-        $('.header-wrap').css({height: headerHeight + 'px'});
-        $('#page-header').affix({
-          offset: {
-            top: function() {
-              return headerHeight + 74; // (54 header + 20 padding-top)
-            }
-          }
-        });
-        $('[data-affix-disable]').on('click', function(e) {
-          $elm = $($(this).data('affix-disable'));
-          $(window).off('.affix');
-          $elm.removeData('affix').removeClass('affix affix-top affix-bottom');
-          return false;
-        });
-    });
-  </script>
   {% endif %}
 </div>
 
+{% block content_main_after %}
+{% endblock %}
+
 {% endblock %}
 
 {% block content_footer %}
 
-<div class="page-attachments">
+
+<div class="page-attachments meta">
   <p>Attachments</p>
   <ul>
   </ul>
 </div>
-<script>
-  (function() {
-    var pageId = $('#content-main').data('page-id');
-    var $pageAttachmentList = $('.page-attachments ul');
-    if (pageId) {
-      $.get('/_api/attachment/page/' + pageId, function(res) {
-        var attachments = res.data.attachments;
-        var urlBase = res.data.fileBaseUrl;
-        if (attachments.length > 0) {
-          $.each(attachments, function(i, file) {
-            $pageAttachmentList.append(
-            '<li><a href="' + urlBase + file.filePath + '">' + (file.originalName || file.fileName) + '</a> <span class="label label-default">' + file.fileFormat + '</span></li>'
-            );
-          })
-        } else {
-          $('.page-attachments').remove();
-        }
-      });
-    }
-  })();
-</script>
-<footer>
-  {% if not page %}
-  {% else %}
-  <p class="meta">
-  Path: <span id="pagePath">{{ page.path }}</span><br />
-  Revision: {{ revision._id.toString() }}<br />
-  {% if author %}
-  Last Updated User: <a href="/user/{{ author.username }}">{{ author.name }}</a><br />
-  {% endif %}
-  Created: {{ page.createdAt|datetz('Y-m-d H:i:s') }}<br />
-  Updated: {{ page.updatedAt|datetz('Y-m-d H:i:s') }}<br />
-  </p>
-  {% endif %}
-</footer>
+
+<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>
+  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 %}
-
-{% if page %} {# {{{ if page #}
-<div class="page-meta">
-  <div class="row">
-    {# default(author) としているのは、v1.1.1 以前に page.creator データが入ってないから。暫定として最新更新ユーザーを表示しちゃう。 #}
-    <div class="col-md-3 creator-picture">
-      <img src="{{ page.creator|default(author)|picture }}" class="picture picture-lg picture-rounded"><br>
-    </div>
-    <div class="col-md-9">
-      <p class="creator">
-        {{ page.creator.name|default(author.name) }}
-      </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/{{ author.username }}"><img src="{{ author|picture }}" class="picture picture-xs picture-rounded" alt="{{ author.name }}"></a>
-      </p>
-    </div>
-  </div>
-
-  <div class="like-box">
-    <dl class="dl-horizontal">
-      <dt>
-        <i class="fa fa-star"></i> お気に入り
-      </dt>
-      <dd>
-        <button class="btn btn-default btn-sm btn-bookmark" id="bookmarkButton"><i class="fa fa-star-o"></i></button>
-      </dd>
-
-      <dt>
-        <i class="fa fa-thumbs-o-up"></i> いいね!
-      </dt>
-      <dd>
-        <p class="liker-count">
-        {{ page.liker.length }}
-        </p>
-        <p class="liker-list">
-          {% for liker in page.liker %}
-            <a href="{{ user_page_root(liker) }}" title="{{ liker.name }}"><img alt="{{ liker.name }}" src="{{ liker|picture }}" class="picture picture-xs picture-rounded"></a>
-          {% endfor %}
-          {% if page.liker.length > 10 %}
-            (...)
-          {% endif %}
-        </p>
-        {% if page.isLiked(user) %}
-          <button data-liked="1" class="btn btn-default btn-sm active" id="pageLikeButton"><i class="fa fa-thumbs-up"></i> いいね!!!</button>
-        {% else %}
-          <button data-liked="0" class="btn btn-default btn-sm" id="pageLikeButton"><i class="fa fa-thumbs-o-up"></i> いいね!!!</button>
-        {% endif %}
-      </dd>
-
-      <dt><i class="fa fa-eye"></i> 見た人</dt>
-      <dd>
-        <p class="seen-user-count">
-          {{ page.seenUsers.length }}
-        </p>
-        <p class="seen-user-list">
-          {% for seenUser in page.seenUsers %}
-          <a href="{{ user_page_root(seenUser) }}" title="{{ seenUser.name }}"><img alt="{{ seenUser.name }}" src="{{ seenUser|picture }}" class="picture picture-xs picture-rounded"></a>
-          {% endfor %}
-          {% if page.seenUsers.length > 10 %}
-            (...)
-          {% endif %}
-        </p>
-      </dd>
-    </dl>
-  </div>
-<script>
-$(function() {
-  $.get('/_api/page/{{ page._id.toString() }}/bookmark', function(data) {
-    if (data.bookmarked) {
-      $('#bookmarkButton')
-        .removeClass('btn-default')
-        .addClass('btn-warning active bookmarked');
-      $('#bookmarkButton i')
-        .removeClass('fa-star-o')
-        .addClass('fa-star');
-    }
-  });
-
-  $('#bookmarkButton').click(function() {
-    var pageId = {{page._id|json|safe}};
-    $.post('/_api/page/{{ page._id.toString() }}/bookmark', function(data) {
-    });
-  });
-  $('#pageLikeButton').click(function() {
-    var pageId = {{page._id|json|safe}};
-    $.post('/_api/page/{{ page._id.toString() }}/like', function(data) {
-    });
-  });
-});
-</script>
-</div>
-{% endif %} {# if page }}} #}
+  {% include 'widget/page_side_header.html' %}
 {% endblock %} {# side_header #}
 
 {% block side_content %}
-
-  <h3><i class="fa fa-link"></i> 共有</h3>
-  <ul class="fitted-list">
-    <li data-toggle="tooltip" data-placement="bottom" title="共有用リンク" class="input-group">
-      <span class="input-group-addon">共有用</span>
-      <input class="copy-link form-control" type="text" value="{{ config.crowi['app:title'] }} {{ path }}  {{ baseUrl }}/_r/{{ page._id.toString() }}">
-    </li>
-    <li data-toggle="tooltip" data-placement="bottom" title="Wiki記法" class="input-group">
-      <span class="input-group-addon">Wikiタグ</span>
-      <input class="copy-link form-control" type="text" value="&lt;{{ path }}&gt;">
-    </li>
-    <li data-toggle="tooltip" data-placement="bottom" title="Markdown形式のリンク" class="input-group">
-      <span class="input-group-addon">Markdown</span>
-      <input class="copy-link form-control" type="text" value="[{{ path }}]({{ baseUrl }}/_r/{{ revision._id.toString() }})">
-    </li>
-  </ul>
-
-  <h3><i class="fa fa-history"></i> History</h3>
-  {% if not page %}
-  {% else %}
-  <ul class="revision-history">
-    {% for t in tree %}
-    <li>
-      <a href="?revision={{ t._id.toString() }}">
-        <img src="{{ t.author|picture }}" class="picture picture-rounded">
-        {% if t.author %}{{ t.author.username }}{% else %}-{% endif %}<br>{{ t.createdAt|datetz('Y-m-d H:i:s') }}
-      </a>
-    </li>
-    {% endfor %}
-  </ul>
-  {% endif %}
+  {% include 'widget/page_side_content.html' %}
 {% endblock %}
 
 {% block footer %}
 <div id="notifPageEdited" class="fk-hide fk-notif fk-notif-danger"><i class="fa fa-exclamation-triangle"></i> <span class="edited-user"></span>さんがこのページを編集しました。 <a href="javascript:location.reload();"><i class="fa fa-angle-double-right"></i> 最新版を読み込む</a></div>
 <div id="notifPageEditing" class="fk-hide fk-notif fk-notif-warning"><i class="fa fa-exclamation-triangle"></i> 他の人がこのページの編集を開始しました。</div>
 
+
 <script>
   $(function() {
     var me = {{ user|json|safe }};

+ 155 - 25
lib/views/page_list.html

@@ -1,41 +1,123 @@
 {% extends 'layout/2column.html' %}
 
+{% block html_title %}{{ path|path2name }} · {{ path }}{% endblock %}
+
 {% block content_head %}
-  <header>
-  <h1 class="title" id="revision-path">{{ path }}</h1>
+
+{% block content_head_before %}
+{% endblock %}
+
+<div class="header-wrap">
+  <header class="portal-header {% if page %}has-page{% endif %}">
+    {% if page %}
+      <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+
+    {% endif %}
+    <h1 class="title">
+      <span class="" id="revision-path">{{ path|insertSpaceToEachSlashes }}</span>
+      <div class="form-group search-input-group">
+        <div class="input-group">
+          <input type="text" class="search-listpage-input form-control input-sm" data-path="{{ path }}">
+          <div class="input-group-addon"><i class="fa fa-search"></i></div>
+        </div>
+      </div>
+    </h1>
   </header>
+</div>
+
+{% endblock %}
+
+{% block content_head_after %}
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
 
-<ul class="nav nav-tabs">
-    <li class="active"><a href="#view-list" data-toggle="tab">リスト表示</a></li>
-    <li><a href="#view-timeline" data-toggle="tab">タイムライン表示</a></li>
-</ul>
+{% block content_main_before %}
+{% endblock %}
+
+<div class="page-list content-main {% if req.body.pageForm %}on-edit{% endif %}"
+  id="content-main"
+  data-path="{{ path }}"
+  data-path-shortname="{{ path|path2name }}"
+  data-page-portal="{% if page and page.isPortal() %}1{% else %}0{% endif %}"
+  data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}"
+  data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
+  data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
+  data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
+  data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
+  >
+
+<div class="portal {% if not page or req.query.offset > 0 %}hide{% endif %}">
+
+  <ul class="nav nav-tabs hidden-print">
+   {# portal tab #}
+    <li class=" {% if not req.body.pageForm %}active{% endif %}">
+      {% if page %}
+      <a href="#revision-body-content" data-toggle="tab">
+        <i class="fa fa-magic"></i>
+        PORTAL
+      </a>
+      {% else %}
+      <a>Create Portal: {{ path }}</a>
+      {% endif %}
+    </li>
+    <li {% if req.body.pageForm %}class="active"{% endif %}><a href="#edit-form" data-toggle="tab"><i class="fa fa-pencil-square-o"></i> 編集</a></li>
+
+    {% if not page %}
+    <li class="pull-right close-button">
+      <a href="#" id="portal-form-close">
+        <i class="fa fa-times"></i>
+      </a>
+    </li>
+    {% endif %}
+  </ul>
 
-<h2>ページ一覧</h2>
   <div class="tab-content">
-    {# list view #}
-    <div class="active wiki tab-pane fade in" id="view-list">
-      {% for page in pages %}
-        <a href="{{ page.path }}">{{ page.path }}</a>
+    <div class="wiki tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body-content">{{ page.revision.body|nl2br|safe }}</div>
 
-        {% if !page.isPublic() %}
-          <i class="fa fa-lock"></i>
-        {% endif %}
-        <br />
-      {% endfor %}
+    <script type="text/template" id="raw-text-original">{{ page.revision.body }}</script>
+    <script type="text/javascript">
+      $(function(){
+          var renderer = new Crowi.renderer($('#raw-text-original').html());
+          renderer.render();
+          Crowi.correctHeaders('#revision-body-content');
+      });
+    </script>
+    <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>
+</div> {# /.portal #}
 
-        <ul class="pagination">
-          {% if pager.prev != null %}
-            <li class="prev"><a href="{{ path }}?offset={{ pager.prev }}&limit={{ pager.limit }}"><i class="fa fa-arrow-left"></i> Prev</a></li>
-          {% endif %}
-          {# この条件は無いな.. #}
-          {% if pages.length > 0 %}
-            <li class="next"><a href="{{ path }}?offset={{ pager.next }}&limit={{ pager.limit }}">Next <i class="fa fa-arrow-right"></i></a></li>
-          {% endif %}
+<div class="page-list-container">
+  <ul class="nav nav-tabs">
+      <li class="active"><a href="#view-list" data-toggle="tab">リスト表示</a></li>
+      <li><a href="#view-timeline" data-toggle="tab">タイムライン表示</a></li>
+  </ul>
+
+  <div class="tab-content">
+    {% if pages.length == 0 %}
+    There are no pages under <strong>{{ path }}</strong>.
+
+    <h3>Next Actions</h3>
+
+    <ul>
+      <li>Create portal page?
+        <ul>
+          <li>Great! To create the portal of <strong>{{ path }}</strong>, click "Create Portal" button.</li>
+        </ul>
+      </li>
+      <li>Create the under page directly?
+        <ul>
+          <li>Nice. To create the page under <strong>{{ path }}</strong> directly, type the page name on your browser.</li>
         </ul>
+      </li>
+    </ul>
+    {% endif  %}
+
+    {# list view #}
+    <div class="active tab-pane fade page-list-container in" id="view-list">
+      {% include 'widget/page_list.html' with { pages: pages, pager: pager } %}
     </div>
 
     {# timeline view #}
@@ -50,6 +132,7 @@
       {% endfor %}
     </div>
   </div>
+</div>
 
   <script type="text/javascript">
     $(function(){
@@ -67,7 +150,10 @@
   </script>
 
 </div> {# /.content-main #}
+{% include 'modal/widget_what_is_portal.html' %}
 
+{% block content_main_after %}
+{% endblock %}
 
 {% endblock %}
 
@@ -78,3 +164,47 @@
 </footer>
 {% endblock %}
 
+
+{% block side_header %}
+
+{% if not page and not isUserPageList(path) %}
+<div class="portal-side">
+  <div class="portal-form-button">
+    <button class="btn btn-primary" id="create-portal-button">Create Portal</button>
+    <p class="help-block"><a href="#" data-target="#help-portal" data-toggle="modal"><i class="fa fa-question-circle"></i> What is Portal?</a></p>
+  </div>
+
+</div>
+{% else %}
+  {% include 'widget/page_side_header.html' %}
+{% endif %}
+
+{% endblock %} {# side_header #}
+
+{% block body_end %}
+<div class="modal fade portal-warning-modal" id="portal-warning-modal">
+  <div class="modal-dialog">
+    <div class="modal-content">
+
+      <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 alert alert-danger">
+
+        <strong>Warning!</strong><br>
+
+        <p>既に <strong><a href="{{ path|removeLastSlash }}">{{ path|removeLastSlash }}</a></strong> のページが存在します。</p>
+
+        <p>
+          <a href="{{ path|removeLastSlash }}">{{ path|removeLastSlash }}</a> をポータル化するには、
+          <a href="{{ path|removeLastSlash }}">{{ path|removeLastSlash }}</a> に移動し、「ページを移動」させてください。<br>
+          <a href="{{ path|removeLastSlash }}">{{ path|removeLastSlash }}</a> とは別に、このページ(<code>{{ path }}</code>)にポータルを作成する場合、このまま編集を続けて作成してください。
+        </p>
+
+      </div>
+    </div>
+  </div>
+</div>
+</div>
+{% endblock %} {# body_end #}

+ 32 - 0
lib/views/user/bookmarks.html

@@ -0,0 +1,32 @@
+{% extends '../layout/2column.html' %}
+
+{% block main_css_class %}bookmark-page{% endblock %}
+
+{% block html_title %}{{ user.name }}'s Bookmarks · {% endblock %}
+
+{% block content_head %}
+<div class="header-wrap">
+  <header id="page-header">
+    <p class="stopper"><a href="#" data-affix-disable="#page-header"><i class="fa fa-chevron-up"></i></a></p>
+
+    <h1 class="title">{{ user.name }}'s Bookmarks</h1>
+  </header>
+</div>
+{% endblock %}
+
+{% block content_main %}
+<div id="content-main" class="content-main page-list" >
+  <div class="page-list-container" id="bookamrk-list">
+    {% include '../widget/page_list.html' with { pages: bookmarks, pagePropertyName: 'page', pager: pager } %}
+  </div>
+</div>
+{% endblock %}
+
+{% block side_header %}
+{% endblock %} {# side_header #}
+
+{% block side_content %}
+{% endblock %}
+
+{% block footer %}
+{% endblock %}

+ 32 - 0
lib/views/user/recent-create.html

@@ -0,0 +1,32 @@
+{% extends '../layout/2column.html' %}
+
+{% block main_css_class %}recent-create-page{% endblock %}
+
+{% block html_title %}{{ user.name }}'s Recent Created Pages 揃 {% endblock %}
+
+{% block content_head %}
+<div class="header-wrap">
+  <header id="page-header">
+    <p class="stopper"><a href="#" data-affix-disable="#page-header"><i class="fa fa-chevron-up"></i></a></p>
+
+    <h1 class="title">{{ user.name }}'s Created Pages</h1>
+  </header>
+</div>
+{% endblock %}
+
+{% block content_main %}
+<div id="content-main" class="content-main page-list" >
+  <div class="page-list-container" id="bookamrk-list">
+    {% include '../widget/page_list.html' with { pages: pages, pager: pager } %}
+  </div>
+</div>
+{% endblock %}
+
+{% block side_header %}
+{% endblock %} {# side_header #}
+
+{% block side_content %}
+{% endblock %}
+
+{% block footer %}
+{% endblock %}

+ 77 - 0
lib/views/user_page.html

@@ -1,2 +1,79 @@
 {% extends 'page.html' %}
 
+{% block main_css_class %}user-page{% endblock %}
+
+{% block content_head %}
+
+{% if pageUser %}
+
+<div class="header-wrap">
+  <h1 class="title" id="revision-path">{{ path|insertSpaceToEachSlashes }}</h1>
+  <div class="user-page-header">
+  {% if page %}
+    <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+  {% endif %}
+    <div class="pull-left user-page-picture">
+      <img src="{{ pageUser|picture }}" class="picture picture-rounded">
+    </div>
+    <div class="user-page-meta">
+      <h2>{{ pageUser.name }}</h2>
+      <ul>
+        <li class="user-page-username"><i class="fa fa-user"></i> @{{ pageUser.username }}</li>
+        <li class="user-page-email"><i class="fa fa-envelope-o"></i> {{ pageUser.email }}</li>
+        {% if pageUser.introduction %}
+        <li class="user-page-introduction"><p>{{ pageUser.introduction|nl2br }}</p></li>
+        {% endif %}
+      </ul>
+    </div>
+  </div>
+
+
+  <div class="user-page-content">
+    <ul class="nav nav-tabs user-page-content-menu">
+      <li class="active">
+        <a href="#user-bookmark-list" data-toggle="tab"><i class="fa fa-star"></i> Bookmarks</a>
+      </li>
+      <li>
+        <a href="#user-created-list" data-toggle="tab"><i class="fa fa-pencil"></i> Recent Created</a>
+      </li>
+      <li>
+        <a href="/me"><i class="fa fa-gears"></i> Setting</a>
+      </li>
+    </ul>
+    <div class="user-page-content-tab tab-content">
+
+      <div class="tab-pane user-bookmark-list page-list active" id="user-bookmark-list">
+        <div class="page-list-container">
+          {% if bookmarkList.length == 0 %}
+          No bookmarks yet.
+          {% else %}
+            {% include 'widget/page_list.html' with { pages: bookmarkList, pagePropertyName: 'page' } %}
+            <div class="user-page-list-additional-link">
+              <a href="/user/{{ pageUser.username }}/bookmarks"><i class="fa fa-angle-double-right"></i> See bookmarks</a>
+            </div>
+          {% endif %}
+        </div>
+      </div>
+
+      <div class="tab-pane user-created-list page-list" id="user-created-list">
+        <div class="page-list-container">
+          {% if createdList.length == 0 %}
+          No created pages yet.
+          {% else %}
+            {% include 'widget/page_list.html' with { pages: createdList } %}
+            <div class="user-page-list-additional-link">
+              <a href="/user/{{ pageUser.username }}/recent-create"><i class="fa fa-angle-double-right"></i> See created pages</a>
+            </div>
+          {% endif %}
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
+{% else %}
+  {% parent %}
+{% endif %}
+
+{% endblock %}
+

+ 46 - 0
lib/views/widget/page_list.html

@@ -0,0 +1,46 @@
+<ul class="page-list-ul">
+{% for data in pages %}
+
+{% if pagePropertyName %}
+  {% set page = data[pagePropertyName] %}
+{% else %}
+  {% set page = data %}
+{% endif %}
+
+<li class="page-list-li">
+  <div class="picture-outer">
+    <img src="{{ page.revision.author|picture }}" class="picture picture-rounded">
+  </div>
+  <div class="page-link-outer">
+    <a class="page-list-link" href="{{ page.path }}"
+      data-path="{{ page.path }}"
+      data-short-path="{{ page.path|path2name }}">{{ page.path }}</a>
+
+    <span class="page-list-meta">
+      {% if page.isPortal() %}
+        <span class="label label-info">PORTAL</span>
+      {% endif  %}
+
+      {% if page.commentCount > 0 %}
+        <i class="fa fa-comment"></i>{{ page.commentCount }}
+      {% endif  %}
+
+      {% if !page.isPublic() %}
+        <i class="fa fa-lock"></i>
+      {% endif %}
+    </span>
+  </div>
+</li>
+{% endfor %}
+</ul>
+
+{% if pager %}
+<ul class="pagination">
+  {% if pager.prev !== null %}
+    <li class="prev"><a href="{{ path }}?offset={{ pager.prev }}"><i class="fa fa-arrow-left"></i> Prev</a></li>
+  {% endif %}
+  {% if pager.next %}
+    <li class="next"><a href="{{ path }}?offset={{ pager.next }}">Next <i class="fa fa-arrow-right"></i></a></li>
+  {% endif %}
+</ul>
+{% endif %}

+ 42 - 0
lib/views/widget/page_side_content.html

@@ -0,0 +1,42 @@
+<h3><i class="fa fa-link"></i> Share</h3>
+<ul class="fitted-list">
+  <li data-toggle="tooltip" data-placement="bottom" title="共有用リンク" class="input-group">
+    <span class="input-group-addon">共有用</span>
+    <input class="copy-link form-control" type="text" value="{{ config.crowi['app:title'] }} {{ path }}  {{ baseUrl }}/{{ page._id.toString() }}">
+  </li>
+  <li data-toggle="tooltip" data-placement="bottom" title="Markdown形式のリンク" class="input-group">
+    <span class="input-group-addon">Markdown</span>
+    <input class="copy-link form-control" type="text" value="[{{ path }}]({{ baseUrl }}/{{ page._id.toString() }})">
+  </li>
+</ul>
+
+<h3><i class="fa fa-comment"></i> Comments</h3>
+<div class="page-comments">
+  <form class="form page-comment-form" id="page-comment-form">
+    <div class="comment-form">
+      <div class="comment-form-main">
+        <div class="comment-write" id="comment-write">
+          <textarea class="comment-form-comment form-control" id="comment-form-comment" name="commentForm[comment]"></textarea>
+        </div>
+        <div class="comment-submit">
+          <input type="hidden" name="commentForm[page_id]" value="{{ page._id.toString() }}">
+          <input type="hidden" name="commentForm[revision_id]" value="{{ revision._id.toString() }}">
+          <span class="text-danger" id="comment-form-message"></span>
+          <input type="submit" id="commenf-form-button" value="Comment" class="btn btn-primary btn-sm form-inline">
+        </div>
+      </div>
+    </div>
+  </form>
+
+  <div class="page-comments-list" id="page-comments-list">
+    <div class="page-comments-list-newer collapse" id="page-comments-list-newer"></div>
+
+    <a class="page-comments-list-toggle-newer text-center" data-toggle="collapse" href="#page-comments-list-newer"><i class="fa fa-angle-double-up"></i> Comments for Newer Revision <i class="fa fa-angle-double-up"></i></a>
+
+    <div class="page-comments-list-current" id="page-comments-list-current"></div>
+
+    <a class="page-comments-list-toggle-older text-center" data-toggle="collapse" href="#page-comments-list-older"><i class="fa fa-angle-double-down"></i> Comments for Older Revision <i class="fa fa-angle-double-down"></i></a>
+
+    <div class="page-comments-list-older collapse in" id="page-comments-list-older"></div>
+  </div>
+</div>

+ 55 - 0
lib/views/widget/page_side_header.html

@@ -0,0 +1,55 @@
+{% if page %} {# {{{ if page #}
+<div class="page-meta">
+  <div class="row">
+    {# default(author) としているのは、v1.1.1 以前に page.creator データが入ってないから。暫定として最新更新ユーザーを表示しちゃう。 #}
+    <div class="col-md-3 creator-picture">
+      <img src="{{ page.creator|default(author)|picture }}" class="picture picture-lg picture-rounded"><br>
+    </div>
+    <div class="col-md-9">
+      <p class="creator">
+        {{ page.creator.name|default(author.name) }}
+      </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>
+      </p>
+    </div>
+  </div>
+
+  <div class="like-box">
+    <dl class="dl-horizontal">
+      <dt>
+        <i class="fa fa-thumbs-o-up"></i> いいね!
+      </dt>
+      <dd>
+        <p class="liker-count">
+        <span id="like-count">{{ page.liker.length }}</span>
+        <button
+          data-liked="{% if page.isLiked(user) %}1{% else %}0{% endif %}"
+          class="btn btn-default btn-sm {% if page.isLiked(user) %}active{% endif %}"
+          id="like-button"><i class="fa fa-thumbs-o-up"></i> いいね!</button>
+        </p>
+        <p id="liker-list" class="liker-list" data-likers="{{ page.liker|default([])|join(',') }}">
+        </p>
+      </dd>
+
+      <dt><i class="fa fa-eye"></i> 見た人</dt>
+      <dd>
+        <p class="seen-user-count">
+          {{ page.seenUsers.length }}
+        </p>
+        <p id="seen-user-list" class="seen-user-list" data-seen-users="{{ page.seenUsers|default([])|join(',') }}">
+        {#
+          {% for seenUser in page.seenUsers %}
+          <a href="{{ user_page_root(seenUser) }}" title="{{ seenUser.name }}"><img alt="{{ seenUser.name }}" src="{{ seenUser|picture }}" class="picture picture-xs picture-rounded"></a>
+          {% endfor %}
+          {% if page.seenUsers.length > 10 %}
+            (...)
+          {% endif %}
+        #}
+        </p>
+      </dd>
+    </dl>
+  </div>
+</div>
+{% endif %} {# if page }}} #}

+ 0 - 100
lib/views/widget/searcher.html

@@ -1,100 +0,0 @@
-{% if config.crowi['searcher:url'] %}
-
-<form id="headerSearch" class="navbar-form navbar-left form-inline" role="search">
-  <div class="form-group">
-    <input id="searchQuery" name="q" type="text" class="form-control" placeholder="検索文字...">
-    <button type="submit" class="btn btn-default">検索</button>
-    <script>
-      function Searcher () {
-      };
-      Searcher.prototype = {
-        baseUrl: "{{ config.crowi['searcher:url'] }}",
-        currentQuery: "",
-        searchData: [],
-        setData: function (data) {
-          this.searchData = data;
-        },
-        search: function (query) {
-          var self = this;
-          self.currentQuery = query;
-          if (query == "") {
-            return false;
-          }
-          $("#searchQuery").addClass('searching');
-          $.ajax({
-            url: self.baseUrl + '/search?type=json&callback=?',
-            data: {q: query},
-            cache: false,
-            dataType: 'jsonp'
-          }).done(function (data) {
-            self.setData(data);
-            self.showSearchWidget();
-            $("#searchQuery").removeClass('searching');
-          });
-        },
-        showSearchWidget: function () {
-          if (this.searchData.length > 0) {
-            $('#headerSearch').popover('show');
-          }
-          $("#searchQuery").removeClass('searching');
-        },
-        createSearchContent: function () {
-          var self = this;
-          var contentHtml = $('<ul class=\"search-list\"></ul>');
-          $.each(self.searchData.slice(0, 6), function (i, d) {
-            var $li = $("<li class=\"list-link\"></li>");
-            var $a = $("<a></a>");
-            $a.attr('href', d.path).html(d.path + "<br>");
-            var $span = $("<span class=\"search-description\"></span>");
-            $span.text(d.body.substr(0, 50) + "...");
-            $a.append($span);
-            $li.append($a);
-            contentHtml.append($li);
-          });
-
-          var $li = $("<li class=\"divider\"></li>");
-          contentHtml.append($li);
-          $li = $("<li class=\"next-link\"></li>");
-          $li.html("<a href=\"" + self.baseUrl + "/search?q=" + encodeURIComponent(self.currentQuery) + "\">もっと見る</a>");
-          contentHtml.append($li);
-          return contentHtml;
-        },
-        jump: function (query) {
-          self = this;
-          top.location.href = self.baseUrl + '/search?q=' + query;
-        }
-      };
-      var SearcherObject = new Searcher();
-
-      $('#headerSearch').popover({
-        placement: 'bottom',
-        trigger: 'manual',
-        html: 'true',
-        content: function () {
-          return SearcherObject.createSearchContent();
-        }
-      });
-      $('#searchQuery').on('focus', function(e) {
-        SearcherObject.showSearchWidget();
-      });
-      $('#searchQuery').on('blur', function(e) {
-        $('#headerSearch').popover('hide');
-      });
-      $('#headerSearch').on('submit', function(e) {
-        SearcherObject.jump($("#searchQuery").val());
-        return false;
-      });
-
-      var previousText = "";
-      setInterval(function (e) {
-        var text = $("#searchQuery").val();
-        if (text != previousText) {
-          SearcherObject.search(text);
-        }
-        previousText = text;
-      }, 1000);
-    </script>
-  </div>
-</form>
-
-{% endif %}

+ 68 - 0
local_modules/crowi-fileupload-aws/index.js

@@ -0,0 +1,68 @@
+// crowi-fileupload-aws
+
+module.exports = function(crowi) {
+  'use strict';
+
+  var aws = require('aws-sdk')
+    , debug = require('debug')('crowi:lib:fileUploaderAws')
+    , Promise = require('bluebird')
+    , Config = crowi.model('Config')
+    , config = crowi.getConfig()
+    , lib = {}
+    , getAwsConfig = function() {
+        var config = crowi.getConfig();
+        return {
+          accessKeyId: config.crowi['aws:accessKeyId'],
+          secretAccessKey: config.crowi['aws:secretAccessKey'],
+          region: config.crowi['aws:region'],
+          bucket: config.crowi['aws:bucket']
+        };
+      };
+
+  lib.deleteFile = function(filePath) {
+    return new Promise(function(resolve, reject) {
+      debug('Unsupported file deletion.');
+      resolve('TODO: ...');
+    });
+  };
+
+  lib.uploadFile = function(filePath, contentType, fileStream, options) {
+    var awsConfig = getAwsConfig();
+    if (!Config.isUploadable(config)) {
+      return new Promise.reject(new Error('AWS is not configured.'));
+    }
+
+    aws.config.update({
+      accessKeyId: awsConfig.accessKeyId,
+      secretAccessKey: awsConfig.secretAccessKey,
+      region: awsConfig.region
+    });
+    var s3 = new aws.S3();
+
+    var params = {Bucket: awsConfig.bucket};
+    params.ContentType = contentType;
+    params.Key = filePath;
+    params.Body = fileStream;
+    params.ACL = 'public-read';
+
+    return new Promise(function(resolve, reject) {
+      s3.putObject(params, function(err, data) {
+        if (err) {
+          return reject(err);
+        }
+
+        return resolve(data);
+      });
+    });
+  };
+
+  lib.generateUrl = function(filePath) {
+    var awsConfig = getAwsConfig()
+      , url = 'https://' + awsConfig.bucket +'.s3.amazonaws.com/' + filePath;
+
+    return url;
+  };
+
+  return lib;
+};
+

+ 61 - 0
local_modules/crowi-fileupload-local/index.js

@@ -0,0 +1,61 @@
+// crowi-fileupload-local
+
+module.exports = function(crowi) {
+  'use strict';
+
+  var debug = require('debug')('crowi:lib:fileUploaderLocal')
+    , fs = require('fs')
+    , path = require('path')
+    , mkdir = require('mkdirp')
+    , Promise = require('bluebird')
+    , Config = crowi.model('Config')
+    , config = crowi.getConfig()
+    , lib = {}
+    , basePath = path.join(crowi.publicDir, 'uploads'); // TODO: to configurable
+
+  lib.deleteFile = function(filePath) {
+    debug('File deletion: ' + filePath);
+    return new Promise(function(resolve, reject) {
+      fs.unlink(path.join(basePath, filePath), function(err) {
+        if (err) {
+          debug(err);
+          return reject(err);
+        }
+
+        resolve();
+      });
+    });
+  };
+
+  lib.uploadFile = function(filePath, contentType, fileStream, options) {
+    debug('File uploading: ' + filePath);
+    return new Promise(function(resolve, reject) {
+      var localFilePath = path.join(basePath, filePath)
+        , dirpath = path.dirname(localFilePath);
+
+      mkdir(dirpath, function(err) {
+        if (err) {
+          return reject(err);
+        }
+
+        var writer = fs.createWriteStream(localFilePath);
+
+        writer.on('error', function(err) {
+          reject(err);
+        }).on('finish', function() {
+          resolve();
+        });
+
+        fileStream.pipe(writer);
+      });
+    });
+  };
+
+  lib.generateUrl = function(filePath) {
+    return path.join('/uploads', filePath);
+  };
+
+  return lib;
+};
+
+

Разница между файлами не показана из-за своего большого размера
+ 25 - 0
local_modules/crowi-fileupload-none/index.js


+ 4 - 1
package.json

@@ -42,7 +42,8 @@
     "consolidate": "~0.11.0",
     "cookie-parser": "~1.3.4",
     "debug": "~2.2.0",
-    "elasticsearch": "~10.0.1",
+    "elasticsearch": "~11.0.1",
+    "diff": "~2.2.2",
     "errorhandler": "~1.3.4",
     "express": "~4.13.3",
     "express-form": "~0.12.0",
@@ -67,6 +68,7 @@
     "kerberos": "0.0.17",
     "marked": "~0.3.5",
     "method-override": "~2.3.1",
+    "mkdirp": "^0.5.1",
     "mongoose": "4.2.5",
     "mongoose-paginate": "4.2.0",
     "morgan": "~1.5.1",
@@ -77,6 +79,7 @@
     "reveal.js": "~3.2.0",
     "socket.io": "~1.3.0",
     "socket.io-client": "~1.3.0",
+    "sprintf": "~0.1.5",
     "swig": "~1.4.0",
     "time": "~0.11.0",
     "vinyl-source-stream": "~1.1.0"

+ 1 - 0
public/js/reveal.js

@@ -0,0 +1 @@
+../../node_modules/reveal.js/

+ 90 - 0
resource/css/_comment.scss

@@ -0,0 +1,90 @@
+.crowi.main-container aside.sidebar .side-content {
+
+.page-comments {
+  margin: 8px 0 0 0;
+
+  .page-comment-form {
+    margin-top: 16px;
+
+    .comment-form {
+    }
+
+    .comment-form-main {
+
+      .comment-form-comment {
+        height: 60px;
+      }
+
+      .comment-submit {
+        margin-top: 8px;
+        text-align: right;
+      }
+    }
+  }
+
+  .page-comments-list {
+    .page-comments-list-toggle-newer,
+    .page-comments-list-toggle-older {
+      text-align: center;
+      display: block;
+      margin: 8px;
+      font-size: .9em;
+      color: #999;
+    }
+    .page-comment {
+      margin-top: 8px;
+      padding-top: 8px;
+      border-top: solid 1px #ccc;
+
+      .picture {
+        float: left;
+        width: 24px;
+        height: 24px;
+      }
+
+
+      .page-comment-main {
+        margin-left: 40px;
+
+        .page-comment-creator {
+          font-weight: bold;
+        }
+        .page-comment-meta {
+          color: #aaa;
+          font-size: .9em;
+        }
+        .page-comment-body {
+          padding: 8px 0;
+        }
+      }
+    }
+
+    .page-comment.page-comment-me {
+      //color: lighten($crowiHeaderBackground, 65%);
+      color: $crowiHeaderBackground;
+      .page-comment-main {
+
+        .page-comment-body,
+        .page-comment-creator,
+        .page-comment-meta {
+        }
+
+        .page-comment-meta {
+          //background: lighten($crowiHeaderBackground, 65%);
+        }
+      }
+    }
+
+    .page-comment.page-comment-old {
+      opacity: .7;
+
+      &:hover {
+        opacity: 1;
+      }
+    }
+  }
+}
+
+
+
+} // .crowi.main-container aside.sidebar .side-content

+ 138 - 4
resource/css/_form.scss

@@ -1,16 +1,150 @@
+.crowi.main-container .main .content-main.on-edit { // {{{ Edit Form of Page
+  padding: 0;
+
+  position: fixed;
+  z-index: 1060;
+  background: #fff;
+  top: 0;
+  left: 0;
+  height: 100%;
+  width: 100%;
+
+  .nav {
+    margin-top: 8px;
+    height: 40px;
+  }
+
+  .tab-content {
+    top: 48px;
+    bottom: 58px;
+    padding: 0 12px;
+    position: absolute;
+    z-index: 1061;
+    left: 0;
+    right: 0;
+    margin-top: 4px;
+
+    .alert-info {
+      display: none;
+    }
+
+    .edit-form { // {{{
+      height: 100%;
+      .row {
+        height: 100%;
+        .col-md-6 {
+          height: 100%;
+        }
+        form {
+          padding: 0;
+          border-right: solid 1px #ccc;
+          &::after {
+            position: absolute;
+            top: 0;
+            right: 15px;
+            font-size: 10px;
+            font-weight: 700;
+            color: #959595;
+            text-transform: uppercase;
+            letter-spacing: 1px;
+            content: "Input Content ...";
+          }
+        }
+        textarea {
+          height: 100%;
+          padding-top: 18px;
+          border: none;
+          box-shadow: none;
+
+          &.dragover {
+            border: dashed 6px #ccc;
+            padding: 12px 6px 0px;
+          }
+        }
+        .preview-body {
+          height: 100%;
+          padding-top: 18px;
+          overflow: scroll;
+
+          &::after {
+            position: absolute;
+            top: 0;
+            right: 15px;
+            font-size: 10px;
+            font-weight: 700;
+            color: #959595;
+            text-transform: uppercase;
+            letter-spacing: 1px;
+            content: "Preview";
+          }
+        }
+      }
+    }
+
+  } // }}}
+
+  .form-group.form-submit-group {
+
+    position: fixed;
+    z-index: 1054;
+    bottom: 0;
+    width: 100%;
+    left: 0;
+    padding: 8px;
+    height: 50px;
+    background: rgba(255,255,255,.8);
+    border-top: solid 1px #ccc;
+    margin-bottom: 0;
+  }
+} // }}}
+
+.crowi.main-container .main .page-list.content-main { // {{{ Edit Form of Page List
+
+  .close-button {
+    display: none;
+  }
+}
+.crowi.main-container .main .page-list.content-main.on-edit { // {{{ Edit Form of Page List
+  .close-button {
+    display: block;
+  }
+
+  .page-list-container {
+    display: none;
+  }
+  .portal-form-header,
+  .portal-form {
+    display: block;
+  }
+
+  .portal-form-header {
+    height: 16px;
+    padding: 8px;
+    border-bottom: solid 1px #ccc;
+  }
+} // }}}
 
 textarea {
   font-family: menlo, monaco, consolas, monospace;
   line-height: 1.1em;
 }
 
-textarea.form-body-height {
-  height: 300px;
-}
-
 input::-webkit-input-placeholder {
   color: #ccc;
 }
 input:-moz-placeholder {
   color: #ccc;
 }
+
+@media (max-width: $screen-sm-max) { // {{{ less than tablet size
+
+  .content-main.on-edit {
+    .form-group.form-submit-group {
+      select.form-control {
+        display: inline-block;
+        width: auto;
+      }
+    }
+  }
+
+} // }}}

+ 31 - 416
resource/css/_layout.scss

@@ -1,5 +1,5 @@
 .crowi { // {{{
-  font-family: 'Maven Pro', 'Helvetica Neue', 'Hiragino Kaku Gothic Pro', 'Meiryo', sans-serif;
+  font-family: 'Open Sans', 'Helvetica Neue', 'Hiragino Kaku Gothic Pro', 'Meiryo', sans-serif;
   h1, h2, h3, h4, h5, h6 {
     font-weight: 500;
   }
@@ -18,6 +18,10 @@
 
       .navbar-brand {
         font-weight: bold;
+        img {
+          display: inline;
+          margin-right: 8px;
+        }
       }
       .navbar-collapse {
         background: $crowiHeaderBackground;
@@ -57,6 +61,18 @@
 
     } // }}}
 
+    .main {
+      padding: 0; // cancel bootstrap padding
+
+      .header-wrap {
+        padding: 16px 16px 0 16px;
+      }
+
+      .content-main {
+        padding: 16px;
+      }
+    }
+
     .layout-control { // {{{
       transition: .3s ease;
       -webkit-transition: .3s ease;
@@ -83,414 +99,18 @@
       }
     } // }}}
 
-
-    aside.sidebar { // {{{
-      z-index: 1030;
-      position: fixed;
-      padding: 65px 0 0 0;
-      margin-bottom: $crowiFooterHeight;
-      color: #333;
-      height: 100%;
-      right: 0;
-      top: 0;
-      overflow: auto;
-      border-left: solid 1px #ccc;
-      background: $crowiAsideBackground;
-
-      transition: .3s ease;
-      -webkit-transition: .3s ease;
-
-
-      .page-meta {
-        padding: 15px 15px 5px 15px;
-        color: #666;
-        font-size: .9em;
-        line-height: 1.4em;
-        border-bottom: solid 1px #ccc;
-
-        .creator-picture {
-          text-align: center;
-          img {
-            width: 48px;
-            height: 48px;
-            box-shadow: 0 0 2px #333;
-          }
-        }
-        .creator {
-          font-size: 1.3em;
-          font-weight: bold;
-        }
-        .created-at {
-        }
-
-        .like-box {
-          padding-bottom: 0;
-
-          .dl-horizontal {
-            margin-bottom: 0;
-
-            dt, dd {
-              border-top: solid 1px #ccc;
-              padding-top: 5px;
-              padding-bottom: 5px;
-            }
-            dt {
-              width: 80px;
-            }
-            dd {
-              margin-left: 90px;
-              text-align: right;
-            }
-          }
-
-          .btn-bookmark {
-            color: #e6b422;
-            &.bookmarked {
-              color: #fff;
-            }
-          }
-        }
-
-        .liker-list, .contributor-list, .seen-user-list {
-          .picture-rounded {
-            box-shadow: 0 0 2px #666;
-          }
-        }
-        .liker-count, .contributor-count, .seen-user-count {
-          font-size: 1.2em;
-          font-weight: bold;
-          margin-bottom: 5px;
-        }
-        .contributor-list, .seen-user-list {
-        }
-      }
-
-
-      .side-content {
-        margin-bottom: $crowiFooterHeight + $crowiHeaderHeight;
-        padding: 15px;
-
-        h3 {
-          font-size: 1.1em;
-        }
-
-        a {
-          color: #ccc;
-          &:hover { color: #aaa;}
-        }
-
-        ul.revision-history {
-          padding: 0;
-          li {
-            position: relative;
-            list-style: none;
-
-            a {
-              color: #666;
-              padding: 3px 5px 3px 40px;
-              display: block;
-
-              &:hover {
-                background: darken($crowiAsideBackground, 10%);
-                text-decoration: none;
-                color: darken($link-color, 35%);
-              }
-            }
-
-          }
-
-          .picture {
-            position: absolute;
-            left: 5px;
-            top: 12px;
-          }
-        }
-
-        ul.fitted-list {
-          padding-left: 0;
-          li {
-            margin-bottom: 2px;
-
-            .input-group-addon {
-              padding: 5px 6px;
-            }
-          }
-        }
-      }
-    } // }}}
-
-    .main { // {{{
-      transition: .5s ease;
-      -webkit-transition: .5s ease;
-      background: #fff;
-
-      padding: 20px;
-      //margin-left: 10px;
-      //padding: 10px;
-      //
-      article {
-        background: #fff;
+    .page-list {
+      .page-list-link {
       }
-
-      article header {
-        background: #fff;
-        width: 100%;
-
-        p.stopper {
-          display: none;
-        }
-
-        &.affix {
-          width: 100%;
-          top: 0;
-          left: 0;
-          padding: 5px 20px;
-          z-index: 1039;
-          background: rgba(255, 255, 255, .9);
-          box-shadow: 0 0px 2px #999;
-
-          h1 {
-            font-size: 1.8em;
-          }
-
-          p.stopper {
-            display: block;
-            position: absolute;
-            bottom: -30px;
-            right: 10px;
-            background: #fff;
-            padding: 7px 14px;
-            margin: 0;
-            border: solid 1px #ccc;
-            border-top: none;
-            border-radius: 0 0 5px 5px;
-            font-size: .8em;
-          }
-        }
-      }
-
-      &.col-md-12 article header.affix {
-        width: 100%;
-      }
-
-
-      article header h1 {
-        margin-top: 0;
-
-        a:last-child {
-          color: #D1E2E4;
-          opacity: .7;
-
-          &:hover {
-            color: inherit;
-          }
-        }
-      }
-
-    } // }}}
-
-    .main.grant-restricted,
-    .main.grant-specified,
-    .main.grant-owner {
-      background: #333;
-      padding: 20px 10px;
-
-      .page-grant {
-        color: #ccc;
-      }
-
-      article {
-        border-radius: 5px;
-        padding: 20px;
-      }
-    }
-
-    .page-attachments {
-      background: #f0f0f0;
-      padding: 10px;
-      font-size: 0.9em;
-      color: #888;
-      margin: 10px 0;
-      border-radius: 5px;
-      p {
-        font-weight: bold;
-      }
-
-      ul {
+      .page-list-meta {
+        font-size: .9em;
+        color: #999;
       }
     }
 
-    .footer { // {{{
-      position: fixed;
-      width: 100%;
-      bottom: 0px;
-      height: 26px;
-      padding: 4px;
-      color: #444;
-      background: $crowiAsideBackground;
-      border-top-left-radius: 5px;
-      z-index: 1055;
-
-      a {
-        color: #666;
-      }
-    } // }}}
   } // }}}
-
-  &.main-container.aside-hidden { // {{{
-    .layout-control {
-      right: 0;
-      i {
-        transform: rotate(180deg);
-      }
-    }
-
-    aside.sidebar { // {{{
-      right: -25%;
-    } // }}}
-
-    .main { // {{{
-      width: 100%;
-
-      article header.affix {
-        width: 100%;
-      }
-    } // }}}
-  } // }}}
-
-  // override bootstrap modals
-  //.modal-backdrop {
-  //  z-index: 1052;
-  //}
-  //.modal {
-  //  z-index: 1055;
-  //}
 } // }}}
 
-.crowi.main-container .main {
-  .wiki-content {
-  }
-
-  .content-main {
-    .tab-content {
-      margin-top: 30px;
-    }
-  }
-
-  .content-main .timeline-body { // {{{ timeline
-     .revision-path {
-       margin-top: 1.6em;
-       margin-bottom: 0;
-       border: solid 2px #ddd;
-       border-bottom: none;
-       padding: 16px;
-       background: #ddd;
-     }
-     .revision-body {
-       font-size: 14px;
-       border: solid 2px #ddd;
-       padding: 16px;
-       background: #fdfdfd;
-     }
-  } // }}}
-
-  // on-edit
-  .content-main.on-edit {
-    position: fixed;
-    z-index: 1060;
-    background: #fff;
-    top: 0;
-    left: 0;
-    height: 100%;
-    width: 100%;
-
-    .nav {
-      margin-top: 8px;
-      height: 40px;
-    }
-
-    .tab-content {
-      .alert-info {
-        display: none;
-      }
-
-      top: 48px;
-      bottom: 58px;
-      padding: 0 12px;
-      position: absolute;
-      left: 0;
-      right: 0;
-      margin-top: 4px;
-
-      .edit-form {
-        height: 100%;
-        .row {
-          height: 100%;
-          .col-md-6 {
-            height: 100%;
-          }
-          form {
-            padding: 0;
-            border-right: solid 1px #ccc;
-            &::after {
-              position: absolute;
-              top: 0;
-              right: 15px;
-              font-size: 10px;
-              font-weight: 700;
-              color: #959595;
-              text-transform: uppercase;
-              letter-spacing: 1px;
-              content: "Input Content ...";
-            }
-          }
-          textarea {
-            height: 100%;
-            padding-top: 18px;
-            border: none;
-            box-shadow: none;
-
-            &.dragover {
-              border: dashed 6px #ccc;
-              padding: 12px 6px 0px;
-            }
-          }
-          .preview-body {
-            height: 100%;
-            padding-top: 18px;
-            overflow: scroll;
-
-            &::after {
-              position: absolute;
-              top: 0;
-              right: 15px;
-              font-size: 10px;
-              font-weight: 700;
-              color: #959595;
-              text-transform: uppercase;
-              letter-spacing: 1px;
-              content: "Preview";
-            }
-          }
-        }
-      }
-    }
-
-    .form-group.form-submit-group {
-
-      position: fixed;
-      z-index: 1054;
-      bottom: 0;
-      width: 100%;
-      left: 0;
-      padding: 8px;
-      height: 50px;
-      background: rgba(255,255,255,.8);
-      border-top: solid 1px #ccc;
-      margin-bottom: 0;
-    }
-  }
-}
 
 .crowi.single { // {{{
 } // }}}
@@ -578,23 +198,16 @@
 
 } // }}}
 
-@media (max-width: $screen-sm-max) { // {{{ less than tablet size
-
-  .content-main.on-edit {
-    .form-group.form-submit-group {
-      select.form-control {
-        display: inline-block;
-        width: auto;
-      }
-    }
-  }
-
+@media (max-width: $screen-sm-max) { // {{{
 } // }}}
 
 @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { // {{{ tablet size
   .crowi.main-container { // {{{
     .main {
       article header {
+        .bookmark-link {
+          font-size: 1.3em;
+        }
         h1 {
           font-size: 1.4em;
           margin-bottom: 0;
@@ -613,6 +226,9 @@
     .main {
       padding: 10px;
       article header {
+        .bookmark-link {
+          font-size: 1em;
+        }
         h1 {
           font-size: 1.1em;
         }
@@ -645,7 +261,6 @@
     .main {
       article header {
         border-bottom: solid 1px #666;
-        margin-bottom: 20px;
         h1 {
           font-size: 2em;
           color: #000;
@@ -672,8 +287,8 @@
       }
 
       .meta {
-        border-top: solid 1px #999;
-        margin-top: 20px;
+        border-top: solid 1px #ccc;
+        margin-top: 32px;
         color: #666;
       }
 

+ 301 - 0
resource/css/_page.scss

@@ -0,0 +1,301 @@
+.crowi.main-container {
+  // padding controll of .header-wrap and .content-main are moved to _layout and _form
+
+  .main { // {{{ .main of layout related
+    transition: .5s ease;
+    -webkit-transition: .5s ease;
+    background: #fff;
+
+    article {
+      background: #fff;
+    }
+
+    article header {
+      background: #fff;
+      width: 100%;
+
+      p.stopper {
+        display: none;
+      }
+
+      &.affix {
+        width: 100%;
+        top: 0;
+        left: 0;
+        padding: 5px 20px;
+        z-index: 1039;
+        background: rgba(255, 255, 255, .9);
+        box-shadow: 0 0px 2px #999;
+
+        h1 {
+          font-size: 1.8em;
+        }
+
+        p.stopper {
+          display: block;
+          position: absolute;
+          bottom: -30px;
+          right: 10px;
+          background: #fff;
+          padding: 7px 14px;
+          margin: 0;
+          border: solid 1px #ccc;
+          border-top: none;
+          border-radius: 0 0 5px 5px;
+          font-size: .8em;
+        }
+      }
+
+    }
+
+    &.col-md-12 article header.affix {
+      width: 100%;
+    }
+
+    article header { // not affixed
+      .bookmark-link {
+        float: right;
+        color: #e6b422;
+        font-size: 2em;
+        &.bookmarked {
+          //color: #fff;
+        }
+      }
+
+      h1 {
+        font-size: 28px;
+        margin-top: 0;
+        margin-bottom: 0;
+
+        a:last-child {
+          color: #D1E2E4;
+          opacity: .7;
+
+          &:hover {
+            color: inherit;
+          }
+        }
+      }
+    }
+
+    .content-main {
+      .tab-content {
+        margin-top: 30px;
+      }
+    }
+  } // }}}
+
+  // {{{ grant related style
+  .main.grant-restricted,
+  .main.grant-specified,
+  .main.grant-owner {
+    background: #333;
+    padding: 16px;
+
+    .page-grant {
+      color: #ccc;
+    }
+
+    article {
+      border-radius: 5px;
+    }
+  }
+  // }}}
+
+  .page-attachments { // {{{
+    p {
+      font-weight: bold;
+    }
+
+    ul {
+    }
+  } // }}}
+
+  aside.sidebar { // {{{
+    z-index: 1030;
+    position: fixed;
+    padding: 65px 0 0 0;
+    margin-bottom: $crowiFooterHeight;
+    color: #333;
+    height: 100%;
+    right: 0;
+    top: 0;
+    overflow: auto;
+    border-left: solid 1px #ccc;
+    background: $crowiAsideBackground;
+
+    transition: .3s ease;
+    -webkit-transition: .3s ease;
+
+
+    .page-meta {
+      padding: 15px 15px 5px 15px;
+      color: #666;
+      font-size: .9em;
+      line-height: 1.4em;
+      border-bottom: solid 1px #ccc;
+
+      .creator-picture {
+        text-align: center;
+        img {
+          width: 48px;
+          height: 48px;
+          box-shadow: 0 0 1px #666;
+        }
+      }
+      .creator {
+        font-size: 1.3em;
+        font-weight: bold;
+      }
+      .created-at {
+      }
+
+      .like-box {
+        padding-bottom: 0;
+
+        .dl-horizontal {
+          margin-bottom: 0;
+
+          dt, dd {
+            border-top: solid 1px #ccc;
+            padding-top: 5px;
+            padding-bottom: 5px;
+          }
+          dt {
+            width: 80px;
+          }
+          dd {
+            margin-left: 90px;
+            text-align: right;
+          }
+        }
+      }
+
+      .liker-count, .contributor-count, .seen-user-count {
+        font-size: 1.2em;
+        font-weight: bold;
+        margin-bottom: 5px;
+      }
+      .contributor-list, .seen-user-list {
+      }
+    }
+
+
+    .side-content {
+      margin-bottom: $crowiFooterHeight + $crowiHeaderHeight;
+      color: #666;
+      padding: 15px;
+
+      h3 {
+        font-size: 1.1em;
+      }
+
+      a {
+        color: #ccc;
+        &:hover { color: #aaa;}
+      }
+
+      ul.fitted-list {
+        padding-left: 0;
+        li {
+          margin-bottom: 2px;
+
+          .input-group-addon {
+            padding: 5px 6px;
+          }
+        }
+      }
+    }
+  } // }}}
+
+  .footer { // {{{
+    position: fixed;
+    width: 100%;
+    bottom: 0px;
+    height: 26px;
+    padding: 4px;
+    color: #444;
+    background: $crowiAsideBackground;
+    border-top-left-radius: 5px;
+    z-index: 1055;
+
+    a {
+      color: #666;
+    }
+  } // }}}
+
+  &.aside-hidden { // {{{
+    .layout-control {
+      right: 0;
+      i {
+        transform: rotate(180deg);
+      }
+    }
+
+    aside.sidebar { // {{{
+      right: -25%;
+    } // }}}
+
+    .main { // {{{
+      width: 100%;
+
+      article header.affix {
+        width: 100%;
+      }
+    } // }}}
+  } // }}}
+
+}
+.crowi.main-container .main .content-main .revision-history { // {{{
+  h1 {
+    padding-bottom: 0.3em;
+    font-size: 2.3em;
+    font-weight: bold;
+    border-bottom: solid 1px #ccc;
+  }
+
+
+  .revision-history-list {
+    .revision-hisory-outer {
+      margin-top: 8px;
+
+      .picture {
+        float: left;
+        width: 32px;
+        height: 32px;
+      }
+
+      .revision-history-main {
+        margin-left: 40px;
+
+        .revision-history-author {
+          color: #666;
+        }
+        .revision-history-comment {
+        }
+        .revision-history-meta {
+        }
+      }
+    }
+
+    li {
+      position: relative;
+      list-style: none;
+
+      a {
+        color: #666;
+        padding: 3px 5px 3px 40px;
+        display: block;
+
+        &:hover {
+          background: darken($crowiAsideBackground, 10%);
+          text-decoration: none;
+          color: darken($link-color, 35%);
+        }
+      }
+
+    }
+  }
+
+} // }}}
+

+ 56 - 0
resource/css/_page_list.scss

@@ -0,0 +1,56 @@
+
+.crowi.main-container .main .content-main .timeline-body { // {{{
+  .revision-path {
+    margin-top: 1.6em;
+    margin-bottom: 0;
+    border: solid 2px #ddd;
+    border-bottom: none;
+    padding: 16px;
+    background: #ddd;
+  }
+  .revision-body {
+    font-size: 14px;
+    border: solid 2px #ddd;
+    padding: 16px;
+    background: #fdfdfd;
+  }
+} // }}}
+
+.page-list {
+  .page-list-container {
+    line-height: 1.6em;
+    font-size: 15px;
+  }
+
+  .page-list-ul {
+    padding-left: 0;
+
+    .page-list-li {
+      list-style: none;
+      line-height: 1.8em;
+
+      .picture-outer {
+        float: left;
+
+        .picture {
+          width: 16px;
+          height: 16px;
+          margin-right: 4px;
+        }
+      }
+
+      .page-link-outer {
+        padding-left: 20px;
+
+        .page-list-link {
+          font-size: 1.1em;
+          color: #666;
+
+          strong {
+            color: #333;
+          }
+        }
+      }
+    }
+  }
+}

+ 36 - 0
resource/css/_portal.scss

@@ -0,0 +1,36 @@
+.portal-header {
+}
+
+.portal-label {
+  background: #5bc0de;
+  //float: right;
+  font-weight: bold;
+  padding: 2px 4px;
+  //margin: 1em 8px 0;
+  font-size: .8em;
+  color: #fff;
+  border-radius: 4px;
+}
+
+.portal {
+  .wiki {
+    margin-bottom: 16px;
+    padding-bottom: 16px;
+  }
+
+  .portal-form {
+
+  }
+
+} // .portal
+
+.portal-side {
+  .portal-form-button {
+    text-align: center;
+  }
+} // .portal-side
+
+
+.portal-warning-modal {
+  z-index: 1062;
+}

+ 11 - 0
resource/css/_search.scss

@@ -0,0 +1,11 @@
+
+
+.search-input-group {
+  display: inline-block;
+  margin-bottom: 0;
+  width: 200px;
+  vertical-align: bottom;
+}
+
+.search-listpage-input {
+}

+ 60 - 0
resource/css/_user.scss

@@ -0,0 +1,60 @@
+.crowi.main-container {
+  .main.user-page { // {{{ .main of layout related
+
+    .header-wrap {
+
+      h1 {
+        margin: 0;
+        font-size: 1.1em;
+      }
+
+      .user-page-header {
+        margin-bottom: 32px;
+
+        .bookmark-link {
+          float: right;
+          color: #e6b422;
+          font-size: 2em;
+          &.bookmarked {
+            //color: #fff;
+          }
+        }
+
+        .user-page-picture {
+          img {
+            height: 64px;
+            width: 64px;
+          }
+        }
+        .user-page-meta {
+          padding-left: 88px;
+          color: #999;
+
+          h2 {
+            font-size: 2.5em;
+            color: #666;
+          }
+
+          ul {
+            padding-left: 0;
+            li {
+              list-style: none;
+            }
+          }
+          .user-page-username {
+            font-weight: bold;
+          }
+          .user-page-email {
+          }
+          .user-page-introduction {
+          }
+        }
+      }
+
+      .user-page-content-tab {
+        padding: 16px 0 8px;
+      }
+
+    }
+  } // }}}
+}

+ 2 - 2
resource/css/_wiki.scss

@@ -108,8 +108,8 @@ div.body {
 
   img {
     margin: 5px 0;
-    box-shadow: 0 0 12px 0px #999;
-    border: solid 1px #999;
+    box-shadow: 0 0 5px 0px rgba(0,0,0,.2);
+    border: solid 1px #ccc;
     max-width: 100%;
   }
 

+ 18 - 11
resource/css/crowi.scss

@@ -8,9 +8,15 @@
 
 // crowi component
 @import 'layout';
+@import 'page';
+@import 'page_list';
 @import 'form';
 @import 'wiki';
 @import 'admin';
+@import 'comment';
+@import 'user';
+@import 'portal';
+@import 'search';
 
 
 ul {
@@ -19,12 +25,19 @@ ul {
 
 
 .meta {
+
+  margin-top: 32px;
+  padding: 16px;
+  color: #666;
+  border-top: solid 1px #ccc;
   background: #f0f0f0;
-  padding: 10px;
-  font-size: 0.9em;
+  font-size: 0.95em;
   color: #888;
-  margin-top: 10px;
-  border-radius: 5px;
+
+  .picture {
+    width: 16px;
+    height: 16px;
+  }
 }
 
 .help-block {
@@ -50,12 +63,6 @@ footer, aside {
   margin-bottom: 1em;
 }
 
-article {
-  header {
-    margin-bottom: 20px;
-  }
-}
-
 footer {
   h4,
   h3 {
@@ -164,7 +171,7 @@ footer {
   }
   &.picture-rounded {
     border-radius: 50%;
-    box-shadow: 0 0 2px #ccc;
+    box-shadow: 0 0 2px rgba(0,0,0,.3);
   }
 }
 // components

+ 32 - 2
resource/js/crowi-form.js

@@ -128,7 +128,7 @@ $(function() {
         var indent = listMarkMatch[1];
         var num = parseInt(listMarkMatch[2]);
         if (num !== 1) {
-          listMark = listMark.return(/\s*\d+/, indent + (num +1));
+          listMark = listMark.replace(/\s*\d+/, indent + (num +1));
         }
       }
       $target.selection('insert', {text: "\n" + listMark, mode: 'before'});
@@ -212,6 +212,36 @@ $(function() {
     }
   });
 
+  var handlePasteEvent = function(event) {
+    var currentLine = getCurrentLine(event);
+
+    if (!currentLine) {
+      return false;
+    }
+    var $target = $(event.target);
+    var pasteText = event.clipboardData.getData('text');
+
+    var match = currentLine.text.match(/^(\s*(?:>|\-|\+|\*|\d+\.) (?:\[(?:x| )\] )?)/);
+    if (match) {
+      if (pasteText.match(/(?:\r\n|\r|\n)/)) {
+        pasteText = pasteText.replace(/(\r\n|\r|\n)/g, "$1" + match[1]);
+      }
+    }
+
+    $target.selection('insert', {text: pasteText, mode: 'after'});
+
+    var newPos = currentLine.end + pasteText.length;
+    $target.selection('setPos', {start: newPos, end: newPos});
+
+    return true;
+  };
+
+  document.getElementById('form-body').addEventListener('paste', function(event) {
+    if (handlePasteEvent(event)) {
+      event.preventDefault();
+    }
+  });
+
   var unbindInlineAttachment = function($form) {
     $form.unbind('.inlineattach');
   };
@@ -270,7 +300,7 @@ $(function() {
             pageId = page._id;
 
         $('#content-main').data('page-id', page._id);
-        $('#page-form [name="pageForm[currentRevision]"]').val(page.revision)
+        $('#page-form [name="pageForm[currentRevision]"]').val(page.revision._id)
 
         unbindInlineAttachment($inputForm);
 

+ 464 - 11
resource/js/crowi.js

@@ -4,6 +4,7 @@
 */
 
 var hljs = require('highlight.js');
+var jsdiff = require('diff');
 var marked = require('marked');
 var Crowi = {};
 
@@ -19,13 +20,20 @@ Crowi.createErrorView = function(msg) {
 Crowi.linkPath = function(revisionPath) {
   var $revisionPath = revisionPath || '#revision-path';
   var $title = $($revisionPath);
-  if (!$title.get(0)) {
-    return;
+  var pathData = $('#content-main').data('path');
+
+  if (!pathData) {
+    return ;
+  }
+
+  var realPath = pathData.trim();
+  if (realPath.substr(-1, 1) == '/') {
+    realPath = realPath.substr(0, realPath.length - 1);
   }
 
   var path = '';
   var pathHtml = '';
-  var splittedPath = $title.html().split(/\//);
+  var splittedPath = realPath.split(/\//);
   splittedPath.shift();
   splittedPath.forEach(function(sub) {
     path += '/';
@@ -195,12 +203,59 @@ Crowi.renderer.prototype = {
   }
 };
 
+// original: middleware.swigFilter
+Crowi.userPicture = function (user) {
+  if (!user) {
+    return '/images/userpicture.png';
+  }
+
+  if (user.image && user.image != '/images/userpicture.png') {
+    return user.image;
+  } else if (user.fbId) {
+    return '//graph.facebook.com/' + user.fbId + '/picture?size=square';
+  } else {
+    return '/images/userpicture.png';
+  }
+};
+
+
+CrowiSearcher = function(path, $el) {
+  this.$el = $el;
+  this.path = path;
+  this.searchResult = {};
+};
+CrowiSearcher.prototype.querySearch = function(keyword, option) {
+};
+CrowiSearcher.prototype.search = function(keyword) {
+  var option = {};
+  this.querySearch(keyword, option);
+  this.$el.html(this.render());
+};
+CrowiSearcher.prototype.render = function() {
+  return $('<div>');
+};
+
+
 $(function() {
+  var pageId = $('#content-main').data('page-id');
+  var revisionId = $('#content-main').data('page-revision-id');
+  var revisionCreatedAt = $('#content-main').data('page-revision-created');
+  var currentUser = $('#content-main').data('current-user');
+  var isSeen = $('#content-main').data('page-is-seen');
+
   Crowi.linkPath();
 
   $('[data-toggle="tooltip"]').tooltip();
   $('[data-tooltip-stay]').tooltip('show');
 
+  $('a[data-toggle="tab"][href="#edit-form"]').on('show.bs.tab', function() {
+    $('.content-main').addClass('on-edit');
+  });
+  $('a[data-toggle="tab"][href="#edit-form"]').on('hide.bs.tab', function() {
+    $('.content-main').removeClass('on-edit');
+  });
+
+
   $('.copy-link').on('click', function () {
     $(this).select();
   });
@@ -224,23 +279,24 @@ $(function() {
     $('#newPageName').focus();
   });
   $('#renamePageForm').submit(function(e) {
-    var path = $('#pagePath').html();
     $.ajax({
       type: 'POST',
-      url: '/_api/page_rename' + path,
+      url: '/_api/pages.rename',
       data: $('#renamePageForm').serialize(),
       dataType: 'json'
-    }).done(function(data) {
-      if (!data.status) {
-        $('#newPageNameCheck').html('<i class="fa fa-times-circle"></i> ' + data.message);
+    }).done(function(res) {
+      if (!res.ok) {
+        $('#newPageNameCheck').html('<i class="fa fa-times-circle"></i> ' + res.error);
         $('#newPageNameCheck').addClass('alert-danger');
       } else {
-        $('#newPageNameCheck').removeClass('alert-danger');
+        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 = data.page.path + '?renamed=' + path;
+          top.location.href = page.path + '?renamed=' + path;
         }, 1000);
       }
     });
@@ -248,5 +304,402 @@ $(function() {
     return false;
   });
 
-});
+  $('#create-portal-button').on('click', function(e) {
+    $('.portal').removeClass('hide');
+    $('.content-main').addClass('on-edit');
+    $('.portal a[data-toggle="tab"][href="#edit-form"]').tab('show');
+
+    var path = $('.content-main').data('path');
+    if (path != '/' && $('.content-main').data('page-id') == '') {
+      var upperPage = path.substr(0, path.length - 1);
+      $.get('/_api/pages.get', {path: upperPage}, function(res) {
+        if (res.ok && res.page) {
+          $('#portal-warning-modal').modal('show');
+        }
+      });
+    }
+  });
+  $('#portal-form-close').on('click', function(e) {
+    $('.portal').addClass('hide');
+    $('.content-main').removeClass('on-edit');
+
+    return false;
+  });
+
+  // list-link
+  $('.page-list-link').each(function() {
+    var $link = $(this);
+    var text = $link.text();
+    var path = $link.data('path');
+    var shortPath = $link.data('short-path');
+
+    $link.html(path.replace(new RegExp(shortPath + '(/)?$'), '<strong>' + shortPath + '$1</strong>'));
+  });
+
+
+  if (pageId) {
+
+    // if page exists
+    var $rawTextOriginal = $('#raw-text-original');
+    if ($rawTextOriginal.length > 0) {
+      var renderer = new Crowi.renderer($('#raw-text-original').html());
+      renderer.render();
+      Crowi.correctHeaders('#revision-body-content');
+      Crowi.revisionToc('#revision-body-content', '#revision-toc');
+    }
+
+    // header
+    var $header = $('#page-header');
+    if ($header.length > 0) {
+      var headerHeight = $header.outerHeight(true);
+      $('.header-wrap').css({height: (headerHeight + 16) + 'px'});
+      $header.affix({
+        offset: {
+          top: function() {
+            return headerHeight + 86; // (54 header + 16 header padding-top + 16 content padding-top)
+          }
+        }
+      });
+      $('[data-affix-disable]').on('click', function(e) {
+        $elm = $($(this).data('affix-disable'));
+        $(window).off('.affix');
+        $elm.removeData('affix').removeClass('affix affix-top affix-bottom');
+        return false;
+      });
+    }
+
+    // omg
+    function createCommentHTML(revision, creator, comment, commentedAt) {
+      var $comment = $('<div>');
+      var $commentImage = $('<img class="picture picture-rounded">')
+        .attr('src', Crowi.userPicture(creator));
+      var $commentCreator = $('<div class="page-comment-creator">')
+        .text(creator.username);
+
+      var $commentRevision = $('<a class="page-comment-revision label">')
+        .attr('href', '?revision=' + revision)
+        .text(revision.substr(0,8));
+      if (revision !== revisionId) {
+        $commentRevision.addClass('label-default');
+      } else {
+        $commentRevision.addClass('label-primary');
+      }
+
+      var $commentMeta = $('<div class="page-comment-meta">')
+        .text(commentedAt + ' ')
+        .append($commentRevision);
+
+      var $commentBody = $('<div class="page-comment-body">')
+        .html(comment.replace(/(\r\n|\r|\n)/g, '<br>'));
+
+      var $commentMain = $('<div class="page-comment-main">')
+        .append($commentCreator)
+        .append($commentBody)
+        .append($commentMeta)
+
+      $comment.addClass('page-comment');
+      if (creator._id === currentUser) {
+        $comment.addClass('page-comment-me');
+      }
+      if (revision !== revisionId) {
+        $comment.addClass('page-comment-old');
+      }
+      $comment
+        .append($commentImage)
+        .append($commentMain);
+
+      return $comment;
+    }
+
+    // get comments
+    var $pageCommentList = $('.page-comments-list');
+    var $pageCommentListNewer =   $('#page-comments-list-newer');
+    var $pageCommentListCurrent = $('#page-comments-list-current');
+    var $pageCommentListOlder =   $('#page-comments-list-older');
+    var hasNewer = false;
+    var hasOlder = false;
+    $.get('/_api/comments.get', {page_id: pageId}, function(res) {
+      if (res.ok) {
+        var comments = res.comments;
+        $.each(comments, function(i, comment) {
+          var commentContent = createCommentHTML(comment.revision, comment.creator, comment.comment, comment.createdAt);
+          if (comment.revision == revisionId) {
+            $pageCommentListCurrent.append(commentContent);
+          } else {
+            if (Date.parse(comment.createdAt)/1000 > revisionCreatedAt) {
+              $pageCommentListNewer.append(commentContent);
+              hasNewer = true;
+            } else {
+              $pageCommentListOlder.append(commentContent);
+              hasOlder = true;
+            }
+          }
+        });
+      }
+    }).fail(function(data) {
+
+    }).always(function() {
+      if (!hasNewer) {
+        $('.page-comments-list-toggle-newer').hide();
+      }
+      if (!hasOlder) {
+        $pageCommentListOlder.addClass('collapse');
+        $('.page-comments-list-toggle-older').hide();
+      }
+    });
+
+    // post comment event
+    $('#page-comment-form').on('submit', function() {
+      $button = $('#commenf-form-button');
+      $button.attr('disabled', 'disabled');
+      $.post('/_api/comments.add', $(this).serialize(), function(data) {
+        $button.removeAttr('disabled');
+        if (data.ok) {
+          var comment = data.comment;
+
+          $pageCommentList.prepend(createCommentHTML(comment.revision, comment.creator, comment.comment, comment.createdAt));
+          $('#comment-form-comment').val('');
+          $('#comment-form-message').text('');
+        } else {
+          $('#comment-form-message').text(data.error);
+        }
+      }).fail(function(data) {
+        if (data.status !== 200) {
+          $('#comment-form-message').text(data.statusText);
+        }
+      });
+
+      return false;
+    });
+
+    // attachment
+    var $pageAttachmentList = $('.page-attachments ul');
+    $.get('/_api/attachment/page/' + pageId, function(res) {
+      var attachments = res.data.attachments;
+      if (attachments.length > 0) {
+        $.each(attachments, function(i, file) {
+          $pageAttachmentList.append(
+          '<li><a href="' + file.fileUrl + '">' + (file.originalName || file.fileName) + '</a> <span class="label label-default">' + file.fileFormat + '</span></li>'
+          );
+        })
+      } else {
+        $('.page-attachments').remove();
+      }
+    });
 
+    // bookmark
+    var $bookmarkButton = $('#bookmark-button');
+    $.get('/_api/bookmarks.get', {page_id: pageId}, function(res) {
+      if (res.ok) {
+        if (res.bookmark) {
+          MarkBookmarked();
+        }
+      }
+    });
+
+    $bookmarkButton.click(function() {
+      var bookmarked = $bookmarkButton.data('bookmarked');
+      if (!bookmarked) {
+        $.post('/_api/bookmarks.add', {page_id: pageId}, function(res) {
+          if (res.ok && res.bookmark) {
+            MarkBookmarked();
+          }
+        });
+      } else {
+        $.post('/_api/bookmarks.remove', {page_id: pageId}, function(res) {
+          if (res.ok) {
+            MarkUnBookmarked();
+          }
+        });
+      }
+
+      return false;
+    });
+
+    function MarkBookmarked()
+    {
+      $('i', $bookmarkButton)
+        .removeClass('fa-star-o')
+        .addClass('fa-star');
+      $bookmarkButton.data('bookmarked', 1);
+    }
+
+    function MarkUnBookmarked()
+    {
+      $('i', $bookmarkButton)
+        .removeClass('fa-star')
+        .addClass('fa-star-o');
+      $bookmarkButton.data('bookmarked', 0);
+    }
+
+    // Like
+    var $likeButton = $('#like-button');
+    var $likeCount = $('#like-count');
+    $likeButton.click(function() {
+      var liked = $likeButton.data('liked');
+      if (!liked) {
+        $.post('/_api/likes.add', {page_id: pageId}, function(res) {
+          if (res.ok) {
+            MarkLiked();
+          }
+        });
+      } else {
+        $.post('/_api/likes.remove', {page_id: pageId}, function(res) {
+          if (res.ok) {
+            MarkUnLiked();
+          }
+        });
+      }
+
+      return false;
+    });
+    var $likerList = $("#liker-list");
+    var likers = $likerList.data('likers');
+    if (likers && likers.length > 0) {
+      // FIXME: user data cache
+      $.get('/_api/users.list', {user_ids: likers}, function(res) {
+        // ignore unless response has error
+        if (res.ok) {
+          AddToLikers(res.users);
+        }
+      });
+    }
+
+    function AddToLikers (users) {
+      $.each(users, function(i, user) {
+        $likerList.append(CreateUserLinkWithPicture(user));
+      });
+    }
+
+    function MarkLiked()
+    {
+      $likeButton.addClass('active');
+      $likeButton.data('liked', 1);
+      $likeCount.text(parseInt($likeCount.text()) + 1);
+    }
+
+    function MarkUnLiked()
+    {
+      $likeButton.removeClass('active');
+      $likeButton.data('liked', 0);
+      $likeCount.text(parseInt($likeCount.text()) - 1);
+    }
+
+    if (!isSeen) {
+      $.post('/_api/pages.seen', {page_id: pageId}, function(res) {
+        // ignore unless response has error
+        if (res.ok && res.seenUser) {
+          $('#content-main').data('page-is-seen', 1);
+        }
+      });
+    }
+
+    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);
+        }
+      });
+    }
+
+    function CreateUserLinkWithPicture (user) {
+      var $userHtml = $('<a>');
+      $userHtml.data('user-id', user._id);
+      $userHtml.attr('href', '/user/' + user.username);
+      $userHtml.attr('title', user.name);
+
+      var $userPicture = $('<img class="picture picture-xs picture-rounded">');
+      $userPicture.attr('alt', user.name);
+      $userPicture.attr('src',  Crowi.userPicture(user));
+
+      $userHtml.append($userPicture);
+      return $userHtml;
+    }
+
+    function AddToSeenUser (users) {
+      $.each(users, function(i, user) {
+        $seenUserList.append(CreateUserLinkWithPicture(user));
+      });
+    }
+
+    // History Diff
+    var allRevisionIds = [];
+    $.each($('.diff-view'), function() {
+      allRevisionIds.push($(this).data('revisionId'));
+    });
+
+    $('.diff-view').on('click', function(e) {
+      e.preventDefault();
+
+      var getBeforeRevisionId = function(revisionId) {
+        var currentPos = $.inArray(revisionId, allRevisionIds);
+        if (currentPos < 0) {
+          return false;
+        }
+
+        var beforeRevisionId = allRevisionIds[currentPos + 1];
+        if (typeof beforeRevisionId === 'undefined') {
+          return false;
+        }
+
+        return beforeRevisionId;
+      };
+
+      var revisionId = $(this).data('revisionId');
+      var beforeRevisionId = getBeforeRevisionId(revisionId);
+      var $diffDisplay = $('#diff-display-' + revisionId);
+      var $diffIcon = $('#diff-icon-' + revisionId);
+
+      if ($diffIcon.hasClass('fa-arrow-circle-right')) {
+        $diffIcon.removeClass('fa-arrow-circle-right');
+        $diffIcon.addClass('fa-arrow-circle-down');
+      } else {
+        $diffIcon.removeClass('fa-arrow-circle-down');
+        $diffIcon.addClass('fa-arrow-circle-right');
+      }
+
+      if (beforeRevisionId === false) {
+        $diffDisplay.text('差分はありません');
+        $diffDisplay.slideToggle();
+      } else {
+        var revisionIds = revisionId + ',' + beforeRevisionId;
+
+        $.ajax({
+          type: 'GET',
+          url: '/_api/revisions.list?revision_ids=' + revisionIds,
+          dataType: 'json'
+        }).done(function(res) {
+          var currentText = res[0].body;
+          var previousText = res[1].body;
+
+          $diffDisplay.text('');
+
+          var diff = jsdiff.diffLines(previousText, currentText);
+          diff.forEach(function(part) {
+            var color = part.added ? 'green' : part.removed ? 'red' : 'grey';
+            var $span = $('<span>');
+            $span.css('color', color);
+            $span.text(part.value);
+            $diffDisplay.append($span);
+          });
+
+          $diffDisplay.slideToggle();
+        });
+      }
+    });
+
+    // default open
+    $('.diff-view').each(function(i, diffView) {
+      if (i < 2) {
+        $(diffView).click();
+      }
+    });
+  }
+
+  // for search
+});

+ 79 - 11
resource/search/mappings.json

@@ -1,16 +1,84 @@
 {
+  "settings": {
+    "analysis": {
+      "filter": {
+        "english_stop": {
+          "type":       "stop",
+          "stopwords":  "_english_"
+        },
+        "english_stemmer": {
+          "type":       "stemmer",
+          "language":   "english"
+        },
+        "english_possessive_stemmer": {
+          "type":       "stemmer",
+          "language":   "possessive_english"
+        }
+      },
+      "analyzer": {
+        "autocomplete": {
+          "tokenizer":  "keyword",
+          "filter": [
+            "lowercase",
+            "nGram"
+          ]
+        },
+        "english": {
+          "tokenizer":  "standard",
+          "filter": [
+            "english_possessive_stemmer",
+            "lowercase",
+            "english_stop",
+            "english_stemmer"
+          ]
+        }
+      }
+    }
+  },
   "mappings": {
-    "page": {
-      "_all":       { "enabled": false  },
-      "properties": {
-        "path":    { "type": "string", "analyzer": "kuromoji" },
-        "body":    { "type": "string", "analyzer": "kuromoji" },
-        "creator": { "type": "string" },
-        "lastUpdateUser": { "type": "string" },
-        "likeCount": { "type": "integer" },
-        "bookmarkCount": { "type": "integer" },
-        "updated": { "type": "date" },
-        "is_public": { "type": "boolean" }
+    "users": {
+      "properties" : {
+        "name": {
+          "type": "string",
+          "analyzer": "autocomplete"
+        }
+      }
+    },
+    "pages": {
+      "properties" : {
+        "path": {
+          "type" : "multi_field",
+          "fields" : {
+            "raw": {"type" : "string", "index" : "not_analyzed"},
+            "ja": {"type" : "string", "analyzer" : "kuromoji"},
+            "en": {"type" : "string", "analyzer" : "english"}
+          }
+        },
+        "body": {
+          "type" : "multi_field",
+          "fields" : {
+            "raw": {"type" : "string", "index" : "not_analyzed"},
+            "ja": {"type" : "string", "analyzer" : "kuromoji"},
+            "en": {"type" : "string", "analyzer" : "english"}
+          }
+        },
+        "username": {
+          "type": "string"
+        },
+        "comment_count": {
+          "type": "integer"
+        },
+        "like_count": {
+          "type": "integer"
+        },
+        "created_at": {
+          "type": "date",
+          "format": "dateOptionalTime"
+        },
+        "updated_at": {
+          "type": "date",
+          "format": "dateOptionalTime"
+        }
       }
     }
   }

+ 7 - 2
test/crowi/crowi.test.js

@@ -46,8 +46,13 @@ describe('Test for Crowi application context', function () {
       p.then(function() {
         expect(mongoose.connection.readyState).to.equals(1);
         done();
-      }).catch(function() {
-        expect(mongoose.connection.readyState).to.equals(1);
+      }).catch(function(err) {
+        //console.log('readyState', mongoose.connection.readyState);
+        if (mongoose.connection.readyState === 2 || mongoose.connection.readyState === 1) { // alreaady connected
+          // throught
+        } else {
+          expect(mongoose.connection.readyState).to.equals(0);
+        }
         done();
       });
     });

+ 25 - 2
test/models/user.test.js

@@ -23,12 +23,35 @@ describe('User', function () {
       });
 
       it('should be found by findUserByUsername', function(done) {
-        User.findUserByUsername('aoi', function (err, userData) {
-          expect(err).to.be.null;
+        User.findUserByUsername('aoi')
+        .then(function(userData) {
           expect(userData).to.instanceof(User);
           done();
         });
       });
     });
   });
+
+  describe('User Utilities', function () {
+    context('Get username from path', function() {
+      it('found', function(done) {
+        var username = null;
+        username = User.getUsernameByPath('/user/sotarok');
+        expect(username).to.equal('sotarok');
+
+        username = User.getUsernameByPath('/user/some.user.name12/'); // with slash
+        expect(username).to.equal('some.user.name12');
+
+        done();
+      });
+
+      it('not found', function(done) {
+        var username = null;
+        username = User.getUsernameByPath('/the/page/is/not/related/to/user/page');
+        expect(username).to.be.null;
+
+        done();
+      });
+    });
+  });
 });

+ 6 - 3
test/utils.js

@@ -43,9 +43,12 @@ after('Close database connection', function (done) {
 });
 
 
-models.Page   = require(MODEL_DIR + '/page.js')(crowi);
-models.User   = require(MODEL_DIR + '/user.js')(crowi);
-models.Config = require(MODEL_DIR + '/config.js')(crowi);
+models.Page     = require(MODEL_DIR + '/page.js')(crowi);
+models.User     = require(MODEL_DIR + '/user.js')(crowi);
+models.Config   = require(MODEL_DIR + '/config.js')(crowi);
+models.Revision = require(MODEL_DIR + '/revision.js')(crowi);
+
+crowi.models = models;
 
 module.exports = {
   models: models,

Некоторые файлы не были показаны из-за большого количества измененных файлов