Przeglądaj źródła

Merge pull request #83 from crowi/feature-search-header

Search from header.
Sotaro KARASAWA 9 lat temu
rodzic
commit
465d59e658
63 zmienionych plików z 2494 dodań i 618 usunięć
  1. 6 0
      .babelrc
  2. 2 0
      README.md
  3. 2 2
      app.js
  4. 2 1
      app.json
  5. 127 0
      bin/search.js
  6. 45 0
      bin/util.js
  7. 48 68
      gulpfile.js
  8. 44 14
      lib/crowi/index.js
  9. 2 2
      lib/events/page.js
  10. 103 47
      lib/models/page.js
  11. 56 0
      lib/routes/admin.js
  12. 9 0
      lib/routes/index.js
  13. 62 11
      lib/routes/page.js
  14. 67 0
      lib/routes/search.js
  15. 434 0
      lib/util/search.js
  16. 9 2
      lib/util/swigFunctions.js
  17. 1 1
      lib/views/_form.html
  18. 1 6
      lib/views/admin/app.html
  19. 2 7
      lib/views/admin/index.html
  20. 1 6
      lib/views/admin/notification.html
  21. 67 0
      lib/views/admin/search.html
  22. 1 7
      lib/views/admin/users.html
  23. 12 0
      lib/views/admin/widget/menu.html
  24. 13 126
      lib/views/layout/2column.html
  25. 1 1
      lib/views/layout/admin.html
  26. 82 46
      lib/views/layout/layout.html
  27. 27 0
      lib/views/layout/single.html
  28. 2 69
      lib/views/login.html
  29. 1 1
      lib/views/me/index.html
  30. 3 3
      lib/views/modal/widget_today_memo.html
  31. 0 36
      lib/views/page.html
  32. 14 26
      lib/views/page_list.html
  33. 2 2
      lib/views/page_presentation.html
  34. 16 0
      lib/views/search.html
  35. 15 5
      lib/views/widget/page_list.html
  36. 2 2
      lib/views/widget/page_side_content.html
  37. 4 10
      lib/views/widget/page_side_header.html
  38. 0 100
      lib/views/widget/searcher.html
  39. 18 3
      package.json
  40. 0 0
      public/js/reveal
  41. 12 3
      resource/css/_page_list.scss
  42. 74 0
      resource/css/_search.scss
  43. 1 0
      resource/css/crowi.scss
  44. 33 0
      resource/js/app.js
  45. 79 0
      resource/js/components/Header/SearchBox.js
  46. 80 0
      resource/js/components/Header/SearchForm.js
  47. 47 0
      resource/js/components/Header/SearchSuggest.js
  48. 89 0
      resource/js/components/Page/PageBody.js
  49. 28 0
      resource/js/components/PageList/ListView.js
  50. 38 0
      resource/js/components/PageList/Page.js
  51. 51 0
      resource/js/components/PageList/PageListMeta.js
  52. 46 0
      resource/js/components/PageList/PagePath.js
  53. 60 0
      resource/js/components/Search/SearchForm.js
  54. 101 0
      resource/js/components/Search/SearchPage.js
  55. 52 0
      resource/js/components/Search/SearchResult.js
  56. 57 0
      resource/js/components/Search/SearchResultList.js
  57. 38 0
      resource/js/components/User/UserPicture.js
  58. 2 0
      resource/js/crowi-admin.js
  59. 4 2
      resource/js/crowi-form.js
  60. 9 6
      resource/js/crowi-presentation.js
  61. 157 3
      resource/js/crowi.js
  62. 87 0
      resource/search/mappings.json
  63. 46 0
      webpack.config.js

+ 6 - 0
.babelrc

@@ -0,0 +1,6 @@
+{
+  "presets": [
+    "es2015",
+    "react"
+  ]
+}

+ 2 - 0
README.md

@@ -37,6 +37,7 @@ Dependencies
 
 
 * Node.js (4.2.x)
 * Node.js (4.2.x)
 * MongoDB
 * MongoDB
+* Elasticsearch (optional)
 * Redis (optional)
 * Redis (optional)
 * Amazon S3 (optional)
 * Amazon S3 (optional)
 * Facebook Application (optional)
 * Facebook Application (optional)
@@ -60,6 +61,7 @@ $ PASSWORD_SEED=somesecretstring MONGO_URI=mongodb://username:password@localhost
 * `NODE_ENV`: `production` OR `development`.
 * `NODE_ENV`: `production` OR `development`.
 * `MONGO_URI`: URI to connect MongoDB. This parameter is also by `MONGOHQ_URL` OR `MONGOLAB_URI`.
 * `MONGO_URI`: URI to connect MongoDB. This parameter is also by `MONGOHQ_URL` OR `MONGOLAB_URI`.
 * `REDIS_URL`: URI to connect Redis (to session store). This parameter is also by `REDISTOGO_URL`.
 * `REDIS_URL`: URI to connect Redis (to session store). This parameter is also by `REDISTOGO_URL`.
+* `ELASTICSEARCH_URI`: URI to connect Elasticearch.
 * `PASSWORD_SEED`: A password seed is used by password hash generator.
 * `PASSWORD_SEED`: A password seed is used by password hash generator.
 * `SECRET_TOKEN`: A secret key for verifying the integrity of signed cookies.
 * `SECRET_TOKEN`: A secret key for verifying the integrity of signed cookies.
 * `FILE_UPLOAD`: `aws` (default), `local`, `none`
 * `FILE_UPLOAD`: `aws` (default), `local`, `none`

+ 2 - 2
app.js

@@ -8,7 +8,7 @@
 var crowi = new (require('./lib/crowi'))(__dirname, process.env);
 var crowi = new (require('./lib/crowi'))(__dirname, process.env);
 
 
 crowi.init()
 crowi.init()
-  .then(function(app) {
-    crowi.start(app);
+  .then(function() {
+    return crowi.start();
   }).catch(crowi.exitOnError);
   }).catch(crowi.exitOnError);
 
 

+ 2 - 1
app.json

@@ -21,6 +21,7 @@
   },
   },
   "addons": [
   "addons": [
     "mongolab",
     "mongolab",
-    "redistogo"
+    "redistogo",
+    "bonsai"
   ]
   ]
 }
 }

+ 127 - 0
bin/search.js

@@ -0,0 +1,127 @@
+
+var program = require('commander')
+  , sprintf = require('sprintf')
+  , debug = require('debug')('crowi:console:search-util')
+  , colors = require('colors')
+  , crowi = new (require('../lib/crowi'))(__dirname + '/../', process.env)
+  ;
+
+crowi.init()
+  .then(function(app) {
+    program
+      .version(crowi.version);
+
+    program
+      .command('create-index')
+      .action(function (cmd, env) {
+        var search = crowi.getSearcher();
+
+        search.buildIndex()
+          .then(function(data) {
+            console.log(data);
+          })
+          .then(function() {
+            process.exit();
+          })
+          .catch(function(err) {
+            console.log("Error", err);
+
+          })
+      });
+
+    program
+      .command('add-pages')
+      .action(function (cmd, env) {
+        var search = crowi.getSearcher();
+
+        search.addAllPages()
+          .then(function(data) {
+            if (data.errors) {
+              console.error(colors.red.underline('Failed to index all pages.'));
+              console.error("");
+              data.items.forEach(function(item, i) {
+                var index = item.index || null;
+                if (index && index.status != 200) {
+                  console.error(colors.red('Error item: id=%s'), index._id)
+                  console.error('error.type=%s, error.reason=%s', index.error.type, index.error.reason);
+                  console.error(index.error.caused_by);
+                }
+                //debug('Item', i, item);
+              });
+            } else {
+              console.log('Data is successfully indexed.');
+            }
+            process.exit(0);
+          })
+          .catch(function(err) {
+            console.log("Error", err);
+          });
+      });
+
+    program
+      .command('rebuild-index')
+      .action(function (cmd, env) {
+        var search = crowi.getSearcher();
+
+        search.deleteIndex()
+          .then(function(data) {
+            if (!data.errors) {
+              console.log('Index deleted.');
+            }
+            return search.buildIndex();
+          })
+          .then(function(data) {
+            if (!data.errors) {
+              console.log('Index created.');
+            }
+            return search.addAllPages();
+          })
+          .then(function(data) {
+            if (!data.errors) {
+              console.log('Data is successfully indexed.');
+            }
+            process.exit(0);
+          })
+          .catch(function(err) {
+            console.error('Error', err);
+          });
+      });
+
+    program
+      .command('search')
+      .action(function (cmd, env) {
+        var Page = crowi.model('Page');
+        var search = crowi.getSearcher();
+        var keyword = cmd;
+
+        search.searchKeyword(keyword, {})
+          .then(function(data) {
+            debug('result is', data);
+            console.log(colors.green('Search result: %d of %d total. (%d ms)'), data.meta.results, data.meta.total, data.meta.took);
+
+            return Page.populatePageListToAnyObjects(data.data);
+          }).then(function(pages) {
+            pages.map(function(page) {
+              console.log(page._score, page._id, page.path);
+            });
+
+            process.exit(0);
+          })
+          .catch(function(err) {
+            console.error('Error', err);
+
+            process.exit(0);
+          });
+      });
+
+
+    program.parse(process.argv);
+
+  }).catch(crowi.exitOnError);
+
+
+//program
+//  .command('search [query]', 'search with optional query')
+//  .command('list', 'list packages installed', {isDefault: true})
+
+

+ 45 - 0
bin/util.js

@@ -0,0 +1,45 @@
+var program = require('commander')
+  , sprintf = require('sprintf')
+  , debug = require('debug')('crowi:console:util')
+  , colors = require('colors')
+  , crowi = new (require('../lib/crowi'))(__dirname + '/../', process.env)
+  ;
+
+crowi.init()
+  .then(function(app) {
+    program
+      .version(crowi.version);
+
+    program
+      .command('count-page-length')
+      .action(function (cmd, env) {
+        var Page = crowi.model('Page');
+        var stream = Page.getStreamOfFindAll();
+        var pages = [];
+
+        stream.on('data', function (doc) {
+          if (!doc.creator || !doc.revision) {
+            return ;
+          }
+
+          pages.push({
+            path: doc.path,
+            body: doc.revision.body,
+            author: doc.creator.username,
+          });
+        }).on('error', function (err) {
+          // TODO: handle err
+          debug('Error stream:', err);
+        }).on('close', function () {
+          // all done
+
+          pages.forEach(function(page, i) {
+            console.log('%d\t%s', page.body.length, page.path);
+          });
+
+          process.exit(0);
+        });
+      });
+
+    program.parse(process.argv);
+  }).catch(crowi.exitOnError);

+ 48 - 68
gulpfile.js

@@ -9,8 +9,9 @@ var rename = require('gulp-rename');
 var uglify = require('gulp-uglify');
 var uglify = require('gulp-uglify');
 var jshint = require('gulp-jshint');
 var jshint = require('gulp-jshint');
 var source = require('vinyl-source-stream');
 var source = require('vinyl-source-stream');
-var browserify = require('browserify');
+var webpack = require('webpack-stream');
 
 
+var del     = require('del');
 var stylish = require('jshint-stylish');
 var stylish = require('jshint-stylish');
 
 
 var pkg = require('./package.json');
 var pkg = require('./package.json');
@@ -37,35 +38,22 @@ var css = {
 };
 };
 
 
 var js = {
 var js = {
-  browserify: {
-    crowi: 'resource/js/crowi.js', // => crowi-bundled.js
-    crowiPresentation: 'resource/js/crowi-presentation.js', // => crowi-presentation.js
-  },
-  src: [
+  bundledSrc: [
     'node_modules/jquery/dist/jquery.js',
     'node_modules/jquery/dist/jquery.js',
     'node_modules/bootstrap-sass/assets/javascripts/bootstrap.js',
     'node_modules/bootstrap-sass/assets/javascripts/bootstrap.js',
     'node_modules/inline-attachment/src/inline-attachment.js',
     'node_modules/inline-attachment/src/inline-attachment.js',
-    'node_modules/socket.io-client/socket.io.js',
     'node_modules/jquery.cookie/jquery.cookie.js',
     'node_modules/jquery.cookie/jquery.cookie.js',
-    'node_modules/diff/dist/diff.js',
     'resource/thirdparty-js/jquery.selection.js',
     'resource/thirdparty-js/jquery.selection.js',
-    dirs.jsDist + '/crowi-bundled.js',
   ],
   ],
-  dist: dirs.jsDist + '/crowi.js',
-  revealSrc: [
-    'node_modules/reveal.js/lib/js/head.min.js',
-    'node_modules/reveal.js/lib/js/html5shiv.js',
-    dirs.jsDist + '/crowi-presentation.js',
-  ],
-  revealDist: dirs.jsDist + '/crowi-reveal.js',
-  formSrc: [
-    'resource/js/crowi-form.js'
-  ],
-  formDist: dirs.jsDist + '/crowi-form.js',
-  adminSrc: [
-    'resource/js/crowi-admin.js'
-  ],
-  adminDist: dirs.jsDist + '/crowi-admin.js',
+  src:          dirs.jsSrc  + '/app.js',
+
+  bundled:      dirs.jsDist + '/bundled.js',
+  dist:         dirs.jsDist + '/crowi.js',
+  admin:        dirs.jsDist + '/admin.js',
+  form:         dirs.jsDist + '/form.js',
+  presentation: dirs.jsDist + '/presentation.js',
+  app:          dirs.jsDist + '/app.js',
+
   clientWatch: ['resource/js/**/*.js'],
   clientWatch: ['resource/js/**/*.js'],
   watch: ['test/**/*.test.js', 'app.js', 'lib/**/*.js'],
   watch: ['test/**/*.test.js', 'app.js', 'lib/**/*.js'],
   lint: ['app.js', 'lib/**/*.js'],
   lint: ['app.js', 'lib/**/*.js'],
@@ -78,56 +66,48 @@ var cssIncludePaths = [
   'node_modules/reveal.js/css'
   'node_modules/reveal.js/css'
 ];
 ];
 
 
-gulp.task('js:browserify', function() {
-  browserify({entries: js.browserify.crowiPresentation})
-    .bundle()
-    .pipe(source('crowi-presentation.js'))
-    .pipe(gulp.dest(dirs.jsDist));
-
-  return browserify({entries: js.browserify.crowi})
-    .bundle()
-    .pipe(source('crowi-bundled.js'))
-    .pipe(gulp.dest(dirs.jsDist));
+gulp.task('js:del', function() {
+  var fileList = [
+    js.dist,
+    js.bundled,
+    js.admin,
+    js.form,
+    js.presentation,
+    js.app,
+  ];
+  fileList = fileList.concat(fileList.map(function(fn){ return fn.replace(/\.js/, '.min.js');}));
+  return del(fileList);
 });
 });
 
 
-gulp.task('js:concat', ['js:browserify'], function() {
-  gulp.src(js.revealSrc)
-    .pipe(concat('crowi-reveal.js'))
-    .pipe(gulp.dest(dirs.jsDist));
-
-  gulp.src(js.adminSrc)
-    .pipe(concat('crowi-admin.js'))
-    .pipe(gulp.dest(dirs.jsDist));
-
-  gulp.src(js.formSrc)
-    .pipe(concat('crowi-form.js'))
+gulp.task('js:concat', ['js:del'], function() {
+  return gulp.src(js.bundledSrc)
+    .pipe(concat('bundled.js')) // jQuery
     .pipe(gulp.dest(dirs.jsDist));
     .pipe(gulp.dest(dirs.jsDist));
+});
 
 
+// move task for css and js to webpack over time.
+gulp.task('webpack', ['js:concat'], function() {
   return gulp.src(js.src)
   return gulp.src(js.src)
-    .pipe(concat('crowi.js'))
+    .pipe(webpack(require('./webpack.config.js')))
     .pipe(gulp.dest(dirs.jsDist));
     .pipe(gulp.dest(dirs.jsDist));
 });
 });
 
 
-gulp.task('js:min', ['js:concat'], function() {
-  gulp.src(js.revealDist)
-    .pipe(uglify())
-    .pipe(rename({suffix: '.min'}))
-    .pipe(gulp.dest(dirs.jsDist));
-
-  gulp.src(js.formDist)
-    .pipe(uglify())
-    .pipe(rename({suffix: '.min'}))
-    .pipe(gulp.dest(dirs.jsDist));
-
-  gulp.src(js.adminDist)
-    .pipe(uglify())
-    .pipe(rename({suffix: '.min'}))
-    .pipe(gulp.dest(dirs.jsDist));
-
-  return gulp.src(js.dist)
-    .pipe(uglify())
-    .pipe(rename({suffix: '.min'}))
-    .pipe(gulp.dest(dirs.jsDist));
+gulp.task('js:min', ['webpack'], function() {
+  var fileList = [
+    js.dist,
+    js.bundled,
+    js.admin,
+    js.form,
+    js.presentation,
+    js.app,
+  ];
+
+  fileList.forEach(function(jsfile) {
+    gulp.src(jsfile)
+      .pipe(uglify())
+      .pipe(rename({suffix: '.min'}))
+      .pipe(gulp.dest(dirs.jsDist));
+  });
 });
 });
 
 
 gulp.task('jshint', function() {
 gulp.task('jshint', function() {
@@ -189,7 +169,7 @@ gulp.task('watch', function() {
 
 
   var cssWatcher = gulp.watch(css.watch, ['css:concat']);
   var cssWatcher = gulp.watch(css.watch, ['css:concat']);
   cssWatcher.on('change', watchLogger);
   cssWatcher.on('change', watchLogger);
-  var jsWatcher = gulp.watch(js.clientWatch, ['js:concat']);
+  var jsWatcher = gulp.watch(js.clientWatch, ['webpack']);
   jsWatcher.on('change', watchLogger);
   jsWatcher.on('change', watchLogger);
   var testWatcher = gulp.watch(js.watch, ['test']);
   var testWatcher = gulp.watch(js.watch, ['test']);
   testWatcher.on('change', watchLogger);
   testWatcher.on('change', watchLogger);
@@ -197,4 +177,4 @@ gulp.task('watch', function() {
 
 
 gulp.task('css', ['css:sass', 'css:concat',]);
 gulp.task('css', ['css:sass', 'css:concat',]);
 gulp.task('default', ['css:min', 'js:min',]);
 gulp.task('default', ['css:min', 'js:min',]);
-gulp.task('dev', ['css:concat', 'js:concat','jshint', 'test']);
+gulp.task('dev', ['css:concat', 'webpack', 'jshint', 'test']);

+ 44 - 14
lib/crowi/index.js

@@ -7,9 +7,6 @@ var debug = require('debug')('crowi:crowi')
   , sep = path.sep
   , sep = path.sep
   , Promise = require('bluebird')
   , Promise = require('bluebird')
 
 
-  , http     = require('http')
-  , express  = require('express')
-
   , mongoose    = require('mongoose')
   , mongoose    = require('mongoose')
 
 
   , models = require('../models')
   , models = require('../models')
@@ -26,10 +23,12 @@ function Crowi (rootdir, env)
   this.publicDir = path.join(this.rootDir, 'public') + sep;
   this.publicDir = path.join(this.rootDir, 'public') + sep;
   this.libDir    = path.join(this.rootDir, 'lib') + sep;
   this.libDir    = path.join(this.rootDir, 'lib') + sep;
   this.eventsDir = path.join(this.libDir, 'events') + sep;
   this.eventsDir = path.join(this.libDir, 'events') + sep;
+  this.resourceDir = path.join(this.rootDir, 'resource') + sep;
   this.viewsDir  = path.join(this.libDir, 'views') + sep;
   this.viewsDir  = path.join(this.libDir, 'views') + sep;
   this.mailDir   = path.join(this.viewsDir, 'mail') + sep;
   this.mailDir   = path.join(this.viewsDir, 'mail') + sep;
 
 
   this.config = {};
   this.config = {};
+  this.searcher = null;
   this.mailer = {};
   this.mailer = {};
 
 
 
 
@@ -76,6 +75,8 @@ Crowi.prototype.init = function() {
         return resolve();
         return resolve();
       });
       });
     });
     });
+  }).then(function() {
+    return self.setupSearcher();
   }).then(function() {
   }).then(function() {
     return self.setupMailer();
     return self.setupMailer();
   }).then(function() {
   }).then(function() {
@@ -189,10 +190,34 @@ Crowi.prototype.getIo = function() {
   return this.io;
   return this.io;
 };
 };
 
 
+Crowi.prototype.getSearcher = function() {
+  return this.searcher;
+};
+
 Crowi.prototype.getMailer = function() {
 Crowi.prototype.getMailer = function() {
   return this.mailer;
   return this.mailer;
 };
 };
 
 
+Crowi.prototype.setupSearcher = function() {
+  var self = this;
+  var searcherUri = this.env.ELASTICSEARCH_URI
+    || this.env.BONSAI_URL
+    || null
+    ;
+
+  return new Promise(function(resolve, reject) {
+    if (searcherUri) {
+      try {
+        self.searcher = new (require(path.join(self.libDir, 'util', 'search')))(self, searcherUri);
+      } catch (e) {
+        debug('Error on setup searcher', e);
+        self.searcher = null;
+      }
+    }
+    resolve();
+  });
+};
+
 Crowi.prototype.setupMailer = function() {
 Crowi.prototype.setupMailer = function() {
   var self = this;
   var self = this;
   return new Promise(function(resolve, reject) {
   return new Promise(function(resolve, reject) {
@@ -219,25 +244,30 @@ Crowi.prototype.setupSlack = function() {
 
 
 
 
 
 
-Crowi.prototype.start = function(app) {
+Crowi.prototype.start = function() {
   var self = this
   var self = this
+    , http = require('http')
     , server
     , server
     , io;
     , io;
 
 
-  server = http.createServer(app).listen(self.port, function() {
-    console.log('[' + self.node_env + '] Express server listening on port ' + self.port);
-  });
+  return self.buildServer()
+    .then(function(app) {
+      server = http.createServer(app).listen(self.port, function() {
+        console.log('[' + self.node_env + '] Express server listening on port ' + self.port);
+      });
 
 
-  io = require('socket.io')(server);
-  io.sockets.on('connection', function (socket) {
-  });
-  this.io = io;
+      io = require('socket.io')(server);
+      io.sockets.on('connection', function (socket) {
+      });
+      self.io = io;
+
+      return app;
+    });
 };
 };
 
 
 Crowi.prototype.buildServer = function() {
 Crowi.prototype.buildServer = function() {
-  var app            = express()
-    , env            = this.node_env
-    , sessionConfig  = this.setupSessionConfig();
+  var express  = require('express')
+    , app = express()
     ;
     ;
 
 
   require('./express-init')(this, app);
   require('./express-init')(this, app);

+ 2 - 2
lib/events/page.js

@@ -10,12 +10,12 @@ function PageEvent(crowi) {
 }
 }
 util.inherits(PageEvent, events.EventEmitter);
 util.inherits(PageEvent, events.EventEmitter);
 
 
-PageEvent.prototype.onCreate = function(context, page, user) {
+PageEvent.prototype.onCreate = function(page, user) {
   var User = this.crowi.model('User');
   var User = this.crowi.model('User');
   var Page = this.crowi.model('Page');
   var Page = this.crowi.model('Page');
 
 
 };
 };
-PageEvent.prototype.onUpdate = function(context, page, user) {
+PageEvent.prototype.onUpdate = function(page, user) {
   var User = this.crowi.model('User');
   var User = this.crowi.model('User');
   var Page = this.crowi.model('Page');
   var Page = this.crowi.model('Page');
 };
 };

+ 103 - 47
lib/models/page.js

@@ -248,35 +248,30 @@ module.exports = function(crowi) {
     });
     });
   };
   };
 
 
-  pageSchema.statics.populatePageList = function(pageList) {
-    var Page = self;
-    var User = crowi.model('User');
+  pageSchema.statics.populatePageListToAnyObjects = function(pageIdObjectArray) {
+    var Page = this;
+    var pageIdMappings = {};
+    var pageIds = pageIdObjectArray.map(function(page, idx) {
+      if (!page._id) {
+        throw new Error('Pass the arg of populatePageListToAnyObjects() must have _id on each element.');
+      }
 
 
-    return new Promise(function(resolve, reject) {
-      Page.populate(
-        pageList,
-        [
-          {path: 'creator', model: 'User', select: User.USER_PUBLIC_FIELDS},
-          {path: 'revision', model: 'Revision'}
-        ],
-        function(err, pageList) {
-          if (err) {
-            return reject(err);
-          }
+      pageIdMappings[String(page._id)] = idx;
+      return page._id;
+    });
 
 
-          Page.populate(pageList, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
-            if (err) {
-              return reject(err);
-            }
+    return new Promise(function(resolve, reject) {
+      Page.findListByPageIds(pageIds, {limit: 100}) // limit => if the pagIds is greater than 100, ignore
+      .then(function(pages) {
+        pages.forEach(function(page) {
+          Object.assign(pageIdObjectArray[pageIdMappings[String(page._id)]], page._doc);
+        });
 
 
-            resolve(data);
-          });
-        }
-      );
+        resolve(pageIdObjectArray);
+      });
     });
     });
   };
   };
 
 
-
   pageSchema.statics.updateCommentCount = function (page, num)
   pageSchema.statics.updateCommentCount = function (page, num)
   {
   {
     var self = this;
     var self = this;
@@ -447,11 +442,13 @@ module.exports = function(crowi) {
     });
     });
   };
   };
 
 
-  pageSchema.statics.findListByPageIds = function(ids, option) {
+  pageSchema.statics.findListByPageIds = function(ids, options) {
     var Page = this;
     var Page = this;
     var User = crowi.model('User');
     var User = crowi.model('User');
-    var limit = option.limit || 50;
-    var offset = option.skip || 0;
+    var options = options || {}
+      , limit = options.limit || 50
+      , offset = options.skip || 0
+      ;
 
 
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {
       Page
       Page
@@ -459,7 +456,10 @@ module.exports = function(crowi) {
       //.sort({createdAt: -1}) // TODO optionize
       //.sort({createdAt: -1}) // TODO optionize
       .skip(offset)
       .skip(offset)
       .limit(limit)
       .limit(limit)
-      .populate('revision')
+      .populate([
+        {path: 'creator', model: 'User', select: User.USER_PUBLIC_FIELDS},
+        {path: 'revision', model: 'Revision'},
+      ])
       .exec(function(err, pages) {
       .exec(function(err, pages) {
         if (err) {
         if (err) {
           return reject(err);
           return reject(err);
@@ -499,6 +499,29 @@ module.exports = function(crowi) {
     });
     });
   };
   };
 
 
+  /**
+   * Bulk get (for internal only)
+   */
+  pageSchema.statics.getStreamOfFindAll = function(options) {
+    var Page = this
+      , options = options || {}
+      , publicOnly = options.publicOnly || true
+      , criteria = {redirectTo: null,}
+      ;
+
+    if (publicOnly) {
+      criteria.grant = GRANT_PUBLIC;
+    }
+
+    return this.find(criteria)
+      .populate([
+        {path: 'creator', model: 'User'},
+        {path: 'revision', model: 'Revision'},
+      ])
+      .sort({updatedAt: -1})
+      .stream();
+  };
+
   /**
   /**
    * findListByStartWith
    * findListByStartWith
    *
    *
@@ -559,7 +582,7 @@ module.exports = function(crowi) {
     });
     });
   };
   };
 
 
-  pageSchema.statics.updatePage = function(page, updateData) {
+  pageSchema.statics.updatePageProperty = function(page, updateData) {
     var Page = this;
     var Page = this;
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {
       // TODO foreach して save
       // TODO foreach して save
@@ -574,27 +597,24 @@ module.exports = function(crowi) {
   };
   };
 
 
   pageSchema.statics.updateGrant = function(page, grant, userData) {
   pageSchema.statics.updateGrant = function(page, grant, userData) {
-    var self = this;
+    var Page = this;
 
 
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {
-      self.update({_id: page._id}, {$set: {grant: grant}}, function(err, data) {
+      page.grant = grant;
+      if (grant == GRANT_PUBLIC) {
+        page.grantedUsers = [];
+      } else {
+        page.grantedUsers = [];
+        page.grantedUsers.push(userData._id);
+      }
+
+      page.save(function(err, data) {
+        debug('Page.updateGrant, saved grantedUsers.', err, data);
         if (err) {
         if (err) {
           return reject(err);
           return reject(err);
         }
         }
 
 
-        if (grant == GRANT_PUBLIC) {
-          page.grantedUsers = [];
-        } else {
-          page.grantedUsers = [];
-          page.grantedUsers.push(userData._id);
-        }
-        page.save(function(err, data) {
-          if (err) {
-            return reject(err);
-          }
-
-          return resolve(data);
-        });
+        return resolve(data);
       });
       });
     });
     });
   };
   };
@@ -617,6 +637,11 @@ module.exports = function(crowi) {
   };
   };
 
 
   pageSchema.statics.pushRevision = function(pageData, newRevision, user) {
   pageSchema.statics.pushRevision = function(pageData, newRevision, user) {
+    var isCreate = false;
+    if (pageData.revision === undefined) {
+      debug('pushRevision on Create');
+      isCreate = true;
+    }
 
 
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {
       newRevision.save(function(err, newRevision) {
       newRevision.save(function(err, newRevision) {
@@ -636,7 +661,9 @@ module.exports = function(crowi) {
           }
           }
 
 
           resolve(data);
           resolve(data);
-          pageEvent.emit('update', data, user);
+          if (!isCreate) {
+            debug('pushRevision on Update');
+          }
         });
         });
       });
       });
     });
     });
@@ -688,6 +715,34 @@ module.exports = function(crowi) {
       });
       });
   };
   };
 
 
+  pageSchema.statics.updatePage = function(pageData, body, user, options) {
+    var Page = this
+      , Revision = crowi.model('Revision')
+      , grant = options.grant || null
+      ;
+    // update existing page
+    var newRevision = Revision.prepareRevision(pageData, body, user);
+
+    return new Promise(function(resolve, reject) {
+      Page.pushRevision(pageData, newRevision, user)
+      .then(function(revision) {
+        if (grant != pageData.grant) {
+          return Page.updateGrant(pageData, grant, user).then(function(data) {
+            debug('Page grant update:', data);
+            resolve(data);
+            pageEvent.emit('update', data, user);
+          });
+        } else {
+          resolve(pageData);
+          pageEvent.emit('update', pageData, user);
+        }
+      }).catch(function(err) {
+        debug('Error on update', err);
+        debug('Error on update', err.stack);
+      });
+    });
+  };
+
   pageSchema.statics.rename = function(pageData, newPagePath, user, options) {
   pageSchema.statics.rename = function(pageData, newPagePath, user, options) {
     var Page = this
     var Page = this
       , Revision = crowi.model('Revision')
       , Revision = crowi.model('Revision')
@@ -697,7 +752,7 @@ module.exports = function(crowi) {
 
 
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {
       // pageData の path を変更
       // pageData の path を変更
-      Page.updatePage(pageData, {updatedAt: Date.now(), path: newPagePath})
+      Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath})
       .then(function(data) {
       .then(function(data) {
         debug('Before ', pageData);
         debug('Before ', pageData);
         // reivisions の path を変更
         // reivisions の path を変更
@@ -708,10 +763,11 @@ module.exports = function(crowi) {
 
 
         if (createRedirectPage) {
         if (createRedirectPage) {
           var body = 'redirect ' + newPagePath;
           var body = 'redirect ' + newPagePath;
-          return Page.create(path, body, user, {redirectTo: newPagePath}).then(resolve).catch(reject);
+          Page.create(path, body, user, {redirectTo: newPagePath}).then(resolve).catch(reject);
         } else {
         } else {
-          return resolve(data);
+          resolve(data);
         }
         }
+        pageEvent.emit('update', pageData, user); // update as renamed page
       });
       });
     });
     });
   };
   };

+ 56 - 0
lib/routes/admin.js

@@ -168,6 +168,62 @@ module.exports = function(crowi, app) {
     });
     });
   };
   };
 
 
+  actions.search = {};
+  actions.search.index = function(req, res) {
+    var search = crowi.getSearcher();
+    if (!search) {
+      return res.redirect('/admin');
+    }
+
+    return res.render('admin/search', {
+    });
+  };
+
+  actions.search.buildIndex = function(req, res) {
+    var search = crowi.getSearcher();
+    if (!search) {
+      return res.redirect('/admin');
+    }
+
+    Promise.resolve().then(function() {
+      return new Promise(function(resolve, reject) {
+        search.deleteIndex()
+          .then(function(data) {
+            debug('Index deleted.');
+            resolve();
+          }).catch(function(err) {
+            debug('Delete index Error, but if it is initialize, its ok.', err);
+            resolve();
+          });
+      });
+    }).then(function() {
+      search.buildIndex()
+        .then(function(data) {
+          if (!data.errors) {
+            debug('Index created.');
+          }
+          return search.addAllPages();
+        })
+        .then(function(data) {
+          if (!data.errors) {
+            debug('Data is successfully indexed.');
+          } else {
+            debug('Data index error.', data);
+          }
+
+          //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));
+        });
+    });
+  };
+
   actions.user = {};
   actions.user = {};
   actions.user.index = function(req, res) {
   actions.user.index = function(req, res) {
     var page = parseInt(req.query.page) || 1;
     var page = parseInt(req.query.page) || 1;

+ 9 - 0
lib/routes/index.js

@@ -12,6 +12,7 @@ module.exports = function(crowi, app) {
     , comment   = require('./comment')(crowi, app)
     , comment   = require('./comment')(crowi, app)
     , bookmark  = require('./bookmark')(crowi, app)
     , bookmark  = require('./bookmark')(crowi, app)
     , revision  = require('./revision')(crowi, app)
     , revision  = require('./revision')(crowi, app)
+    , search    = require('./search')(crowi, app)
     , loginRequired = middleware.loginRequired
     , loginRequired = middleware.loginRequired
     , accessTokenParser = middleware.accessTokenParser
     , accessTokenParser = middleware.accessTokenParser
     ;
     ;
@@ -44,6 +45,10 @@ module.exports = function(crowi, app) {
   app.post('/_api/admin/settings/google', loginRequired(crowi, app) , middleware.adminRequired() , form.admin.google, admin.api.appSetting);
   app.post('/_api/admin/settings/google', loginRequired(crowi, app) , middleware.adminRequired() , form.admin.google, admin.api.appSetting);
   app.post('/_api/admin/settings/fb'    , loginRequired(crowi, app) , middleware.adminRequired() , form.admin.fb , admin.api.appSetting);
   app.post('/_api/admin/settings/fb'    , loginRequired(crowi, app) , middleware.adminRequired() , form.admin.fb , admin.api.appSetting);
 
 
+  // search admin
+  app.get('/admin/search'              , loginRequired(crowi, app) , middleware.adminRequired() , admin.search.index);
+  app.post('/admin/search/build'       , loginRequired(crowi, app) , middleware.adminRequired() , admin.search.buildIndex);
+
   // notification admin
   // notification admin
   app.get('/admin/notification'              , loginRequired(crowi, app) , middleware.adminRequired() , admin.notification.index);
   app.get('/admin/notification'              , loginRequired(crowi, app) , middleware.adminRequired() , admin.notification.index);
   app.post('/admin/notification/slackSetting', loginRequired(crowi, app) , middleware.adminRequired() , form.admin.slackSetting, admin.notification.slackSetting);
   app.post('/admin/notification/slackSetting', loginRequired(crowi, app) , middleware.adminRequired() , form.admin.slackSetting, admin.notification.slackSetting);
@@ -73,6 +78,10 @@ module.exports = function(crowi, app) {
 
 
   app.get( '/:id([0-9a-z]{24})'       , loginRequired(crowi, app) , page.api.redirector);
   app.get( '/:id([0-9a-z]{24})'       , loginRequired(crowi, app) , page.api.redirector);
   app.get( '/_r/:id([0-9a-z]{24})'    , loginRequired(crowi, app) , page.api.redirector); // alias
   app.get( '/_r/:id([0-9a-z]{24})'    , loginRequired(crowi, app) , page.api.redirector); // alias
+
+  app.get( '/_search'                 , loginRequired(crowi, app) , search.searchPage);
+  app.get( '/_api/search'             , accessTokenParser(crowi, app) , loginRequired(crowi, app) , search.api.search);
+
   app.get( '/_api/check_username'     , user.api.checkUsername);
   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) , me.api.uploadPicture);
   app.get( '/_api/user/bookmarks'     , loginRequired(crowi, app) , user.api.bookmarks);
   app.get( '/_api/user/bookmarks'     , loginRequired(crowi, app) , user.api.bookmarks);

+ 62 - 11
lib/routes/page.js

@@ -98,6 +98,66 @@ module.exports = function(crowi, app) {
     });
     });
   };
   };
 
 
+  actions.search = function(req, res) {
+    // spec: ?q=query&sort=sort_order&author=author_filter
+    var query = req.query.q;
+    var search = require('../util/search')(crowi);
+
+    search.searchPageByKeyword(query)
+    .then(function(pages) {
+      debug('pages', pages);
+
+      if (pages.hits.total <= 0) {
+        return Promise.resolve([]);
+      }
+
+      var ids = pages.hits.hits.map(function(page) {
+        return page._id;
+      });
+
+      return Page.findListByPageIds(ids);
+    }).then(function(pages) {
+
+      res.render('page_list', {
+        path: '/',
+        pages: pages,
+        pager: generatePager({offset: 0, limit: 50})
+      });
+    }).catch(function(err) {
+      debug('search error', err);
+    });
+  };
+
+  actions.search = function(req, res) {
+    // spec: ?q=query&sort=sort_order&author=author_filter
+    var query = req.query.q;
+    var search = require('../util/search')(crowi);
+
+    search.searchPageByKeyword(query)
+    .then(function(pages) {
+      debug('pages', pages);
+
+      if (pages.hits.total <= 0) {
+        return Promise.resolve([]);
+      }
+
+      var ids = pages.hits.hits.map(function(page) {
+        return page._id;
+      });
+
+      return Page.findListByPageIds(ids);
+    }).then(function(pages) {
+
+      res.render('page_list', {
+        path: '/',
+        pages: pages,
+        pager: generatePager({offset: 0, limit: 50})
+      });
+    }).catch(function(err) {
+      debug('search error', err);
+    });
+  };
+
   function renderPage(pageData, req, res) {
   function renderPage(pageData, req, res) {
     // create page
     // create page
     if (!pageData) {
     if (!pageData) {
@@ -259,10 +319,7 @@ module.exports = function(crowi, app) {
 
 
       if (data) {
       if (data) {
         previousRevision = data.revision;
         previousRevision = data.revision;
-        // update existing page
-        var newRevision = Revision.prepareRevision(data, body, req.user);
-        updateOrCreate = 'update';
-        return Page.pushRevision(data, newRevision, req.user);
+        return Page.updatePage(data, body, req.user, {grant: grant});
       } else {
       } else {
         // new page
         // new page
         updateOrCreate = 'create';
         updateOrCreate = 'create';
@@ -289,13 +346,7 @@ module.exports = function(crowi, app) {
         }
         }
       }
       }
 
 
-      if (grant != data.grant) {
-        return Page.updateGrant(data, grant, req.user).then(function(data) {
-          return res.redirect(redirectPath);
-        });
-      } else {
-        return res.redirect(redirectPath);
-      }
+      return res.redirect(redirectPath);
     }).catch(function(err) {
     }).catch(function(err) {
       debug('Page create or edit error.', err);
       debug('Page create or edit error.', err);
       if (pageData && !req.form.isValid) {
       if (pageData && !req.form.isValid) {

+ 67 - 0
lib/routes/search.js

@@ -0,0 +1,67 @@
+module.exports = function(crowi, app) {
+  'use strict';
+
+  var debug = require('debug')('crowi:routes:search')
+    , Page = crowi.model('Page')
+    , User = crowi.model('User')
+    , ApiResponse = require('../util/apiResponse')
+
+    , sprintf = require('sprintf')
+
+    , actions = {};
+  var api = actions.api = {};
+
+  actions.searchPage = function(req, res) {
+    var keyword = req.query.q || null;
+    var search = crowi.getSearcher();
+    if (!search) {
+      return res.json(ApiResponse.error('Configuration of ELASTICSEARCH_URI is required.'));
+    }
+
+    return res.render('search', {
+      q: keyword,
+    });
+  };
+
+  /**
+   * @api {get} /search search page
+   * @apiName Search
+   * @apiGroup Search
+   *
+   * @apiParam {String} q keyword
+   * @apiParam {String} path
+   */
+  api.search = function(req, res){
+    var keyword = req.query.q || null;
+    if (keyword === null || keyword === '') {
+      return res.json(ApiResponse.error('keyword should not empty.'));
+    }
+
+    var search = crowi.getSearcher();
+    if (!search) {
+      return res.json(ApiResponse.error('Configuration of ELASTICSEARCH_URI is required.'));
+    }
+
+    var result = {};
+    search.searchKeyword(keyword, {})
+      .then(function(data) {
+        result.meta = data.meta;
+
+        return Page.populatePageListToAnyObjects(data.data);
+      }).then(function(pages) {
+        result.data = pages.filter(function(page) {
+          if (Object.keys(page).length < 12) { // FIXME: 12 is a number of columns.
+            return false;
+          }
+          return true;
+        });
+        return res.json(ApiResponse.success(result));
+      })
+      .catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
+  };
+
+
+  return actions;
+};

+ 434 - 0
lib/util/search.js

@@ -0,0 +1,434 @@
+/**
+ * Search
+ */
+
+var elasticsearch = require('elasticsearch'),
+  debug = require('debug')('crowi:lib:search');
+
+function SearchClient(crowi, esUri) {
+  this.DEFAULT_OFFSET = 0;
+  this.DEFAULT_LIMIT = 50;
+
+  this.esUri = esUri;
+  this.crowi = crowi;
+
+  var uri = this.parseUri(this.esUri);
+  this.host = uri.host;
+  this.index_name = uri.index_name;
+
+  this.client = new elasticsearch.Client({
+    host: this.host,
+    requestTimeout: 5000,
+  });
+
+  this.registerUpdateEvent();
+
+  this.mappingFile = crowi.resourceDir + 'search/mappings.json';
+}
+
+SearchClient.prototype.checkESVersion = function() {
+  // TODO
+};
+
+SearchClient.prototype.registerUpdateEvent = function() {
+  var pageEvent = this.crowi.event('page');
+  pageEvent.on('create', this.syncPageCreated.bind(this))
+  pageEvent.on('update', this.syncPageUpdated.bind(this))
+};
+
+SearchClient.prototype.shouldIndexed = function(page) {
+  // FIXME: Magic Number
+  if (page.grant !== 1) {
+    return false;
+  }
+
+  if (page.redirectTo !== null) {
+    return false;
+  }
+
+  return true;
+};
+
+
+// BONSAI_URL is following format:
+// => https://{ID}:{PASSWORD}@{HOST}
+SearchClient.prototype.parseUri = function(uri) {
+  var index_name = 'crowi';
+  var host = uri;
+  if (m = uri.match(/^(https?:\/\/[^\/]+)\/(.+)$/)) {
+    host = m[1];
+    index_name = m[2];
+  }
+
+  return {
+    host,
+    index_name,
+  };
+};
+
+SearchClient.prototype.buildIndex = function(uri) {
+  return this.client.indices.create({
+    index: this.index_name,
+    body: require(this.mappingFile)
+  });
+};
+
+SearchClient.prototype.deleteIndex = function(uri) {
+  return this.client.indices.delete({
+    index: this.index_name,
+  });
+};
+
+SearchClient.prototype.prepareBodyForUpdate = function(body, page) {
+  if (!Array.isArray(body)) {
+    throw new Error('Body must be an array.');
+  }
+
+  var command = {
+    update: {
+      _index: this.index_name,
+      _type: 'pages',
+      _id: page._id.toString(),
+    }
+  };
+
+  var document = {
+    doc: {
+      path: page.path,
+      body: page.revision.body,
+      comment_count: page.commentCount,
+      bookmark_count: 0, // todo
+      like_count: page.liker.length || 0,
+      updated_at: page.updatedAt,
+    },
+    doc_as_upsert: true,
+  };
+
+  body.push(command);
+  body.push(document);
+};
+
+SearchClient.prototype.prepareBodyForCreate = function(body, page) {
+  if (!Array.isArray(body)) {
+    throw new Error('Body must be an array.');
+  }
+
+  var command = {
+    index: {
+      _index: this.index_name,
+      _type: 'pages',
+      _id: page._id.toString(),
+    }
+  };
+
+  var document = {
+    path: page.path,
+    body: page.revision.body,
+    username: page.creator.username,
+    comment_count: page.commentCount,
+    bookmark_count: 0, // todo
+    like_count: page.liker.length || 0,
+    created_at: page.createdAt,
+    updated_at: page.updatedAt,
+  };
+
+  body.push(command);
+  body.push(document);
+};
+
+SearchClient.prototype.prepareBodyForDelete = function(body, page) {
+  if (!Array.isArray(body)) {
+    throw new Error('Body must be an array.');
+  }
+
+  var command = {
+    delete: {
+      _index: this.index_name,
+      _type: 'pages',
+      _id: page._id.toString(),
+    }
+  };
+
+  body.push(command);
+};
+
+
+SearchClient.prototype.addPages = function(pages)
+{
+  var self = this;
+  var body = [];
+
+  pages.map(function(page) {
+    self.prepareBodyForCreate(body, page);
+  });
+
+  debug('addPages(): Sending Request to ES', body);
+  return this.client.bulk({
+    body: body,
+  });
+};
+
+SearchClient.prototype.updatePages = function(pages)
+{
+  var self = this;
+  var body = [];
+
+  pages.map(function(page) {
+    self.prepareBodyForUpdate(body, page);
+  });
+
+  debug('updatePages(): Sending Request to ES', body);
+  return this.client.bulk({
+    body: body,
+  });
+};
+
+SearchClient.prototype.deletePages = function(pages)
+{
+  var self = this;
+  var body = [];
+
+  pages.map(function(page) {
+    self.prepareBodyForDelete(body, page);
+  });
+
+  debug('deletePages(): Sending Request to ES', body);
+  return this.client.bulk({
+    body: body,
+  });
+};
+
+SearchClient.prototype.addAllPages = function()
+{
+  var self = this;
+  var offset = 0;
+  var Page = this.crowi.model('Page');
+  var stream = Page.getStreamOfFindAll();
+  var body = [];
+
+  return new Promise(function(resolve, reject) {
+    stream.on('data', function (doc) {
+      if (!doc.creator || !doc.revision || !self.shouldIndexed(doc)) {
+        debug('Skipped', doc.path);
+        return ;
+      }
+
+      self.prepareBodyForCreate(body, doc);
+    }).on('error', function (err) {
+      // TODO: handle err
+      debug('Error stream:', err);
+    }).on('close', function () {
+      // all done
+
+      // 最後に送信
+      self.client.bulk({ body: body, })
+      .then(function(res) {
+        debug('Reponse from es:', res);
+        return resolve(res);
+      }).catch(function(err) {
+        debug('Err from es:', err);
+        return reject(err);
+      });
+    });
+  });
+};
+
+/**
+ * search returning type:
+ * {
+ *   meta: { total: Integer, results: Integer},
+ *   data: [ pages ...],
+ * }
+ */
+SearchClient.prototype.search = function(query)
+{
+  var self = this;
+
+  return new Promise(function(resolve, reject) {
+    self.client.search(query)
+    .then(function(data) {
+      var result = {
+        meta: {
+          took: data.took,
+          total: data.hits.total,
+          results: data.hits.hits.length,
+        },
+        data: data.hits.hits.map(function(elm) {
+          return {_id: elm._id, _score: elm._score};
+        })
+      };
+
+      resolve(result);
+    }).catch(function(err) {
+      reject(err);
+    });
+  });
+};
+
+SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option)
+{
+  // getting path by default is almost for debug
+  var fields = ['path', '_id'];
+  if (option) {
+    fields = option.fields || fields;
+  }
+
+  // default is only id field, sorted by updated_at
+  var query = {
+    index: this.index_name,
+    type: 'pages',
+    body: {
+      fields: fields,
+      sort: [{ updated_at: { order: 'desc'}}],
+      query: {}, // query
+    }
+  };
+  this.appendResultSize(query);
+
+  return query;
+};
+
+SearchClient.prototype.createSearchQuerySortedByScore = function(option)
+{
+  var fields = ['path', '_id'];
+  if (option) {
+    fields = option.fields || fields;
+  }
+
+  // sort by score
+  var query = {
+    index: this.index_name,
+    type: 'pages',
+    body: {
+      fields: fields,
+      sort: [ {_score: { order: 'desc'} }],
+      query: {}, // query
+    }
+  };
+  this.appendResultSize(query);
+
+  return query;
+};
+
+SearchClient.prototype.appendResultSize = function(query, from, size)
+{
+  query.from = from || this.DEFAULT_OFFSET;
+  query.size = size || this.DEFAULT_LIMIT;
+};
+
+SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keyword)
+{
+  // query is created by createSearchQuerySortedByScore() or createSearchQuerySortedByUpdatedAt()
+  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 = [];
+  }
+
+  query.body.query.bool.must.push({
+    multi_match: {
+      query: keyword,
+      fields: [
+        "path.ja^2", // ためしに。
+        "body.ja"
+      ],
+      operator: "and"
+    }
+  });
+};
+
+SearchClient.prototype.appendCriteriaForPathFilter = function(query, path)
+{
+  // query is created by createSearchQuerySortedByScore() or createSearchQuerySortedByUpdatedAt()
+  if (!query.body.query.bool) {
+    query.body.query.bool = {};
+  }
+
+  if (!query.body.query.bool.filter || !Array.isArray(query.body.query.bool.filter)) {
+    query.body.query.bool.filter = [];
+  }
+
+  if (path.match(/\/$/)) {
+    path = path.substr(0, path.length - 1);
+  }
+  query.body.query.bool.filter.push({
+    wildcard: {
+      "path.raw": path + "/*"
+    }
+  });
+};
+
+SearchClient.prototype.searchKeyword = function(keyword, option)
+{
+  var from = option.offset || null;
+  var query = this.createSearchQuerySortedByScore();
+  this.appendCriteriaForKeywordContains(query, keyword);
+
+  return this.search(query);
+};
+
+SearchClient.prototype.searchByPath = function(keyword, prefix)
+{
+  // TODO path 名だけから検索
+};
+
+SearchClient.prototype.searchKeywordUnderPath = function(keyword, path, option)
+{
+  var from = option.offset || null;
+  var query = this.createSearchQuerySortedByScore();
+  this.appendCriteriaForKeywordContains(query, keyword);
+  this.appendCriteriaForPathFilter(query, path);
+
+  if (from) {
+    this.appendResultSize(query, from);
+  }
+
+  return this.search(query);
+};
+
+SearchClient.prototype.syncPageCreated = function(page, user)
+{
+  debug('SearchClient.syncPageCreated', page);
+
+  if (!this.shouldIndexed(page)) {
+    return ;
+  }
+
+  this.addPages([page])
+  .then(function(res) {
+    debug('ES Response', res);
+  })
+  .catch(function(err){
+    debug('ES Error', err);
+  });
+};
+
+SearchClient.prototype.syncPageUpdated = function(page, user)
+{
+  debug('SearchClient.syncPageUpdated', page);
+  // TODO delete
+  if (!this.shouldIndexed(page)) {
+    this.deletePages([page])
+    .then(function(res) {
+      debug('deletePages: ES Response', res);
+    })
+    .catch(function(err){
+      debug('deletePages:ES Error', err);
+    });
+
+    return ;
+  }
+
+  this.updatePages([page])
+  .then(function(res) {
+    debug('ES Response', res);
+  })
+  .catch(function(err){
+    debug('ES Error', err);
+  });
+};
+
+
+module.exports = SearchClient;

+ 9 - 2
lib/util/swigFunctions.js

@@ -15,6 +15,13 @@ module.exports = function(crowi, app, locals) {
     return config.crowi['google:clientId'] && config.crowi['google:clientSecret'];
     return config.crowi['google:clientId'] && config.crowi['google:clientSecret'];
   };
   };
 
 
+  locals.searchConfigured = function() {
+    if (crowi.getSearcher()) {
+      return true;
+    }
+    return false;
+  };
+
   locals.slackConfigured = function() {
   locals.slackConfigured = function() {
     var config = crowi.getConfig()
     var config = crowi.getConfig()
     if (Config.hasSlackToken(config)) {
     if (Config.hasSlackToken(config)) {
@@ -36,8 +43,8 @@ module.exports = function(crowi, app, locals) {
     return false;
     return false;
   };
   };
 
 
-  locals.user_page_root = function(user) {
-    if (!user) {
+  locals.userPageRoot = function(user) {
+    if (!user || !user.username) {
       return '';
       return '';
     }
     }
     return '/user/' + user.username;
     return '/user/' + user.username;

+ 1 - 1
lib/views/_form.html

@@ -59,5 +59,5 @@
   </div>
   </div>
   <div class="file-module hidden">
   <div class="file-module hidden">
   </div>
   </div>
-  <script src="/js/crowi-form{% if env  == 'production' %}.min{% endif %}.js"></script>
 </div>
 </div>
+<script src="/js/form{% if env  == 'production' %}.min{% endif %}.js"></script>

+ 1 - 6
lib/views/admin/app.html

@@ -28,12 +28,7 @@
 
 
   <div class="row">
   <div class="row">
     <div class="col-md-3">
     <div class="col-md-3">
-      <ul class="nav nav-pills nav-stacked">
-        <li><a href="/admin"><i class="fa fa-cube"></i> Wiki管理トップ</a></li>
-        <li class="active"><a href="/admin/app"><i class="fa fa-gears"></i> アプリ設定</a></li>
-        <li><a href="/admin/notification"><i class="fa fa-bell"></i> 通知設定</a></li>
-        <li><a href="/admin/users"><i class="fa fa-users"></i> ユーザー管理</a></li>
-      </ul>
+      {% include './widget/menu.html' with {current: 'app'} %}
     </div>
     </div>
     <div class="col-md-9">
     <div class="col-md-9">
 
 

+ 2 - 7
lib/views/admin/index.html

@@ -12,7 +12,7 @@
 
 
 {% block content_main %}
 {% block content_main %}
 <div class="content-main">
 <div class="content-main">
-  
+
   {% set emessage = req.flash('errorMessage') %}
   {% set emessage = req.flash('errorMessage') %}
   {% if emessage.length %}
   {% if emessage.length %}
   <div class="alert alert-danger">
   <div class="alert alert-danger">
@@ -22,12 +22,7 @@
 
 
   <div class="row">
   <div class="row">
     <div class="col-md-3">
     <div class="col-md-3">
-      <ul class="nav nav-pills nav-stacked">
-        <li class="active"><a href="/admin"><i class="fa fa-cube"></i> Wiki管理トップ</a></li>
-        <li><a href="/admin/app"><i class="fa fa-gears"></i> アプリ設定</a></li>
-        <li><a href="/admin/notification"><i class="fa fa-bell"></i> 通知設定</a></li>
-        <li><a href="/admin/users"><i class="fa fa-users"></i> ユーザー管理</a></li>
-      </ul>
+      {% include './widget/menu.html' %}
     </div>
     </div>
     <div class="col-md-9">
     <div class="col-md-9">
       <p>
       <p>

+ 1 - 6
lib/views/admin/notification.html

@@ -14,12 +14,7 @@
 <div class="content-main">
 <div class="content-main">
   <div class="row">
   <div class="row">
     <div class="col-md-3">
     <div class="col-md-3">
-      <ul class="nav nav-pills nav-stacked">
-        <li><a href="/admin"><i class="fa fa-cube"></i> Wiki管理トップ</a></li>
-        <li><a href="/admin/app"><i class="fa fa-gears"></i> アプリ設定</a></li>
-        <li class="active"><a href="/admin/notification"><i class="fa fa-bell"></i> 通知設定</a></li>
-        <li><a href="/admin/users"><i class="fa fa-users"></i> ユーザー管理</a></li>
-      </ul>
+      {% include './widget/menu.html' with {current: 'notification'} %}
     </div>
     </div>
     <div class="col-md-9">
     <div class="col-md-9">
 
 

+ 67 - 0
lib/views/admin/search.html

@@ -0,0 +1,67 @@
+{% extends '../layout/admin.html' %}
+
+{% block html_title %}検索管理 · {{ path }}{% endblock %}
+
+{% block content_head %}
+<div class="header-wrap">
+  <header id="page-header">
+    <h1 class="title" id="">検索管理</h1>
+  </header>
+</div>
+{% endblock %}
+
+{% block content_main %}
+<div class="content-main">
+  <div class="row">
+    <div class="col-md-3">
+      {% include './widget/menu.html' with {current: 'search'} %}
+    </div>
+    <div class="col-md-9">
+
+      {% set smessage = req.flash('successMessage') %}
+      {% if smessage.length %}
+      <div class="alert alert-success">
+        {% for e in smessage %}
+          {{ e }}<br>
+        {% endfor %}
+      </div>
+      {% endif %}
+
+      {% set emessage = req.flash('errorMessage') %}
+      {% if emessage.length %}
+      <div class="alert alert-danger">
+        {% for e in emessage %}
+        {{ e }}<br>
+        {% endfor %}
+      </div>
+      {% endif %}
+
+      <form action="/admin/search/build" method="post" class="form-horizontal" id="appSettingForm" role="form">
+      <fieldset>
+        <legend>Index Build</legend>
+        <div class="form-group">
+          <label for="" class="col-xs-3 control-label">Index Build</label>
+          <div class="col-xs-6">
+            <button type="submit" class="btn btn-primary">Build Now</button>
+            <p class="help-block">
+              Force rebuild index.<br>
+              Click "Build Now" to delete and create mapping file and add all pages.<br>
+              This may take a while.
+            </p>
+          </div>
+        </div>
+      </fieldset>
+      </form>
+
+    </div>
+  </div>
+
+</div>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock content_footer %}
+
+
+
+

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

@@ -28,12 +28,7 @@
 
 
   <div class="row">
   <div class="row">
     <div class="col-md-3">
     <div class="col-md-3">
-      <ul class="nav nav-pills nav-stacked">
-        <li><a href="/admin"><i class="fa fa-cube"></i> Wiki管理トップ</a></li>
-        <li><a href="/admin/app"><i class="fa fa-gears"></i> アプリ設定</a></li>
-        <li><a href="/admin/notification"><i class="fa fa-bell"></i> 通知設定</a></li>
-        <li class="active"><a href="/admin/users"><i class="fa fa-users"></i> ユーザー管理</a></li>
-      </ul>
+      {% include './widget/menu.html' with {current: 'user'} %}
     </div>
     </div>
 
 
     <div class="col-md-9">
     <div class="col-md-9">
@@ -78,7 +73,6 @@
           </div><!-- /.modal-content -->
           </div><!-- /.modal-content -->
         </div><!-- /.modal-dialog -->
         </div><!-- /.modal-dialog -->
       </div><!-- /.modal -->
       </div><!-- /.modal -->
-      <script>$(function() { $('#createdUserModal').modal('show'); });</script>
       {% endif %}
       {% endif %}
 
 
       <h2>ユーザー一覧</h2>
       <h2>ユーザー一覧</h2>

+ 12 - 0
lib/views/admin/widget/menu.html

@@ -0,0 +1,12 @@
+{% if not current %}
+  {% set current = 'index' %}
+{% endif  %}
+<ul class="nav nav-pills nav-stacked">
+  <li class="{% if current == 'index'%}active{% endif %}"><a href="/admin"><i class="fa fa-cube"></i> Wiki管理トップ</a></li>
+  <li class="{% if current == 'app'%}active{% endif %}"><a href="/admin/app"><i class="fa fa-gears"></i> アプリ設定</a></li>
+  <li class="{% if current == 'notification'%}active{% endif %}"><a href="/admin/notification"><i class="fa fa-bell"></i> 通知設定</a></li>
+  <li class="{% if current == 'user'%}active{% endif %}"><a href="/admin/users"><i class="fa fa-users"></i> ユーザー管理</a></li>
+  {% if searchConfigured() %}
+  <li class="{% if current == 'search'%}active{% endif %}"><a href="/admin/search"><i class="fa fa-search"></i> 検索管理</a></li>
+  {% endif %}
+</ul>

+ 13 - 126
lib/views/layout/2column.html

@@ -1,121 +1,16 @@
 {% extends 'layout.html' %}
 {% extends 'layout.html' %}
 
 
-{% block layout_head_nav %}
-<nav class="crowi-header navbar navbar-default" role="navigation">
-  <!-- Brand and toggle get grouped for better mobile display -->
-  <div class="navbar-header">
-    <a class="navbar-brand" href="/">
-      <img alt="Crowi" src="/logo/32x32.png" width="16">
-      {% block title %}{{ config.crowi['app:title'] }}{% endblock %}
-    </a>
-  </div>
-
-  <button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#navbarCollapse">
-    <span class="sr-only">Toggle navigation</span>
-    <span class="icon-bar"></span>
-    <span class="icon-bar"></span>
-    <span class="icon-bar"></span>
-  </button>
-  <!-- Collect the nav links, forms, and other content for toggling -->
-  <div class="collapse navbar-collapse" id="navbarCollapse">
-    {% include '../widget/searcher.html' %}
-
-    <ul class="nav navbar-nav navbar-right">
-
-      {% if user and user.admin %}
-      <li id="">
-        <a href="/admin" id="link-mypage">
-          <i class="fa fa-cube"></i> 管理
-        </a>
-      </li>
-      {% endif %}
-      {#
-      <li id="">
-        <a href="#" id="createPage">
-          <i class="fa fa-plus"> 新規</i>
-        </a>
-      </li>
-      #}
-      {% if user %}
-      {#
-      <li id="" class="notif">
-        <a href="" id="notif-opener">
-          <i class="fa fa-globe"></i> <span class="badge badge-danger">6</span>
-        </a>
-        <script>
-          $('#notif-opener').popover({
-            placement: 'bottom',
-            trigger: 'manual',
-            html: 'true',
-            content: function () {
-              return '<div></div>';
-            }
-          });
-          $('#notif-opener').click(function(e) {
-            $('#notif-opener').popover('show');
-            return false;
-          });
-        </script>
-      </li>
-      #}
-      <li id="login-user">
-        <a href="/user/{{ user.username }}" id="link-mypage">
-          <img src="{{ user|picture }}" class="picture picture-rounded" width="25" /> {{ user.name }}
-        </a>
-      </li>
-      <li><a href="" title="今日のメモを作成" data-target="#createMemo" data-toggle="modal"><i class="fa fa-pencil"></i></a></li>
-      <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="" data-target="#createMemo" data-toggle="modal"><i class="fa fa-pencil"></i> 今日のメモを作成</a></li>
-          <li class="divider"></li>
-          <li><a href="/me"><i class="fa fa-gears"></i> ユーザー設定</a></li>
-          <li class="divider"></li>
-          <li><a href="/logout"><i class="fa fa-sign-out"></i> ログアウト</a></li>
-          {# <li><a href="#">今日の日報を作成</a></li> #}
-          {# <li class="divider"></li> #}
-          {# <li class="divider"></li> #}
-          {# <li><a href="#">ログアウト</a></li> #}
-        </ul>
-      </li>
-      {% else %}
-      <li id="login-user"><a href="/login" id="login"><i class="fa fa-user"></i> Login</a></li>
-      {% endif %}
-      {% if config.crowi['app:confidential'] && config.crowi['app:confidential'] != '' %}
-      <li class="confidential"><a href="#">{{ config.crowi['app:confidential'] }}</a></li>
-      {% endif %}
-    </ul>
-  </div><!-- /.navbar-collapse -->
-</nav>
-{% include '../modal/widget_today_memo.html' %}
-{% endblock  %} {# layout_head_nav #}
-
 {% block layout_sidebar %}
 {% block layout_sidebar %}
 
 
 <a href="" class=" hidden-xs hidden-sm layout-control" id="toggle-sidebar"><i class="fa fa-chevron-right"></i> <span class="hide-on-affix-top"></span></a>
 <a href="" class=" hidden-xs hidden-sm layout-control" id="toggle-sidebar"><i class="fa fa-chevron-right"></i> <span class="hide-on-affix-top"></span></a>
-<script>
-  $(function() {
-    $('#toggle-sidebar').click(function(e) {
-      var $mainContainer = $('.main-container');
-      if ($mainContainer.hasClass('aside-hidden')) {
-        $('.main-container').removeClass('aside-hidden');
-        $.cookie('aside-hidden', 0, { expires: 30, path: '/' });
-      } else {
-        $mainContainer.addClass('aside-hidden');
-        $.cookie('aside-hidden', 1, { expires: 30, path: '/' });
-      }
-      return false;
-    });
-  });
-</script>
 <aside class="sidebar col-md-3 hidden-xs hidden-sm hidden-print">
 <aside class="sidebar col-md-3 hidden-xs hidden-sm hidden-print">
 
 
   {% block side_header %}
   {% block side_header %}
   {% endblock %}
   {% endblock %}
 
 
   <div class="side-content">
   <div class="side-content">
-  {% block side_content %}
-  {% endblock %}
+    {% block side_content %}
+    {% endblock %}
   </div>
   </div>
 
 
   {% block side_footer %}
   {% block side_footer %}
@@ -123,40 +18,32 @@
 
 
   <div id="footer-container" class="footer">
   <div id="footer-container" class="footer">
     <footer class="">
     <footer class="">
-    <p>
-    <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"> ヘルプ</i></a>
-    &copy; {{ now|date('Y') }} {{ config.crowi['app:title'] }} <img src="/logo/100x11_g.png" alt="powered by Crowi"> </p>
+      <p>
+      <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"> ヘルプ</i></a>
+      &copy; {{ now|date('Y') }} {{ config.crowi['app:title'] }} <img src="/logo/100x11_g.png" alt="powered by Crowi"> </p>
     </footer>
     </footer>
   </div>
   </div>
 </aside>
 </aside>
-{% include '../modal/widget_help.html' %}
 
 
-<script>
-  $(function() {
-    if ($.cookie('aside-hidden') == 1) {
-      $('.main-container').addClass('aside-hidden');
-    }
-  });
-</script>
 {% endblock %} {# layout_sidebar #}
 {% endblock %} {# layout_sidebar #}
 
 
 {% block layout_main %}
 {% block layout_main %}
 <div id="main" class="main col-md-9 {% if page %}{{ css.grant(page) }}{% endif %} {% block main_css_class %}{% endblock %}">
 <div id="main" class="main col-md-9 {% if page %}{{ css.grant(page) }}{% endif %} {% block main_css_class %}{% endblock %}">
   {% if page && page.grant != 1 %}
   {% if page && page.grant != 1 %}
   <p class="page-grant">
   <p class="page-grant">
-    <i class="fa fa-lock"></i> {{ consts.pageGrants[page.grant] }} (このページの閲覧は制限されています)
+  <i class="fa fa-lock"></i> {{ consts.pageGrants[page.grant] }} (このページの閲覧は制限されています)
   </p>
   </p>
   {% endif %}
   {% endif %}
   <article>
   <article>
-  {% block content_head %}
-  {% endblock %}
+    {% block content_head %}
+    {% endblock %}
 
 
-  {% block content_main %}
-  //
-  {% endblock content_main %}
+    {% block content_main %}
+    //
+    {% endblock content_main %}
 
 
-  {% block content_footer %}
-  {% endblock %}
+    {% block content_footer %}
+    {% endblock %}
   </article>
   </article>
 </div>
 </div>
 
 

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

@@ -1,6 +1,6 @@
 {% extends '2column.html' %}
 {% extends '2column.html' %}
 
 
 {% block footer %}
 {% block footer %}
-  <script src="/js/crowi-admin{% if env  == 'production' %}.min{% endif %}.js"></script>
+  <script src="/js/admin{% if env  == 'production' %}.min{% endif %}.js"></script>
 {% endblock footer %}
 {% endblock footer %}
 
 

+ 82 - 46
lib/views/layout/layout.html

@@ -12,13 +12,17 @@
   <meta name="viewport" content="width=device-width,initial-scale=1">
   <meta name="viewport" content="width=device-width,initial-scale=1">
 
 
   <link rel="stylesheet" href="/css/crowi{% if env  == 'production' %}.min{% endif %}.css">
   <link rel="stylesheet" href="/css/crowi{% if env  == 'production' %}.min{% endif %}.css">
-  <script src="/js/crowi{% if env  == 'production' %}.min{% endif %}.js"></script>
+  <script src="/js/bundled{% if env  == 'production' %}.min{% endif %}.js"></script>
   <link href='//fonts.googleapis.com/css?family=Open+Sans:400,700' rel='stylesheet' type='text/css'>
   <link href='//fonts.googleapis.com/css?family=Open+Sans:400,700' rel='stylesheet' type='text/css'>
 </head>
 </head>
 {% endblock %}
 {% endblock %}
 
 
 {% block html_body %}
 {% block html_body %}
-<body class="crowi main-container {% block html_base_css %}{% endblock %}">
+<body
+  class="crowi main-container {% block html_base_css %}{% endblock %}"
+  data-me="{{ user._id.toString() }}"
+ {% block html_base_attr %}{% endblock %}
+ >
 <div id="fb-root"></div>
 <div id="fb-root"></div>
 <script>
 <script>
   window.fbAsyncInit = function() {
   window.fbAsyncInit = function() {
@@ -41,11 +45,79 @@
 
 
 {% block layout_head_nav %}
 {% block layout_head_nav %}
 <nav class="crowi-header navbar navbar-default" role="navigation">
 <nav class="crowi-header navbar navbar-default" role="navigation">
+  <!-- Brand and toggle get grouped for better mobile display -->
   <div class="navbar-header">
   <div class="navbar-header">
-    <a class="navbar-brand" href="/">{% block title %}{{ config.crowi['app.title']|default('Crowi') }}{% endblock %}</a>
+    <a class="navbar-brand" href="/">
+      <img alt="Crowi" src="/logo/32x32.png" width="16">
+      {% block title %}{{ config.crowi['app:title'] }}{% endblock %}
+    </a>
+  {% if searchConfigured() %}
+  <div class="navbar-form navbar-left search-top" role="search" id="search-top">
   </div>
   </div>
+  {% endif %}
+  </div>
+
 
 
-  <div class="collapse navbar-collapse">
+  <button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#navbarCollapse">
+    <span class="sr-only">Toggle navigation</span>
+    <span class="icon-bar"></span>
+    <span class="icon-bar"></span>
+    <span class="icon-bar"></span>
+  </button>
+  <!-- Collect the nav links, forms, and other content for toggling -->
+  <div class="collapse navbar-collapse" id="navbarCollapse">
+
+    <ul class="nav navbar-nav navbar-right">
+
+      {% if user and user.admin %}
+      <li id="">
+        <a href="/admin" id="link-mypage">
+          <i class="fa fa-cube"></i> 管理
+        </a>
+      </li>
+      {% endif %}
+      {#
+      <li id="">
+        <a href="#" id="createPage">
+          <i class="fa fa-plus"> 新規</i>
+        </a>
+      </li>
+      #}
+      {% if user %}
+      {#
+      <li id="" class="notif">
+        <a href="" id="notif-opener">
+          <i class="fa fa-globe"></i> <span class="badge badge-danger">6</span>
+        </a>
+      </li>
+      #}
+      <li id="login-user">
+        <a href="/user/{{ user.username }}" id="link-mypage">
+          <img src="{{ user|picture }}" class="picture picture-rounded" width="25" /> {{ user.name }}
+        </a>
+      </li>
+      <li><a href="" title="今日のメモを作成" data-target="#createMemo" data-toggle="modal"><i class="fa fa-pencil"></i></a></li>
+      <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="" data-target="#createMemo" data-toggle="modal"><i class="fa fa-pencil"></i> 今日のメモを作成</a></li>
+          <li class="divider"></li>
+          <li><a href="/me"><i class="fa fa-gears"></i> ユーザー設定</a></li>
+          <li class="divider"></li>
+          <li><a href="/logout"><i class="fa fa-sign-out"></i> ログアウト</a></li>
+          {# <li><a href="#">今日の日報を作成</a></li> #}
+          {# <li class="divider"></li> #}
+          {# <li class="divider"></li> #}
+          {# <li><a href="#">ログアウト</a></li> #}
+        </ul>
+      </li>
+      {% else %}
+      <li id="login-user"><a href="/login" id="login"><i class="fa fa-user"></i> Login</a></li>
+      {% endif %}
+      {% if config.crowi['app:confidential'] && config.crowi['app:confidential'] != '' %}
+      <li class="confidential"><a href="#">{{ config.crowi['app:confidential'] }}</a></li>
+      {% endif %}
+    </ul>
   </div><!-- /.navbar-collapse -->
   </div><!-- /.navbar-collapse -->
 </nav>
 </nav>
 {% include '../modal/widget_today_memo.html' %}
 {% include '../modal/widget_today_memo.html' %}
@@ -54,47 +126,11 @@
 <div class="container-fluid">
 <div class="container-fluid">
   <div class="row">
   <div class="row">
 
 
-{% block layout_sidebar %}
-<aside class="sidebar col-md-3">
-    <div class="side-content">
-    {% block side_header %}
-    {% endblock %}
-
-    {% block side_content %}
-    {% endblock %}
-
-    {% block side_footer %}
-    {% endblock %}
-    </div>
-  </aside>
-</div>
-{% endblock %} {# layout_sidebar #}
-
-{% block layout_main %}
-<div id="main" class="main col-md-9" ng-controller="WikiPageController">
-  <article>
-  {% block content_head %}
-    <header>
-    <h2>-</h2>
-    <p>-</p>
-    </header>
-  {% endblock %}
-
-  {% block content_main %}
-  //
-  {% endblock content_main %}
-
-  {% block content_footer %}
-    <footer>
-    <h3>-</h3>
-    <p>-</p>
-    </footer>
-  {% endblock content_footer %}
-  </article>
-</div>
-{% endblock %} {# layout_main #}
-
+  {% block layout_sidebar %}
+  {% endblock %} {# layout_sidebar #}
 
 
+  {% block layout_main %}
+  {% endblock %} {# layout_main #}
 
 
 {% block footer %}
 {% block footer %}
 {% endblock %}
 {% endblock %}
@@ -108,7 +144,7 @@
 </body>
 </body>
 {% endblock %}
 {% endblock %}
 
 
+<script src="/js/app{% if env  == 'production' %}.min{% endif %}.js"></script>
+<script src="/js/crowi{% if env  == 'production' %}.min{% endif %}.js"></script>
 </html>
 </html>
 
 
-
-

+ 27 - 0
lib/views/layout/single.html

@@ -1 +1,28 @@
 {% extends 'layout.html' %}
 {% extends 'layout.html' %}
+
+{% block layout_main %}
+<div id="main" class="main col-md-12">
+  <article>
+    {% block content_head %}
+    {% endblock %}
+
+    {% block content_main %}
+    {% endblock content_main %}
+
+    {% block content_footer %}
+    {% endblock %}
+  </article>
+</div>
+
+{% endblock %} {# layout_main #}
+
+{% block footer %}
+<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>
+    &copy; {{ now|date('Y') }} {{ config.crowi['app:title'] }} <img src="/logo/100x11_g.png" alt="powered by Crowi"> </p>
+  </footer>
+</div>
+{% include '../modal/widget_help.html' %}
+{% endblock %}

+ 2 - 69
lib/views/login.html

@@ -74,7 +74,7 @@
     </div>
     </div>
 
 
     {% if config.crowi['security:registrationMode'] != 'Closed' %}
     {% if config.crowi['security:registrationMode'] != 'Closed' %}
-    <p class="bottom-text"><a href="#register" onclick="$('#login-dialog').addClass('to-flip'); return false;"><i class="fa fa-pencil"></i> 新規登録はこちら</a></p>
+    <p class="bottom-text"><a href="#register" id="register"><i class="fa fa-pencil"></i> 新規登録はこちら</a></p>
     {% endif %}
     {% endif %}
   </div>
   </div>
 
 
@@ -187,7 +187,7 @@
       {% endif %}
       {% endif %}
     </div>
     </div>
 
 
-    <p class="bottom-text"><a href="#login" onclick="$('#login-dialog').removeClass('to-flip'); return false;"><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> ログインはこちら</a></p>
   </div>
   </div>
   {% endif %} {# if registrationMode == Closed #}
   {% endif %} {# if registrationMode == Closed #}
 
 
@@ -201,71 +201,4 @@
 </div>
 </div>
 #}
 #}
 
 
-<script>
-$(function() {
-  $('#btn-login-facebook').click(function(e)
-  {
-    var afterLogin = function(response) {
-      if (response.status !== 'connected') {
-        $('#login-form-errors').html('<p class="alert alert-danger">Facebookでのログインに失敗しました。</p>');
-      } else {
-        location.href = '/login/facebook';
-      }
-    };
-    FB.getLoginStatus(function(response) {
-      if (response.status === 'connected') {
-        afterLogin(response);
-      } else {
-        FB.login(function(response) {
-          afterLogin(response);
-        }, {scope: 'email'});
-      }
-    });
-  });
-
-  $('#register-form input[name="registerForm[username]"]').change(function(e) {
-    var username = $(this).val();
-    $('#input-group-username').removeClass('has-error');
-    $('#help-block-username').html("");
-
-    $.getJSON('/_api/check_username', {username: username}, function(json) {
-      if (!json.valid) {
-        $('#help-block-username').html('<i class="fa fa-warning"></i>このユーザーIDは利用できません。<br>');
-        $('#input-group-username').addClass('has-error');
-      }
-    });
-  });
-
-  $('#btn-register-facebook').click(function(e)
-  {
-    var afterLogin = function(response) {
-      if (response.status !== 'connected') {
-        $('#register-form-errors').html('<p class="alert alert-danger">Facebookでのログインに失敗しました。</p>');
-
-      } else {
-        var authR = response.authResponse;
-        $('#register-form input[name="registerForm[fbId]"]').val(authR.userID);
-        FB.api('/me?fields=name,username,email', function(res) {
-          $('#register-form input[name="registerForm[name]"]').val(res.name);
-          $('#register-form input[name="registerForm[username]"]').val(res.username || '');
-          $('#register-form input[name="registerForm[email]"]').val(res.email);
-
-          $('#register-form .facebook-info').remove();
-          $('#register-form').prepend('<div class="facebook-info"><img src="//graph.facebook.com/' + res.id + '/picture?size=square" width="25"> <i class="fa fa-facebook-square"></i> ' + res.name + 'さんとして登録します</div>');
-        });
-      }
-    };
-    FB.getLoginStatus(function(response) {
-      if (response.status === 'connected') {
-        afterLogin(response);
-      } else {
-        FB.login(function(response) {
-          afterLogin(response);
-        }, {scope: 'email'});
-      }
-    });
-  });
-});
-</script>
-
 {% endblock %}
 {% endblock %}

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

@@ -5,7 +5,7 @@
 {% block content_head %}
 {% block content_head %}
 <div class="header-wrap">
 <div class="header-wrap">
   <header id="page-header">
   <header id="page-header">
-  <h1 class="title" id="">ユーザー設定</h1>
+    <h1 class="title" id="">ユーザー設定</h1>
   </header>
   </header>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 3 - 3
lib/views/modal/widget_today_memo.html

@@ -9,13 +9,13 @@
         <h4 class="modal-title">新規メモを作成</h4>
         <h4 class="modal-title">新規メモを作成</h4>
       </div>
       </div>
       <div class="modal-body">
       <div class="modal-body">
-        <p><a href="{{ user_page_root(user) }}/メモ/"><i class="fa fa-list-ul"></i> 自分のメモ一覧を見る</a></p>
+        <p><a href="{{ userPageRoot(user) }}/メモ/"><i class="fa fa-list-ul"></i> 自分のメモ一覧を見る</a></p>
 
 
           <div class="input-group">
           <div class="input-group">
-            <span class="input-group-addon">{{ user_page_root(user) }}/メモ/{{ now|datetz('Y/m/d') }}/</span>
+            <span class="input-group-addon">{{ userPageRoot(user) }}/メモ/{{ now|datetz('Y/m/d') }}/</span>
             <input type="text" class="form-control" id="memoName" name="memoName" placeholder="メモ名を入力">
             <input type="text" class="form-control" id="memoName" name="memoName" placeholder="メモ名を入力">
           </div>
           </div>
-          <input type="hidden" class="form-control" name="memoNamePrefix" value="{{ user_page_root(user) }}/メモ/{{ now|datetz('Y/m/d') }}/">
+          <input type="hidden" class="form-control" name="memoNamePrefix" value="{{ userPageRoot(user) }}/メモ/{{ now|datetz('Y/m/d') }}/">
       </div>
       </div>
       <div class="modal-footer">
       <div class="modal-footer">
         <button type="button" class="btn btn-default" data-dismiss="modal">キャンセル</button>
         <button type="button" class="btn btn-default" data-dismiss="modal">キャンセル</button>

+ 0 - 36
lib/views/page.html

@@ -157,19 +157,6 @@
   {% endif %}
   {% 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>さんがこのページを編集しました。 <a href="javascript:location.reload();"><i class="fa fa-angle-double-right"></i> 最新版を読み込む</a></div>
-<script>
-  $(function() {
-    var me = {{ user|json|safe }};
-    var socket = io();
-    socket.on('page edited', function (data) {
-      if (data.user._id != me._id
-        && data.page.path == {{ page.path|json|safe }}) {
-        $('#notifPageEdited').show();
-        $('#notifPageEdited .edited-user').html(data.user.name);
-      }
-    });
-  });
-</script>
 </div>
 </div>
 
 
 {% block content_main_after %}
 {% block content_main_after %}
@@ -211,27 +198,4 @@
   <div id="presentation-layer" class="fullscreen-layer">
   <div id="presentation-layer" class="fullscreen-layer">
     <div id="presentation-container"></div>
     <div id="presentation-container"></div>
   </div>
   </div>
-  <script>
-    $(function() {
-      var presentaionInitialized = false
-        , $b = $('body');
-
-      $(document).on('click', '.toggle-presentation', function(e) {
-        var $a = $(this);
-
-        e.preventDefault();
-        $b.toggleClass('overlay-on');
-
-        if (!presentaionInitialized) {
-          presentaionInitialized = true;
-
-          $('<iframe />').attr({
-            src: $a.attr('href')
-          }).appendTo($('#presentation-container'));
-        }
-      }).on('click', '.fullscreen-layer', function() {
-        $b.toggleClass('overlay-on');
-      });
-    });
-  </script>
 {% endblock %}
 {% endblock %}

+ 14 - 26
lib/views/page_list.html

@@ -13,17 +13,25 @@
       <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
       <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
 
 
     {% endif %}
     {% endif %}
-    <h1 class="title" id="revision-path">
-      {{ path|insertSpaceToEachSlashes }}
+    <h1 class="title">
+      <span class="" id="revision-path">{{ path|insertSpaceToEachSlashes }}</span>
+      {#
+      {% if searchConfigured() && path != '/' %}
+      <div class="form-group form-group-sm has-feedback search-input-group" data-toggle="tooltip" data-placement="bottom" title="{{ path }} 以下から検索">
+        <label class="control-label sr-only" for="inputSuccess5">Search</label>
+        <input
+        type="text" class="search-listpage-input form-control" data-path="{{ path }}"
+        >
+        <i class="form-control-feedback search-listpage-icon fa fa-search"></i>
+      </div>
+      {% endif %}
+      #}
     </h1>
     </h1>
   </header>
   </header>
 </div>
 </div>
 
 
 {% endblock %}
 {% endblock %}
 
 
-{% block content_head_after %}
-{% endblock %}
-
 {% block content_main %}
 {% block content_main %}
 
 
 {% block content_main_before %}
 {% block content_main_before %}
@@ -70,13 +78,7 @@
     <div class="wiki tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body-content">{{ page.revision.body|nl2br|safe }}</div>
     <div class="wiki tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body-content">{{ page.revision.body|nl2br|safe }}</div>
 
 
     <script type="text/template" id="raw-text-original">{{ page.revision.body }}</script>
     <script type="text/template" id="raw-text-original">{{ page.revision.body }}</script>
-    <script type="text/javascript">
-      $(function(){
-          var renderer = new Crowi.renderer($('#raw-text-original').html());
-          renderer.render();
-          Crowi.correctHeaders('#revision-body-content');
-      });
-    </script>
+
     <div class="tab-pane edit-form portal-form {% if req.body.pageForm %}active{% endif %}" id="edit-form">
     <div class="tab-pane edit-form portal-form {% if req.body.pageForm %}active{% endif %}" id="edit-form">
       {% include '_form.html' with {forceGrant: 1} %}
       {% include '_form.html' with {forceGrant: 1} %}
     </div>
     </div>
@@ -128,20 +130,6 @@
   </div>
   </div>
 </div>
 </div>
 
 
-  <script type="text/javascript">
-    $(function(){
-        $('#view-timeline .timeline-body').each(function()
-        {
-          var id = $(this).attr('id');
-          var contentId = '#' + id + ' > script';
-          var revisionBody = '#' + id + ' .revision-body';
-          var revisionPath = '#' + id + ' .revision-path';
-          var renderer = new Crowi.renderer($(contentId).html(), $(revisionBody));
-          renderer.render();
-        });
-        //$('.tooltip .tabs').tabs();
-    });
-  </script>
 
 
 </div> {# /.content-main #}
 </div> {# /.content-main #}
 {% include 'modal/widget_what_is_portal.html' %}
 {% include 'modal/widget_what_is_portal.html' %}

+ 2 - 2
lib/views/page_presentation.html

@@ -8,7 +8,7 @@
 
 
 
 
     <link rel="stylesheet" type="text/css" href="/css/crowi-reveal{% if env  == 'production' %}.min{% endif %}.css">
     <link rel="stylesheet" type="text/css" href="/css/crowi-reveal{% if env  == 'production' %}.min{% endif %}.css">
-    <link rel="stylesheet" type="text/css" href="/js/reveal.js/lib/css/zenburn.css">
+    <link rel="stylesheet" type="text/css" href="/js/reveal/lib/css/zenburn.css">
 
 
     <title>{{ path|path2name }} | {{ path }}</title>
     <title>{{ path|path2name }} | {{ path }}</title>
   </head>
   </head>
@@ -28,6 +28,6 @@
     </div>
     </div>
 
 
     <script src="/js/crowi{% if env  == 'production' %}.min{% endif %}.js"></script>
     <script src="/js/crowi{% if env  == 'production' %}.min{% endif %}.js"></script>
-    <script src="/js/crowi-reveal{% if env  == 'production' %}.min{% endif %}.js"></script>
+    <script src="/js/presentation{% if env  == 'production' %}.min{% endif %}.js"></script>
   </body>
   </body>
 </html>
 </html>

+ 16 - 0
lib/views/search.html

@@ -0,0 +1,16 @@
+{% extends 'layout/single.html' %}
+
+{% block main_css_class %}search-page{% endblock %}
+{% block html_base_attr %}
+  data-spy="scroll"
+  data-target="#search-result-list"
+{% endblock %}
+
+{% block html_title %}Search {% endblock %}
+
+{% block content_main %}
+
+<div class="" id="search-page">
+</div>
+
+{% endblock %}

+ 15 - 5
lib/views/widget/page_list.html

@@ -7,24 +7,34 @@
   {% set page = data %}
   {% set page = data %}
 {% endif %}
 {% endif %}
 
 
-<li class="page-list-li">
+<li>
   <img src="{{ page.revision.author|picture }}" class="picture picture-rounded">
   <img src="{{ page.revision.author|picture }}" class="picture picture-rounded">
-
-  <a class="page-list-link" href="{{ page.path }}"
+  <a href="{{ page.path }}"
+    class="page-list-link"
     data-path="{{ page.path }}"
     data-path="{{ page.path }}"
-    data-short-path="{{ page.path|path2name }}">{{ page.path }}</a>
-
+    data-short-path="{{ page.path|path2name }}">{{ page.path }}
+  </a>
   <span class="page-list-meta">
   <span class="page-list-meta">
     {% if page.isPortal() %}
     {% if page.isPortal() %}
       <span class="label label-info">PORTAL</span>
       <span class="label label-info">PORTAL</span>
     {% endif  %}
     {% endif  %}
 
 
     {% if page.commentCount > 0 %}
     {% if page.commentCount > 0 %}
+    <span>
       <i class="fa fa-comment"></i>{{ page.commentCount }}
       <i class="fa fa-comment"></i>{{ page.commentCount }}
+    </span>
+    {% endif  %}
+
+    {% if page.liker.length > 0 %}
+    <span>
+      <i class="fa fa-thumbs-up"></i>{{ page.liker.length }}
+    </span>
     {% endif  %}
     {% endif  %}
 
 
     {% if !page.isPublic() %}
     {% if !page.isPublic() %}
+    <span>
       <i class="fa fa-lock"></i>
       <i class="fa fa-lock"></i>
+    </span>
     {% endif %}
     {% endif %}
   </span>
   </span>
 </li>
 </li>

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

@@ -12,7 +12,7 @@
 
 
 <h3><i class="fa fa-comment"></i> Comments</h3>
 <h3><i class="fa fa-comment"></i> Comments</h3>
 <div class="page-comments">
 <div class="page-comments">
-  <form class="form page-comment-form" id="page-comment-form">
+  <form class="form page-comment-form" id="page-comment-form" onsubmit="return false;">
     <div class="comment-form">
     <div class="comment-form">
       <div class="comment-form-main">
       <div class="comment-form-main">
         <div class="comment-write" id="comment-write">
         <div class="comment-write" id="comment-write">
@@ -22,7 +22,7 @@
           <input type="hidden" name="commentForm[page_id]" value="{{ page._id.toString() }}">
           <input type="hidden" name="commentForm[page_id]" value="{{ page._id.toString() }}">
           <input type="hidden" name="commentForm[revision_id]" value="{{ revision._id.toString() }}">
           <input type="hidden" name="commentForm[revision_id]" value="{{ revision._id.toString() }}">
           <span class="text-danger" id="comment-form-message"></span>
           <span class="text-danger" id="comment-form-message"></span>
-          <input type="submit" id="commenf-form-button" value="Comment" class="btn btn-primary btn-sm form-inline">
+          <input type="submit" id="comment-form-button" value="Comment" class="btn btn-primary btn-sm form-inline">
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>

+ 4 - 10
lib/views/widget/page_side_header.html

@@ -3,11 +3,13 @@
   <div class="row">
   <div class="row">
     {# default(author) としているのは、v1.1.1 以前に page.creator データが入ってないから。暫定として最新更新ユーザーを表示しちゃう。 #}
     {# default(author) としているのは、v1.1.1 以前に page.creator データが入ってないから。暫定として最新更新ユーザーを表示しちゃう。 #}
     <div class="col-md-3 creator-picture">
     <div class="col-md-3 creator-picture">
-      <img src="{{ page.creator|default(author)|picture }}" class="picture picture-lg picture-rounded"><br>
+      <a href="{{ userPageRoot(page.creator) }}">
+        <img src="{{ page.creator|default(author)|picture }}" class="picture picture-lg picture-rounded"><br>
+      </a>
     </div>
     </div>
     <div class="col-md-9">
     <div class="col-md-9">
       <p class="creator">
       <p class="creator">
-        {{ page.creator.name|default(author.name) }}
+        <a href="{{ userPageRoot(page.creator) }}">{{ page.creator.name|default(author.name) }}</a>
       </p>
       </p>
       <p class="created-at">
       <p class="created-at">
         作成日: {{ page.createdAt|datetz('Y/m/d H:i:s') }}<br>
         作成日: {{ page.createdAt|datetz('Y/m/d H:i:s') }}<br>
@@ -39,14 +41,6 @@
           {{ page.seenUsers.length }}
           {{ page.seenUsers.length }}
         </p>
         </p>
         <p id="seen-user-list" class="seen-user-list" data-seen-users="{{ page.seenUsers|default([])|join(',') }}">
         <p id="seen-user-list" class="seen-user-list" data-seen-users="{{ page.seenUsers|default([])|join(',') }}">
-        {#
-          {% for seenUser in page.seenUsers %}
-          <a href="{{ user_page_root(seenUser) }}" title="{{ seenUser.name }}"><img alt="{{ seenUser.name }}" src="{{ seenUser|picture }}" class="picture picture-xs picture-rounded"></a>
-          {% endfor %}
-          {% if page.seenUsers.length > 10 %}
-            (...)
-          {% endif %}
-        #}
         </p>
         </p>
       </dd>
       </dd>
     </dl>
     </dl>

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

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

+ 18 - 3
package.json

@@ -30,6 +30,11 @@
   "dependencies": {
   "dependencies": {
     "async": "~1.5.0",
     "async": "~1.5.0",
     "aws-sdk": "~2.2.26",
     "aws-sdk": "~2.2.26",
+    "axios": "0.9.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",
     "basic-auth-connect": "~1.0.0",
     "bluebird": "~3.0.5",
     "bluebird": "~3.0.5",
     "body-parser": "~1.14.1",
     "body-parser": "~1.14.1",
@@ -37,12 +42,16 @@
     "botkit": "~0.1.1",
     "botkit": "~0.1.1",
     "browserify": "~12.0.1",
     "browserify": "~12.0.1",
     "cli": "~0.6.0",
     "cli": "~0.6.0",
+    "colors": "^1.1.2",
+    "commander": "~2.9.0",
     "connect-flash": "~0.1.1",
     "connect-flash": "~0.1.1",
     "connect-redis": "~2.1.0",
     "connect-redis": "~2.1.0",
     "consolidate": "~0.11.0",
     "consolidate": "~0.11.0",
     "cookie-parser": "~1.3.4",
     "cookie-parser": "~1.3.4",
     "debug": "~2.2.0",
     "debug": "~2.2.0",
-    "diff": "^2.2.2",
+    "del": "~2.2.0",
+    "diff": "~2.2.2",
+    "elasticsearch": "~11.0.1",
     "errorhandler": "~1.3.4",
     "errorhandler": "~1.3.4",
     "express": "~4.13.3",
     "express": "~4.13.3",
     "express-form": "~0.12.0",
     "express-form": "~0.12.0",
@@ -67,13 +76,16 @@
     "kerberos": "0.0.17",
     "kerberos": "0.0.17",
     "marked": "~0.3.5",
     "marked": "~0.3.5",
     "method-override": "~2.3.1",
     "method-override": "~2.3.1",
-    "mkdirp": "^0.5.1",
+    "mkdirp": "~0.5.1",
+    "moment": "~2.13.0",
     "mongoose": "4.2.5",
     "mongoose": "4.2.5",
     "mongoose-paginate": "4.2.0",
     "mongoose-paginate": "4.2.0",
     "morgan": "~1.5.1",
     "morgan": "~1.5.1",
     "multer": "~0.1.8",
     "multer": "~0.1.8",
     "nodemailer": "~1.2.2",
     "nodemailer": "~1.2.2",
     "nodemailer-ses-transport": "~1.1.0",
     "nodemailer-ses-transport": "~1.1.0",
+    "react": "~15.0.1",
+    "react-dom": "~15.0.1",
     "redis": "~0.12.1",
     "redis": "~0.12.1",
     "reveal.js": "~3.2.0",
     "reveal.js": "~3.2.0",
     "socket.io": "~1.3.0",
     "socket.io": "~1.3.0",
@@ -81,7 +93,9 @@
     "sprintf": "~0.1.5",
     "sprintf": "~0.1.5",
     "swig": "~1.4.0",
     "swig": "~1.4.0",
     "time": "~0.11.0",
     "time": "~0.11.0",
-    "vinyl-source-stream": "~1.1.0"
+    "vinyl-source-stream": "~1.1.0",
+    "webpack": "~1.13.0",
+    "webpack-stream": "~3.1.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "chai": "~1.10.0",
     "chai": "~1.10.0",
@@ -95,6 +109,7 @@
     "start": "node app.js",
     "start": "node app.js",
     "test": "gulp test",
     "test": "gulp test",
     "build": "gulp",
     "build": "gulp",
+    "webpack": "webpack",
     "postinstall": "gulp"
     "postinstall": "gulp"
   },
   },
   "env": {
   "env": {

+ 0 - 0
public/js/reveal.js → public/js/reveal


+ 12 - 3
resource/css/_page_list.scss

@@ -25,17 +25,23 @@
   .page-list-ul {
   .page-list-ul {
     padding-left: 0;
     padding-left: 0;
 
 
-    .page-list-li {
+    > li {
       list-style: none;
       list-style: none;
       line-height: 1.8em;
       line-height: 1.8em;
 
 
+      .page-list-option {
+        float: right;
+        margin-left: 4px;
+      }
+
       .picture {
       .picture {
         width: 16px;
         width: 16px;
         height: 16px;
         height: 16px;
-        margin-right: 4px;
       }
       }
 
 
-      .page-list-link {
+      > a {
+        display: inline;
+        padding: 0 4px;
         font-size: 1.1em;
         font-size: 1.1em;
         color: #666;
         color: #666;
 
 
@@ -43,6 +49,9 @@
           color: #333;
           color: #333;
         }
         }
       }
       }
+
+      > span.page-list-meta {
+      }
     }
     }
   }
   }
 }
 }

+ 74 - 0
resource/css/_search.scss

@@ -0,0 +1,74 @@
+.search-listpage-icon {
+  font-size: 16px;
+  color: #999;
+}
+
+.search-input-group {
+  display: inline-block;
+  margin-bottom: 0;
+  width: 200px;
+  vertical-align: bottom;
+}
+
+.search-listpage-input {
+}
+
+.search-top {
+  .search-top-input-group {
+    .search-top-input {
+    }
+    &:focus {
+      width: 400px;
+      transition: .3s ease;
+    }
+  }
+}
+
+.search-suggest {
+  // => dicided by JS
+  // top: 43px;
+  // left: 125px;
+  //display: none;
+  position: absolute;
+  width: 500px;
+  background: #fff;
+  border: solid 1px #ccc;
+  box-shadow: 0 0 1px rgba(0,0,0,.3);
+  padding: 16px;
+
+
+  .searching {
+    color: #666;
+  }
+}
+
+
+.search-result {
+
+  .search-result-list {
+    nav {
+      &.affix {
+        top: 8px;
+        width: 33.33333%;
+        padding-right: 30px;
+        padding-bottom: 50px;
+        height: 100%;
+        overflow-y: scroll;
+      }
+      .nav {
+
+        > li {
+          padding: 0px 11px 0 8px;
+          &.active {
+            padding: 0px 8px;
+            border-right: solid 3px #666;
+            background: #f0f0f0;
+          }
+        }
+      }
+    }
+  }
+
+  .search-result-content {
+  }
+}

+ 1 - 0
resource/css/crowi.scss

@@ -16,6 +16,7 @@
 @import 'comment';
 @import 'comment';
 @import 'user';
 @import 'user';
 @import 'portal';
 @import 'portal';
+@import 'search';
 
 
 
 
 ul {
 ul {

+ 33 - 0
resource/js/app.js

@@ -0,0 +1,33 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import SearchBox  from './components/Header/SearchBox';
+import SearchPage  from './components/Search/SearchPage';
+
+/*
+class Crowi extends React.Component {
+  constructor(props) {
+    super(props);
+    //this.state = {count: props.initialCount};
+    //this.tick = this.tick.bind(this);
+  }
+
+  render() {
+    return (
+      <h1>Hello</h1>
+    );
+  }
+}
+*/
+
+var componentMappings = {
+  'search-top': <SearchBox />,
+  'search-page': <SearchPage />,
+};
+
+Object.keys(componentMappings).forEach((key) => {
+  var elem = document.getElementById(key);
+  if (elem) {
+    ReactDOM.render(componentMappings[key], elem);
+  }
+});

+ 79 - 0
resource/js/components/Header/SearchBox.js

@@ -0,0 +1,79 @@
+// This is the root component for #search-top
+
+import React from 'react';
+
+import SearchForm from './SearchForm';
+import SearchSuggest from './SearchSuggest';
+import axios from 'axios'
+
+export default class SearchBox extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      searchingKeyword: '',
+      searchedPages: [],
+      searchError: null,
+      searching: false,
+    }
+
+    this.search = this.search.bind(this);
+  }
+
+  search(data) {
+    const keyword = data.keyword;
+    if (keyword === '') {
+      this.setState({
+        searchingKeyword: '',
+        searchedPages: [],
+      });
+
+      return true;
+    }
+
+    this.setState({
+      searchingKeyword: keyword,
+      searching: true,
+    });
+
+    axios.get('/_api/search', {params: {q: keyword}})
+    .then((res) => {
+      if (res.data.ok) {
+        this.setState({
+          searchingKeyword: keyword,
+          searchedPages: res.data.data,
+          searching: false,
+        });
+      }
+      // TODO error
+    }).catch((res) => {
+      // TODO error
+      this.setState({
+        searchError: res,
+        searching: false,
+      });
+    });
+  }
+
+  render() {
+    return (
+      <div className="search-box">
+        <SearchForm onSearchFormChanged={this.search} />
+        <SearchSuggest
+          searchingKeyword={this.state.searchingKeyword}
+          searchedPages={this.state.searchedPages}
+          searchError={this.state.searchError}
+          searching={this.state.searching}
+          />
+      </div>
+    );
+  }
+}
+
+SearchBox.propTypes = {
+  //pollInterval: React.PropTypes.number,
+};
+SearchBox.defaultProps = {
+  //pollInterval: 1000,
+};

+ 80 - 0
resource/js/components/Header/SearchForm.js

@@ -0,0 +1,80 @@
+import React from 'react';
+
+// Header.SearchForm
+export default class SearchForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      keyword: '',
+      searchedKeyword: '',
+    };
+
+    this.handleChange = this.handleChange.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+    this.ticker = null;
+  }
+
+  componentDidMount() {
+    this.ticker = setInterval(this.searchFieldTicker.bind(this), this.props.pollInterval);
+  }
+
+  componentWillUnmount() {
+    clearInterval(this.ticker);
+  }
+
+  search() {
+    if (this.state.searchedKeyword != this.state.keyword) {
+      this.props.onSearchFormChanged({keyword: this.state.keyword});
+      this.setState({searchedKeyword: this.state.keyword});
+    }
+  }
+
+  searchFieldTicker() {
+    this.search();
+  }
+
+  handleSubmit(event) {
+    event.preventDefault();
+    this.search();
+  }
+
+  handleChange(event) {
+    const keyword = event.target.value;
+    this.setState({keyword});
+  }
+
+  render() {
+    return (
+      <form
+        action="/_search"
+        className="search-form form-group input-group search-top-input-group"
+        onSubmit={this.handleSubmit}
+      >
+        <input
+          autocomplete="off"
+          type="text"
+          className="search-top-input form-control"
+          placeholder="Search ..."
+          name="q"
+          value={this.state.keyword}
+          onChange={this.handleChange}
+        />
+        <span className="input-group-btn">
+          <button type="submit" className="btn btn-default">
+            <i className="search-top-icon fa fa-search"></i>
+          </button>
+        </span>
+      </form>
+    );
+  }
+}
+
+SearchForm.propTypes = {
+  onSearchFormChanged: React.PropTypes.func.isRequired,
+  pollInterval: React.PropTypes.number,
+};
+SearchForm.defaultProps = {
+  pollInterval: 1000,
+};

+ 47 - 0
resource/js/components/Header/SearchSuggest.js

@@ -0,0 +1,47 @@
+import React from 'react';
+
+import ListView from '../PageList/ListView';
+
+export default class SearchSuggest extends React.Component {
+
+  render() {
+    if (this.props.searching) {
+      return (
+        <div className="search-suggest" id="search-suggest">
+          <i className="searcing fa fa-circle-o-notch fa-spin fa-fw"></i> Searching ...
+        </div>
+      );
+    }
+
+    if (this.props.searchedPages.length < 1) {
+      if (this.props.searchingKeyword !== '') {
+        return (
+          <div className="search-suggest" id="search-suggest">
+            No results for "{this.props.searchingKeyword}".
+          </div>
+        );
+      }
+      return <div></div>;
+    }
+
+    return (
+      <div className="search-suggest" id="search-suggest">
+        <ListView pages={this.props.searchedPages} />
+      </div>
+    );
+  }
+
+}
+
+SearchSuggest.propTypes = {
+  searchedPages: React.PropTypes.array.isRequired,
+  searchingKeyword: React.PropTypes.string.isRequired,
+  searching: React.PropTypes.bool.isRequired,
+};
+
+SearchSuggest.defaultProps = {
+  searchedPages: [],
+  searchingKeyword: '',
+  searchError: null,
+  searching: false,
+};

+ 89 - 0
resource/js/components/Page/PageBody.js

@@ -0,0 +1,89 @@
+import React from 'react';
+import marked from 'marked';
+import hljs from 'highlight.js';
+
+export default class PageBody extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.getMarkupHTML = this.getMarkupHTML.bind(this);
+  }
+
+  getMarkupHTML() {
+    let body = this.props.pageBody;
+    if (body === '') {
+      body = this.props.page.revision.body;
+    }
+
+
+    //var contentHtml = Crowi.unescape(contentText);
+    //// TODO 前処理系のプラグイン化
+    //contentHtml = this.preFormatMarkdown(contentHtml);
+    //contentHtml = this.expandImage(contentHtml);
+    //contentHtml = this.link(contentHtml);
+
+    //var $body = this.$revisionBody;
+    // Using async version of marked
+    //{}, function (err, content) {
+    //  if (err) {
+    //    throw err;
+    //  }
+    //  $body.html(content);
+    //});
+    //return body;
+    try {
+    marked.setOptions({
+      gfm: true,
+      highlight: (code, lang, callback) => {
+        let result, hl;
+        if (lang) {
+          try {
+            hl = hljs.highlight(lang, code);
+            result = hl.value;
+          } catch (e) {
+            result = code;
+          }
+        } else {
+          result = code;
+        }
+        return callback(null, result);
+      },
+      tables: true,
+      breaks: true,
+      pedantic: false,
+      sanitize: false,
+      smartLists: true,
+      smartypants: false,
+      langPrefix: 'lang-'
+    });
+    console.log('parsing', 'いくぜ');
+    const parsed = marked(body);
+    console.log('parsed', parsed);
+    } catch (e) { console.log(e); }
+
+    return { __html: parsed };
+  }
+
+  render() {
+    console.log('Render!');
+
+    return (
+      <div
+        className="content"
+        dangerouslySetInnerHTML={this.getMarkupHTML()}
+        />
+    );
+  }
+}
+
+PageBody.propTypes = {
+  page: React.PropTypes.object.isRequired,
+  pageBody: React.PropTypes.string,
+};
+
+PageBody.defaultProps = {
+  page: {},
+  pageBody: '',
+};
+

+ 28 - 0
resource/js/components/PageList/ListView.js

@@ -0,0 +1,28 @@
+import React from 'react';
+
+import Page from './Page';
+
+export default class ListView extends React.Component {
+
+  render() {
+    const listView = this.props.pages.map((page) => {
+      return <Page page={page} />;
+    });
+
+    return (
+      <div className="page-list">
+        <ul className="page-list-ul">
+        {listView}
+        </ul>
+      </div>
+    );
+  }
+}
+
+ListView.propTypes = {
+  pages: React.PropTypes.array.isRequired,
+};
+
+ListView.defaultProps = {
+  pages: [],
+};

+ 38 - 0
resource/js/components/PageList/Page.js

@@ -0,0 +1,38 @@
+import React from 'react';
+
+import UserPicture from '../User/UserPicture';
+import PageListMeta from './PageListMeta';
+import PagePath from './PagePath';
+
+export default class Page extends React.Component {
+
+  render() {
+    const page = this.props.page;
+    let link = this.props.linkTo;
+    if (link === '') {
+      link = page.path;
+    }
+
+    return (
+      <li className="page-list-li">
+        {this.props.children}
+        <UserPicture user={page.revision.author} />
+        <a className="page-list-link" href={link}>
+          <PagePath page={page} />
+        </a>
+        <PageListMeta page={page} />
+      </li>
+    );
+  }
+}
+
+Page.propTypes = {
+  page: React.PropTypes.object.isRequired,
+  linkTo: React.PropTypes.string,
+};
+
+Page.defaultProps = {
+  page: {},
+  linkTo: '',
+};
+

+ 51 - 0
resource/js/components/PageList/PageListMeta.js

@@ -0,0 +1,51 @@
+import React from 'react';
+
+export default class PageListMeta extends React.Component {
+
+  isPortalPath(path) {
+    if (path.match(/.*\/$/)) {
+      return true;
+    }
+
+    return false;
+  }
+
+  render() {
+    // TODO isPortal()
+    const page = this.props.page;
+
+    // portal check
+    let PortalLabel;
+    if (this.isPortalPath(page.path)) {
+      PortalLabel = <span className="label label-info">PORTAL</span>;
+    }
+
+    let CommentCount;
+    if (page.commentCount > 0) {
+      CommentCount = <span><i className="fa fa-comment" />{page.commentCount}</span>;
+    }
+
+    let LikerCount;
+    if (page.liker.length > 0) {
+      LikerCount = <span><i className="fa fa-thumbs-up" />{page.liker.length}</span>;
+    }
+
+
+    return (
+      <span className="page-list-meta">
+        {PortalLabel}
+        {CommentCount}
+        {LikerCount}
+      </span>
+    );
+  }
+}
+
+PageListMeta.propTypes = {
+  page: React.PropTypes.object.isRequired,
+};
+
+PageListMeta.defaultProps = {
+  page: {},
+};
+

+ 46 - 0
resource/js/components/PageList/PagePath.js

@@ -0,0 +1,46 @@
+import React from 'react';
+
+export default class PagePath extends React.Component {
+
+  getShortPath(path) {
+    let name = path.replace(/(\/)$/, '');
+
+    // /.../hoge/YYYY/MM/DD 形式のページ
+    if (name.match(/.+\/([^/]+\/\d{4}\/\d{2}\/\d{2})$/)) {
+      return name.replace(/.+\/([^/]+\/\d{4}\/\d{2}\/\d{2})$/, '$1');
+    }
+
+    // /.../hoge/YYYY/MM 形式のページ
+    if (name.match(/.+\/([^/]+\/\d{4}\/\d{2})$/)) {
+      return name.replace(/.+\/([^/]+\/\d{4}\/\d{2})$/, '$1');
+    }
+
+    // /.../hoge/YYYY 形式のページ
+    if (name.match(/.+\/([^/]+\/\d{4})$/)) {
+      return name.replace(/.+\/([^/]+\/\d{4})$/, '$1');
+    }
+
+    // ページの末尾を拾う
+    return name.replace(/.+\/(.+)?$/, '$1');
+  }
+
+  render() {
+    const page = this.props.page;
+    const shortPath = this.getShortPath(page.path);
+    const pathPrefix = page.path.replace(new RegExp(shortPath + '(/)?$'), '');
+
+    return (
+      <span className="page-path">
+        {pathPrefix}<strong>{shortPath}</strong>
+      </span>
+    );
+  }
+}
+
+PagePath.propTypes = {
+  page: React.PropTypes.object.isRequired,
+};
+
+PagePath.defaultProps = {
+  page: {},
+};

+ 60 - 0
resource/js/components/Search/SearchForm.js

@@ -0,0 +1,60 @@
+import React from 'react';
+
+// Search.SearchForm
+export default class SearchForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      keyword: this.props.keyword,
+      searchedKeyword: this.props.keyword,
+    };
+
+    this.handleSubmit = this.handleSubmit.bind(this);
+    this.handleChange = this.handleChange.bind(this);
+  }
+
+  componentDidMount() {
+  }
+
+  componentWillUnmount() {
+  }
+
+  search() {
+    if (this.state.searchedKeyword != this.state.keyword) {
+      this.props.onSearchFormChanged({keyword: this.state.keyword});
+      this.setState({searchedKeyword: this.state.keyword});
+    }
+  }
+
+  handleSubmit(event) {
+    event.preventDefault();
+    this.search({keyword: this.state.keyword});
+  }
+
+  handleChange(event) {
+    const keyword = event.target.value;
+    this.setState({keyword});
+  }
+
+  render() {
+    return (
+      <form className="form" onSubmit={this.handleSubmit}>
+        <input
+          type="text"
+          name="q"
+          value={this.state.keyword}
+          onChange={this.handleChange}
+          className="form-control"
+          />
+      </form>
+    );
+  }
+}
+
+SearchForm.propTypes = {
+  onSearchFormChanged: React.PropTypes.func.isRequired,
+};
+SearchForm.defaultProps = {
+};

+ 101 - 0
resource/js/components/Search/SearchPage.js

@@ -0,0 +1,101 @@
+// This is the root component for #search-page
+
+import React from 'react';
+
+import SearchForm from './SearchForm';
+import SearchResult from './SearchResult';
+import axios from 'axios'
+
+export default class SearchPage extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      location: location,
+      searchingKeyword: this.props.query.q || '',
+      searchedPages: [],
+      searchError: null,
+    }
+
+    this.search = this.search.bind(this);
+  }
+
+  componentDidMount() {
+    if (this.state.searchingKeyword !== '')  {
+      this.search({keyword: this.state.searchingKeyword});
+      this.setState({searchedKeyword: this.state.keyword});
+    }
+  }
+
+  static getQueryByLocation(location) {
+    let search = location.search || '';
+    let query = {};
+
+    search.replace(/^\?/, '').split('&').forEach(function(element) {
+      let queryParts = element.split('=');
+      query[queryParts[0]] = decodeURIComponent(queryParts[1]).replace(/\+/g, ' ');
+    });
+
+    return query;
+  }
+
+  search(data) {
+    const keyword = data.keyword;
+    if (keyword === '') {
+      this.setState({
+        searchingKeyword: '',
+        searchedPages: [],
+      });
+
+      return true;
+    }
+
+    this.setState({
+      searchingKeyword: keyword,
+    });
+
+    axios.get('/_api/search', {params: {q: keyword}})
+    .then((res) => {
+      if (res.data.ok) {
+        this.setState({
+          searchingKeyword: keyword,
+          searchedPages: res.data.data,
+        });
+      }
+      // TODO error
+    })
+    .catch((res) => {
+      // TODO error
+    });
+  };
+
+  render() {
+    return (
+      <div>
+        <div className="header-wrap">
+          <header>
+            <SearchForm
+              onSearchFormChanged={this.search}
+              keyword={this.state.searchingKeyword}
+              />
+          </header>
+        </div>
+
+        <SearchResult
+          pages={this.state.searchedPages}
+          searchingKeyword={this.state.searchingKeyword}
+          />
+      </div>
+    );
+  }
+}
+
+SearchPage.propTypes = {
+  query: React.PropTypes.object,
+};
+SearchPage.defaultProps = {
+  //pollInterval: 1000,
+  query: SearchPage.getQueryByLocation(location || {}),
+};
+

+ 52 - 0
resource/js/components/Search/SearchResult.js

@@ -0,0 +1,52 @@
+import React from 'react';
+
+import Page from '../PageList/Page';
+import SearchResultList from './SearchResultList';
+
+// Search.SearchResult
+export default class SearchResult extends React.Component {
+
+  render() {
+
+    const listView = this.props.pages.map((page) => {
+      const pageId = "#" + page._id;
+      return (
+        <Page page={page} linkTo={pageId}>
+          <div className="page-list-option">
+            <a href={page.path}><i className="fa fa-arrow-circle-right" /></a>
+          </div>
+        </Page>
+      );
+    });
+
+    return (
+      <div className="content-main" id="content-main">
+        <div className="search-result row" id="search-result">
+          <div className="col-md-4 page-list search-result-list" id="search-result-list">
+            <nav data-spy="affix"  data-offset-top="120">
+              <ul className="page-list-ul nav">
+                {listView}
+              </ul>
+            </nav>
+          </div>
+          <div className="col-md-8 search-result-content" id="search-result-content">
+            <SearchResultList
+              pages={this.props.pages}
+              searchingKeyword={this.props.searchingKeyword}
+              />
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+
+SearchResult.propTypes = {
+  searchedPages: React.PropTypes.array.isRequired,
+  searchingKeyword: React.PropTypes.string.isRequired,
+};
+SearchResult.defaultProps = {
+  searchedPages: [],
+  searchingKeyword: '',
+};
+

+ 57 - 0
resource/js/components/Search/SearchResultList.js

@@ -0,0 +1,57 @@
+import React from 'react';
+
+import PageBody from '../Page/PageBody.js';
+
+export default class SearchResultList extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.getHighlightBody = this.getHighlightBody.bind(this);
+  }
+
+  getHighlightBody(body) {
+    let returnBody = body;
+
+    this.props.searchingKeyword.split(' ').forEach((keyword) => {
+      const keywordExp = new RegExp('(' + keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ')', 'g');
+      returnBody = returnBody.replace(keyword, '<span style="highlighted">$&</span>');
+    });
+
+    //console.log(this.props.searchingKeyword, body);
+    return returnBody;
+  }
+
+  render() {
+    const resultList = this.props.pages.map((page) => {
+      const pageBody = this.getHighlightBody(page.revision.body);
+      //console.log('resultList.page.path', page.path);
+      //console.log('resultList.pageBody', pageBody);
+      return (
+        <div id={page._id}>
+          <h2>{page.path}</h2>
+          <div>
+            <PageBody page={page} pageBody={pageBody} />
+          </div>
+        </div>
+      );
+    });
+
+    return (
+      <div>
+      {resultList}
+      </div>
+    );
+  }
+}
+
+SearchResultList.propTypes = {
+  pages: React.PropTypes.array.isRequired,
+  searchingKeyword: React.PropTypes.string.isRequired,
+};
+
+SearchResultList.defaultProps = {
+  pages: [],
+  searchingKeyword: '',
+};
+

+ 38 - 0
resource/js/components/User/UserPicture.js

@@ -0,0 +1,38 @@
+import React from 'react';
+
+// TODO UserComponent?
+export default class UserPicture extends React.Component {
+
+  getUserPicture(user) {
+    // from swig.setFilter('picture', function(user)
+
+    user.fbId = user.userId; // migration
+    if (user.image && user.image != '/images/userpicture.png') {
+      return user.image;
+    } else if (user.fbId) {
+      return '//graph.facebook.com/' + user.fbId + '/picture?size=square';
+    } else {
+      return '/images/userpicture.png';
+    }
+  }
+
+  render() {
+    const user = this.props.user;
+
+    return (
+      <img
+        src={this.getUserPicture(user)}
+        alt={user.username}
+        className="picture picture-rounded"
+        />
+    );
+  }
+}
+
+UserPicture.propTypes = {
+  user: React.PropTypes.object.isRequired,
+};
+
+UserPicture.defaultProps = {
+  user: {},
+};

+ 2 - 0
resource/js/crowi-admin.js

@@ -21,4 +21,6 @@ $(function() {
     });
     });
     return false;
     return false;
   });
   });
+
+  $('#createdUserModal').modal('show');
 });
 });

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

@@ -2,6 +2,10 @@ $(function() {
   var pageId = $('#content-main').data('page-id');
   var pageId = $('#content-main').data('page-id');
   var pagePath= $('#content-main').data('path');
   var pagePath= $('#content-main').data('path');
 
 
+  //require('inline-attachment/src/inline-attachment');
+  //require('jquery.selection');
+  //require('bootstrap-sass');
+
   // show/hide
   // show/hide
   function FetchPagesUpdatePostAndInsert(path) {
   function FetchPagesUpdatePostAndInsert(path) {
     $.get('/_api/pages.updatePost', {path: path}, function(res) {
     $.get('/_api/pages.updatePost', {path: path}, function(res) {
@@ -39,8 +43,6 @@ $(function() {
     $('.content-main').removeClass('on-edit');
     $('.content-main').removeClass('on-edit');
   });
   });
 
 
-  $('[data-toggle="popover"]').popover();
-
   // preview watch
   // preview watch
   var originalContent = $('#form-body').val();
   var originalContent = $('#form-body').val();
   var prevContent = "";
   var prevContent = "";

+ 9 - 6
resource/js/crowi-presentation.js

@@ -1,5 +1,8 @@
 var Reveal = require('reveal.js');
 var Reveal = require('reveal.js');
 
 
+require('reveal.js/lib/js/head.min.js');
+require('reveal.js/lib/js/html5shiv.js');
+
 if (!window) {
 if (!window) {
   window = {};
   window = {};
 }
 }
@@ -14,12 +17,12 @@ Reveal.initialize({
 
 
   // Optional libraries used to extend on reveal.js
   // Optional libraries used to extend on reveal.js
   dependencies: [
   dependencies: [
-    { src: '/js/reveal.js/lib/js/classList.js', condition: function() { return !document.body.classList; } },
-    { src: '/js/reveal.js/plugin/markdown/marked.js', condition: function() { return !!document.querySelector( '[data-markdown]' ); } },
-    { src: '/js/reveal.js/plugin/markdown/markdown.js', condition: function() { return !!document.querySelector( '[data-markdown]' ); } },
-    { src: '/js/reveal.js/plugin/highlight/highlight.js', async: true, callback: function() { hljs.initHighlightingOnLoad(); } },
-    { src: '/js/reveal.js/plugin/zoom-js/zoom.js', async: true, condition: function() { return !!document.body.classList; } },
-    { src: '/js/reveal.js/plugin/notes/notes.js', async: true, condition: function() { return !!document.body.classList; } }
+    { src: '/js/reveal/lib/js/classList.js', condition: function() { return !document.body.classList; } },
+    { src: '/js/reveal/plugin/markdown/marked.js', condition: function() { return !!document.querySelector( '[data-markdown]' ); } },
+    { src: '/js/reveal/plugin/markdown/markdown.js', condition: function() { return !!document.querySelector( '[data-markdown]' ); } },
+    { src: '/js/reveal/plugin/highlight/highlight.js', async: true, callback: function() { hljs.initHighlightingOnLoad(); } },
+    { src: '/js/reveal/plugin/zoom-js/zoom.js', async: true, condition: function() { return !!document.body.classList; } },
+    { src: '/js/reveal/plugin/notes/notes.js', async: true, condition: function() { return !!document.body.classList; } }
   ]
   ]
 });
 });
 
 

+ 157 - 3
resource/js/crowi.js

@@ -1,11 +1,15 @@
 /* jshint browser: true, jquery: true */
 /* jshint browser: true, jquery: true */
-/* global FB, marked */
 /* Author: Sotaro KARASAWA <sotarok@crocos.co.jp>
 /* Author: Sotaro KARASAWA <sotarok@crocos.co.jp>
 */
 */
 
 
 var hljs = require('highlight.js');
 var hljs = require('highlight.js');
 var jsdiff = require('diff');
 var jsdiff = require('diff');
 var marked = require('marked');
 var marked = require('marked');
+var io = require('socket.io-client');
+
+//require('bootstrap-sass');
+//require('jquery.cookie');
+
 var Crowi = {};
 var Crowi = {};
 
 
 if (!window) {
 if (!window) {
@@ -228,6 +232,23 @@ Crowi.userPicture = function (user) {
 };
 };
 
 
 
 
+//CrowiSearcher = function(path, $el) {
+//  this.$el = $el;
+//  this.path = path;
+//  this.searchResult = {};
+//};
+//CrowiSearcher.prototype.querySearch = function(keyword, option) {
+//};
+//CrowiSearcher.prototype.search = function(keyword) {
+//  var option = {};
+//  this.querySearch(keyword, option);
+//  this.$el.html(this.render());
+//};
+//CrowiSearcher.prototype.render = function() {
+//  return $('<div>');
+//};
+
+
 $(function() {
 $(function() {
   var pageId = $('#content-main').data('page-id');
   var pageId = $('#content-main').data('page-id');
   var revisionId = $('#content-main').data('page-revision-id');
   var revisionId = $('#content-main').data('page-revision-id');
@@ -238,9 +259,25 @@ $(function() {
 
 
   Crowi.linkPath();
   Crowi.linkPath();
 
 
+  $('[data-toggle="popover"]').popover();
   $('[data-toggle="tooltip"]').tooltip();
   $('[data-toggle="tooltip"]').tooltip();
   $('[data-tooltip-stay]').tooltip('show');
   $('[data-tooltip-stay]').tooltip('show');
 
 
+  $('#toggle-sidebar').click(function(e) {
+    var $mainContainer = $('.main-container');
+    if ($mainContainer.hasClass('aside-hidden')) {
+      $('.main-container').removeClass('aside-hidden');
+      $.cookie('aside-hidden', 0, { expires: 30, path: '/' });
+    } else {
+      $mainContainer.addClass('aside-hidden');
+      $.cookie('aside-hidden', 1, { expires: 30, path: '/' });
+    }
+    return false;
+  });
+
+  if ($.cookie('aside-hidden') == 1) {
+    $('.main-container').addClass('aside-hidden');
+  }
 
 
   $('.copy-link').on('click', function () {
   $('.copy-link').on('click', function () {
     $(this).select();
     $(this).select();
@@ -327,6 +364,88 @@ $(function() {
     $link.html(path.replace(new RegExp(pattern), '<strong>' + shortPath + '$1</strong>'));
     $link.html(path.replace(new RegExp(pattern), '<strong>' + shortPath + '$1</strong>'));
   });
   });
 
 
+  // for list page
+  $('#view-timeline .timeline-body').each(function()
+  {
+    var id = $(this).attr('id');
+    var contentId = '#' + id + ' > script';
+    var revisionBody = '#' + id + ' .revision-body';
+    var revisionPath = '#' + id + ' .revision-path';
+    var renderer = new Crowi.renderer($(contentId).html(), $(revisionBody));
+    renderer.render();
+  });
+
+  // login
+  $('#register').on('click', function() {
+    $('#login-dialog').addClass('to-flip');
+    return false;
+  });
+  $('#login').on('click', function() {
+    $('#login-dialog').removeClass('to-flip');
+    return false;
+  });
+  $('#btn-login-facebook').click(function(e)
+  {
+    var afterLogin = function(response) {
+      if (response.status !== 'connected') {
+        $('#login-form-errors').html('<p class="alert alert-danger">Facebookでのログインに失敗しました。</p>');
+      } else {
+        location.href = '/login/facebook';
+      }
+    };
+    FB.getLoginStatus(function(response) {
+      if (response.status === 'connected') {
+        afterLogin(response);
+      } else {
+        FB.login(function(response) {
+          afterLogin(response);
+        }, {scope: 'email'});
+      }
+    });
+  });
+
+  $('#register-form input[name="registerForm[username]"]').change(function(e) {
+    var username = $(this).val();
+    $('#input-group-username').removeClass('has-error');
+    $('#help-block-username').html("");
+
+    $.getJSON('/_api/check_username', {username: username}, function(json) {
+      if (!json.valid) {
+        $('#help-block-username').html('<i class="fa fa-warning"></i>このユーザーIDは利用できません。<br>');
+        $('#input-group-username').addClass('has-error');
+      }
+    });
+  });
+
+  $('#btn-register-facebook').click(function(e)
+  {
+    var afterLogin = function(response) {
+      if (response.status !== 'connected') {
+        $('#register-form-errors').html('<p class="alert alert-danger">Facebookでのログインに失敗しました。</p>');
+
+      } else {
+        var authR = response.authResponse;
+        $('#register-form input[name="registerForm[fbId]"]').val(authR.userID);
+        FB.api('/me?fields=name,username,email', function(res) {
+          $('#register-form input[name="registerForm[name]"]').val(res.name);
+          $('#register-form input[name="registerForm[username]"]').val(res.username || '');
+          $('#register-form input[name="registerForm[email]"]').val(res.email);
+
+          $('#register-form .facebook-info').remove();
+          $('#register-form').prepend('<div class="facebook-info"><img src="//graph.facebook.com/' + res.id + '/picture?size=square" width="25"> <i class="fa fa-facebook-square"></i> ' + res.name + 'さんとして登録します</div>');
+        });
+      }
+    };
+    FB.getLoginStatus(function(response) {
+      if (response.status === 'connected') {
+        afterLogin(response);
+      } else {
+        FB.login(function(response) {
+          afterLogin(response);
+        }, {scope: 'email'});
+      }
+    });
+  });
 
 
   if (pageId) {
   if (pageId) {
 
 
@@ -441,7 +560,7 @@ $(function() {
 
 
     // post comment event
     // post comment event
     $('#page-comment-form').on('submit', function() {
     $('#page-comment-form').on('submit', function() {
-      $button = $('#commenf-form-button');
+      var $button = $('#comment-form-button');
       $button.attr('disabled', 'disabled');
       $button.attr('disabled', 'disabled');
       $.post('/_api/comments.add', $(this).serialize(), function(data) {
       $.post('/_api/comments.add', $(this).serialize(), function(data) {
         $button.removeAttr('disabled');
         $button.removeAttr('disabled');
@@ -690,5 +809,40 @@ $(function() {
         $(diffView).click();
         $(diffView).click();
       }
       }
     });
     });
-  }
+
+    // presentation
+    var presentaionInitialized = false
+      , $b = $('body');
+
+    $(document).on('click', '.toggle-presentation', function(e) {
+      var $a = $(this);
+
+      e.preventDefault();
+      $b.toggleClass('overlay-on');
+
+      if (!presentaionInitialized) {
+        presentaionInitialized = true;
+
+        $('<iframe />').attr({
+          src: $a.attr('href')
+        }).appendTo($('#presentation-container'));
+      }
+    }).on('click', '.fullscreen-layer', function() {
+      $b.toggleClass('overlay-on');
+    });
+
+    //
+    var me = $('body').data('me');
+    var socket = io();
+    socket.on('page edited', function (data) {
+      if (data.user._id != me
+        && data.page.path == pagePath) {
+        $('#notifPageEdited').show();
+        $('#notifPageEdited .edited-user').html(data.user.name);
+      }
+    });
+  } // end if pageId
+
+  // for search
+  //
 });
 });

+ 87 - 0
resource/search/mappings.json

@@ -0,0 +1,87 @@
+{
+  "settings": {
+    "analysis": {
+      "filter": {
+        "english_stop": {
+          "type":       "stop",
+          "stopwords":  "_english_"
+        },
+        "english_stemmer": {
+          "type":       "stemmer",
+          "language":   "english"
+        },
+        "english_possessive_stemmer": {
+          "type":       "stemmer",
+          "language":   "possessive_english"
+        }
+      },
+      "analyzer": {
+        "autocomplete": {
+          "tokenizer":  "keyword",
+          "filter": [
+            "lowercase",
+            "nGram"
+          ]
+        },
+        "english": {
+          "tokenizer":  "standard",
+          "filter": [
+            "english_possessive_stemmer",
+            "lowercase",
+            "english_stop",
+            "english_stemmer"
+          ]
+        }
+      }
+    }
+  },
+  "mappings": {
+    "users": {
+      "properties" : {
+        "name": {
+          "type": "string",
+          "analyzer": "autocomplete"
+        }
+      }
+    },
+    "pages": {
+      "properties" : {
+        "path": {
+          "type" : "multi_field",
+          "fields" : {
+            "raw": {"type" : "string", "index" : "not_analyzed"},
+            "ja": {"type" : "string", "analyzer" : "kuromoji"},
+            "en": {"type" : "string", "analyzer" : "english"}
+          }
+        },
+        "body": {
+          "type" : "multi_field",
+          "fields" : {
+            "ja": {"type" : "string", "analyzer" : "kuromoji"},
+            "en": {"type" : "string", "analyzer" : "english"}
+          }
+        },
+        "username": {
+          "type": "string"
+        },
+        "comment_count": {
+          "type": "integer"
+        },
+        "bookmark_count": {
+          "type": "integer"
+        },
+        "like_count": {
+          "type": "integer"
+        },
+        "created_at": {
+          "type": "date",
+          "format": "dateOptionalTime"
+        },
+        "updated_at": {
+          "type": "date",
+          "format": "dateOptionalTime"
+        }
+      }
+    }
+  }
+}

+ 46 - 0
webpack.config.js

@@ -0,0 +1,46 @@
+var path = require('path');
+var webpack = require('webpack');
+
+module.exports = {
+  entry: {
+    app: './resource/js/app.js',
+    crowi: './resource/js/crowi.js',
+    presentation: './resource/js/crowi-presentation.js',
+    form: './resource/js/crowi-form.js',
+    admin: './resource/js/crowi-admin.js',
+  },
+  output: {
+    path: path.join(__dirname + "/public/js"),
+    filename: "[name].js"
+  },
+  resolve: {
+    modulesDirectories: [
+      './node_modules', './resource/thirdparty-js',
+    ],
+  },
+  module: {
+    loaders: [
+      {
+        test: /.jsx?$/,
+        loader: 'babel-loader',
+        exclude: /node_modules/,
+        query: {
+          presets: ['es2015', 'react']
+        }
+      }
+    ]
+  },
+  plugins: [
+    new webpack.DefinePlugin({
+      "process.env": {
+        NODE_ENV: JSON.stringify("production")
+      }
+    })
+    //new webpack.ProvidePlugin({
+    //  jQuery: "jquery",
+    //  $: "jquery",
+    //  jqeury: "jquery",
+    //}),
+    //new webpack.optimize.DedupePlugin(),
+  ]
+};