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

Merge pull request #175 from crowi/wip-v1.6

Prepare v1.6
Sotaro KARASAWA 9 лет назад
Родитель
Сommit
56e8fe76aa
73 измененных файлов с 1735 добавлено и 829 удалено
  1. 2 1
      README.md
  2. 2 2
      circle.yml
  3. 5 1
      gulpfile.js
  4. 29 2
      lib/crowi/express-init.js
  5. 17 6
      lib/crowi/index.js
  6. 20 18
      lib/form/index.js
  7. 1 1
      lib/form/invited.js
  8. 1 1
      lib/form/login.js
  9. 1 2
      lib/form/me/password.js
  10. 2 1
      lib/form/me/user.js
  11. 1 1
      lib/form/register.js
  12. 1 2
      lib/models/attachment.js
  13. 5 5
      lib/models/page.js
  14. 7 0
      lib/models/revision.js
  15. 47 13
      lib/models/user.js
  16. 32 34
      lib/routes/admin.js
  17. 11 10
      lib/routes/attachment.js
  18. 6 3
      lib/routes/index.js
  19. 3 2
      lib/routes/installer.js
  20. 10 9
      lib/routes/login.js
  21. 17 15
      lib/routes/me.js
  22. 1 1
      lib/routes/page.js
  23. 30 1
      lib/routes/revision.js
  24. 19 0
      lib/util/i18nUserSettingDetector.js
  25. 2 1
      lib/util/middlewares.js
  26. 136 18
      lib/util/search.js
  27. 2 2
      lib/views/_form.html
  28. 1 20
      lib/views/admin/users.html
  29. 3 3
      lib/views/layout/2column.html
  30. 5 5
      lib/views/layout/layout.html
  31. 1 1
      lib/views/layout/single.html
  32. 25 25
      lib/views/login.html
  33. 12 12
      lib/views/me/api_token.html
  34. 34 34
      lib/views/me/index.html
  35. 14 14
      lib/views/me/password.html
  36. 8 9
      lib/views/modal/create_page.html
  37. 18 18
      lib/views/modal/help.html
  38. 6 9
      lib/views/modal/rename.html
  39. 12 37
      lib/views/page.html
  40. 13 13
      lib/views/page_list.html
  41. 3 1
      lib/views/user_page.html
  42. 4 4
      lib/views/widget/page_side_content.html
  43. 6 6
      lib/views/widget/page_side_header.html
  44. 32 0
      lib/views/widget/pager.html
  45. 1 2
      local_modules/crowi-fileupload-aws/index.js
  46. 0 1
      local_modules/crowi-fileupload-local/index.js
  47. 1 0
      locales/en
  48. 166 0
      locales/en-US/translation.json
  49. 166 0
      locales/ja/translation.json
  50. 287 318
      npm-shrinkwrap.json
  51. 25 22
      package.json
  52. 1 0
      public/css/diff2html
  53. 24 7
      resource/css/_page.scss
  54. 6 0
      resource/css/crowi.scss
  55. 17 3
      resource/js/app.js
  56. 22 0
      resource/js/components/Common/Icon.js
  57. 34 0
      resource/js/components/Common/UserDate.js
  58. 139 0
      resource/js/components/PageHistory.js
  59. 54 0
      resource/js/components/PageHistory/PageRevisionList.js
  60. 59 0
      resource/js/components/PageHistory/Revision.js
  61. 42 0
      resource/js/components/PageHistory/RevisionDiff.js
  62. 6 2
      resource/js/components/SearchPage/SearchResultList.js
  63. 4 84
      resource/js/crowi.js
  64. 16 1
      resource/js/util/Crowi.js
  65. 52 18
      resource/search/mappings.json
  66. 0 2
      test/bootstrap.js
  67. 3 1
      test/crowi/crowi.test.js
  68. 0 1
      test/models/config.test.js
  69. 0 1
      test/models/page.test.js
  70. 0 1
      test/models/updatePost.test.js
  71. 1 2
      test/models/user.test.js
  72. 1 0
      test/utils.js
  73. 1 0
      tmp/uploads/.gitignore

+ 2 - 1
README.md

@@ -30,12 +30,13 @@ More info are [here](https://github.com/crowi/crowi/wiki/Install-and-Configurati
 ### WARNING
 
 Don't use `master` branch because it is unstable but use released tag version expect when you want to contribute the project.
+`master` branch is prepared for v1.6. See [here](https://github.com/crowi/crowi/wiki/Roadmaps-v1.6) to know further info.
 
 
 Dependencies
 -------------
 
-* Node.js (4.x)
+* Node.js (6.x)
 * MongoDB
 * Elasticsearch (optional)
 * Redis (optional)

+ 2 - 2
circle.yml

@@ -5,9 +5,9 @@ machine:
   environment:
     MONGO_URI: mongodb://127.0.0.1/crowi_test
   node:
-      version: 4.6.2
+      version: 6.9.4
   post:
-    - npm install -g npm@3
+    - npm install -g npm@4
 notify:
   webhooks:
     - url: https://webhooks.gitter.im/e/5a035388e3274b621d20

+ 5 - 1
gulpfile.js

@@ -129,7 +129,11 @@ gulp.task('css:sass', function() {
 });
 
 gulp.task('css:concat', ['css:sass'], function() {
-  return gulp.src([css.main, 'node_modules/highlight.js/styles/tomorrow-night.css'])
+  return gulp.src([
+      css.main,
+      'node_modules/highlight.js/styles/tomorrow-night.css',
+      'node_modules/diff2html/dist/diff2html.css',
+    ])
     .pipe(concat('crowi.css'))
     .pipe(gulp.dest(dirs.cssDist))
 });

+ 29 - 2
lib/crowi/express-init.js

@@ -4,7 +4,6 @@ module.exports = function(crowi, app) {
   var debug = require('debug')('crowi:crowi:express-init')
     , express        = require('express')
     , bodyParser     = require('body-parser')
-    , multer         = require('multer')
     , cookieParser   = require('cookie-parser')
     , methodOverride = require('method-override')
     , session        = require('express-session')
@@ -12,10 +11,37 @@ module.exports = function(crowi, app) {
     , flash          = require('connect-flash')
     , cons           = require('consolidate')
     , swig           = require('swig')
+    , i18next        = require('i18next')
+    , i18nFsBackend  = require('i18next-node-fs-backend')
+    , i18nSprintf    = require('i18next-sprintf-postprocessor')
+    , i18nMiddleware = require('i18next-express-middleware')
+    , i18nUserSettingDetector  = require('../util/i18nUserSettingDetector')
     , env            = crowi.node_env
     , middleware     = require('../util/middlewares')
+
+    , User = crowi.model('User')
     ;
 
+  var lngDetector = new i18nMiddleware.LanguageDetector();
+  lngDetector.addDetector(i18nUserSettingDetector);
+
+  i18next
+    .use(lngDetector)
+    .use(i18nFsBackend)
+    .use(i18nSprintf)
+    .init({
+      debug: (crowi.node_env === 'development'),
+      fallbackLng: [User.LANG_EN_US],
+      whitelist: Object.keys(User.getLanguageLabels()).map((k) => User[k]),
+      backend: {
+        loadPath: 'locales/{{lng}}/translation.json'
+      },
+      detection: {
+        order: ['userSettingDetector', 'header', 'navigator'],
+      },
+      overloadTranslationOptionHandler: i18nSprintf.overloadTranslationOptionHandler
+    });
+
   app.use(function(req, res, next) {
     var now = new Date()
       , baseUrl
@@ -42,6 +68,7 @@ module.exports = function(crowi, app) {
     res.locals.consts   = {
         pageGrants: Page.getGrantLabels(),
         userStatus: User.getUserStatusLabels(),
+        language:   User.getLanguageLabels(),
         registrationMode: Config.getRegistrationModeLabels(),
     };
 
@@ -71,7 +98,6 @@ module.exports = function(crowi, app) {
   app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
   app.use(bodyParser.json({limit: '50mb'}));
   app.use(cookieParser());
-  app.use(multer());
   app.use(session(crowi.sessionConfig));
   app.use(flash());
 
@@ -80,4 +106,5 @@ module.exports = function(crowi, app) {
 
   app.use(middleware.loginChecker(crowi, app));
 
+  app.use(i18nMiddleware.handle(i18next));
 };

+ 17 - 6
lib/crowi/index.js

@@ -6,7 +6,6 @@ var debug = require('debug')('crowi:crowi')
   , path = require('path')
   , fs = require('fs')
   , sep = path.sep
-  , Promise = require('bluebird')
 
   , mongoose    = require('mongoose')
 
@@ -24,6 +23,7 @@ function Crowi (rootdir, env)
   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.localeDir = path.join(this.rootDir, 'locales') + 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;
@@ -55,16 +55,13 @@ function Crowi (rootdir, env)
   };
 
   if (this.node_env == 'development') {
-    Promise.longStackTraces();
   }
-
-  //time.tzset('Asia/Tokyo');
 };
 
 Crowi.prototype.init = function() {
   var self = this;
 
-  return new Promise.resolve()
+  return Promise.resolve()
   .then(function() {
     // setup database server and load all modesl
     return self.setupDatabase();
@@ -99,6 +96,18 @@ Crowi.prototype.init = function() {
   });
 }
 
+Crowi.prototype.isPageId = function(pageId) {
+  if (!pageId) {
+    return false;
+  }
+
+  if (typeof pageId === 'string' && pageId.match(/^[\da-f]{24}$/)) {
+    return true;
+  }
+
+  return false;
+};
+
 Crowi.prototype.setConfig = function(config) {
   this.config = config;
 };
@@ -143,6 +152,8 @@ Crowi.prototype.event = function(name, event) {
 
 Crowi.prototype.setupDatabase = function() {
   // mongoUri = mongodb://user:password@host/dbname
+  mongoose.Promise = global.Promise;
+
   var mongoUri = this.env.MONGOLAB_URI || // for B.C.
     this.env.MONGODB_URI || // MONGOLAB changes their env name
     this.env.MONGOHQ_URL ||
@@ -328,7 +339,7 @@ Crowi.prototype.buildServer = function() {
     });
   }
 
-  return new Promise.resolve(app);
+  return Promise.resolve(app);
 };
 
 Crowi.prototype.exitOnError = function(err) {

+ 20 - 18
lib/form/index.js

@@ -1,19 +1,21 @@
-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'),
-  apiToken: require('./me/apiToken'),
-};
-exports.admin = {
-  app: require('./admin/app'),
-  sec: require('./admin/sec'),
-  mail: require('./admin/mail'),
-  aws: require('./admin/aws'),
-  google: require('./admin/google'),
-  userInvite: require('./admin/userInvite'),
-  slackSetting: require('./admin/slackSetting'),
+module.exports = {
+  login: require('./login'),
+  register: require('./register'),
+  invited: require('./invited'),
+  revision: require('./revision'),
+  comment: require('./comment'),
+  me: {
+    user: require('./me/user'),
+    password: require('./me/password'),
+    apiToken: require('./me/apiToken'),
+  },
+  admin: {
+    app: require('./admin/app'),
+    sec: require('./admin/sec'),
+    mail: require('./admin/mail'),
+    aws: require('./admin/aws'),
+    google: require('./admin/google'),
+    userInvite: require('./admin/userInvite'),
+    slackSetting: require('./admin/slackSetting'),
+  },
 };

+ 1 - 1
lib/form/invited.js

@@ -6,6 +6,6 @@ var form = require('express-form')
 module.exports = form(
   field('invitedForm.username').required().is(/^[\da-zA-Z\-_]+$/),
   field('invitedForm.name').required(),
-  field('invitedForm.password').required().is(/^[\da-zA-Z@#$%-_&\+\*\?]{6,64}$/)
+  field('invitedForm.password').required().is(/^[\x20-\x7F]{6,}$/)
 );
 

+ 1 - 1
lib/form/login.js

@@ -5,5 +5,5 @@ var form = require('express-form')
 
 module.exports = form(
   field('loginForm.email').required(),
-  field('loginForm.password').required().is(/^[\x20-\x7F]{6,40}$/)
+  field('loginForm.password').required().is(/^[\x20-\x7F]{6,}$/)
 );

+ 1 - 2
lib/form/me/password.js

@@ -5,7 +5,6 @@ var form = require('express-form')
 
 module.exports = form(
   field('mePassword.oldPassword'),
-  field('mePassword.newPassword').required().is(/^[\x20-\x7F]{6,40}$/),
+  field('mePassword.newPassword').required().is(/^[\x20-\x7F]{6,}$/),
   field('mePassword.newPasswordConfirm').required()
 );
-  //[}m943&T^x7.2kB%98;9CD2Kx[kr{/v!4

+ 2 - 1
lib/form/me/user.js

@@ -5,5 +5,6 @@ var form = require('express-form')
 
 module.exports = form(
   field('userForm.name').trim().required(),
-  field('userForm.email').trim().isEmail().required()
+  field('userForm.email').trim().isEmail().required(),
+  field('userForm.lang').required()
 );

+ 1 - 1
lib/form/register.js

@@ -7,7 +7,7 @@ module.exports = form(
   field('registerForm.username').required().is(/^[\da-zA-Z\-_\.]+$/),
   field('registerForm.name').required(),
   field('registerForm.email').required(),
-  field('registerForm.password').required().is(/^[\x20-\x7F]{6,40}$/),
+  field('registerForm.password').required().is(/^[\x20-\x7F]{6,}$/),
   field('registerForm.googleId'),
   field('registerForm.googleImage')
 );

+ 1 - 2
lib/models/attachment.js

@@ -2,7 +2,6 @@ module.exports = function(crowi) {
   var debug = require('debug')('crowi:models:attachment')
     , mongoose = require('mongoose')
     , ObjectId = mongoose.Schema.Types.ObjectId
-    , Promise = require('bluebird')
     , fileUploader = require('../util/fileUploader')(crowi)
   ;
 
@@ -101,7 +100,7 @@ module.exports = function(crowi) {
   };
 
   attachmentSchema.statics.createAttachmentFilePath = function (pageId, fileName, fileType) {
-    var ext = '.' + fileName.match(/(.*)(?:\.([^.]+$))/)[2];
+    var ext = '.' + fileName.match(/(.*)(?:\.([^.]+$))/)[2] || '';
 
     return 'attachment/' + pageId + '/' + generateFileHash(fileName) + ext;
   };

+ 5 - 5
lib/models/page.js

@@ -329,10 +329,10 @@ module.exports = function(crowi) {
 
   pageSchema.statics.getGrantLabels = function() {
     var grantLabels = {};
-    grantLabels[GRANT_PUBLIC]     = '公開';
-    grantLabels[GRANT_RESTRICTED] = 'リンクを知っている人のみ';
-    //grantLabels[GRANT_SPECIFIED]  = '特定ユーザーのみ';
-    grantLabels[GRANT_OWNER]      = '自分のみ';
+    grantLabels[GRANT_PUBLIC]     = 'Public'; // 公開
+    grantLabels[GRANT_RESTRICTED] = 'Anyone with the link'; // リンクを知っている人のみ
+    //grantLabels[GRANT_SPECIFIED]  = 'Specified users only'; // 特定ユーザーのみ
+    grantLabels[GRANT_OWNER]      = 'Just me'; // 自分のみ
 
     return grantLabels;
   };
@@ -580,7 +580,7 @@ module.exports = function(crowi) {
         {path: 'revision', model: 'Revision'},
       ])
       .sort({updatedAt: -1})
-      .stream();
+      .cursor();
   };
 
   /**

+ 7 - 0
lib/models/revision.js

@@ -60,6 +60,13 @@ module.exports = function(crowi) {
     });
   };
 
+  revisionSchema.statics.findRevisionIdList = function(path) {
+    return this.find({path: path})
+      .select('_id author createdAt')
+      .sort({createdAt: -1})
+      .exec();
+  };
+
   revisionSchema.statics.findRevisionList = function(path, options) {
     var Revision = this,
         User = crowi.model('User');

+ 47 - 13
lib/models/user.js

@@ -11,7 +11,12 @@ module.exports = function(crowi) {
     , STATUS_SUSPENDED  = 3
     , STATUS_DELETED    = 4
     , STATUS_INVITED    = 5
-    , USER_PUBLIC_FIELDS = '_id image googleId name username email introduction status createdAt admin' // TODO: どこか別の場所へ...
+    , USER_PUBLIC_FIELDS = '_id image googleId name username email introduction status lang createdAt admin' // TODO: どこか別の場所へ...
+
+    , LANG_EN    = 'en'
+    , LANG_EN_US = 'en-US'
+    , LANG_EN_GB = 'en-GB'
+    , LANG_JA    = 'ja'
 
     , PAGE_ITEMS        = 50
 
@@ -29,6 +34,11 @@ module.exports = function(crowi) {
     introduction: { type: String },
     password: String,
     apiToken: String,
+    lang: {
+      type: String,
+      enum: Object.keys(getLanguageLabels()).map((k) => eval(k)),
+      default: LANG_EN_US
+    },
     status: { type: Number, required: true, default: STATUS_ACTIVE, index: true  },
     createdAt: { type: Date, default: Date.now },
     admin: { type: Boolean, default: 0, index: true  }
@@ -84,6 +94,16 @@ module.exports = function(crowi) {
     return hasher.digest('base64');
   }
 
+  function getLanguageLabels() {
+    var lang = {};
+    lang.LANG_EN    = LANG_EN;
+    lang.LANG_EN_US = LANG_EN_US;
+    lang.LANG_EN_GB = LANG_EN_GB;
+    lang.LANG_JA    = LANG_JA;
+
+    return lang;
+  }
+
   userSchema.methods.isPasswordSet = function() {
     if (this.password) {
       return true;
@@ -107,9 +127,11 @@ module.exports = function(crowi) {
     return false;
   };
 
-  userSchema.methods.update = function(name, email, callback) {
+  userSchema.methods.update = function(name, email, lang, callback) {
     this.name = name;
     this.email = email;
+    this.lang = lang;
+
     this.save(function(err, userData) {
       return callback(err, userData);
     });
@@ -232,6 +254,7 @@ module.exports = function(crowi) {
     });
   };
 
+  userSchema.statics.getLanguageLabels = getLanguageLabels;
   userSchema.statics.getUserStatusLabels = function() {
     var userStatus = {};
     userStatus[STATUS_REGISTERED] = '承認待ち';
@@ -292,13 +315,18 @@ module.exports = function(crowi) {
     var User = this;
     var option = option || {}
       , sort = option.sort || {createdAt: -1}
-      , status = option.status || STATUS_ACTIVE
+      , status = option.status || [STATUS_ACTIVE, STATUS_SUSPENDED]
       , fields = option.fields || USER_PUBLIC_FIELDS
       ;
 
+    if (!Array.isArray(status)) {
+      status = [status];
+    }
+
     return new Promise(function(resolve, reject) {
       User
-        .find({status: status })
+        .find()
+        .or(status.map(s => { return {status: s}; }))
         .select(fields)
         .sort(sort)
         .exec(function (err, userData) {
@@ -347,13 +375,13 @@ module.exports = function(crowi) {
   userSchema.statics.findUsersWithPagination = function(options, callback) {
     var sort = options.sort || {status: 1, username: 1, createdAt: 1};
 
-    this.paginate({}, { page: options.page || 1, limit: PAGE_ITEMS }, function(err, paginatedResults, pageCount, itemCount) {
+    this.paginate({}, { page: options.page || 1, limit: options.limit || PAGE_ITEMS }, function(err, result) {
       if (err) {
         debug('Error on pagination:', err);
         return callback(err, null);
       }
 
-      return callback(err, paginatedResults, pageCount, itemCount);
+      return callback(err, result);
     }, { sortBy : sort });
   };
 
@@ -612,7 +640,7 @@ module.exports = function(crowi) {
     );
   };
 
-  userSchema.statics.createUserByEmailAndPassword = function(name, username, email, password, callback) {
+  userSchema.statics.createUserByEmailAndPassword = function(name, username, email, password, lang, callback) {
     var User = this
       , newUser = new User();
 
@@ -620,6 +648,7 @@ module.exports = function(crowi) {
     newUser.username = username;
     newUser.email = email;
     newUser.setPassword(password);
+    newUser.lang = lang;
     newUser.createdAt = Date.now();
     newUser.status = decideUserStatusOnRegistration();
 
@@ -647,13 +676,18 @@ module.exports = function(crowi) {
   };
 
 
-  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.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;
-  userSchema.statics.PAGE_ITEMS = PAGE_ITEMS;
+  userSchema.statics.PAGE_ITEMS         = PAGE_ITEMS;
+
+  userSchema.statics.LANG_EN            = LANG_EN;
+  userSchema.statics.LANG_EN_US         = LANG_EN_US;
+  userSchema.statics.LANG_EN_GB         = LANG_EN_US;
+  userSchema.statics.LANG_JA            = LANG_JA;
 
   return mongoose.model('User', userSchema);
 };

+ 32 - 34
lib/routes/admin.js

@@ -11,33 +11,36 @@ module.exports = function(crowi, app) {
     , MAX_PAGE_LIST = 5
     , actions = {};
 
-  function createPager(currentPage, pageCount, itemCount, maxPageList) {
-    var pager = {};
-    pager.currentPage = currentPage;
-    pager.pageCount = pageCount;
-    pager.itemCount = itemCount;
-
-    pager.previous = null;
-    if (currentPage > 1) {
-      pager.previous = currentPage - 1;
+  function createPager(total, limit, page, pagesCount, maxPageList) {
+    const pager = {
+      page: page,
+      pagesCount: pagesCount,
+      pages: [],
+      total: total,
+      previous: null,
+      previousDots: false,
+      next: null,
+      nextDots: false,
+    };
+
+    if (page > 1) {
+      pager.previous = page - 1;
     }
 
-    pager.next = null;
-    if (currentPage < pageCount) {
-      pager.next = currentPage + 1;
+    if (page < pagesCount) {
+      pager.next = page + 1;
     }
 
-    pager.pages = [];
-    var pagerMin = Math.max(1, Math.ceil(currentPage - maxPageList/2));
-    var pagerMax = Math.min(pageCount, Math.floor(currentPage + maxPageList/2));
-    if (pagerMin == 1) {
-      if (MAX_PAGE_LIST < pageCount) {
+    let pagerMin = Math.max(1, Math.ceil(page - maxPageList/2));
+    let pagerMax = Math.min(pagesCount, Math.floor(page + maxPageList/2));
+    if (pagerMin === 1) {
+      if (MAX_PAGE_LIST < pagesCount) {
         pagerMax = MAX_PAGE_LIST;
       } else {
-        pagerMax = pageCount;
+        pagerMax = pagesCount;
       }
     }
-    if (pagerMax == pageCount) {
+    if (pagerMax === pagesCount) {
       if ((pagerMax - MAX_PAGE_LIST) < 1) {
         pagerMin = 1;
       } else {
@@ -51,13 +54,11 @@ module.exports = function(crowi, app) {
     }
 
     pager.nextDots = null;
-    if (pagerMax < pageCount) {
+    if (pagerMax < pagesCount) {
       pager.nextDots = true;
     }
 
-    for (var i = pagerMin;
-      i <= pagerMax;
-      i++) {
+    for (let i = pagerMin; i <= pagerMax; i++) {
       pager.pages.push(i);
     }
 
@@ -208,19 +209,15 @@ module.exports = function(crowi, app) {
           if (!data.errors) {
             debug('Data is successfully indexed.');
           } else {
-            debug('Data index error.', data);
+            debug('Data index error.', data.errors);
           }
-
-          //return res.json(ApiResponse.success({}));
-          req.flash('successMessage', 'Successfully re-build index.');
-          return res.redirect('/admin/search');
         })
         .catch(function(err) {
           debug('Error', err);
-          req.flash('errorMessage', 'Error');
-          return res.redirect('/admin/search');
-          //return res.json(ApiResponse.error(err));
         });
+
+      req.flash('successMessage', 'Now re-building index ... this takes a while.');
+      return res.redirect('/admin/search');
     });
   };
 
@@ -228,10 +225,11 @@ module.exports = function(crowi, app) {
   actions.user.index = function(req, res) {
     var page = parseInt(req.query.page) || 1;
 
-    User.findUsersWithPagination({page: page}, function(err, users, pageCount, itemCount) {
-      var pager = createPager(page, pageCount, itemCount, MAX_PAGE_LIST);
+    User.findUsersWithPagination({page: page}, function(err, result) {
+      const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
+
       return res.render('admin/users', {
-        users: users,
+        users: result.docs,
         pager: pager
       });
     });

+ 11 - 10
lib/routes/attachment.js

@@ -5,7 +5,6 @@ module.exports = function(crowi, app) {
     , Attachment = crowi.model('Attachment')
     , User = crowi.model('User')
     , Page = crowi.model('Page')
-    , Promise = require('bluebird')
     , config = crowi.getConfig()
     , fs = require('fs')
     , fileUploader = require('../util/fileUploader')(crowi, app)
@@ -68,7 +67,7 @@ module.exports = function(crowi, app) {
 
     debug('id and path are: ', id, path);
 
-    var tmpFile = req.files.file || null;
+    var tmpFile = req.file || null;
     debug('Uploaded tmpFile: ', tmpFile);
     if (!tmpFile) {
       return res.json(ApiResponse.error('File error.'));
@@ -95,7 +94,7 @@ module.exports = function(crowi, app) {
 
       var tmpPath = tmpFile.path,
         originalName = tmpFile.originalname,
-        fileName = tmpFile.name,
+        fileName = tmpFile.filename + tmpFile.originalname,
         fileType = tmpFile.mimetype,
         fileSize = tmpFile.size,
         filePath = Attachment.createAttachmentFilePath(id, fileName, fileType),
@@ -118,19 +117,21 @@ module.exports = function(crowi, app) {
 
           result.page.creator = User.filterToPublicFields(result.page.creator);
           result.attachment.creator = User.filterToPublicFields(result.attachment.creator);
+
+          // delete anyway
+          fs.unlink(tmpPath, function (err) { if (err) { debug('Error while deleting tmp file.'); } });
+
           return res.json(ApiResponse.success(result));
         }).catch(function (err) {
           debug('Error on saving attachment data', err);
           // @TODO
           // Remove from S3
+
+          // delete anyway
+          fs.unlink(tmpPath, function (err) { if (err) { debug('Error while deleting tmp file.'); } });
+
           return res.json(ApiResponse.error('Error while uploading.'));
-        }).finally(function() {
-          fs.unlink(tmpPath, function (err) {
-            if (err) {
-              debug('Error while deleting tmp file.');
-            }
-          });
-        })
+        });
       ;
     }).catch(function(err) {
       debug('Attachement upload error', err);

+ 6 - 3
lib/routes/index.js

@@ -1,5 +1,7 @@
 module.exports = function(crowi, app) {
   var middleware = require('../util/middlewares')
+    , multer    = require('multer')
+    , uploads   = multer({dest: crowi.tmpDir + 'uploads'})
     , form      = require('../form')
     , page      = require('./page')(crowi, app)
     , login     = require('./login')(crowi, app)
@@ -85,7 +87,7 @@ module.exports = function(crowi, app) {
   app.get( '/_api/search'             , accessTokenParser , loginRequired(crowi, app) , search.api.search);
 
   app.get( '/_api/check_username'     , user.api.checkUsername);
-  app.post('/_api/me/picture/upload'  , loginRequired(crowi, app) , me.api.uploadPicture);
+  app.post('/_api/me/picture/upload'  , loginRequired(crowi, app) , uploads.single('userPicture'), me.api.uploadPicture);
   app.get( '/_api/user/bookmarks'     , loginRequired(crowi, app) , user.api.bookmarks);
 
   app.get( '/user/:username([^/]+)/bookmarks'      , loginRequired(crowi, app) , page.userBookmarkList);
@@ -109,11 +111,12 @@ module.exports = function(crowi, app) {
   app.post('/_api/likes.add'          , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.like);
   app.post('/_api/likes.remove'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.unlike);
   app.get( '/_api/attachments.list'   , accessTokenParser , loginRequired(crowi, app) , attachment.api.list);
-  app.post('/_api/attachments.add'    , accessTokenParser , loginRequired(crowi, app) , csrf, attachment.api.add);
+  app.post('/_api/attachments.add'    , accessTokenParser , loginRequired(crowi, app) , uploads.single('file'), csrf, attachment.api.add);
   app.post('/_api/attachments.remove' , accessTokenParser , loginRequired(crowi, app) , csrf, attachment.api.remove);
 
   app.get( '/_api/revisions.get'      , accessTokenParser , loginRequired(crowi, app) , revision.api.get);
-  app.get( '/_api/revisions.list'     , accessTokenParser , loginRequired(crowi, app) ,revision.api.list);
+  app.get( '/_api/revisions.ids'      , accessTokenParser , loginRequired(crowi, app) , revision.api.ids);
+  app.get( '/_api/revisions.list'     , accessTokenParser , 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);

+ 3 - 2
lib/routes/installer.js

@@ -14,6 +14,7 @@ module.exports = function(crowi, app) {
 
   actions.createAdmin = function(req, res) {
     var registerForm = req.body.registerForm || {};
+    var language = req.language || 'en';
 
     if (req.form.isValid) {
       var name = registerForm.name;
@@ -21,7 +22,7 @@ module.exports = function(crowi, app) {
       var email = registerForm.email;
       var password = registerForm.password;
 
-      User.createUserByEmailAndPassword(name, username, email, password, function(err, userData) {
+      User.createUserByEmailAndPassword(name, username, email, password, language, function(err, userData) {
         if (err) {
           req.form.errors.push('管理ユーザーの作成に失敗しました。' + err.message);
           // TODO
@@ -38,7 +39,7 @@ module.exports = function(crowi, app) {
             // login処理
             req.user = req.session.user = userData;
             req.flash('successMessage', 'Crowi のインストールが完了しました!はじめに、このページでこの Wiki の各種設定を確認してください。');
-            return res.redirect('admin/app');
+            return res.redirect('/admin/app');
           });
         });
       });

+ 10 - 9
lib/routes/login.js

@@ -39,7 +39,7 @@ module.exports = function(crowi, app) {
   };
 
   var loginFailure = function(req, res) {
-    req.flash('warningMessage', 'ログインに失敗しました');
+    req.flash('warningMessage', 'Sign in failure.');
     return res.redirect('/login');
   };
 
@@ -59,9 +59,9 @@ module.exports = function(crowi, app) {
       ;
 
     if (reason === 'suspended') {
-      reasonMessage = 'このアカウントは停止されています。';
+      reasonMessage = 'This account is suspended.';
     } else if (reason === 'registered') {
-      reasonMessage = '管理者の承認をお待ちください。';
+      reasonMessage = 'Wait for approved by administrators.';
     } else {
     }
 
@@ -129,6 +129,7 @@ module.exports = function(crowi, app) {
 
   actions.register = function(req, res) {
     var googleAuth = require('../util/googleAuth')(config);
+    var lang= req.lang || User.LANG_EN_US;
 
     // ログイン済みならさようなら
     if (req.user) {
@@ -155,16 +156,16 @@ module.exports = function(crowi, app) {
         var isError = false;
         if (!User.isEmailValid(email)) {
           isError = true;
-          req.flash('registerWarningMessage', 'このメールアドレスは登録できません。(ホワイトリストなどを確認してください)');
+          req.flash('registerWarningMessage', 'This email address could not be used. (Make sure the allowed email address)');
         }
         if (!isRegisterable) {
           if (!errOn.username) {
             isError = true;
-            req.flash('registerWarningMessage', 'このユーザーIDは利用できません。');
+            req.flash('registerWarningMessage', 'This User ID is not available.');
           }
           if (!errOn.email) {
             isError = true;
-            req.flash('registerWarningMessage', 'このメールアドレスは登録済みです。');
+            req.flash('registerWarningMessage', 'This email address is already registered.');
           }
 
         }
@@ -173,9 +174,9 @@ module.exports = function(crowi, app) {
           return res.redirect('/register');
         }
 
-        User.createUserByEmailAndPassword(name, username, email, password, function(err, userData) {
+        User.createUserByEmailAndPassword(name, username, email, password, lang, function(err, userData) {
           if (err) {
-            req.flash('registerWarningMessage', 'ユーザー登録に失敗しました。');
+            req.flash('registerWarningMessage', 'Failed to register.');
             return res.redirect('/register');
           } else {
 
@@ -272,7 +273,7 @@ module.exports = function(crowi, app) {
           req.session.googleAuthCode = null;
 
           if (err) {
-            req.flash('registerWarningMessage', 'Googleコネクト中にエラーが発生しました。');
+            req.flash('registerWarningMessage', 'Error on connectiong Google');
             return res.redirect('/login?register=1'); // TODO Handling
           }
 

+ 17 - 15
lib/routes/me.js

@@ -20,7 +20,7 @@ module.exports = function(crowi, app) {
     //var storagePlugin = new pluginService('storage');
     //var storage = require('../service/storage').StorageService(config);
 
-    var tmpFile = req.files.userPicture || null;
+    var tmpFile = req.file || null;
     if (!tmpFile) {
       return res.json({
         'status': false,
@@ -29,7 +29,7 @@ module.exports = function(crowi, app) {
     }
 
     var tmpPath = tmpFile.path;
-    var filePath = User.createUserPictureFilePath(req.user, tmpFile.name);
+    var filePath = User.createUserPictureFilePath(req.user, tmpFile.filename + tmpFile.originalname);
     var acceptableFileType = /image\/.+/;
 
     if (!tmpFile.mimetype.match(acceptableFileType)) {
@@ -82,13 +82,14 @@ module.exports = function(crowi, app) {
     if (req.method == 'POST' && req.form.isValid) {
       var name = userForm.name;
       var email = userForm.email;
+      var lang= userForm.lang;
 
       if (!User.isEmailValid(email)) {
-        req.form.errors.push('このメールアドレスは登録できません。(ホワイトリストなどを確認してください)');
+        req.form.errors.push('You can\'t update to that email address');
         return res.render('me/index', {});
       }
 
-      userData.update(name, email, function(err, userData) {
+      userData.update(name, email, lang, function(err, userData) {
         if (err) {
           for (var e in err.errors) {
             if (err.errors.hasOwnProperty(e)) {
@@ -98,7 +99,8 @@ module.exports = function(crowi, app) {
           return res.render('me/index', {});
         }
 
-        req.flash('successMessage', '更新しました');
+        req.i18n.changeLanguage(lang);
+        req.flash('successMessage', req.t('Updated'));
         return res.redirect('/me');
       });
     } else { // method GET
@@ -128,14 +130,14 @@ module.exports = function(crowi, app) {
       var oldPassword = passwordForm.oldPassword;
 
       if (userData.isPasswordSet() && !userData.isPasswordValid(oldPassword)) {
-        req.form.errors.push('現在のパスワードが違います。');
+        req.form.errors.push('Wrong current password');
         return res.render('me/password', {
         });
       }
 
       // check password confirm
       if (newPassword != newPasswordConfirm) {
-        req.form.errors.push('確認用パスワードが一致しません');
+        req.form.errors.push('Failed to verify passwords');
       } else {
         userData.updatePassword(newPassword, function(err, userData) {
           if (err) {
@@ -147,7 +149,7 @@ module.exports = function(crowi, app) {
             return res.render('me/password', {});
           }
 
-          req.flash('successMessage', 'パスワードを変更しました');
+          req.flash('successMessage', 'Password updated');
           return res.redirect('/me/password');
         });
       }
@@ -164,12 +166,12 @@ module.exports = function(crowi, app) {
     if (req.method == 'POST' && req.form.isValid) {
       userData.updateApiToken()
       .then(function(userData) {
-          req.flash('successMessage', 'API Token を更新しました');
+          req.flash('successMessage', 'API Token updated');
           return res.redirect('/me/apiToken');
       })
       .catch(function(err) {
           //req.flash('successMessage',);
-          req.form.errors.push('API Token の更新に失敗しました');
+          req.form.errors.push('Failed to update API Token');
           return res.render('me/api_token', {
           });
       });
@@ -187,7 +189,7 @@ module.exports = function(crowi, app) {
   actions.deletePicture = function(req, res) {
     // TODO: S3 からの削除
     req.user.deleteImage(function(err, data) {
-      req.flash('successMessage', 'プロフィール画像を削除しました');
+      req.flash('successMessage', 'Deleted profile picture');
       res.redirect('/me');
     });
   };
@@ -201,7 +203,7 @@ module.exports = function(crowi, app) {
     var toConnect = req.body.connectGoogle ? true : false;
     if (toDisconnect) {
       userData.deleteGoogleId(function(err, userData) {
-        req.flash('successMessage', 'Googleコネクトを解除しました。');
+        req.flash('successMessage', 'Disconnected from Google account');
 
         return res.redirect('/me');
       });
@@ -232,13 +234,13 @@ module.exports = function(crowi, app) {
       var googleId = tokenInfo.user_id;
       var googleEmail = tokenInfo.email;
       if (!User.isEmailValid(googleEmail)) {
-        req.flash('warningMessage.auth.google', 'このメールアドレスのGoogleアカウントはコネクトできません。');
+        req.flash('warningMessage.auth.google', 'You can\'t connect with this  Google\'s account');
         return res.redirect('/me');
       }
 
       User.findUserByGoogleId(googleId, function(err, googleUser) {
         if (!err && googleUser) {
-          req.flash('warningMessage.auth.google', 'このGoogleアカウントは他のユーザーがコネクト済みです。');
+          req.flash('warningMessage.auth.google', 'This Google\'s account is connected by another user');
           return res.redirect('/me');
         } else {
           userData.updateGoogleId(googleId, function(err, userData) {
@@ -249,7 +251,7 @@ module.exports = function(crowi, app) {
             }
 
             // TODO if err
-            req.flash('successMessage', 'Googleコネクトを設定しました。');
+            req.flash('successMessage', 'Connected with Google');
             return res.redirect('/me');
           });
         }

+ 1 - 1
lib/routes/page.js

@@ -844,7 +844,7 @@ module.exports = function(crowi, app) {
 
         return res.json(ApiResponse.success(result));
       }).catch(function(err) {
-        return res.json(ApiResponse.error('エラーが発生しました。ページを更新できません。'));
+        return res.json(ApiResponse.error('Failed to update page.'));
       });
     });
   };

+ 30 - 1
lib/routes/revision.js

@@ -22,13 +22,42 @@ module.exports = function(crowi, app) {
     Revision
       .findRevision(revisionId)
       .then(function(revisionData) {
-        return res.json(ApiResponse.success(revisionData));
+        var result = {
+          revision: revisionData,
+        }
+        return res.json(ApiResponse.success(result));
       })
       .catch(function(err) {
+        debug('Error revisios.get', err);
         return res.json(ApiResponse.error(err));
       });
   };
 
+  /**
+   * @api {get} /revisions.ids Get revision id list of the page
+   * @apiName ids
+   * @apiGroup Revision
+   *
+   * @apiParam {String} page_id      Page Id.
+   */
+  actions.api.ids = function(req, res) {
+    var pageId = req.query.page_id || null;
+
+    if (pageId && crowi.isPageId(pageId)) {
+      Page.findPageByIdAndGrantedUser(pageId, req.user)
+      .then(function(pageData) {
+        debug('Page found', pageData._id, pageData.path);
+        return Revision.findRevisionIdList(pageData.path);
+      }).then(function(revisions) {
+        return res.json(ApiResponse.success({revisions}));
+      }).catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
+    } else {
+      return res.json(ApiResponse.error('Parameter error.'));
+    }
+  };
+
   /**
    * @api {get} /revisions.list Get revisions
    * @apiName ListRevision

+ 19 - 0
lib/util/i18nUserSettingDetector.js

@@ -0,0 +1,19 @@
+module.exports = {
+  name: 'userSettingDetector',
+
+  lookup: function(req, res, options) {
+    var lang = null;
+
+    if (req.user) {
+      if ('lang' in req.user) {
+        lang = req.user.lang || null;
+      }
+    }
+
+    return lang;
+  },
+
+  cacheUserlanguage: function(req, res, lng, options) {
+    // nothing to do
+  }
+};

+ 2 - 1
lib/util/middlewares.js

@@ -43,6 +43,7 @@ exports.csrfVerify = function(crowi, app) {
       return next();
     }
 
+    debug('csrf verification failed. return 403', csrfKey, token);
     return res.sendStatus(403);
   };
 };
@@ -188,7 +189,7 @@ exports.loginRequired = function(crowi, app) {
 
 exports.accessTokenParser = function(crowi, app) {
   return function(req, res, next) {
-    var accessToken = req.query.access_token || req.body.access_token || null;
+    var accessToken = req.query.access_token || req.body.access_token || req.get('Authorization') || null;
     if (!accessToken) {
       return next();
     }

+ 136 - 18
lib/util/search.js

@@ -19,6 +19,7 @@ function SearchClient(crowi, esUri) {
   this.client = new elasticsearch.Client({
     host: this.host,
     requestTimeout: 5000,
+    //log: 'debug',
   });
 
   this.registerUpdateEvent();
@@ -208,11 +209,13 @@ SearchClient.prototype.addAllPages = function()
   var self = this;
   var offset = 0;
   var Page = this.crowi.model('Page');
-  var stream = Page.getStreamOfFindAll();
+  var cursor = Page.getStreamOfFindAll();
   var body = [];
 
+  var counter = 0;
+
   return new Promise(function(resolve, reject) {
-    stream.on('data', function (doc) {
+    cursor.on('data', function (doc) {
       if (!doc.creator || !doc.revision || !self.shouldIndexed(doc)) {
         debug('Skipped', doc.path);
         return ;
@@ -221,7 +224,7 @@ SearchClient.prototype.addAllPages = function()
       self.prepareBodyForCreate(body, doc);
     }).on('error', function (err) {
       // TODO: handle err
-      debug('Error stream:', err);
+      debug('Error cursor:', err);
     }).on('close', function () {
       // all done
 
@@ -276,7 +279,7 @@ SearchClient.prototype.search = function(query)
 SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option)
 {
   // getting path by default is almost for debug
-  var fields = ['path', '_id'];
+  var fields = ['path'];
   if (option) {
     fields = option.fields || fields;
   }
@@ -286,9 +289,9 @@ SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option)
     index: this.index_name,
     type: 'pages',
     body: {
-      fields: fields,
       sort: [{ updated_at: { order: 'desc'}}],
       query: {}, // query
+      _source: fields,
     }
   };
   this.appendResultSize(query);
@@ -298,7 +301,7 @@ SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option)
 
 SearchClient.prototype.createSearchQuerySortedByScore = function(option)
 {
-  var fields = ['path', '_id'];
+  var fields = ['path'];
   if (option) {
     fields = option.fields || fields;
   }
@@ -308,9 +311,9 @@ SearchClient.prototype.createSearchQuerySortedByScore = function(option)
     index: this.index_name,
     type: 'pages',
     body: {
-      fields: fields,
       sort: [ {_score: { order: 'desc'} }],
       query: {}, // query
+      _source: fields,
     }
   };
   this.appendResultSize(query);
@@ -330,21 +333,88 @@ SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keywor
   if (!query.body.query.bool) {
     query.body.query.bool = {};
   }
-
   if (!query.body.query.bool.must || !Array.isArray(query.body.query.must)) {
     query.body.query.bool.must = [];
   }
+  if (!query.body.query.bool.must_not || !Array.isArray(query.body.query.must_not)) {
+    query.body.query.bool.must_not = [];
+  }
 
-  query.body.query.bool.must.push({
-    multi_match: {
-      query: keyword,
-      fields: [
-        "path.ja^2", // ためしに。
-        "body.ja"
-      ],
-      operator: "and"
+  var appendMultiMatchQuery = function(query, type, keywords) {
+    var target;
+    var operator = 'and';
+    switch (type) {
+      case 'not_match':
+        target = query.body.query.bool.must_not;
+        operator = 'or';
+        break;
+      case 'match':
+      default:
+        target = query.body.query.bool.must;
     }
-  });
+
+    target.push({
+      multi_match: {
+        query: keywords.join(' '),
+        // TODO: By user's i18n setting, change boost or search target fields
+        fields: [
+          "path_ja^2",
+          "body_ja",
+          // "path_en",
+          // "body_en",
+        ],
+        operator: operator,
+      }
+    });
+
+    return query;
+  };
+
+  var parsedKeywords = this.getParsedKeywords(keyword);
+
+  if (parsedKeywords.match.length > 0) {
+    query = appendMultiMatchQuery(query, 'match', parsedKeywords.match);
+  }
+
+  if (parsedKeywords.not_match.length > 0) {
+    query = appendMultiMatchQuery(query, 'not_match', parsedKeywords.not_match);
+  }
+
+  if (parsedKeywords.phrase.length > 0) {
+    var phraseQueries = [];
+    parsedKeywords.phrase.forEach(function(phrase) {
+      phraseQueries.push({
+        multi_match: {
+          query: phrase, // each phrase is quoteted words
+          type: 'phrase',
+          fields: [ // Not use "*.ja" fields here, because we want to analyze (parse) search words
+            "path_raw^2",
+            "body_raw",
+          ],
+        }
+      });
+    });
+
+    query.body.query.bool.must.push(phraseQueries);
+  }
+
+  if (parsedKeywords.not_phrase.length > 0) {
+    var notPhraseQueries = [];
+    parsedKeywords.not_phrase.forEach(function(phrase) {
+      notPhraseQueries.push({
+        multi_match: {
+          query: phrase, // each phrase is quoteted words
+          type: 'phrase',
+          fields: [ // Not use "*.ja" fields here, because we want to analyze (parse) search words
+            "path_raw^2",
+            "body_raw",
+          ],
+        }
+      });
+    });
+
+    query.body.query.bool.must_not.push(notPhraseQueries);
+  }
 };
 
 SearchClient.prototype.appendCriteriaForPathFilter = function(query, path)
@@ -363,7 +433,7 @@ SearchClient.prototype.appendCriteriaForPathFilter = function(query, path)
   }
   query.body.query.bool.filter.push({
     wildcard: {
-      "path.raw": path + "/*"
+      "path": path + "/*"
     }
   });
 };
@@ -396,6 +466,54 @@ SearchClient.prototype.searchKeywordUnderPath = function(keyword, path, option)
   return this.search(query);
 };
 
+SearchClient.prototype.getParsedKeywords = function(keyword)
+{
+  var matchWords = [];
+  var notMatchWords = [];
+  var phraseWords = [];
+  var notPhraseWords = [];
+
+  keyword.trim();
+  keyword = keyword.replace(/\s+/g, ' ');
+
+  // First: Parse phrase keywords
+  var phraseRegExp = new RegExp(/(-?"[^"]+")/g);
+  var phrases = keyword.match(phraseRegExp);
+
+  if (phrases !== null) {
+    keyword = keyword.replace(phraseRegExp, '');
+
+    phrases.forEach(function(phrase) {
+      phrase.trim();
+      if (phrase.match(/^\-/)) {
+        notPhraseWords.push(phrase.replace(/^\-/, ''));
+      } else {
+        phraseWords.push(phrase);
+      }
+    });
+  }
+
+  // Second: Parse other keywords (include minus keywords)
+  keyword.split(' ').forEach(function(word) {
+    if (word === '') {
+      return;
+    }
+
+    if (word.match(/^\-(.+)$/)) {
+      notMatchWords.push((RegExp.$1));
+    } else {
+      matchWords.push(word);
+    }
+  });
+
+  return {
+    match: matchWords,
+    not_match: notMatchWords,
+    phrase: phraseWords,
+    not_phrase: notPhraseWords,
+  };
+}
+
 SearchClient.prototype.syncPageCreated = function(page, user)
 {
   debug('SearchClient.syncPageCreated', page.path);

+ 2 - 2
lib/views/_form.html

@@ -45,12 +45,12 @@
         {% 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>
+          <option value="{{ grantId }}" {% if pageForm.grant|default(page.grant) == grantId %}selected{% endif %}>{{ t(grantLabel) }}</option>
           {% endfor %}
         </select>
         {% endif %}
         <input type="hidden" id="edit-form-csrf" name="_csrf" value="{{ csrf() }}">
-        <input type="submit" class="btn btn-primary" id="edit-form-submit" value="ページを更新" />
+        <input type="submit" class="btn btn-primary" id="edit-form-submit" value="{{ t('Update Page') }}" />
       </div>
     </div>
   </form>

+ 1 - 20
lib/views/admin/users.html

@@ -256,26 +256,7 @@
         </tbody>
       </table>
 
-      <ul class="pagination">
-
-        <li {% if pager.currentPage == 1 %}class="disabled"{% endif %}>
-          <a href="/admin/users?page={{ pager.previous|default(1) }}">&laquo;</a>
-        </li>
-        {% if pager.previousDots %}
-        <li><a href="#">...</a></li>
-        {% endif  %}
-        {% for page in pager.pages %}
-        <li {% if pager.currentPage == page %}class="active"{% endif %}>
-          <a href="/admin/users?page={{ page }}">{{ page }}</a>
-        </li>
-        {% endfor %}
-        {% if pager.nextDots %}
-        <li><a href="#">...</a></li>
-        {% endif  %}
-        <li {% if pager.currentPage == pager.pageCount %}class="disabled"{% endif %}>
-          <a href="/admin/users?page={{ pager.next|default(pager.pageCount) }}">&raquo;</a>
-        </li>
-      </ul>
+      {% include '../widget/pager.html' with {path: "/admin/users", pager: pager} %}
 
     </div>
   </div>

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

@@ -19,7 +19,7 @@
   <div id="footer-container" class="footer">
     <footer class="">
       <p>
-      <a href="" data-target="#help-modal" 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> {{ t('Help') }}</a>
       &copy; {{ now|date('Y') }} {{ config.crowi['app:title']|default('Crowi') }} <img src="/logo/100x11_g.png" alt="powered by Crowi"> </p>
     </footer>
   </div>
@@ -31,12 +31,12 @@
 <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] }} (このページの閲覧は制限されています)
+    <i class="fa fa-lock"></i> {{ consts.pageGrants[page.grant] }} ({{ t('Browsing of this page is restricted') }})
   </p>
   {% endif %}
   {% if page && page.grant == 2 %}
   <p class="alert alert-info">
-    このページの共有用URL
+    {{ t('Shareable Link') }}
     <input type="text" class="copy-link form-control" value="{{ baseUrl }}/{{ page._id.toString() }}" readonly>
   </p>
   {% endif %}

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

@@ -54,7 +54,7 @@
       {% if user and user.admin %}
       <li id="">
         <a href="/admin" id="link-mypage">
-          <i class="fa fa-cube"></i> 管理
+          <i class="fa fa-cube"></i> {{ t('Admin') }}
         </a>
       </li>
       {% endif %}
@@ -75,7 +75,7 @@
       #}
       <li id="" class="dropdown">
         <button class="btn btn-default create-page-button" data-target="#create-page" data-toggle="modal">
-          <i class="fa fa-pencil"></i> 作成
+          <i class="fa fa-pencil"></i> {{ t('New') }}
         </button>
       </li>
       <li id="login-user">
@@ -86,11 +86,11 @@
       <li class="dropdown">
         <a href="#" class="dropdown-toggle" data-toggle="dropdown"><i class="fa fa-bars"></i> <label class="sr-only">メニュー</label></a>
         <ul class="dropdown-menu">
-          <li><a href="/me"><i class="fa fa-gears"></i> ユーザー設定</a></li>
+          <li><a href="/me"><i class="fa fa-gears"></i> {{ t('User Settings') }}</a></li>
           <li class="divider"></li>
-          <li><a href="/trash/"><i class="fa fa-trash-o"></i> 削除済みページ</a></li>
+          <li><a href="/trash/"><i class="fa fa-trash-o"></i> {{ t('Deleted Pages') }}</a></li>
           <li class="divider"></li>
-          <li><a href="/logout"><i class="fa fa-sign-out"></i> ログアウト</a></li>
+          <li><a href="/logout"><i class="fa fa-sign-out"></i> {{ t('Sign out') }}</a></li>
           {# <li><a href="#">今日の日報を作成</a></li> #}
           {# <li class="divider"></li> #}
           {# <li class="divider"></li> #}

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

@@ -20,7 +20,7 @@
 <div id="footer-container" class="footer">
   <footer class="">
     <p>
-    <a href="" data-target="#help-modal" 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> {{ t('Help') }}</a>
     &copy; {{ now|date('Y') }} {{ config.crowi['app:title'] }} <img src="/logo/100x11_g.png" alt="powered by Crowi"> </p>
   </footer>
 </div>

+ 25 - 25
lib/views/login.html

@@ -1,6 +1,6 @@
 {% extends 'layout/single-nologin.html' %}
 
-{% block html_title %}Login · {% endblock %}
+{% block html_title %}{{ t('Sign in') }} · {% endblock %}
 
 {% block content_main %}
 
@@ -18,7 +18,7 @@
 <div class="login-dialog flipper {% if req.query.register or req.body.registerForm or isRegistering or googleId %}to-flip{% endif %}" id="login-dialog">
 
   <div class="login-dialog-inner front">
-    <h2>ログイン</h2>
+    <h2>{{ t('Sign in') }}</h2>
 
     <div id="login-form-errors">
       {% set message = req.flash('warningMessage') %}
@@ -50,17 +50,17 @@
       </div>
 
       <input type="hidden" name="_csrf" value="{{ csrf() }}">
-      <input type="submit" class="btn btn-primary btn-lg btn-block" value="Login">
+      <input type="submit" class="btn btn-primary btn-lg btn-block" value="{{ t('Sign in') }}">
     </form>
 
     <hr>
 
     <div class="row">
       {% if googleLoginEnabled() %}
-      <div class="col-md-6">
-        <p>Google でログイン</p>
+      <div class="col-md-8">
+        <p>{{ t('Sign in by Google Account') }}</p>
         <form role="form" action="/login/google" method="get">
-          <button type="submit" class="btn btn-block btn-google"><i class="fa fa-google-plus-square"></i> Login</button>
+          <button type="submit" class="btn btn-block btn-google"><i class="fa fa-google-plus-square"></i> {{ t('Sign in') }}</button>
           <input type="hidden" name="_csrf" value="{{ csrf() }}">
         </form>
       </div>
@@ -68,19 +68,19 @@
     </div>
 
     {% if config.crowi['security:registrationMode'] != 'Closed' %}
-    <p class="bottom-text"><a href="#register" id="register"><i class="fa fa-pencil"></i> 新規登録はこちら</a></p>
+    <p class="bottom-text"><a href="#register" id="register"><i class="fa fa-pencil"></i> {{ t('Sign up is here') }}</a></p>
     {% endif %}
   </div>
 
   {% if config.crowi['security:registrationMode'] != 'Closed' %}
   <div class="register-dialog-inner back">
 
-    <h2>新規登録</h2>
+    <h2>{{ t('Sign up') }}</h2>
 
     {% if config.crowi['security:registrationMode'] == 'Restricted' %}
     <p class="alert alert-warning">
-    この Wiki への新規登録は制限されています。<br>
-    利用を開始するには、新規登録後、管理者による承認が必要です。
+      {{ t('page_register.notice.restricted') }}<br>
+      {{ t('page_register.notice.restricted_defail') }}
     </p>
     {% endif %}
 
@@ -91,8 +91,8 @@
         <img src="{{ googleImage }}" class="picture picture-rounded picture-lg">
       </p>
       {% endif %}
-      <code>{{ googleEmail }}</code> この Google アカウントで登録します<br>
-      ユーザーID、名前、パスワードを決めて登録を継続してください。
+      <code>{{ googleEmail }}</code> {{ t('page_register with this Google Account') }}<br>
+      {{ t('page_register.notice.google_account_continue') }}
     </div>
     {% endif %}
 
@@ -120,30 +120,30 @@
     <form role="form" method="post" action="/register" id="register-form">
       <input type="hidden" class="form-control" name="registerForm[googleId]" value="{{ googleId|default(req.body.registerForm.googleId) }}">
 
-      <label>ユーザーID</label>
+      <label>{{ t('User ID') }}</label>
       <div class="input-group" id="input-group-username">
         <span class="input-group-addon"><strong>@</strong></span>
-        <input type="text" class="form-control" placeholder="記入例: taroyama" name="registerForm[username]" value="{{ req.body.registerForm.username }}" required>
+        <input type="text" class="form-control" placeholder="{{ t('Example') }}: taroyama" name="registerForm[username]" value="{{ req.body.registerForm.username }}" required>
       </div>
       <p class="help-block">
       <span id="help-block-username" class="text-danger"></span>
-      ユーザーIDは、ユーザーページのURLなどに利用されます。半角英数字と一部の記号のみ利用できます。
+      {{ t('page_register.form_help.user_id') }}
       </p>
 
-      <label>名前</label>
+      <label>{{ t('Name') }}</label>
       <div class="input-group">
         <span class="input-group-addon"><i class="fa fa-user"></i></span>
-        <input type="text" class="form-control" placeholder="記入例: 山田 太郎" name="registerForm[name]" value="{{ googleName|default(req.body.registerForm.name) }}" required>
+        <input type="text" class="form-control" placeholder="{{ t('Example') }}: {{ t('Taro Yamada') }}" name="registerForm[name]" value="{{ googleName|default(req.body.registerForm.name) }}" required>
       </div>
 
-      <label >メールアドレス</label>
+      <label>{{ t('Email') }}</label>
       <div class="input-group">
         <span class="input-group-addon"><i class="fa fa-envelope"></i></span>
         <input type="email" class="form-control" placeholder="E-mail" name="registerForm[email]" value="{{ googleEmail|default(req.body.registerForm.email) }}" required>
       </div>
       {% if config.crowi['security:registrationWhiteList'] && config.crowi['security:registrationWhiteList'].length %}
       <p class="help-block">
-      この Wiki では以下のメールアドレスのみ登録可能です。
+        {{ t('page_register.form_help.email') }}
       </p>
       <ul>
         {% for em in config.crowi['security:registrationWhiteList'] %}
@@ -152,20 +152,20 @@
       </ul>
       {% endif %}
 
-      <label>パスワード</label>
+      <label>{{ t('Password') }}</label>
       <div class="input-group">
         <span class="input-group-addon"><i class="fa fa-key"></i></span>
         <input type="password" class="form-control" placeholder="Password" name="registerForm[password]" required>
       </div>
       <p class="help-block">
-      パスワードは6文字以上の半角英数字または記号
+        {{ t('page_register.form_help.password') }}
       </p>
 
       {% if googleImage %}
         <input type="hidden" name="registerForm[googleImage]" value="{{ googleImage }}">
       {% endif  %}
       <input type="hidden" name="_csrf" value="{{ csrf() }}">
-      <input type="submit" class="btn btn-primary btn-lg btn-block" value="新規登録">
+      <input type="submit" class="btn btn-primary btn-lg btn-block" value="{{ t('Sign up') }}">
     </form>
 
     <hr>
@@ -173,16 +173,16 @@
     <div class="row">
       {% if googleLoginEnabled() %}
       <div class="col-md-6">
-        <p>Google で登録</p>
+        <p>{{ t('Sign up with Google Account') }}</p>
         <form role="form" method="post" action="/register/google">
           <input type="hidden" name="_csrf" value="{{ csrf() }}">
-          <button type="submit" class="btn btn-block btn-google"><i class="fa fa-google-plus-square"></i> Login</button>
+          <button type="submit" class="btn btn-block btn-google"><i class="fa fa-google-plus-square"></i> {{ t('Login') }}</button>
         </form>
       </div>
       {% endif %}
     </div>
 
-    <p class="bottom-text"><a href="#login" id="login"><i class="fa fa-sign-out"></i> ログインはこちら</a></p>
+    <p class="bottom-text"><a href="#login" id="login"><i class="fa fa-sign-out"></i> {{ t('Sign in is here') }}</a></p>
   </div>
   {% endif %} {# if registrationMode == Closed #}
 

+ 12 - 12
lib/views/me/api_token.html

@@ -1,12 +1,12 @@
 {% extends '../layout/2column.html' %}
 
 
-{% block html_title %}APIの設定 · {{ path }}{% endblock %}
+{% block html_title %}{{ t('API Settings') }} · {{ path }}{% endblock %}
 
 {% block content_head %}
 <div class="header-wrap">
   <header id="page-header">
-  <h1 class="title" id="">ユーザー設定</h1>
+  <h1 class="title" id="">{{ t('User Settings') }}</h1>
   </header>
 </div>
 {% endblock %}
@@ -15,9 +15,9 @@
 <div class="content-main">
 
   <ul class="nav nav-tabs">
-    <li><a href="/me"><i class="fa fa-gears"></i> ユーザー情報</a></li>
-    <li><a href="/me/password"><i class="fa fa-key"></i> パスワード設定</a></li>
-    <li class="active"><a href="/me/apiToken"><i class="fa fa-rocket"></i> API設定</a></li>
+    <li><a href="/me"><i class="fa fa-gears"></i> {{ t('User Information') }}</a></li>
+    <li><a href="/me/password"><i class="fa fa-key"></i> {{ t('Password Setting') }}</a></li>
+    <li class="active"><a href="/me/apiToken"><i class="fa fa-rocket"></i> {{ t('API Settings') }}</a></li>
   </ul>
 
   <div class="tab-content">
@@ -43,29 +43,29 @@
 
     <form action="/me/apiToken" method="post" class="form-horizontal" role="form">
     <fieldset>
-      <legend>API Token 設定</legend>
+      <legend>{{ t('API Token Settings') }}</legend>
       <div class="form-group {% if not user.password %}has-error{% endif %}">
-        <label for="" class="col-xs-2 control-label">現在のAPI Token</label>
+        <label for="" class="col-xs-3 control-label">{{ t('Current API Token') }}</label>
         <div class="col-xs-6">
           {% if user.apiToken %}
             <input class="form-control" type="text" value="{{ user.apiToken }}">
           {% else %}
           <p class="form-control-static">
-            API Token が設定されていません。更新するボタンから発行してください。
+            {{ t('page_me_apitoken.notice.apitoken_issued') }}
           </p>
           {% endif %}
         </div>
       </div>
 
       <div class="form-group">
-        <div class="col-xs-offset-2 col-xs-10">
+        <div class="col-xs-offset-3 col-xs-10">
 
           <p class="alert alert-warning">
-          API Token を更新すると、自動的に新しい Token が生成されます。<br>
-          現在の Token を利用している処理は動かなくなります。
+            {{ t('page_me_apitoken.notice.update_token1') }}<br>
+            {{ t('page_me_apitoken.notice.update_token2') }}
           </p>
 
-          <button type="submit" value="1" name="apiTokenForm[confirm]" class="btn btn-primary">API Tokenを更新する</button>
+          <button type="submit" value="1" name="apiTokenForm[confirm]" class="btn btn-primary">{{ t('Update API Token') }}</button>
         </div>
       </div>
 

+ 34 - 34
lib/views/me/index.html

@@ -1,11 +1,11 @@
 {% extends '../layout/2column.html' %}
 
-{% block html_title %}ユーザー設定 · {{ path }}{% endblock %}
+{% block html_title %}{{ t('User Settings') }} · {{ path }}{% endblock %}
 
 {% block content_head %}
 <div class="header-wrap">
   <header id="page-header">
-    <h1 class="title" id="">ユーザー設定</h1>
+    <h1 class="title" id="">{{ t('User Settings') }}</h1>
   </header>
 </div>
 {% endblock %}
@@ -14,9 +14,9 @@
 <div class="content-main">
 
   <ul class="nav nav-tabs">
-    <li class="active"><a href="/me"><i class="fa fa-gears"></i> ユーザー情報</a></li>
-    <li><a href="/me/password"><i class="fa fa-key"></i> パスワード設定</a></li>
-    <li><a href="/me/apiToken"><i class="fa fa-rocket"></i> API設定</a></li>
+    <li class="active"><a href="/me"><i class="fa fa-gears"></i> {{ t('User Information') }}</a></li>
+    <li><a href="/me/password"><i class="fa fa-key"></i> {{ t('Password Settings') }}</a></li>
+    <li><a href="/me/apiToken"><i class="fa fa-rocket"></i> {{ t('API Settings') }}</a></li>
   </ul>
 
   <div class="tab-content">
@@ -49,31 +49,22 @@
   <div class="form-box">
     <form action="/me" method="post" class="form-horizontal" role="form">
       <fieldset>
-        <legend>ユーザーの基本情報</legend>
+        <legend>{{ t('Basic Info') }}</legend>
       <div class="form-group">
-        <label for="userForm[name]" class="col-sm-2 control-label">名前</label>
+        <label for="userForm[name]" class="col-sm-2 control-label">{{ t('Name') }}</label>
         <div class="col-sm-4">
           <input class="form-control" type="text" name="userForm[name]" value="{{ user.name }}" required>
         </div>
       </div>
       <div class="form-group {% if not user.email %}has-error{% endif %}">
-        <label for="userForm[email]" class="col-sm-2 control-label">メールアドレス</label>
+        <label for="userForm[email]" class="col-sm-2 control-label">{{ t('Email') }}</label>
         <div class="col-sm-4">
           <input class="form-control" type="email" name="userForm[email]" value="{{ user.email }}" required>
         </div>
         <div class="col-sm-offset-2 col-sm-10">
-          {# ↓ そのうちこのコードは削除する #}
-          {% if not user.email %}
-          <p class="help-block help-danger">
-          メールアドレスは登録必須項目です。<br>
-          (以前のバージョンのWikiで作成されたユーザー情報の場合、メールアドレスが登録されていません)<br>
-          更新ボタンを押して新規登録してください。
-          </p>
-          {% endif %}
-
           {% if config.crowi['security:registrationWhiteList'] && config.crowi['security:registrationWhiteList'].length %}
           <p class="help-block">
-          この Wiki では以下のメールアドレスのみ登録可能です。
+            {{ t('page_register.form_help.email') }}
           <ul>
             {% for em in config.crowi['security:registrationWhiteList'] %}
             <li><code>{{ em }}</code></li>
@@ -82,11 +73,20 @@
           </p>
           {% endif %}
         </div>
+        <div class="form-group {% if not user.lang %}has-error{% endif %}">
+          <label for="userForm[lang]" class="col-sm-2 control-label">{{ t('Language') }}</label>
+          <div class="col-sm-4 radio">
+            <label><input type="radio" name="userForm[lang]" value="{{ consts.language.LANG_EN_US }}" {% if user.lang == consts.language.LANG_EN_US %}checked="checked"{% endif %}>{{ t('English') }}</label>
+            <label><input type="radio" name="userForm[lang]" value="{{ consts.language.LANG_JA }}" {% if user.lang == consts.language.LANG_JA %}checked="checked"{% endif %}>{{ t('Japanese') }}</label>
+          </div>
+          <div class="col-sm-offset-2 col-sm-10">
+          </div>
+        </div>
       </div>
 
       <div class="form-group">
         <div class="col-sm-offset-2 col-sm-10">
-          <button type="submit" class="btn btn-primary">更新</button>
+          <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
         </div>
       </div>
     </fieldset>
@@ -95,11 +95,11 @@
 
   <div class="form-box">
     <fieldset>
-      <legend>プロフィール画像の設定</legend>
+      <legend>{{ t('Set Profile Image') }}</legend>
         <div class="form-group">
           <div id="pictureUploadFormMessage"></div>
           <label for="" class="col-sm-3 control-label">
-            現在の画像
+            {{ t('Current Image') }}
           </label>
           <div class="col-sm-9">
             <p>
@@ -107,8 +107,8 @@
             </p>
             <p>
             {% if user.image %}
-            <form action="/me/picture/delete" method="post" class="form-horizontal" role="form" onsubmit="return window.confirm('削除してよろしいですか?');">
-              <button type="submit" class="btn btn-danger">画像を削除</button>
+            <form action="/me/picture/delete" method="post" class="form-horizontal" role="form" onsubmit="return window.confirm('{{ t('Delete this image?') }}');">
+              <button type="submit" class="btn btn-danger">{{ t('Delete Image') }}</button>
             </form>
             {% endif %}
             </p>
@@ -117,7 +117,7 @@
 
         <div class="form-group">
           <label for="" class="col-sm-3 control-label">
-            新しい画像をアップロード
+            {{ t('Upload new image') }}
           </label>
           <div class="col-sm-9">
             {% if isUploadable() %}
@@ -127,8 +127,8 @@
               </div>
             </form>
             {% else %}
-            * 画像をアップロードをするための設定がされていません。<br>
-            * アップロードできるようにするには、AWS またはローカルアップロードの設定をしてください。<br>
+            * {{ t('page_me.form_help.profile_image1') }}<br>
+            * {{ t('page_me.form_help.profile_image2') }}<br>
             {% endif %}
           </div>
         </div>
@@ -178,7 +178,7 @@
       <div class="form-box">
         <form action="/me/auth/google" method="post" class="form-horizontal" role="form">
           <fieldset>
-            <legend><i class="fa fa-google-plus-square"></i> Google設定</legend>
+            <legend><i class="fa fa-google-plus-square"></i> {{ t('Google Setting') }}</legend>
 
             {% set wmessage = req.flash('warningMessage.auth.google') %}
             {% if wmessage.length %}
@@ -192,13 +192,13 @@
 
             <div class="col-sm-12">
               <p>
-                接続されています
+                {{ t('Connected') }}
 
-                <input type="submit" name="disconnectGoogle" class="btn btn-default" value="接続を解除">
+                <input type="submit" name="disconnectGoogle" class="btn btn-default" value="{{ t('Disconnect') }}">
               </p>
               <p class="help-block">
-              接続を解除すると、Googleでログインができなくなります。<br>
-              解除後はメールアドレスとパスワードでログインすることができます。
+                {{ t('page_me.form_help.google_disconnect1') }}<br>
+                {{ t('page_me.form_help.google_disconnect2') }}
               </p>
             </div>
 
@@ -209,11 +209,12 @@
                 <input type="submit" name="connectGoogle" class="btn btn-google" value="Googleコネクト">
               </div>
               <p class="help-block">
-              Googleコネクトをすると、Googleアカウントでログイン可能になります。<br>
+                {{ t('page_me.form_help.google_connect1') }}<br>
               </p>
               {% if config.crowi['security:registrationWhiteList'] && config.crowi['security:registrationWhiteList'].length %}
               <p class="help-block">
-              この Wiki では、登録可能なメールアドレスが限定されているため、コネクト可能なGoogleアカウントは、以下のメールアドレスの発行できるGoogle Appsアカウントに限られます。
+                {{ t('page_register.form_help.email') }}<br>
+                {{ t('page_me.form_help.google_connect2') }}
               </p>
               <ul>
                 {% for em in config.crowi['security:registrationWhiteList'] %}
@@ -271,4 +272,3 @@
 
 {% block footer %}
 {% endblock footer %}
-

+ 14 - 14
lib/views/me/password.html

@@ -1,11 +1,11 @@
 {% extends '../layout/2column.html' %}
 
-{% block html_title %}パスワードの設定 · {{ path }}{% endblock %}
+{% block html_title %}{{ t('Password Settings') }} · {{ path }}{% endblock %}
 
 {% block content_head %}
 <div class="header-wrap">
   <header id="page-header">
-    <h1 class="title" id="">ユーザー設定</h1>
+    <h1 class="title" id="">{{ t('User Settings') }}</h1>
   </header>
 </div>
 {% endblock %}
@@ -14,16 +14,16 @@
 <div class="content-main">
 
   <ul class="nav nav-tabs">
-    <li><a href="/me"><i class="fa fa-gears"></i> ユーザー情報</a></li>
-    <li class="active"><a href="/me/password"><i class="fa fa-key"></i> パスワード設定</a></li>
-    <li><a href="/me/apiToken"><i class="fa fa-rocket"></i> API設定</a></li>
+    <li><a href="/me"><i class="fa fa-gears"></i> {{ t('User Information') }}</a></li>
+    <li class="active"><a href="/me/password"><i class="fa fa-key"></i> {{ t('Password Settings') }}</a></li>
+    <li><a href="/me/apiToken"><i class="fa fa-rocket"></i> {{ t('API Settings') }}</a></li>
   </ul>
 
   <div class="tab-content">
 
   {% if not user.password %}
   <div class="alert alert-danger">
-    パスワードを設定してください
+    {{ t('Please set a password') }}
   </div>
   {% endif %}
 
@@ -46,7 +46,7 @@
 
   {% if user.email %}
   <p>
-  <code>{{ user.email }}</code> と設定されたパスワードの組み合わせでログイン可能になります。
+    {{ t('You can sign in with email and password', user.email) }}
   </p>
   {% endif %}
 
@@ -55,37 +55,37 @@
     <form action="/me/password" method="post" class="form-horizontal" role="form">
     <fieldset>
       {% if user.password %}
-      <legend>パスワードを更新</legend>
+      <legend>{{ t('Update Password') }}</legend>
       {% else %}
-      <legend>パスワードを新規に設定</legend>
+      <legend>{{ t('Set new Password') }}</legend>
       {% endif %}
       {% if user.password %}
       <div class="form-group">
-        <label for="mePassword[oldPassword]" class="col-xs-2 control-label">現在のパスワード</label>
+        <label for="mePassword[oldPassword]" class="col-xs-3 control-label">{{ t('Current password') }}</label>
         <div class="col-xs-6">
           <input class="form-control" type="password" name="mePassword[oldPassword]">
         </div>
       </div>
       {% endif %}
       <div class="form-group {% if not user.password %}has-error{% endif %}">
-        <label for="mePassword[newPassword]" class="col-xs-2 control-label">新しいパスワード</label>
+        <label for="mePassword[newPassword]" class="col-xs-3 control-label">{{ t('New password') }}</label>
         <div class="col-xs-6">
           <input class="form-control" type="password" name="mePassword[newPassword]" required>
         </div>
       </div>
       <div class="form-group">
-        <label for="mePassword[newPasswordConfirm]" class="col-xs-2 control-label">確認</label>
+        <label for="mePassword[newPasswordConfirm]" class="col-xs-3 control-label">{{ t('Re-enter new password') }}</label>
         <div class="col-xs-6">
           <input class="form-control col-xs-4" type="password" name="mePassword[newPasswordConfirm]" required>
 
-          <p class="help-block">パスワードには、6文字以上の半角英数字または記号等を設定してください。</p>
+          <p class="help-block">{{ t('page_register.form_help.password') }}</p>
         </div>
       </div>
 
 
       <div class="form-group">
         <div class="col-xs-offset-2 col-xs-10">
-          <button type="submit" class="btn btn-primary">更新</button>
+          <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
         </div>
       </div>
 

+ 8 - 9
lib/views/modal/create_page.html

@@ -4,7 +4,7 @@
 
       <div class="modal-header">
         <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-        <h4 class="modal-title">New Page</h4>
+        <h4 class="modal-title">{{ t('New Page') }}</h4>
       </div>
 
       <div class="modal-body">
@@ -12,16 +12,16 @@
         <form class="form-horizontal" id="create-page-today" role="form">
           <fieldset>
             <div class="col-xs-12">
-              <h4>今日の◯◯を作成</h4>
+              <h4>{{ t("Create today's") }}</h4>
             </div>
             <div class="col-xs-10">
               <span class="page-today-prefix">{{ userPageRoot(user) }}/</span>
-              <input type="text" data-prefix="{{ userPageRoot(user) }}/" class="page-today-input1 form-control" value="メモ" id="" name="">
+              <input type="text" data-prefix="{{ userPageRoot(user) }}/" class="page-today-input1 form-control" value="{{ t('Memo') }}" id="" name="">
               <span class="page-today-suffix">/{{ now|datetz('Y/m/d') }}/</span>
-              <input type="text" data-prefix="/{{ now|datetz('Y/m/d') }}/" class="page-today-input2 form-control" id="page-today-input2" name="" placeholder="ページ名を入力(空欄OK)">
+              <input type="text" data-prefix="/{{ now|datetz('Y/m/d') }}/" class="page-today-input2 form-control" id="page-today-input2" name="" placeholder="{{ t('Input page name (optional)') }}">
             </div>
             <div class="col-xs-2">
-              <button type="submit" class="btn btn-primary">作成</button>
+              <button type="submit" class="btn btn-primary">{{ t('Create') }}</button>
             </div>
           </fieldset>
         </form>
@@ -31,13 +31,13 @@
         <form class="form-horizontal" id="create-page-under-tree" role="form">
           <fieldset>
             <div class="col-xs-12">
-              <h4><code>{{ parentPath(path) }}</code>以下に作成</h4>
+              <h4>{{ t('Create under', parentPath(path)) }}</h4>
             </div>
             <div class="col-xs-10">
-              <input type="text" value="{{ parentPath(path) }}" class="page-name-input form-control " placeholder="ページ名を入力" required>
+              <input type="text" value="{{ parentPath(path) }}" class="page-name-input form-control " placeholder="{{ t('Input page name') }}" required>
             </div>
             <div class="col-xs-2">
-              <button type="submit" class="btn btn-primary">作成</button>
+              <button type="submit" class="btn btn-primary">{{ t('Create') }}</button>
             </div>
           </fieldset>
         </form>
@@ -49,4 +49,3 @@
     </div><!-- /.modal-content -->
   </div><!-- /.modal-dialog -->
 </div><!-- /.modal -->
-

+ 18 - 18
lib/views/modal/help.html

@@ -4,50 +4,50 @@
 
       <div class="modal-header">
         <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-        <h4 class="modal-title">ヘルプ</h4>
+        <h4 class="modal-title">{{ t('Help') }}</h4>
       </div>
       <div class="modal-body">
-        <h4>基本的な機能</h4>
+        <h4>{{ t('modal_help.basic.title') }}</h4>
         <br>
         <ul>
-          <li>表示される画面には、「一覧ページ」と「ページ」の2種類があります</li>
-          <li>スラッシュ <code>/</code> で終わるページは、その階層の一覧ページとなります。</li>
-          <li>ページでの変更はすべて記録されています。サイドバーには、変更の履歴が一覧となっていて、クリックするとそのページの過去の状態を見ることができます。</li>
+          <li>{{ t('modal_help.basic.body1') }}</li>
+          <li>{{ t('modal_help.basic.body2') }}</li>
+          <li>{{ t('modal_help.basic.body3') }}</li>
         </ul>
         <br>
 
-        <h4>編集のコツ</h4>
+        <h4>{{ t('modal_help.tips.title') }}</h4>
         <br>
         <p>
-        文章の <strong>構造</strong> を意識しましょう。本を書くように、内容と文脈を整理してセクション・サブセクション...と構造的に書くと、わかりやすく他人に伝わりやすいページがになります。
+        {{ t('modal_help.tips.body1') }}
         </p>
         <br>
 
-        <h4>記法</h4>
+        <h4>{{ t('modal_help.markdown.title') }}</h4>
         <br>
         <div class="wiki">
-        <pre># セクション</pre>
-        <h1>セクション</h1>
+        <pre># Section</pre>
+        <h1>Section</h1>
         </div>
         <hr>
 
         <div class="wiki">
-        <pre>## サブセクション</pre>
-        <h2>サブセクション</h2>
+        <pre>## Sub section</pre>
+        <h2>Sub Section</h2>
         </div>
         <hr>
 
         <div class="wiki">
-        <pre>### サブサブセクション</pre>
-        <h3>サブサブセクション</h3>
+        <pre>### Sub sub section</pre>
+        <h3>Sub Sub Section</h3>
         </div>
         <hr>
 
         <div class="wiki">
-        <pre>* このようにアスタリスクと半角スペースを先頭に書くと、
-* 箇条書きのリストにになります
-    * タブキーを押すと半角スペース4つが挿入され、インデントされます
-    * インデントはリストにも反映されます</pre>
+        <pre>- このようにハイフンと半角スペースを先頭に書くと、
+- 箇条書きのリストにになります
+    - タブキーを押すと半角スペース4つが挿入され、インデントされます
+    - インデントはリストにも反映されます</pre>
           <ul>
             <li>リスト記法はこのように</li>
             <li>箇条書きになります

+ 6 - 9
lib/views/modal/rename.html

@@ -6,19 +6,15 @@
 
         <div class="modal-header">
           <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-          <h4 class="modal-title">ページを移動する</h4>
+          <h4 class="modal-title">{{ t('Rename page') }}</h4>
         </div>
         <div class="modal-body">
-          <ul>
-           <li>移動先にページが存在する場合は、移動できません。</li>
-           <li>過去の履歴も含めてすべて移動されます。</li>
-          </ul>
             <div class="form-group">
-              <label for="">このページ</label><br>
+              <label for="">{{ t('Current page name') }}</label><br>
               <code>{{ page.path }}</code>
             </div>
             <div class="form-group">
-              <label for="newPageName">移動先のページ名</label><br>
+              <label for="newPageName">{{ t('New page name') }}</label><br>
               <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 }}">
@@ -26,9 +22,10 @@
             </div>
             <div class="checkbox">
                <label>
-                 <input name="create_redirect" value="1"  type="checkbox"> リダイレクトページを作成
+                 <input name="create_redirect" value="1"  type="checkbox"> {{ t('Redirect') }}
                </label>
-               <p class="help-block">チェックを入れると、<code>{{ page.path }}</code>にアクセスされた際に自動的に新しいページにジャンプします。</p>
+               <p class="help-block"> {{ t('modal_rename.help.redirect', page.path) }}
+               </p>
             </div>
             {# <div class="checkbox"> #}
             {#    <label> #}

+ 12 - 37
lib/views/page.html

@@ -67,7 +67,7 @@
   <ul class="nav nav-tabs hidden-print">
     <li><a>Create: {{ path }}</a></li>
     <li class="dropdown pull-right">
-      <a href="#" onclick="history.back();"><i class="fa fa-times"></i> キャンセル</a>
+      <a href="#" onclick="history.back();"><i class="fa fa-times"></i> {{ t('Cancel') }}</a>
     </li>
   </ul>
   <div class="tab-content">
@@ -107,7 +107,7 @@
       </a>
     </li>
 
-    <li {% if req.body.pageForm %}class="active"{% endif %}><a href="#edit-form" data-toggle="tab"><i class="fa fa-pencil-square-o"></i> Edit</a></li>
+    <li {% if req.body.pageForm %}class="active"{% endif %}><a href="#edit-form" data-toggle="tab"><i class="fa fa-pencil-square-o"></i> {{ t('Edit') }}</a></li>
 
 
     <li class="dropdown pull-right">
@@ -115,11 +115,11 @@
         <i class="fa fa-wrench"></i> <span class="caret"></span>
       </a>
       <ul class="dropdown-menu">
-       <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="fa fa-share"></i> 移動</a></li>
-       <li><a href="?presentation=1" class="toggle-presentation"><i class="fa fa-arrows-alt"></i> プレゼンモード (beta)</a></li>
+       <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="fa fa-share"></i> {{ t('Move') }}</a></li>
+       <li><a href="?presentation=1" class="toggle-presentation"><i class="fa fa-arrows-alt"></i> {{ t('Presentation Mode') }} (beta)</a></li>
        {% if isDeletablePage() %}
        <li class="divider"></li>
-       <li class=""><a href="#" data-target="#deletePage" data-toggle="modal"><i class="fa fa-trash-o text-danger"></i> 削除</a></li>
+       <li class=""><a href="#" data-target="#deletePage" data-toggle="modal"><i class="fa fa-trash-o text-danger"></i> {{ t('Delete') }}</a></li>
        {% endif %}
       </ul>
     </li>
@@ -132,12 +132,12 @@
   <div class="tab-content wiki-content">
   {% if req.query.renamed and not page.isDeleted() %}
   <div class="alert alert-info">
-    <strong>移動しました: </strong> このページは <code>{{ req.query.renamed }}</code> から移動しました。
+    <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.query.renamed) }}
   </div>
   {% endif %}
   {% if not page.isLatestRevision() %}
   <div class="alert alert-warning">
-    <strong>注意: </strong> これは現在の版ではありません。 <i class="fa fa-magic"></i> <a href="{{ page.path }}">最新のページを表示</a>
+    <strong>{{ t('Warning') }}: </strong> {{ t('page_page.notice.version') }} <i class="fa fa-magic"></i> <a href="{{ page.path }}">{{ t('Show latest') }}</a>
   </div>
   {% endif %}
 
@@ -154,7 +154,7 @@
     {# formatted text #}
     <div class="tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body">
       <div class="revision-toc" id="revision-toc">
-        <a data-toggle="collapse" data-parent="#revision-toc" href="#revision-toc-content" class="revision-toc-head collapsed">目次</a>
+        <a data-toggle="collapse" data-parent="#revision-toc" href="#revision-toc-content" class="revision-toc-head collapsed">{{ t('Table of Contents') }}</a>
 
       </div>
       <div class="wiki" id="revision-body-content"></div>
@@ -168,41 +168,16 @@
     {% endif %}
 
     {# raw revision history #}
+    {% if not page %}
+    {% else %}
     <div class="tab-pane revision-history" id="revision-history">
-      <h1><i class="fa fa-history"></i> History</h1>
-      {% if not page %}
-      {% else %}
-      <div class="revision-history-list">
-        {% for t in tree %}
-        <div class="revision-hisory-outer">
-          <img src="{{ t.author|picture }}" class="picture picture-rounded">
-          <div class="revision-history-main">
-            <div class="revision-history-author">
-              <strong>{% if t.author %}{{ t.author.username }}{% else %}-{% endif %}</strong>
-            </div>
-            <div class="revision-history-comment">
-            </div>
-            <div class="revision-history-meta">
-              {{ t.createdAt|datetz('Y-m-d H:i:s') }}
-              <br>
-              <a href="?revision={{ t._id.toString() }}"><i class="fa fa-history"></i> このバージョンを見る</a>
-              <a class="diff-view" data-revision-id="{{ t._id.toString() }}">
-                <i id="diff-icon-{{ t._id.toString() }}" class="fa fa-arrow-circle-right"></i> 差分を見る
-              </a>
-              <pre class="" id="diff-display-{{ t._id.toString()}}" style="display: none"></pre>
-            </div>
-          </div>
-        </div>
-        {% endfor %}
-      </div>
-      {% endif %}
-
     </div>
+    {% endif %}
 
   </div>
   {% endif %}
 
-<div id="notifPageEdited" class="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="notifPageEdited" class="fk-notif fk-notif-danger"><i class="fa fa-exclamation-triangle"></i> <span class="edited-user"></span> {{ t('edited this page') }} <a href="javascript:location.reload();"><i class="fa fa-angle-double-right"></i> {{ t('Load latest') }}</a></div>
 </div>
 
 {% block content_main_after %}

+ 13 - 13
lib/views/page_list.html

@@ -88,7 +88,7 @@
       <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>
+    <li {% if req.body.pageForm %}class="active"{% endif %}><a href="#edit-form" data-toggle="tab"><i class="fa fa-pencil-square-o"></i> {{ t('Edit') }}</a></li>
 
     {% if not page %}
     <li class="pull-right close-button">
@@ -102,7 +102,7 @@
         <i class="fa fa-wrench"></i> <span class="caret"></span>
       </a>
       <ul class="dropdown-menu">
-       <li><a href="#" data-target="#unportalize" data-toggle="modal"><i class="fa fa-share"></i> ポータル解除</a></li>
+        <li><a href="#" data-target="#unportalize" data-toggle="modal"><i class="fa fa-share"></i> {{ t('Unportalize') }}</a></li>
       </ul>
     </li>
     <li class="pull-right"><a href="#revision-history" data-toggle="tab"><i class="fa fa-history"></i> History</a></li>
@@ -112,7 +112,7 @@
   <div class="tab-content">
   {% if page and not page.isLatestRevision() %}
   <div class="alert alert-warning">
-    <strong>注意: </strong> これは現在の版ではありません。 <i class="fa fa-magic"></i> <a href="{{ page.path }}">最新のポータルを表示</a>
+    <strong>{{ t('Warning') }}: </strong> {{ t('page.notice.version') }} <i class="fa fa-magic"></i> <a href="{{ page.path }}">最新のポータルを表示</a>
   </div>
   {% endif %}
     <div class="tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body">
@@ -132,23 +132,23 @@
       {% if not page %}
       {% else %}
       <div class="revision-history-list">
-        {% for t in tree %}
+        {% for tr in tree %}
         <div class="revision-hisory-outer">
-          <img src="{{ t.author|picture }}" class="picture picture-rounded">
+          <img src="{{ tr.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>
+              <strong>{% if tr.author %}{{ tr.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') }}
+              {{ tr.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 href="?revision={{ tr._id.toString() }}"><i class="fa fa-history"></i> {{ t('View this version') }}</a>
+              <a class="diff-view" data-revision-id="{{ tr._id.toString() }}">
+                <i id="diff-icon-{{ tr._id.toString() }}" class="fa fa-arrow-circle-right"></i> {{ t('View diff') }}
               </a>
-              <pre class="" id="diff-display-{{ t._id.toString()}}" style="display: none"></pre>
+              <div class="" id="diff-display-{{ tr._id.toString()}}" style="display: none"></div>
             </div>
           </div>
         </div>
@@ -162,8 +162,8 @@
 
 <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>
+      <li class="active"><a href="#view-list" data-toggle="tab">{{ t('List View') }}</a></li>
+      <li><a href="#view-timeline" data-toggle="tab">{{ t('Timeline View') }}</a></li>
   </ul>
 
   <div class="tab-content">

+ 3 - 1
lib/views/user_page.html

@@ -36,9 +36,11 @@
       <li>
         <a href="#user-created-list" data-toggle="tab"><i class="fa fa-pencil"></i> Recent Created</a>
       </li>
+      {% if user._id.toString() == pageUser._id.toString() %}
       <li>
-        <a href="/me"><i class="fa fa-gears"></i> Setting</a>
+        <a href="/me"><i class="fa fa-gears"></i> Settings</a>
       </li>
+      {% endif %}
     </ul>
     <div class="user-page-content-tab tab-content">
 

+ 4 - 4
lib/views/widget/page_side_content.html

@@ -1,10 +1,10 @@
-<h3><i class="fa fa-link"></i> Share</h3>
+<h3><i class="fa fa-link"></i> {{ t('Share') }}</h3>
 <ul class="fitted-list">
-  <li data-toggle="tooltip" data-placement="bottom" title="共有用リンク" class="input-group">
-    <span class="input-group-addon">共有用</span>
+  <li class="input-group">
+    <span class="input-group-addon">{{ t('Share Link') }}</span>
     <input readonly class="copy-link form-control" type="text" value="{{ config.crowi['app:title']|default('Crowi') }} {{ path }}  {{ baseUrl }}/{{ page._id.toString() }}">
   </li>
-  <li data-toggle="tooltip" data-placement="bottom" title="Markdown形式のリンク" class="input-group">
+  <li class="input-group">
     <span class="input-group-addon">Markdown</span>
     <input readonly class="copy-link form-control" type="text" value="[{{ path }}]({{ baseUrl }}/{{ page._id.toString() }})">
   </li>

+ 6 - 6
lib/views/widget/page_side_header.html

@@ -12,13 +12,13 @@
         <a href="{{ userPageRoot(page.creator) }}">{{ page.creator.name|default(author.name) }}</a>
       </p>
       <p class="created-at">
-        作成日: {{ page.createdAt|datetz('Y/m/d H:i:s') }}<br>
+        {{ t('Created') }}: {{ page.createdAt|datetz('Y/m/d H:i:s') }}<br>
 
         {% if page.lastUpdateUser %}
-          最終更新: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.lastUpdateUser.username }}"><img src="{{ page.lastUpdateUser|picture }}" class="picture picture-xs picture-rounded" alt="{{ page.lastUpdateUser.name }}"></a>
+          {{ t('Last updated') }}: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.lastUpdateUser.username }}"><img src="{{ page.lastUpdateUser|picture }}" class="picture picture-xs picture-rounded" alt="{{ page.lastUpdateUser.name }}"></a>
         {% else %}
           {# for BC 1.5.x #}
-          最終更新: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.revision.author.username }}"><img src="{{ page.revision.author|picture }}" class="picture picture-xs picture-rounded" alt="{{ page.revision.author.name }}"></a>
+          {{ t('Last updated') }}: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.revision.author.username }}"><img src="{{ page.revision.author|picture }}" class="picture picture-xs picture-rounded" alt="{{ page.revision.author.name }}"></a>
         {% endif %}
       </p>
     </div>
@@ -27,7 +27,7 @@
   <div class="like-box">
     <dl class="dl-horizontal">
       <dt>
-        <i class="fa fa-thumbs-o-up"></i> いいね!
+        <i class="fa fa-thumbs-o-up"></i> {{ t('Like!') }}
       </dt>
       <dd>
         <p class="liker-count">
@@ -36,13 +36,13 @@
           data-csrftoken="{{ csrf() }}"
           data-liked="{% if page.isLiked(user) %}1{% else %}0{% endif %}"
           class="like-button btn btn-default btn-sm {% if page.isLiked(user) %}active{% endif %}"
-          ><i class="fa fa-thumbs-o-up"></i> いいね!</button>
+          ><i class="fa fa-thumbs-o-up"></i> {{ t('Like!') }}</button>
         </p>
         <p id="liker-list" class="liker-list" data-likers="{{ page.liker|default([])|join(',') }}">
         </p>
       </dd>
 
-      <dt><i class="fa fa-paw"></i> 見た人</dt>
+      <dt><i class="fa fa-paw"></i> {{ t('Seen by') }}</dt>
       <dd>
         <div id="seen-user-list" data-seen-users="{{ page.seenUsers|default([])|join(',') }}"></div>
       </dd>

+ 32 - 0
lib/views/widget/pager.html

@@ -0,0 +1,32 @@
+<ul class="pagination">
+
+  <li {% if pager.page == 1 %}class="disabled"{% endif %}>
+    <a href="{{ path }}?page={{ pager.previous|default(1) }}">&laquo;</a>
+  </li>
+  {% if pager.previousDots %}
+    {% if pager.page !== 1 %}
+    <li>
+      <a href="{{ path }}?page=1">1</a>
+    </li>
+    {% endif %}
+  <li><a href="#">...</a></li>
+  {% endif  %}
+
+  {% for page in pager.pages %}
+  <li {% if pager.page == page %}class="active"{% endif %}>
+    <a href="{{ path }}?page={{ page }}">{{ page }}</a>
+  </li>
+  {% endfor %}
+
+  {% if pager.nextDots %}
+  <li><a href="#">...</a></li>
+    {% if pager.page !== pager.pagesCount %}
+    <li>
+      <a href="{{ path }}?page={{ pager.pagesCount }}">{{ pager.pagesCount }}</a>
+    </li>
+    {% endif %}
+  {% endif  %}
+  <li {% if pager.page == pager.pagesCount %}class="disabled"{% endif %}>
+    <a href="{{ path }}?page={{ pager.next|default(pager.pagesCount) }}">&raquo;</a>
+  </li>
+</ul>

+ 1 - 2
local_modules/crowi-fileupload-aws/index.js

@@ -5,7 +5,6 @@ module.exports = function(crowi) {
 
   var aws = require('aws-sdk')
     , debug = require('debug')('crowi:lib:fileUploaderAws')
-    , Promise = require('bluebird')
     , Config = crowi.model('Config')
     , config = crowi.getConfig()
     , lib = {}
@@ -29,7 +28,7 @@ module.exports = function(crowi) {
   lib.uploadFile = function(filePath, contentType, fileStream, options) {
     var awsConfig = getAwsConfig();
     if (!Config.isUploadable(config)) {
-      return new Promise.reject(new Error('AWS is not configured.'));
+      return Promise.reject(new Error('AWS is not configured.'));
     }
 
     aws.config.update({

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

@@ -7,7 +7,6 @@ module.exports = function(crowi) {
     , fs = require('fs')
     , path = require('path')
     , mkdir = require('mkdirp')
-    , Promise = require('bluebird')
     , Config = crowi.model('Config')
     , config = crowi.getConfig()
     , lib = {}

+ 1 - 0
locales/en

@@ -0,0 +1 @@
+en-US

+ 166 - 0
locales/en-US/translation.json

@@ -0,0 +1,166 @@
+{
+  "Help": "Help",
+  "Edit": "Edit",
+  "Delete": "Delete",
+  "Move": "Move",
+  "Moved": "Moved",
+  "Like!": "Like!",
+  "Seen by": "Seen by",
+  "Cancel": "Cancel",
+  "Create": "Create",
+  "Admin": "Admin",
+  "New": "New",
+
+  "Update": "Update",
+  "Update Page": "Update Page",
+  "Warning": "Warning",
+
+  "Sign in": "Sign in",
+  "Sign up is here": "Sign up",
+  "Sign in is here": "Sign in",
+  "Sign up": "Sign up",
+  "Sign up with Google Account": "Sign up with Google Account",
+  "Sign in with Google Account": "Sign in with Google Account",
+  "Sign up with this Google Account": "Sign up with this Google Account",
+  "Example": "Example",
+  "Taro Yamada": "James Bond",
+
+  "List View": "List",
+  "Timeline View": "Timeline",
+  "Presentation Mode": "Presentation Mode",
+
+  "Created": "Created",
+  "Last updated": "Updated",
+
+  "Share": "Share",
+  "Share Link": "Share Link",
+  "Markdown Link": "Markdown Link",
+
+  "Unportalize": "Unportalize",
+
+  "View this version": "View this version",
+  "View diff": "View diff",
+
+  "User ID": "User ID",
+  "User Settings": "User Settings",
+  "User Information": "User Information",
+  "Basic Info": "Basic Info",
+  "Name": "Name",
+  "Email": "Email",
+  "Language": "Language",
+  "English": "English",
+  "Japanese": "Japanese",
+  "Set Profile Image": "Set Profile Image",
+  "Current Image": "Current Image",
+  "Delete Image": "Delete Image",
+  "Delete this image?": "Delete this image?",
+  "Updated": "Updated",
+  "Upload new image": "Upload new image",
+  "Google Setting": "Google Setting",
+  "Connected": "Connected",
+  "Disconnect": "Disconnect",
+
+  "Create today's": "Create today's ...",
+  "Memo": "memo",
+  "Input page name": "Input page name",
+  "Input page name (optional)": "Input page name (optional)",
+  "New Page": "New Page",
+  "Create under": "Create page under: <code>%s</code>",
+
+  "Table of Contents": "Table of Contents",
+
+  "Public": "Public",
+  "Anyone with the link": "Anyone with the link",
+  "Specified users only": "Specified users only",
+  "Just me": "Just me",
+  "Shareable link": "Shareable link",
+
+  "Show latest": "Show latest",
+  "Load latest": "Load latest",
+  "edited this page": "edited this page.",
+
+  "Deleted Pages": "Deleted Pages",
+  "Sign out": "Logout",
+
+  "page_register": {
+    "notice": {
+      "restricted": "Admin approval required.",
+      "restricted_defail": "Once the admin approves your sign up, you'll be able to access this wiki.",
+      "google_account_continue": "Enter your user ID, name and password to continue."
+    },
+    "form_help": {
+      "email": "You must have email address which listed below to sign up to this wiki.",
+      "password": "Your password must be at least 6 characters long.",
+      "user_id": "The URL of pages you create will contain your User ID. Your User ID can consist of letters, numbers, and some symbols."
+    }
+  },
+
+  "page_me": {
+    "form_help": {
+      "profile_image1": "Image upload settings not completed.",
+      "profile_image2": "Set up AWS or enable local uploads.",
+      "google_connect1": "With Google Connect, you can sign in with your Google Account.",
+      "google_connect2": "Only Google Apps accounts with the following email addresses are connectable Google accounts:",
+      "google_disconnect1": "If you disconnect your Google account, you will be unable to sign in using Google Authentication",
+      "google_disconnect2": "After disconnecting your Google account, you can sign in normally using your email and password"
+    }
+  },
+  "page_me_apitoken": {
+    "notice": {
+      "apitoken_issued": "API Token is not issued.",
+      "update_token1": "You can update to generate a new API Token.",
+      "update_token2": "You will need to update the API Token in any existing processes."
+    },
+    "form_help": {
+    }
+  },
+
+  "Password": "Password",
+  "Password Settings": "Password Settings",
+  "Set new Password": "Set new Password",
+  "Update Password": "Update Password",
+  "Current password": "Current password",
+  "New password": "New password",
+  "Re-enter new password": "Re-enter new password",
+  "Please set a password": "Please set a password",
+  "You can sign in with email and password": "You can sign in with <code>%s</code> and password",
+
+  "API Settings": "API Settings",
+  "API Token Settings": "API Token Settings",
+  "Current API Token": "Current API Token",
+  "Update API Token": "Update API Token",
+
+  "page_page": {
+      "notice": {
+          "version": "This is not the current version.",
+          "moved": "This page was moved from <code>%s</code>",
+          "restricted": "Access to this page is restricted"
+      }
+  },
+
+  "Rename page": "Rename page",
+  "New page name": "New page name",
+  "Current page name": "Current page name",
+  "Redirect": "Redirect",
+  "modal_rename": {
+    "help": {
+      "redirect": "Redirect to new page if someone accesses <code>%s</code>"
+    }
+  },
+
+  "modal_help": {
+      "basic": {
+          "title": "Basics",
+          "body1": "There are 2 types of pages: List pages (showing lists of links to other pages) and normal pages.",
+          "body2": "Pages that end with a slash / are List pages for anything following the slash.",
+          "body3": "You can view older versions of a page from the History tab. Any changes made will be stored here."
+      },
+      "tips": {
+          "title": "Quick Tips on Editing",
+          "body1": "Use sections and subsections to make it easier for your friends to read your pages."
+      },
+      "markdown": {
+          "title": "Markdown Rules"
+      }
+  }
+}

+ 166 - 0
locales/ja/translation.json

@@ -0,0 +1,166 @@
+{
+  "Help": "ヘルプ",
+  "Edit": "編集",
+  "Delete": "削除",
+  "Move": "移動",
+  "Moved": "移動しました",
+  "Like!": "いいね!",
+  "Seen by": "見た人",
+  "Cancel": "キャンセル",
+  "Create": "作成",
+  "Admin": "管理",
+  "New": "作成",
+
+  "Update": "更新",
+  "Update Page": "ページを更新",
+  "Warning": "注意",
+
+  "Sign in": "ログイン",
+  "Sign up is here": "新規登録はこちら",
+  "Sign in is here": "ログインはこちら",
+  "Sign up": "新規登録",
+  "Sign up with Google Account": "Google で登録",
+  "Sign in with Google Account": "Google でログイン",
+  "Sign up with this Google Account": "この Google アカウントで登録します",
+  "Example": "例",
+  "Taro Yamada": "山田 太郎",
+
+  "List View": "リスト表示",
+  "Timeline View": "タイムライン表示",
+  "Presentation Mode": "プレゼンモード",
+
+  "Created": "作成日",
+  "Last updated": "最終更新",
+
+  "Share": "共有",
+  "Share Link": "共有用リンク",
+  "Markdown Link": "Markdown形式のリンク",
+
+  "Unportalize": "ポータル解除",
+
+  "View this version": "このバージョンを見る",
+  "View diff": "差分を見る",
+
+  "User ID": "ユーザーID",
+  "User Settings": "ユーザー設定",
+  "User Information": "ユーザー情報",
+  "Basic Info": "ユーザーの基本情報",
+  "Name": "名前",
+  "Email": "メールアドレス",
+  "Language": "言語",
+  "English": "英語",
+  "Japanese": "日本語",
+  "Set Profile Image": "プロフィール画像の設定",
+  "Current Image": "現在の画像",
+  "Delete Image": "画像を削除",
+  "Delete this image?": "削除してよろしいですか?",
+  "Updated": "更新しました",
+  "Upload new image": "新しい画像をアップロード",
+  "Google Setting": "Google設定",
+  "Connected": "接続されています",
+  "Disconnect": "接続を解除",
+
+  "Create today's": "今日の◯◯を作成",
+  "Memo": "メモ",
+  "Input page name": "ページ名を入力",
+  "Input page name (optional)": "ページ名を入力(空欄OK)",
+  "New Page": "新規ページ",
+  "Create under": "<code>%s</code>以下に作成",
+
+  "Table of Contents": "目次",
+
+  "Public": "公開",
+  "Anyone with the link": "リンクを知っている人のみ",
+  "Specified users": "特定ユーザーのみ",
+  "Just me": "自分のみ",
+  "Shareable link": "このページの共有用URL",
+
+  "Show latest": "最新のページを表示",
+  "Load latest": "最新版を読み込む",
+  "edited this page": "さんがこのページを編集しました。",
+
+  "Deleted Pages": "削除済みページ",
+  "Sign out": "ログアウト",
+
+  "page_register": {
+    "notice": {
+       "restricted": "この Wiki への新規登録は制限されています。",
+       "restricted_defail": "利用を開始するには、新規登録後、管理者による承認が必要です。",
+       "google_account_continue": "ユーザーID、名前、パスワードを決めて登録を継続してください。"
+    },
+    "form_help": {
+      "email": "この Wiki では以下のメールアドレスのみ登録可能です。",
+      "password": "パスワードには、6文字以上の半角英数字または記号等を設定してください。",
+      "user_id": "ユーザーIDは、ユーザーページのURLなどに利用されます。半角英数字と一部の記号のみ利用できます。"
+    }
+  },
+
+  "page_me": {
+    "form_help": {
+      "profile_image1": "画像をアップロードをするための設定がされていません。",
+      "profile_image2": "アップロードできるようにするには、AWS またはローカルアップロードの設定をしてください。",
+      "google_connect1": "Googleコネクトをすると、Googleアカウントでログイン可能になります。",
+      "google_connect2": "コネクト可能なGoogleアカウントは、以下のメールアドレスの発行できるGoogle Appsアカウントに限られます。",
+      "google_disconnect1": "接続を解除すると、Googleでログインができなくなります。",
+      "google_disconnect2": "解除後はメールアドレスとパスワードでログインすることができます。"
+    }
+  },
+  "page_me_apitoken": {
+    "notice": {
+      "apitoken_issued": "API Token が設定されていません。",
+      "update_token1": "API Token を更新すると、自動的に新しい Token が生成されます。",
+      "update_token2": "現在の Token を利用している処理は動かなくなります。"
+    },
+    "form_help": {
+    }
+  },
+
+  "Password": "パスワード",
+  "Password Settings": "パスワード設定",
+  "Set new Password": "パスワードを新規に設定",
+  "Update Password": "パスワードを更新",
+  "Current password": "現在のパスワード",
+  "New password": "新しいパスワード",
+  "Re-enter new password": "(確認用)",
+  "Please set a password": "パスワードを設定してください",
+  "You can sign in with email and password": "<code>%s</code> と設定されたパスワードの組み合わせでログイン可能になります。",
+
+  "API Settings": "API設定",
+  "API Token Settings": "API Token設定",
+  "Current API Token": "現在のAPI Token",
+  "Update API Token": "API Tokenを更新",
+
+  "page_page": {
+      "notice": {
+          "version": "これは現在の版ではありません。",
+          "moved": "このページは <code>%s</code> から移動しました。",
+          "restricted": "このページの閲覧は制限されています"
+      }
+  },
+
+  "Rename page": "ページを移動する",
+  "New page name": "移動先のページ名",
+  "Current page name": "現在のページ名",
+  "Redirect": "リダイレクトする",
+  "modal_rename": {
+    "help": {
+      "redirect": "チェックを入れると、<code>%s</code>にアクセスされた際に自動的に新しいページにジャンプします。"
+    }
+  },
+
+  "modal_help": {
+      "basic": {
+          "title": "基本的な機能",
+          "body1": "表示される画面には、「一覧ページ」と「ページ」の2種類があります",
+          "body2": "スラッシュ <code>/</code> で終わるページは、その階層の一覧ページとなります。",
+          "body3": "ページでの変更はすべて記録されています。History からそのページの過去の状態を見ることができます。"
+      },
+      "tips": {
+          "title": "編集のコツ",
+          "body1": "文章の <strong>構造</strong> を意識しましょう。本を書くように、内容と文脈を整理してセクション・サブセクション...と構造的に書くと、わかりやすく他人に伝わりやすいページがになります。"
+      },
+      "markdown": {
+          "title": "記法"
+      }
+  }
+}

Разница между файлами не показана из-за своего большого размера
+ 287 - 318
npm-shrinkwrap.json


+ 25 - 22
package.json

@@ -1,6 +1,6 @@
 {
   "name": "crowi",
-  "version": "1.5.3",
+  "version": "1.6.0",
   "description": "The simple & powerful Wiki",
   "tags": [
     "wiki",
@@ -24,19 +24,18 @@
     "url": "https://github.com/crowi/crowi.git"
   },
   "engines": {
-    "node": "4.x",
-    "npm": "3.x"
+    "node": "6.x",
+    "npm": "4.x"
   },
   "dependencies": {
     "async": "~1.5.0",
     "aws-sdk": "~2.2.26",
-    "axios": "0.13.x",
+    "axios": "0.15.x",
     "babel-core": "~6.7.6",
     "babel-loader": "~6.2.4",
     "babel-preset-es2015": "~6.6.0",
     "babel-preset-react": "~6.5.0",
     "basic-auth-connect": "~1.0.0",
-    "bluebird": "~3.0.5",
     "body-parser": "~1.14.1",
     "bootstrap-sass": "~3.3.6",
     "botkit": "~0.1.1",
@@ -45,54 +44,58 @@
     "commander": "~2.9.0",
     "connect-flash": "~0.1.1",
     "connect-redis": "~2.1.0",
-    "consolidate": "~0.11.0",
+    "consolidate": "~0.14.0",
     "cookie-parser": "~1.3.4",
     "csrf": "~3.0.3",
     "debug": "~2.2.0",
     "del": "~2.2.0",
-    "diff": "~2.2.2",
-    "elasticsearch": "~11.0.1",
+    "diff": "~3.2.0",
+    "diff2html": "~2.0.12",
+    "elasticsearch": "^12.1.3",
     "emojify.js": "^1.1.0",
     "errorhandler": "~1.3.4",
-    "express": "~4.13.3",
+    "express": "~4.14.0",
     "express-form": "~0.12.0",
-    "express-session": "~1.12.0",
-    "font-awesome": "~4.5.0",
+    "express-session": "~1.14.0",
+    "font-awesome": "~4.7.0",
     "googleapis": "=12.3.0",
     "gulp": "~3.9.0",
     "gulp-concat": "~2.6.0",
     "gulp-cssmin": "~0.1.7",
     "gulp-jshint": "~1.12.0",
     "gulp-rename": "~1.2.2",
-    "gulp-sass": "~3.0.0",
+    "gulp-sass": "~3.1.0",
     "gulp-spawn-mocha": "~2.2.1",
     "gulp-uglify": "~1.4.2",
     "gulp-watch": "~4.3.5",
-    "highlight.js": "~9.0.0",
+    "highlight.js": "~9.9.0",
+    "i18next": "~4.1.0",
+    "i18next-express-middleware": "~1.0.2",
+    "i18next-node-fs-backend": "~0.1.3",
+    "i18next-sprintf-postprocessor": "~0.2.2",
     "inline-attachment": "git+https://github.com/Rovak/InlineAttachment.git#2.0.3",
     "jquery": "~2.1.4",
     "jquery.cookie": "~1.4.1",
     "jshint-stylish": "~2.1.0",
-    "kerberos": "0.0.17",
+    "kerberos": "0.0.22",
     "marked": "~0.3.5",
     "method-override": "~2.3.1",
     "mkdirp": "~0.5.1",
     "moment": "~2.13.0",
-    "mongoose": "4.2.5",
-    "mongoose-paginate": "4.2.0",
-    "morgan": "~1.5.1",
-    "multer": "~0.1.8",
-    "nodemailer": "~1.2.2",
-    "nodemailer-ses-transport": "~1.1.0",
+    "mongoose": "4.7.x",
+    "mongoose-paginate": "5.0.x",
+    "morgan": "~1.7.0",
+    "multer": "~1.2.1",
+    "nodemailer": "~2.7.0",
+    "nodemailer-ses-transport": "~1.5.0",
     "react": "~15.0.1",
     "react-dom": "~15.0.1",
-    "redis": "~0.12.1",
+    "redis": "~2.6.5",
     "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",
     "webpack": "~1.13.0",
     "webpack-manifest-plugin": "~1.0.1",

+ 1 - 0
public/css/diff2html

@@ -0,0 +1 @@
+../../node_modules/diff2html/dist

+ 24 - 7
resource/css/_page.scss

@@ -277,22 +277,39 @@
     .revision-hisory-outer {
       margin-top: 8px;
 
-      .picture {
-        float: left;
-        width: 32px;
-        height: 32px;
-      }
-
       .revision-history-main {
-        margin-left: 40px;
+
+        .picture {
+          float: left;
+          width: 32px;
+          height: 32px;
+        }
 
         .revision-history-author {
+          margin-left: 40px;
           color: #666;
         }
         .revision-history-comment {
+          margin-left: 40px;
         }
         .revision-history-meta {
+          margin-left: 40px;
+
+          p {
+            margin-bottom: 8px;
+          }
+
+          a {
+            margin-right: 8px;
+          }
+          a:hover {
+            cursor: pointer;
+          }
         }
+
+      }
+      .revision-history-diff {
+        margin-left: 40px;
       }
     }
 

+ 6 - 0
resource/css/crowi.scss

@@ -251,6 +251,12 @@ footer {
   border-radius: 3px;
 }
 
+// adjust
+// this is for diff2html. hide page name from diff view
+.d2h-file-header {
+  display: none;
+}
+
 // components
 .flip-container { // {{{
   perspective: 1000;

+ 17 - 3
resource/js/app.js

@@ -5,14 +5,22 @@ import Crowi from './util/Crowi';
 import CrowiRenderer from './util/CrowiRenderer';
 
 import HeaderSearchBox  from './components/HeaderSearchBox';
-import SearchPage  from './components/SearchPage';
-import PageListSearch  from './components/PageListSearch';
+import SearchPage       from './components/SearchPage';
+import PageListSearch   from './components/PageListSearch';
+import PageHistory      from './components/PageHistory';
+import SeenUserList     from './components/SeenUserList';
 //import PageComment  from './components/PageComment';
-import SeenUserList from './components/SeenUserList';
 
 if (!window) {
   window = {};
 }
+
+const mainContent = document.querySelector('#content-main');
+let pageId = null;
+if (mainContent !== null) {
+  pageId = mainContent.attributes['data-page-id'].value;
+}
+
 // FIXME
 const crowi = new Crowi({me: $('#content-main').data('current-username')}, window);
 window.crowi = crowi;
@@ -25,6 +33,7 @@ const componentMappings = {
   'search-top': <HeaderSearchBox />,
   'search-page': <SearchPage />,
   'page-list-search': <PageListSearch />,
+  //'revision-history': <PageHistory pageId={pageId} />,
   //'page-comment': <PageComment />,
   'seen-user-list': <SeenUserList />,
 };
@@ -35,3 +44,8 @@ Object.keys(componentMappings).forEach((key) => {
     ReactDOM.render(componentMappings[key], elem);
   }
 });
+
+// うわーもうー
+$('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
+  ReactDOM.render(<PageHistory pageId={pageId} crowi={crowi} />, document.getElementById('revision-history'));
+});

+ 22 - 0
resource/js/components/Common/Icon.js

@@ -0,0 +1,22 @@
+import React from 'react';
+
+export default class Icon extends React.Component {
+
+  render() {
+    const name = this.props.name || null;
+
+    if (!name) {
+      return '';
+    }
+
+    return (
+      <i className={"fa fa-" + name} />
+    );
+  }
+}
+
+// TODO: support spin, size and so far
+Icon.propTypes = {
+  name: React.PropTypes.string.isRequired,
+};
+

+ 34 - 0
resource/js/components/Common/UserDate.js

@@ -0,0 +1,34 @@
+import React from 'react';
+
+import moment from 'moment';
+
+/**
+ * UserDate
+ *
+ * display date depends on user timezone of user settings
+ */
+export default class UserDate extends React.Component {
+
+  render() {
+    const dt = moment(this.props.dateTime).format(this.props.format);
+
+    return (
+      <span className={this.props.className}>
+        {dt}
+      </span>
+    );
+  }
+}
+
+UserDate.propTypes = {
+  dateTime: React.PropTypes.string.isRequired,
+  format: React.PropTypes.string,
+  className: React.PropTypes.string,
+};
+
+UserDate.defaultProps = {
+  dateTime: 'now',
+  format: 'YYYY/MM/DD HH:mm:ss',
+  className: '',
+};
+

+ 139 - 0
resource/js/components/PageHistory.js

@@ -0,0 +1,139 @@
+import React from 'react';
+
+import Icon from './Common/Icon';
+import PageRevisionList from './PageHistory/PageRevisionList';
+
+export default class PageHistory extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      revisions: [],
+      diffOpened: {},
+    };
+
+    this.getPreviousRevision = this.getPreviousRevision.bind(this);
+    this.onDiffOpenClicked = this.onDiffOpenClicked.bind(this);
+  }
+
+  componentDidMount() {
+    const pageId = this.props.pageId;
+
+    if (!pageId) {
+      return ;
+    }
+
+    this.props.crowi.apiGet('/revisions.ids', {page_id: pageId})
+    .then(res => {
+
+      const rev = res.revisions;
+      let diffOpened = {};
+      const lastId = rev.length - 1;
+      res.revisions.map((revision, i) => {
+        const user = this.props.crowi.findUserById(revision.author);
+        if (user) {
+          rev[i].author = user;
+        }
+
+        if (i === 0 || i === lastId) {
+          diffOpened[revision._id] = true;
+        } else {
+          diffOpened[revision._id] = false;
+        }
+      });
+
+      this.setState({
+        revisions: rev,
+        diffOpened: diffOpened,
+      });
+
+      // load 0, and last default
+      if (rev[0]) {
+        this.fetchPageRevisionBody(rev[0]);
+      }
+      if (rev[1]) {
+        this.fetchPageRevisionBody(rev[1]);
+      }
+      if (lastId !== 0 && lastId !== 1 && rev[lastId]) {
+        this.fetchPageRevisionBody(rev[lastId]);
+      }
+    }).catch(err => {
+      // do nothing
+    });
+  }
+
+  getPreviousRevision(currentRevision) {
+    let cursor = null;
+    for (let revision of this.state.revisions) {
+      if (cursor && cursor._id == currentRevision._id) {
+        cursor = revision;
+        break;
+      }
+
+      cursor = revision;
+    }
+
+    return cursor;
+  }
+
+  onDiffOpenClicked(revision) {
+    const diffOpened = this.state.diffOpened,
+      revisionId = revision._id;
+
+    if (diffOpened[revisionId]) {
+      return ;
+    }
+
+    diffOpened[revisionId] = true;
+    this.setState({
+      diffOpened
+    });
+
+    this.fetchPageRevisionBody(revision);
+    this.fetchPageRevisionBody(this.getPreviousRevision(revision));
+  }
+
+  fetchPageRevisionBody(revision) {
+    if (revision.body) {
+      return ;
+    }
+
+    this.props.crowi.apiGet('/revisions.get', {revision_id: revision._id})
+    .then(res => {
+      if (res.ok) {
+        this.setState({
+          revisions: this.state.revisions.map((rev) => {
+            if (rev._id == res.revision._id) {
+              return res.revision;
+            }
+
+            return rev;
+          })
+        })
+      }
+    }).catch(err => {
+
+    });
+
+  }
+
+  render() {
+    return (
+      <div>
+        <h1><Icon name="history" /> History</h1>
+        <PageRevisionList
+          revisions={this.state.revisions}
+          diffOpened={this.state.diffOpened}
+          getPreviousRevision={this.getPreviousRevision}
+          onDiffOpenClicked={this.onDiffOpenClicked}
+        />
+      </div>
+    );
+  }
+}
+
+PageHistory.propTypes = {
+  pageId: React.PropTypes.string,
+  crowi: React.PropTypes.object.isRequired,
+};

+ 54 - 0
resource/js/components/PageHistory/PageRevisionList.js

@@ -0,0 +1,54 @@
+import React from 'react';
+
+import Revision     from './Revision';
+import RevisionDiff from './RevisionDiff';
+
+export default class PageRevisionList extends React.Component {
+
+  render() {
+    const revisions = this.props.revisions,
+      revisionCount = this.props.revisions.length;
+
+    const revisionList = this.props.revisions.map((revision, idx) => {
+      const revisionId = revision._id
+        , revisionDiffOpened = this.props.diffOpened[revisionId] || false
+
+
+      let previousRevision;
+      if (idx+1 < revisionCount) {
+        previousRevision = revisions[idx + 1];
+      } else {
+        previousRevision = revision; // if it is the first revision, show full text as diff text
+      }
+
+      return (
+        <div className="revision-hisory-outer" key={"revision-history-" + revisionId}>
+          <Revision
+            revision={revision}
+            onDiffOpenClicked={this.props.onDiffOpenClicked}
+            key={"revision-history-rev-" + revisionId}
+            />
+          <RevisionDiff
+            revisionDiffOpened={revisionDiffOpened}
+            currentRevision={revision}
+            previousRevision={previousRevision}
+            key={"revision-diff-" + revisionId}
+          />
+        </div>
+      );
+    });
+
+    return (
+      <div className="revision-history-list">
+        {revisionList}
+      </div>
+    );
+  }
+}
+
+PageRevisionList.propTypes = {
+  revisions: React.PropTypes.array,
+  diffOpened: React.PropTypes.object,
+  onDiffOpenClicked: React.PropTypes.func.isRequired,
+}
+

+ 59 - 0
resource/js/components/PageHistory/Revision.js

@@ -0,0 +1,59 @@
+import React from 'react';
+
+import UserDate     from '../Common/UserDate';
+import Icon         from '../Common/Icon';
+import UserPicture  from '../User/UserPicture';
+
+export default class Revision extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this._onDiffOpenClicked = this._onDiffOpenClicked.bind(this);
+  }
+
+  componentDidMount() {
+  }
+
+  _onDiffOpenClicked() {
+    this.props.onDiffOpenClicked(this.props.revision);
+  }
+
+  render() {
+    const revision = this.props.revision;
+    const author = revision.author;
+
+    let pic = '';
+    if (typeof author === 'object') {
+      pic = <UserPicture user={author} />;
+    }
+
+    return (
+      <div className="revision-history-main">
+        {pic}
+        <div className="revision-history-author">
+          <strong>{author.username}</strong>
+        </div>
+        <div className="revision-history-meta">
+          <p>
+            <UserDate dateTime={revision.createdAt} />
+          </p>
+          <p>
+            <a href={"?revision=" + revision._id }>
+              <Icon name="history" /> View this version
+            </a>
+            <a className="diff-view" onClick={this._onDiffOpenClicked}>
+              <Icon name="level-down" /> View diff
+            </a>
+          </p>
+        </div>
+      </div>
+    );
+  }
+}
+
+Revision.propTypes = {
+  revision: React.PropTypes.object,
+  onDiffOpenClicked: React.PropTypes.func.isRequired,
+}
+

+ 42 - 0
resource/js/components/PageHistory/RevisionDiff.js

@@ -0,0 +1,42 @@
+import React from 'react';
+
+import { createPatch } from 'diff';
+import { Diff2Html } from 'diff2html';
+
+export default class RevisionDiff extends React.Component {
+
+  render() {
+    const currentRevision = this.props.currentRevision,
+      previousRevision = this.props.previousRevision,
+      revisionDiffOpened = this.props.revisionDiffOpened;
+
+
+    let diffViewHTML = '';
+    if (currentRevision.body
+      && previousRevision.body
+      && revisionDiffOpened) {
+
+      let previousText = previousRevision.body;
+      if (currentRevision._id == previousRevision._id) {
+        previousText = '';
+      }
+
+      const patch = createPatch(
+        currentRevision.path,
+        previousText,
+        currentRevision.body
+      );
+
+      diffViewHTML = Diff2Html.getPrettyHtml(patch);
+    }
+
+    const diffView = {__html: diffViewHTML};
+    return <div className="revision-history-diff" dangerouslySetInnerHTML={diffView} />;
+  }
+}
+
+RevisionDiff.propTypes = {
+  currentRevision: React.PropTypes.object.isRequired,
+  previousRevision: React.PropTypes.object.isRequired,
+  revisionDiffOpened: React.PropTypes.bool.isRequired,
+}

+ 6 - 2
resource/js/components/SearchPage/SearchResultList.js

@@ -14,7 +14,12 @@ export default class SearchResultList extends React.Component {
     let returnBody = body;
 
     this.props.searchingKeyword.split(' ').forEach((keyword) => {
-      const k = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+      if (keyword === '') {
+        return;
+      }
+      const k = keyword
+            .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+            .replace(/(^"|"$)/g, ''); // for phrase (quoted) keyword
       const keywordExp = new RegExp(`(${k}(?!(.*?\]|.*?\\)|.*?"|.*?>)))`, 'ig');
       returnBody = returnBody.replace(keywordExp, '<em class="highlighted">$&</em>');
     });
@@ -53,4 +58,3 @@ SearchResultList.defaultProps = {
   pages: [],
   searchingKeyword: '',
 };
-

+ 4 - 84
resource/js/crowi.js

@@ -2,7 +2,6 @@
 /* Author: Sotaro KARASAWA <sotarok@crocos.co.jp>
 */
 
-var jsdiff = require('diff');
 var io = require('socket.io-client');
 
 //require('bootstrap-sass');
@@ -271,7 +270,9 @@ $(function() {
         var page = res.page;
 
         $('#newPageNameCheck').removeClass('alert-danger');
-        $('#newPageNameCheck').html('<img src="/images/loading_s.gif"> 移動しました。移動先にジャンプします。');
+        //$('#newPageNameCheck').html('<img src="/images/loading_s.gif"> 移動しました。移動先にジャンプします。');
+        // fix
+        $('#newPageNameCheck').html('<img src="/images/loading_s.gif"> Page moved! Redirecting to new page location.');
 
         setTimeout(function() {
           top.location.href = page.path + '?renamed=' + pagePath;
@@ -404,7 +405,7 @@ $(function() {
 
     $.getJSON('/_api/check_username', {username: username}, function(json) {
       if (!json.valid) {
-        $('#help-block-username').html('<i class="fa fa-warning"></i>このユーザーIDは利用できません。<br>');
+        $('#help-block-username').html('<i class="fa fa-warning"></i> This User ID is not available.<br>');
         $('#input-group-username').addClass('has-error');
       }
     });
@@ -699,87 +700,6 @@ $(function() {
       return $userHtml;
     }
 
-    // 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;
-
-        if ($diffDisplay.data('loaded')) {
-          $diffDisplay.slideToggle();
-          return true;
-        }
-
-        $.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.data('loaded', 1);
-          $diffDisplay.slideToggle();
-        });
-      }
-    });
-
-    // default open
-    $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
-      $('.diff-view').each(function(i, diffView) {
-        if (i < 2) {
-          $(diffView).click();
-        }
-      });
-    });
-
     // presentation
     var presentaionInitialized = false
       , $b = $('body');

+ 16 - 1
resource/js/util/Crowi.js

@@ -22,6 +22,7 @@ export default class Crowi {
 
     this.users = [];
     this.userByName = {};
+    this.userById   = {};
     this.draft = {};
 
     this.recoverData();
@@ -34,6 +35,7 @@ export default class Crowi {
   recoverData() {
     const keys = [
       'userByName',
+      'userById',
       'users',
       'draft',
     ];
@@ -50,7 +52,7 @@ export default class Crowi {
   }
 
   fetchUsers () {
-    const interval = 1000*60*10; // 5min
+    const interval = 1000*60*15; // 15min
     const currentTime = new Date();
     if (!this.localStorage.lastFetched && interval > currentTime - new Date(this.localStorage.lastFetched)) {
       return ;
@@ -62,13 +64,18 @@ export default class Crowi {
       this.localStorage.users = JSON.stringify(data.users);
 
       let userByName = {};
+      let userById = {};
       for (let i = 0; i < data.users.length; i++) {
         const user = data.users[i];
         userByName[user.username] = user;
+        userById[user._id] = user;
       }
       this.userByName = userByName;
       this.localStorage.userByName = JSON.stringify(userByName);
 
+      this.userById = userById;
+      this.localStorage.userById = JSON.stringify(userById);
+
       this.localStorage.lastFetched = new Date();
     }).catch(err => {
       this.localStorage.removeItem('lastFetched');
@@ -94,6 +101,14 @@ export default class Crowi {
     return null;
   }
 
+  findUserById(userId) {
+    if (this.userById && this.userById[userId]) {
+      return this.userById[userId];
+    }
+
+    return null;
+  }
+
   findUser(username) {
     if (this.userByName && this.userByName[username]) {
       return this.userByName[username];

+ 52 - 18
resource/search/mappings.json

@@ -40,46 +40,80 @@
       "properties" : {
         "name": {
           "type": "string",
-          "analyzer": "autocomplete"
+          "analyzer": "autocomplete",
+          "include_in_all": false
         }
       }
     },
     "pages": {
       "properties" : {
         "path": {
-          "type" : "multi_field",
-          "fields" : {
-            "raw": {"type" : "string", "index" : "not_analyzed"},
-            "ja": {"type" : "string", "analyzer" : "kuromoji"},
-            "en": {"type" : "string", "analyzer" : "english"}
-          }
+          "type": "string",
+          "copy_to": ["path_raw", "path_ja", "path_en"],
+          "include_in_all": false,
+          "index": "not_analyzed"
+        },
+        "path_raw": {
+          "type": "string",
+          "analyzer": "standard",
+          "include_in_all": false
+        },
+        "path_ja": {
+          "type": "string",
+          "analyzer": "kuromoji",
+          "include_in_all": false
+        },
+        "path_en": {
+          "type": "string",
+          "analyzer": "english",
+          "include_in_all": false
         },
         "body": {
-          "type" : "multi_field",
-          "fields" : {
-            "ja": {"type" : "string", "analyzer" : "kuromoji"},
-            "en": {"type" : "string", "analyzer" : "english"}
-          }
+          "type": "string",
+          "copy_to": ["body_raw", "body_ja", "body_en"],
+          "include_in_all": false,
+          "index": "not_analyzed"
+        },
+        "body_raw": {
+          "type": "string",
+          "analyzer": "standard",
+          "include_in_all": false
+        },
+        "body_ja": {
+          "type": "string",
+          "analyzer": "kuromoji",
+          "include_in_all": false
+        },
+        "body_en": {
+          "type": "string",
+          "analyzer": "english",
+          "include_in_all": false
         },
         "username": {
-          "type": "string"
+          "type": "string",
+          "include_in_all": false
         },
         "comment_count": {
-          "type": "integer"
+          "type": "integer",
+          "include_in_all": false
         },
         "bookmark_count": {
-          "type": "integer"
+          "type": "integer",
+          "include_in_all": false
         },
         "like_count": {
-          "type": "integer"
+          "type": "integer",
+          "include_in_all": false
         },
         "created_at": {
           "type": "date",
-          "format": "dateOptionalTime"
+          "format": "dateOptionalTime",
+          "include_in_all": false
         },
         "updated_at": {
           "type": "date",
-          "format": "dateOptionalTime"
+          "format": "dateOptionalTime",
+          "include_in_all": false
         }
       }
     }

+ 0 - 2
test/bootstrap.js

@@ -1,10 +1,8 @@
 'use strict';
 
 var express = require('express')
-  , async = require('async')
   , ROOT_DIR = __dirname + '/..'
   , MODEL_DIR = __dirname + '/../lib/models'
-  , Promise = require('bluebird')
   , testDBUtil
   ;
 

+ 3 - 1
test/crowi/crowi.test.js

@@ -5,7 +5,6 @@ var chai = require('chai')
   , proxyquire = require('proxyquire')
 
   , path = require('path')
-  , Promise = require('bluebird')
   ;
 chai.use(sinonChai);
 
@@ -38,6 +37,9 @@ describe('Test for Crowi application context', function () {
   });
 
   describe('.setupDatabase', function() {
+    before(function() {
+      mongoose.disconnect(); // avoid error of Trying to open unclosed connection
+    });
     it('setup completed', function(done) {
       var crowi = new Crowi(path.normalize(__dirname + '/../../'), process.env);
       // set

+ 0 - 1
test/models/config.test.js

@@ -2,7 +2,6 @@ var chai = require('chai')
   , expect = chai.expect
   , sinon = require('sinon')
   , sinonChai = require('sinon-chai')
-  , Promise = require('bluebird')
   , utils = require('../utils.js')
   ;
 chai.use(sinonChai);

+ 0 - 1
test/models/page.test.js

@@ -2,7 +2,6 @@ var chai = require('chai')
   , expect = chai.expect
   , sinon = require('sinon')
   , sinonChai = require('sinon-chai')
-  , Promise = require('bluebird')
   , utils = require('../utils.js')
   ;
 chai.use(sinonChai);

+ 0 - 1
test/models/updatePost.test.js

@@ -2,7 +2,6 @@ var chai = require('chai')
   , expect = chai.expect
   , sinon = require('sinon')
   , sinonChai = require('sinon-chai')
-  , Promise = require('bluebird')
   , utils = require('../utils.js')
   ;
 chai.use(sinonChai);

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

@@ -2,7 +2,6 @@ var chai = require('chai')
   , expect = chai.expect
   , sinon = require('sinon')
   , sinonChai = require('sinon-chai')
-  , Promise = require('bluebird')
   , utils = require('../utils.js')
   ;
 chai.use(sinonChai);
@@ -15,7 +14,7 @@ describe('User', function () {
   describe('Create and Find.', function () {
     context('The user', function() {
       it('should created', function(done) {
-        User.createUserByEmailAndPassword('Aoi Miyazaki', 'aoi', 'aoi@example.com', 'hogefuga11', function (err, userData) {
+        User.createUserByEmailAndPassword('Aoi Miyazaki', 'aoi', 'aoi@example.com', 'hogefuga11', 'en', function (err, userData) {
           expect(err).to.be.null;
           expect(userData).to.instanceof(User);
           done();

+ 1 - 0
test/utils.js

@@ -7,6 +7,7 @@ var mongoUri = process.env.MONGOLAB_URI || process.env.MONGOHQ_URL || process.en
   , crowi = new (require(ROOT_DIR + '/lib/crowi'))(ROOT_DIR, process.env)
   ;
 
+mongoose.Promise = global.Promise;
 
 before('Create database connection and clean up', function (done) {
   if (!mongoUri) {

+ 1 - 0
tmp/uploads/.gitignore

@@ -0,0 +1 @@
+*

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