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

Merge pull request #67 from crowi/slack-notification

Implement Slack notification
Sotaro KARASAWA 10 лет назад
Родитель
Сommit
d3b1eea1ba

+ 13 - 0
gulpfile.js

@@ -62,6 +62,10 @@ var js = {
     'resource/js/crowi-form.js'
   ],
   formDist: dirs.jsDist + '/crowi-form.js',
+  adminSrc: [
+    'resource/js/crowi-admin.js'
+  ],
+  adminDist: dirs.jsDist + '/crowi-admin.js',
   clientWatch: ['resource/js/**/*.js'],
   watch: ['test/**/*.test.js', 'app.js', 'lib/**/*.js'],
   lint: ['app.js', 'lib/**/*.js'],
@@ -91,6 +95,10 @@ gulp.task('js:concat', ['js:browserify'], function() {
     .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'))
     .pipe(gulp.dest(dirs.jsDist));
@@ -111,6 +119,11 @@ gulp.task('js:min', ['js:concat'], function() {
     .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'}))

+ 20 - 0
lib/crowi/index.js

@@ -41,6 +41,7 @@ function Crowi (rootdir, env)
 
   this.events = {
     user: new (require(self.eventsDir + 'user'))(this),
+    page: new (require(self.eventsDir + 'page'))(this),
   };
 
   if (this.node_env == 'development') {
@@ -77,6 +78,8 @@ Crowi.prototype.init = function() {
     });
   }).then(function() {
     return self.setupMailer();
+  }).then(function() {
+    return self.setupSlack();
   }).then(function() {
     return self.buildServer();
   });
@@ -198,6 +201,23 @@ Crowi.prototype.setupMailer = function() {
   });
 };
 
+Crowi.prototype.setupSlack = function() {
+  var self = this;
+  var config = this.getConfig();
+  var Config = this.model('Config');
+
+  return new Promise(function(resolve, reject) {
+    if (!Config.hasSlackConfig(config)) {
+      self.slack = {};
+    } else {
+      self.slack = require('../util/slack')(self);
+    }
+
+    resolve();
+  });
+};
+
+
 
 Crowi.prototype.start = function(app) {
   var self = this

+ 23 - 0
lib/events/page.js

@@ -0,0 +1,23 @@
+var debug = require('debug')('crowi:events:page');
+var util = require('util');
+var events = require('events');
+var sprintf = require('sprintf');
+
+function PageEvent(crowi) {
+  this.crowi = crowi;
+
+  events.EventEmitter.call(this);
+}
+util.inherits(PageEvent, events.EventEmitter);
+
+PageEvent.prototype.onCreate = function(context, page, user) {
+  var User = this.crowi.model('User');
+  var Page = this.crowi.model('Page');
+
+};
+PageEvent.prototype.onUpdate = function(context, page, user) {
+  var User = this.crowi.model('User');
+  var Page = this.crowi.model('Page');
+};
+
+module.exports = PageEvent;

+ 10 - 0
lib/form/admin/slackSetting.js

@@ -0,0 +1,10 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field;
+
+module.exports = form(
+  field('slackSetting[slack:clientId]', 'clientId').is(/(\d+)\.(\d+)/).required(),
+  field('slackSetting[slack:clientSecret]', 'clientSecret').required().is(/([0-9a-f]+)/)
+);
+

+ 1 - 0
lib/form/index.js

@@ -16,4 +16,5 @@ exports.admin = {
   google: require('./admin/google'),
   fb: require('./admin/fb'),
   userInvite: require('./admin/userInvite'),
+  slackSetting: require('./admin/slackSetting'),
 };

+ 2 - 1
lib/form/revision.js

@@ -7,5 +7,6 @@ module.exports = form(
   field('pageForm.path').required(),
   field('pageForm.body').required().custom(function(value) { return value.replace(/\r/g, '\n'); }),
   field('pageForm.currentRevision'),
-  field('pageForm.grant').toInt().required()
+  field('pageForm.grant').toInt().required(),
+  field('pageForm.notify')
 );

+ 31 - 2
lib/models/config.js

@@ -89,11 +89,14 @@ module.exports = function(crowi) {
 
   configSchema.statics.setupCofigFormData = function(ns, config)
   {
-    var defaultConfig;
+    var defaultConfig = {};
     if (ns === 'crowi') {
       defaultConfig  = getArrayForInstalling();
     }
-    Object.keys(config[ns]).forEach(function (key) {
+    if (!defaultConfig[ns]) {
+      defaultConfig[ns] = {};
+    }
+    Object.keys(config[ns] || {}).forEach(function (key) {
       if (config[ns][key]) {
         defaultConfig[key] = config[ns][key];
       }
@@ -179,6 +182,32 @@ module.exports = function(crowi) {
     return method != 'none';
   };
 
+  configSchema.statics.hasSlackConfig = function(config)
+  {
+    if (!config.notification) {
+      return false;
+    }
+    if (!config.notification['slack:clientId'] ||
+        !config.notification['slack:clientSecret']) {
+      return false;
+    }
+
+    return true;
+  };
+
+  configSchema.statics.hasSlackToken = function(config)
+  {
+    if (!this.hasSlackConfig(config)) {
+      return false;
+    }
+
+    if (!config.notification['slack:token']) {
+      return false;
+    }
+
+    return true;
+  };
+
   /*
   configSchema.statics.isInstalled = function(config)
   {

+ 1 - 0
lib/models/index.js

@@ -7,4 +7,5 @@ module.exports = {
   Bookmark: require('./bookmark'),
   Comment: require('./comment'),
   Attachment: require('./attachment'),
+  UpdatePost: require('./updatePost'),
 };

+ 165 - 121
lib/models/page.js

@@ -7,6 +7,9 @@ module.exports = function(crowi) {
     , GRANT_SPECIFIED = 3
     , GRANT_OWNER = 4
     , PAGE_GRANT_ERROR = 1
+
+    , pageEvent = crowi.event('page')
+
     , pageSchema;
 
   function isPortalPath(path) {
@@ -27,10 +30,30 @@ module.exports = function(crowi) {
     liker: [{ type: ObjectId, ref: 'User', index: true }],
     seenUsers: [{ type: ObjectId, ref: 'User', index: true }],
     commentCount: { type: Number, default: 0 },
+    extended: {
+      type: String,
+      default: '{}',
+      get: function(data) {
+        try {
+          return JSON.parse(data);
+        } catch(e) {
+          return data;
+        }
+      },
+      set: function(data) {
+        return JSON.stringify(data);
+      }
+    },
     createdAt: { type: Date, default: Date.now },
     updatedAt: Date
+  }, {
+    toJSON: {getters: true},
+    toObject: {getters: true}
   });
 
+  pageEvent.on('create', pageEvent.onCreate);
+  pageEvent.on('update', pageEvent.onUpdate);
+
   pageSchema.methods.isPublic = function() {
     if (!this.grant || this.grant == GRANT_PUBLIC) {
       return true;
@@ -92,21 +115,21 @@ module.exports = function(crowi) {
     var self = this,
       Page = self;
 
-    return new Promise(function(resolve, reject) {
-      var added = self.liker.addToSet(userData._id);
-      if (added.length > 0) {
-        self.save(function(err, data) {
-          if (err) {
-            return reject(err);
-          }
-          debug('liker updated!', added);
-          return resolve(data);
-        });
-      } else {
-        debug('liker not updated');
-        return reject(self);
-      }
-    });
+      return new Promise(function(resolve, reject) {
+        var added = self.liker.addToSet(userData._id);
+        if (added.length > 0) {
+          self.save(function(err, data) {
+            if (err) {
+              return reject(err);
+            }
+            debug('liker updated!', added);
+            return resolve(data);
+          });
+        } else {
+          debug('liker not updated');
+          return reject(self);
+        }
+      });
 
   };
 
@@ -114,21 +137,21 @@ module.exports = function(crowi) {
     var self = this,
       Page = self;
 
-    return new Promise(function(resolve, reject) {
-      var beforeCount = self.liker.length;
-      self.liker.pull(userData._id);
-      if (self.liker.length != beforeCount) {
-        self.save(function(err, data) {
-          if (err) {
-            return reject(err);
-          }
-          return resolve(data);
-        });
-      } else {
-        debug('liker not updated');
-        return reject(self);
-      }
-    });
+      return new Promise(function(resolve, reject) {
+        var beforeCount = self.liker.length;
+        self.liker.pull(userData._id);
+        if (self.liker.length != beforeCount) {
+          self.save(function(err, data) {
+            if (err) {
+              return reject(err);
+            }
+            return resolve(data);
+          });
+        } else {
+          debug('liker not updated');
+          return reject(self);
+        }
+      });
 
   };
 
@@ -136,33 +159,62 @@ module.exports = function(crowi) {
     var self = this,
       Page = self;
 
-    return this.seenUsers.some(function(seenUser) {
-      return seenUser.equals(userData._id);
-    });
+      return this.seenUsers.some(function(seenUser) {
+        return seenUser.equals(userData._id);
+      });
   };
 
   pageSchema.methods.seen = function(userData) {
     var self = this,
       Page = self;
 
-    if (this.isSeenUser(userData)) {
-      debug('seenUsers not updated');
-      return Promise.resolve(this);
+      if (this.isSeenUser(userData)) {
+        debug('seenUsers not updated');
+        return Promise.resolve(this);
+      }
+
+      return new Promise(function(resolve, reject) {
+        if (!userData || !userData._id) {
+          reject(new Error('User data is not valid'));
+        }
+
+        var added = self.seenUsers.addToSet(userData);
+        self.save(function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+
+          debug('seenUsers updated!', added);
+          return resolve(self);
+        });
+      });
+  };
+
+  pageSchema.methods.getSlackChannel = function() {
+    var extended = this.get('extended');
+    if (!extended) {
+      return '';
     }
 
-    return new Promise(function(resolve, reject) {
-      if (!userData || !userData._id) {
-        reject(new Error('User data is not valid'));
-      }
+    return extended.slack || '';
+  };
+
+  pageSchema.methods.updateSlackChannel = function(slackChannel) {
+    var extended = this.extended;
+    extended.slack = slackChannel;
 
-      var added = self.seenUsers.addToSet(userData);
-      self.save(function(err, data) {
+    return this.updateExtended(extended);
+  };
+
+  pageSchema.methods.updateExtended = function(extended) {
+    var page = this;
+    page.extended = extended;
+    return new Promise(function(resolve, reject) {
+      return page.save(function(err, doc) {
         if (err) {
           return reject(err);
         }
-
-        debug('seenUsers updated!', added);
-        return resolve(self);
+        return resolve(doc);
       });
     });
   };
@@ -308,13 +360,13 @@ module.exports = function(crowi) {
 
   pageSchema.statics.findUpdatedList = function(offset, limit, cb) {
     this
-      .find({})
-      .sort({updatedAt: -1})
-      .skip(offset)
-      .limit(limit)
-      .exec(function(err, data) {
-        cb(err, data);
-      });
+    .find({})
+    .sort({updatedAt: -1})
+    .skip(offset)
+    .limit(limit)
+    .exec(function(err, data) {
+      cb(err, data);
+    });
   };
 
   pageSchema.statics.findPageById = function(id) {
@@ -403,24 +455,24 @@ module.exports = function(crowi) {
 
     return new Promise(function(resolve, reject) {
       Page
-        .find({ _id: { $in: ids }, grant: GRANT_PUBLIC })
-        //.sort({createdAt: -1}) // TODO optionize
-        .skip(offset)
-        .limit(limit)
-        .populate('revision')
-        .exec(function(err, pages) {
+      .find({ _id: { $in: ids }, grant: GRANT_PUBLIC })
+      //.sort({createdAt: -1}) // TODO optionize
+      .skip(offset)
+      .limit(limit)
+      .populate('revision')
+      .exec(function(err, pages) {
+        if (err) {
+          return reject(err);
+        }
+
+        Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
           if (err) {
             return reject(err);
           }
 
-          Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
-            if (err) {
-              return reject(err);
-            }
-
-            return resolve(data);
-          });
+          return resolve(data);
         });
+      });
     });
   };
 
@@ -435,24 +487,15 @@ module.exports = function(crowi) {
 
     return new Promise(function(resolve, reject) {
       Page
-        .find({ creator: user._id, grant: GRANT_PUBLIC, redirectTo: null })
-        .sort({createdAt: -1})
-        .skip(offset)
-        .limit(limit)
-        .populate('revision')
-        .exec(function(err, pages) {
-          if (err) {
-            return reject(err);
-          }
-
-          Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
-            if (err) {
-              return reject(err);
-            }
-
-            return resolve(data);
-          });
-        });
+      .find({ creator: user._id, grant: GRANT_PUBLIC, redirectTo: null })
+      .sort({createdAt: -1})
+      .skip(offset)
+      .limit(limit)
+      .populate('revision')
+      .exec()
+      .then(function(pages) {
+        return Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}).then(resolve);
+      });
     });
   };
 
@@ -491,15 +534,14 @@ module.exports = function(crowi) {
     return new Promise(function(resolve, reject) {
       // FIXME: might be heavy
       var q = Page.find({
-          redirectTo: null,
-          $or: [
-            {grant: null},
-            {grant: GRANT_PUBLIC},
-            {grant: GRANT_RESTRICTED, grantedUsers: userData._id},
-            {grant: GRANT_SPECIFIED, grantedUsers: userData._id},
-            {grant: GRANT_OWNER, grantedUsers: userData._id},
-          ],
-        })
+        redirectTo: null,
+        $or: [
+          {grant: null},
+          {grant: GRANT_PUBLIC},
+          {grant: GRANT_RESTRICTED, grantedUsers: userData._id},
+          {grant: GRANT_SPECIFIED, grantedUsers: userData._id},
+          {grant: GRANT_OWNER, grantedUsers: userData._id},
+        ],})
         .populate('revision')
         .and({
           $or: pathCondition
@@ -594,6 +636,7 @@ module.exports = function(crowi) {
           }
 
           resolve(data);
+          pageEvent.emit('update', data, user);
         });
       });
     });
@@ -607,41 +650,42 @@ module.exports = function(crowi) {
       , redirectTo = options.redirectTo || null;
 
     // force public
-    if (isPortalPath(path)) {
-      grant = GRANT_PUBLIC;
-    }
-
-    return new Promise(function(resolve, reject) {
-      Page.findOne({path: path}, function(err, pageData) {
-        if (pageData) {
-          return reject(new Error('Cannot create new page to existed path'));
-        }
+      if (isPortalPath(path)) {
+        grant = GRANT_PUBLIC;
+      }
 
-        var newPage = new Page();
-        newPage.path = path;
-        newPage.creator = user;
-        newPage.createdAt = Date.now();
-        newPage.updatedAt = Date.now();
-        newPage.redirectTo = redirectTo;
-        newPage.grant = grant;
-        newPage.grantedUsers = [];
-        newPage.grantedUsers.push(user);
-
-        newPage.save(function (err, newPage) {
-          if (err) {
-            return reject(err);
+      return new Promise(function(resolve, reject) {
+        Page.findOne({path: path}, function(err, pageData) {
+          if (pageData) {
+            return reject(new Error('Cannot create new page to existed path'));
           }
 
-          var newRevision = Revision.prepareRevision(newPage, body, user, {format: format});
-          Page.pushRevision(newPage, newRevision, user).then(function(data) {
-            resolve(data);
-          }).catch(function(err) {
-            debug('Push Revision Error on create page', err);
-            return reject(err);
+          var newPage = new Page();
+          newPage.path = path;
+          newPage.creator = user;
+          newPage.createdAt = Date.now();
+          newPage.updatedAt = Date.now();
+          newPage.redirectTo = redirectTo;
+          newPage.grant = grant;
+          newPage.grantedUsers = [];
+          newPage.grantedUsers.push(user);
+
+          newPage.save(function (err, newPage) {
+            if (err) {
+              return reject(err);
+            }
+
+            var newRevision = Revision.prepareRevision(newPage, body, user, {format: format});
+            Page.pushRevision(newPage, newRevision, user).then(function(data) {
+              resolve(data);
+              pageEvent.emit('create', data, user);
+            }).catch(function(err) {
+              debug('Push Revision Error on create page', err);
+              return reject(err);
+            });
           });
         });
       });
-    });
   };
 
   pageSchema.statics.rename = function(pageData, newPagePath, user, options) {

+ 154 - 0
lib/models/updatePost.js

@@ -0,0 +1,154 @@
+/**
+ * This is the setting for notify to 3rd party tool (like Slack).
+ */
+module.exports = function(crowi) {
+  var debug = require('debug')('crowi:models:updatePost')
+    , mongoose = require('mongoose')
+    , ObjectId = mongoose.Schema.Types.ObjectId
+  ;
+
+  // TODO: slack 以外の対応
+  updatePostSchema = new mongoose.Schema({
+    pathPattern: { type: String, required: true },
+    patternPrefix:  { type: String, required: true },
+    patternPrefix2: { type: String, required: true },
+    channel: { type: String, required: true },
+    provider: { type: String, required: true },
+    creator: { type: ObjectId, ref: 'User', index: true  },
+    createdAt: { type: Date, default: Date.now }
+  });
+
+  updatePostSchema.statics.normalizeChannelName = function(channel)
+  {
+    return channel.replace(/(#|,)/g, '');
+  }
+
+  updatePostSchema.statics.createPrefixesByPathPattern = function(pathPattern)
+  {
+    var patternPrefix = ['*', '*'];
+
+    // not begin with slash
+    if (!pathPattern.match(/^\/.+/)) {
+      return patternPrefix;
+    }
+
+    var pattern = pathPattern.split('/');
+    pattern.shift();
+    if (pattern[0] && pattern[0] != '*') {
+      patternPrefix[0] = pattern[0];
+    }
+
+    if (pattern[1] && pattern[1] != '*') {
+      patternPrefix[1] = pattern[1];
+    }
+    return patternPrefix;
+  }
+
+  updatePostSchema.statics.getRegExpByPattern = function(pattern)
+  {
+    var reg = pattern;
+    if (!reg.match(/^\/.*/)) {
+      reg = '/*' + reg + '*';
+    }
+    reg = '^' + reg;
+    reg = reg.replace(/\//g, '\\/');
+    reg = reg.replace(/(\*)/g, '.*');
+
+    return new RegExp(reg);
+  }
+
+  updatePostSchema.statics.findSettingsByPath = function(path)
+  {
+    var UpdatePost = this;
+    var prefixes = UpdatePost.createPrefixesByPathPattern(path);
+
+    return new Promise(function(resolve, reject) {
+      UpdatePost.find({$or: [
+        {patternPrefix: prefixes[0], patternPrefix2: prefixes[1]},
+        {patternPrefix: '*', patternPrefix2: '*'},
+        {patternPrefix: prefixes[0], patternPrefix2: '*'},
+        {patternPrefix: '*', patternPrefix2: prefixes[1]},
+      ]}).then(function(settings) {
+        if (settings.length <= 0) {
+          return resolve(settings);
+        }
+
+        settings = settings.filter(function(setting) {
+          var patternRegex = UpdatePost.getRegExpByPattern(setting.pathPattern);
+          return patternRegex.test(path);
+        });
+
+        return resolve(settings);
+      });
+    });
+  };
+
+  updatePostSchema.statics.findAll = function(offset)
+  {
+    var UpdatePost = this;
+    var offset = offset || 0;
+
+    return new Promise(function(resolve, reject) {
+      UpdatePost
+        .find()
+        .sort({'createdAt': 1})
+        .populate('creator')
+        .exec(function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+
+          if (data.length < 1) {
+            return resolve([]);
+          }
+
+          return resolve(data);
+        });
+    });
+  };
+
+  updatePostSchema.statics.create = function(pathPattern, channel, user)
+  {
+    var UpdatePost = this;
+    var provider = 'slack'; // now slack only
+
+    var prefixes = UpdatePost.createPrefixesByPathPattern(pathPattern);
+    var notif = new UpdatePost;
+    notif.pathPattern = pathPattern;
+    notif.patternPrefix = prefixes[0];
+    notif.patternPrefix2 = prefixes[1];
+    notif.channel = UpdatePost.normalizeChannelName(channel);
+    notif.provider = provider;
+    notif.creator = user;
+    notif.createdAt = Date.now();
+
+    return new Promise(function(resolve, reject) {
+      notif.save(function(err, doc) {
+       if (err) {
+         return reject(err);
+       }
+
+       return resolve(doc);
+      });
+    });
+  };
+
+  updatePostSchema.statics.remove = function(id)
+  {
+    var UpdatePost = this;
+
+    return new Promise(function(resolve, reject) {
+      UpdatePost.findOneAndRemove({_id: id}, function(err, data) {
+        if (err) {
+          debug('UpdatePost.findOneAndRemove failed', err);
+          return reject(err);
+        }
+
+        return resolve(data);
+      });
+    });
+  };
+
+  return mongoose.model('UpdatePost', updatePostSchema);
+};
+

+ 126 - 2
lib/routes/admin.js

@@ -6,6 +6,7 @@ module.exports = function(crowi, app) {
     , Page = models.Page
     , User = models.User
     , Config = models.Config
+    , ApiResponse = require('../util/apiResponse')
 
     , MAX_PAGE_LIST = 5
     , actions = {};
@@ -72,7 +73,6 @@ module.exports = function(crowi, app) {
     var settingForm;
     settingForm = Config.setupCofigFormData('crowi', req.config);
 
-    debug('settingForm', settingForm);
     return res.render('admin/app', {
       settingForm: settingForm,
     });
@@ -81,6 +81,93 @@ module.exports = function(crowi, app) {
   actions.app.settingUpdate = function(req, res) {
   };
 
+  // app.get('/admin/notification'               , admin.notification.index);
+  actions.notification = {};
+  actions.notification.index = function(req, res) {
+    var config = crowi.getConfig();
+    var UpdatePost = crowi.model('UpdatePost');
+    var slackSetting = Config.setupCofigFormData('notification', config);
+    var hasSlackConfig = Config.hasSlackConfig(config);
+    var hasSlackToken = Config.hasSlackToken(config);
+    var slack = crowi.slack;
+    var slackAuthUrl = '';
+
+    if (!Config.hasSlackConfig(req.config)) {
+      slackSetting['slack:clientId'] = '';
+      slackSetting['slack:clientSecret'] = '';
+    } else {
+      slackAuthUrl = slack.getAuthorizeURL();
+    }
+
+    if (req.session.slackSetting) {
+      slackSetting = req.session.slackSetting;
+      req.session.slackSetting = null;
+    }
+
+    UpdatePost.findAll()
+    .then(function(settings) {
+      return res.render('admin/notification', {
+        settings,
+        slackSetting,
+        hasSlackConfig,
+        hasSlackToken,
+        slackAuthUrl
+      });
+    });
+  };
+
+  // app.post('/admin/notification/slackSetting' , admin.notification.slackauth);
+  actions.notification.slackSetting = function(req, res) {
+    var slackSetting = req.form.slackSetting;
+
+    req.session.slackSetting = slackSetting;
+    if (req.form.isValid) {
+      Config.updateNamespaceByArray('notification', slackSetting, function(err, config) {
+        Config.updateConfigCache('notification', config);
+        req.session.slackSetting = null;
+
+        crowi.setupSlack().then(function() {
+          return res.redirect('/admin/notification');
+        });
+      });
+    } else {
+      req.flash('errorMessage', req.form.errors);
+      return res.redirect('/admin/notification');
+    }
+  };
+
+  // app.get('/admin/notification/slackAuth'     , admin.notification.slackauth);
+  actions.notification.slackAuth = function(req, res) {
+    var code = req.query.code;
+    var config = crowi.getConfig();
+
+    if (!code || !Config.hasSlackConfig(req.config)) {
+      return res.redirect('/admin/notification');
+    }
+
+    var slack = crowi.slack;
+    var bot = slack.createBot();
+    bot.api.oauth.access({code}, function(err, data) {
+      debug('oauth response', err, data);
+      if (!data.ok || !data.access_token) {
+        req.flash('errorMessage', ['Failed to fetch access_token. Please do connect again.']);
+        return res.redirect('/admin/notification');
+      } else {
+        Config.updateNamespaceByArray('notification', {'slack:token': data.access_token}, function(err, config) {
+          if (err) {
+            req.flash('errorMessage', ['Failed to save access_token. Please try again.']);
+          } else {
+            Config.updateConfigCache('notification', config);
+            req.flash('successMessage', ['Successfully Connected!']);
+          }
+
+          slack.createBot();
+          return res.redirect('/admin/notification');
+        });
+      }
+    });
+  };
+
   actions.user = {};
   actions.user.index = function(req, res) {
     var page = parseInt(req.query.page) || 1;
@@ -173,11 +260,12 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.user.remove= function(req, res) {
+  actions.user.remove = function(req, res) {
     // 未実装
     return res.redirect('/admin/users');
   };
 
+  // これやったときの relation の挙動未確認
   actions.user.removeCompletely = function(req, res) {
     // ユーザーの物理削除
     var id = req.params.id;
@@ -219,6 +307,42 @@ module.exports = function(crowi, app) {
     }
   };
 
+  // app.post('/_api/admin/notifications.add'    , admin.api.notificationAdd);
+  actions.api.notificationAdd = function(req, res) {
+    var UpdatePost = crowi.model('UpdatePost');
+    var pathPattern = req.body.pathPattern;
+    var channel = req.body.channel;
+
+    debug('notification.add', pathPattern, channel);
+    UpdatePost.create(pathPattern, channel, req.user)
+    .then(function(doc) {
+      debug('Successfully save updatePost', doc);
+
+      // fixme: うーん
+      doc.creator = doc.creator._id.toString();
+      return res.json(ApiResponse.success({updatePost: doc}));
+    }).catch(function(err) {
+      debug('Failed to save updatePost', err);
+      return res.json(ApiResponse.error());
+    });
+  };
+
+  // app.post('/_api/admin/notifications.remove' , admin.api.notificationRemove);
+  actions.api.notificationRemove = function(req, res) {
+    var UpdatePost = crowi.model('UpdatePost');
+    var id = req.body.id;
+
+    UpdatePost.remove(id)
+    .then(function() {
+      debug('Successfully remove updatePost');
+
+      return res.json(ApiResponse.success({}));
+    }).catch(function(err) {
+      debug('Failed to remove updatePost', err);
+      return res.json(ApiResponse.error());
+    });
+  };
+
   function saveSetting(req, res, form)
   {
     Config.updateNamespaceByArray('crowi', form, function(err, config) {

+ 8 - 0
lib/routes/index.js

@@ -44,6 +44,13 @@ 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/fb'    , loginRequired(crowi, app) , middleware.adminRequired() , form.admin.fb , admin.api.appSetting);
 
+  // notification admin
+  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.get('/admin/notification/slackAuth'    , loginRequired(crowi, app) , middleware.adminRequired() , admin.notification.slackAuth);
+  app.post('/_api/admin/notification.add'    , loginRequired(crowi, app) , middleware.adminRequired() , admin.api.notificationAdd);
+  app.post('/_api/admin/notification.remove' , loginRequired(crowi, app) , middleware.adminRequired() , admin.api.notificationRemove);
+
   app.get('/admin/users'                , loginRequired(crowi, app) , middleware.adminRequired() , admin.user.index);
   app.post('/admin/user/invite'         , form.admin.userInvite ,  loginRequired(crowi, app) , middleware.adminRequired() , admin.user.invite);
   app.post('/admin/user/:id/makeAdmin'  , loginRequired(crowi, app) , middleware.adminRequired() , admin.user.makeAdmin);
@@ -79,6 +86,7 @@ module.exports = function(crowi, app) {
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   app.get('/_api/users.list'          , accessTokenParser(crowi, app) , loginRequired(crowi, app) , user.api.list);
   app.get('/_api/pages.get'           , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.get);
+  app.get('/_api/pages.updatePost'    , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.getUpdatePost);
   app.post('/_api/pages.seen'         , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.seen);
   app.post('/_api/pages.rename'       , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.rename);
   app.get('/_api/comments.get'        , accessTokenParser(crowi, app) , loginRequired(crowi, app) , comment.api.get);

+ 74 - 9
lib/routes/page.js

@@ -135,7 +135,6 @@ module.exports = function(crowi, app) {
 
           return Bookmark.findByUser(userData, {limit: 10, populatePage: true, requestUser: req.user});
         }).then(function(bookmarkList) {
-          debug(bookmarkList);
           renderVars.bookmarkList = bookmarkList;
 
           return Page.findListByCreator(userData, {limit: 10});
@@ -220,7 +219,15 @@ module.exports = function(crowi, app) {
     var grant = pageForm.grant;
     var path = pageForm.path;
 
+    // TODO: make it pluggable
+    var notify = pageForm.notify || {};
+
+    debug('notify: ', notify);
+
     var redirectPath = encodeURI(path);
+    var pageData = {};
+    var updateOrCreate;
+    var previousRevision = false;
 
     // set to render
     res.locals.pageForm = pageForm;
@@ -232,26 +239,51 @@ module.exports = function(crowi, app) {
 
     var ignoreNotFound = true;
     Page.findPage(path, req.user, null, ignoreNotFound)
-    .then(function(pageData) {
+    .then(function(data) {
+      pageData = data;
+
       if (!req.form.isValid) {
-        return renderPage(pageData, req, res);
+        debug('Form data not valid');
+        throw new Error('Form data not valid.');
       }
 
-      if (pageData && !pageData.isUpdatable(currentRevision)) {
+      if (data && !data.isUpdatable(currentRevision)) {
+        debug('Conflict occured');
         req.form.errors.push('すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。');
-        return renderPage(pageData, req, res);
+        throw new Error('Conflict.');
       }
 
-      if (pageData) {
+      if (data) {
+        previousRevision = data.revision;
         // update existing page
-        var newRevision = Revision.prepareRevision(pageData, body, req.user);
-        return Page.pushRevision(pageData, newRevision, req.user);
+        var newRevision = Revision.prepareRevision(data, body, req.user);
+        updateOrCreate = 'update';
+        return Page.pushRevision(data, newRevision, req.user);
       } else {
         // new page
+        updateOrCreate = 'create';
         return Page.create(path, body, req.user, {grant: grant});
       }
     }).then(function(data) {
+      // data is a saved page data.
+      pageData = data;
+      if (!data) {
+        throw new Error('Data not found');
+      }
+      // TODO: move to events
       crowi.getIo().sockets.emit('page edited', {page: data, user: req.user});
+      if (notify.slack) {
+        if (notify.slack.on && notify.slack.channel) {
+          data.updateSlackChannel(notify.slack.channel).then(function(){}).catch(function(){});
+
+          if (crowi.slack) {
+            notify.slack.channel.split(',').map(function(chan) {
+              var message = crowi.slack.prepareSlackMessage(pageData, req.user, chan, updateOrCreate, previousRevision);
+              crowi.slack.post(message).then(function(){}).catch(function(){});
+            });
+          }
+        }
+      }
 
       if (grant != data.grant) {
         return Page.updateGrant(data, grant, req.user).then(function(data) {
@@ -261,7 +293,11 @@ module.exports = function(crowi, app) {
         return res.redirect(redirectPath);
       }
     }).catch(function(err) {
-      debug('Create or edit page error', err);
+      debug('Page create or edit error.', err);
+      if (pageData && !req.form.isValid) {
+        return renderPage(pageData, req, res);
+      }
+
       return res.redirect(redirectPath);
     });
   };
@@ -461,6 +497,35 @@ module.exports = function(crowi, app) {
     });
   };
 
+  /**
+   * @api {get} /pages.updatePost
+   * @apiName Get UpdatePost setting list
+   * @apiGroup Page
+   *
+   * @apiParam {String} path
+   */
+  api.getUpdatePost = function(req, res) {
+    var path = req.query.path;
+    var UpdatePost = crowi.model('UpdatePost');
+
+    if (!path) {
+      return res.json(ApiResponse.error({}));
+    }
+
+    UpdatePost.findSettingsByPath(path)
+    .then(function(data) {
+      data = data.map(function(e) {
+        return e.channel;
+      });
+      debug('Found updatePost data', data);
+      var result = {updatePost: data};
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      debug('Error occured while get setting', err);
+      return res.json(ApiResponse.error({}));
+    });
+  };
+
   /**
    * @api {post} /pages.rename Rename page
    * @apiName SeenPage

+ 26 - 0
lib/service/notification.js

@@ -0,0 +1,26 @@
+'use strict';
+
+function Notification (crowi)
+{
+  this.crowi = crowi;
+  this.config = crowi.getConfig();
+}
+
+Notification.prototype.hasSlackConfig = function()
+{
+  if (!this.config.notification['slack']) {
+    return false;
+  }
+
+  //var config = ;
+};
+
+Notification.prototype.noitfyByEmail = function()
+{
+};
+
+Notification.prototype.noitfyByChat = function()
+{
+};
+
+module.exports = Notification;

+ 194 - 0
lib/util/slack.js

@@ -0,0 +1,194 @@
+/**
+ * slack
+ */
+
+module.exports = function(crowi) {
+  'use strict';
+
+  var debug = require('debug')('crowi:util:slack'),
+    Config = crowi.model('Config'),
+    Botkit = require('botkit'),
+    sprintf = require('sprintf'),
+    bot = null,
+    slack = {};
+  slack.controller = undefined;
+
+  slack.createBot = function() {
+    // alreay created
+    if (bot) {
+      return bot;
+    }
+
+    var config = crowi.getConfig();
+
+    if (!slack.controller) {
+      slack.configureSlackApp();
+    }
+
+    if (!slack.controller) {
+      return false;
+    }
+
+    if (Config.hasSlackToken(config)) {
+      bot = slack.controller.spawn({token: config.notification['slack:token']});
+    } else {
+      bot = slack.controller.spawn();
+    }
+    return bot;
+  };
+
+  slack.configureSlackApp = function ()
+  {
+    var config = crowi.getConfig();
+    if (Config.hasSlackConfig(config)) {
+      slack.controller = Botkit.slackbot();
+      slack.controller.configureSlackApp({
+        clientId: config.notification['slack:clientId'],
+        clientSecret: config.notification['slack:clientSecret'],
+        redirectUri: slack.getSlackAuthCallbackUrl(),
+        scopes: ['chat:write:bot']
+      });
+
+      return true;
+    }
+
+    return false;
+  }
+
+  // hmmm
+  slack.getSlackAuthCallbackUrl = function()
+  {
+    var config = crowi.getConfig();
+    // Web アクセスがきてないと app:url がセットされないので crowi.setupSlack 時にはできない
+    // cli, bot 系作るときに問題なりそう
+    return (config.crowi['app:url'] || '') + '/admin/notification/slackAuth';
+  }
+
+  slack.getAuthorizeURL = function () {
+    if (!slack.controller) {
+      slack.configureSlackApp();
+    }
+
+    if (!slack.controller) {
+      return '';
+    }
+
+    return slack.controller.getAuthorizeURL();
+  }
+
+  slack.post = function (message) {
+    var bot = slack.createBot();
+
+    return new Promise(function(resolve, reject) {
+      bot.api.chat.postMessage(message, function(err, res) {
+        if (err) {
+          debug('Post error', err, res);
+          debug('Sent data to slack is:', message);
+          return reject(err);
+        }
+
+        resolve(res);
+      });
+    });
+  };
+
+  slack.convertMarkdownToMrkdwn = function(body) {
+    var config = crowi.getConfig();
+    var url = '';
+    if (config.crowi && config.crowi['app:url']) {
+      url = config.crowi['app:url'];
+    }
+
+    body = body
+      .replace(/\n\*\s(.+)/g, '\n• $1')
+      .replace(/#{1,}\s?(.+)/g, '\n*$1*')
+      .replace(/(\[(.+)\]\((https?:\/\/.+)\))/g, '<$3|$2>')
+      .replace(/(\[(.+)\]\((\/.+)\))/g, '<' + url + '$3|$2>')
+      ;
+
+    return body;
+  };
+
+  slack.prepareAttachmentTextForCreate = function(page, user) {
+    var body = page.revision.body;
+    if (body.length > 2000) {
+      body = body.substr(0, 2000) + '...';
+    }
+
+    return this.convertMarkdownToMrkdwn(body);
+  };
+
+  slack.prepareAttachmentTextForUpdate = function(page, user, previousRevision) {
+    var diff = require('diff');
+    var diffText = ''
+
+    diff.diffLines(previousRevision.body, page.revision.body).forEach(function(line) {
+      debug('diff line', line)
+      var value = line.value.replace(/\r\n|\r/g, '\n');
+      if (line.added) {
+        diffText += sprintf(':pencil2: ...\n%s', line.value);
+      } else if (line.removed) {
+        // diffText += '-' + line.value.replace(/(.+)?\n/g, '- $1\n');
+        // 1以下は無視
+        if (line.count > 1) {
+          diffText += sprintf(':wastebasket: ... %s lines\n', line.count);
+        }
+      } else {
+        //diffText += '...\n';
+      }
+    });
+
+    debug('diff is', diffText)
+
+    return diffText;
+  };
+
+  slack.prepareSlackMessage = function(page, user, channel, updateType, previousRevision) {
+    var config = crowi.getConfig();
+    var url = config.crowi['app:url'] || '';
+    var body = page.revision.body;
+
+    if (updateType == 'create') {
+      body = this.prepareAttachmentTextForCreate(page, user);
+    } else {
+      body = this.prepareAttachmentTextForUpdate(page, user, previousRevision);
+    }
+
+    var attachment = {
+      color: '#263a3c',
+      author_name: '@' + user.username,
+      author_link: url + '/user/' + user.username,
+      author_icon: user.image,
+      title: page.path,
+      title_link: url + page.path,
+      text: body,
+      mrkdwn_in: ["text"],
+    };
+    if (user.image) {
+      attachment.author_icon = user.image;
+    }
+
+    var message = {
+      channel: '#' + channel,
+      username: 'Crowi',
+      text: this.getSlackMessageText(page.path, user, updateType),
+      attachments: [attachment],
+    };
+
+    return message;
+  };
+
+  slack.getSlackMessageText = function(path, user, updateType) {
+    var text;
+
+    if (updateType == 'create') {
+      text = sprintf(':white_check_mark: %s created a new page! %s', user.username, path);
+    } else {
+      text = sprintf(':up: %s updated %s', user.username, path);
+    }
+
+    return text;
+  };
+
+  return slack;
+};

+ 8 - 0
lib/util/swigFunctions.js

@@ -15,6 +15,14 @@ module.exports = function(crowi, app, locals) {
     return config.crowi['google:clientId'] && config.crowi['google:clientSecret'];
   };
 
+  locals.slackConfigured = function() {
+    var config = crowi.getConfig()
+    if (Config.hasSlackToken(config)) {
+      return true;
+    }
+    return false;
+  };
+
   locals.isUploadable = function() {
     var config = crowi.getConfig()
     return Config.isUploadable(config);

+ 21 - 2
lib/views/_form.html

@@ -9,7 +9,7 @@
 </div>
 {% endif %}
 <div id="form-box" class="row">
-  <form action="/_/edit" id="page-form" method="post" class="col-md-6 {% if isUploadable() %}uploadable{% endif %}">
+  <form action="/_/edit" id="page-form" method="post" class="col-md-6 {% if isUploadable() %}uploadable{% endif %} page-form">
     <textarea name="pageForm[body]" class="form-control" id="form-body">{% if pageForm.body %}{{ pageForm.body }}{% elseif not page.revision.body %}# {{ path|path2name }}{% else %}{{ page.revision.body }}{% endif %}</textarea>
 
     <input type="hidden" name="pageForm[path]" value="{{ path }}">
@@ -20,7 +20,26 @@
         ファイルを追加 ...
       </button>#}
 
-      <div class="pull-right form-inline">
+      <div class="pull-right form-inline page-form-setting" id="page-form-setting" data-slack-configured="{{ slackConfigured() }}">
+        {% if slackConfigured() %}
+        <span class="input-group extended-setting">
+          <span class="input-group-addon">
+            <label>
+              <i class="fa fa-slack"></i>
+              <input class="" type="checkbox" name="pageForm[notify][slack][on]" value="1">
+            </label>
+          </span>
+          <input class="form-control" type="text" name="pageForm[notify][slack][channel]" value="{{ page.extended.slack|default('') }}" placeholder="slack-channel-name"
+            id="page-form-slack-channel"
+            data-toggle="popover"
+            title="Slack通知"
+            data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
+            data-trigger="focus"
+            data-placement="top"
+          >
+        </span>
+        {% endif %}
+
         {% if forceGrant %}
         <input type="hidden" name="pageForm[grant]" value="{{ forceGrant }}">
         {% else %}

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

@@ -1,4 +1,4 @@
-{% extends '../layout/2column.html' %}
+{% extends '../layout/admin.html' %}
 
 {% block html_title %}アプリ設定 · {% endblock %}
 
@@ -31,6 +31,7 @@
       <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>
     </div>
@@ -45,7 +46,6 @@
             <input class="form-control" type="text" name="settingForm[app:title]" value="{{ settingForm['app:title'] }}">
 
             <p class="help-block">ヘッダーやHTMLタイトルに使用されるWikiの名前を変更できます。</p>
-            </p>
           </div>
         </div>
 
@@ -328,7 +328,3 @@
 {% block content_footer %}
 {% endblock content_footer %}
 
-{% block footer %}
-{% endblock footer %}
-
-

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

@@ -1,4 +1,4 @@
-{% extends '../layout/2column.html' %}
+{% extends '../layout/admin.html' %}
 
 {% block html_title %}Wiki管理 · {{ path }}{% endblock %}
 
@@ -17,6 +17,7 @@
       <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>
     </div>
@@ -34,7 +35,3 @@
 {% block content_footer %}
 {% endblock content_footer %}
 
-{% block footer %}
-{% endblock footer %}
-
-

+ 178 - 0
lib/views/admin/notification.html

@@ -0,0 +1,178 @@
+{% 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">
+      <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>
+    </div>
+    <div class="col-md-9">
+
+      <ul class="nav nav-tabs">
+        <li class="active"><a href="#slack" data-toggle="tab"><i class="fa fa-slack"></i> Slack</a></li>
+      </ul>
+
+      <br>
+
+      {% 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/notification/slackSetting" method="post" class="form-horizontal" id="appSettingForm" role="form">
+      <fieldset>
+        <legend>Slack App Configuration</legend>
+        <div class="form-group">
+          <label for="slackSetting[slack:clientId]" class="col-xs-3 control-label">clientId</label>
+          <div class="col-xs-6">
+            <input class="form-control" type="text" name="slackSetting[slack:clientId]" value="{{ slackSetting['slack:clientId'] }}">
+          </div>
+        </div>
+
+        <div class="form-group">
+          <label for="slackSetting[slack:clientSecret]" class="col-xs-3 control-label">clientSecret</label>
+          <div class="col-xs-6">
+            <input class="form-control" type="text" name="slackSetting[slack:clientSecret]" value="{{ slackSetting['slack:clientSecret'] }}">
+          </div>
+        </div>
+
+        <div class="form-group">
+          <div class="col-xs-offset-3 col-xs-6">
+            <button type="submit" class="btn btn-primary">Submit</button>
+          </div>
+        </div>
+      </fieldset>
+      </form>
+
+      {% if hasSlackConfig %}
+      <div class="text-center">
+        {% if hasSlackToken %}
+        <p>Crowi and Slack is already <strong>connected</strong>. You can re-connect to refresh and overwirte the token with your Slack account.</p>
+        <a class="btn btn-default" href="{{ slackAuthUrl }}">
+          <i class="fa fa-slack"></i> Reconnect to Slack
+        </a>
+        {% else %}
+        <p>Slack clientId and clientSecret is configured. Now, you can connect with Slack.</p>
+        <a class="btn btn-primary" href="{{ slackAuthUrl }}">
+          <i class="fa fa-slack"></i> Connect to Slack
+        </a>
+        {% endif %}
+      </div>
+
+      <hr>
+
+      <h4>Default Notification Settings for Patterns</h4>
+
+      <table class="table table-bordered">
+        <thead>
+          <th>Pattern</th>
+          <th>Channel</th>
+          <th>Operation</th>
+        </thead>
+        <tbody class="admin-notif-list">
+          <form id="slackNotificationForm">
+          <tr>
+            <td>
+              <input class="form-control" type="text" name="pathPattern" value="" placeholder="e.g. /projects/xxx/MTG/*">
+              <p class="help-block">
+                Path name of wiki. Pattern expression with <code>*</code> can be used.
+              </p>
+            </td>
+            <td>
+              <input class="form-control form-inline" type="text" name="channel" value="" placeholder="e.g. project-xxx">
+              <p class="help-block">
+                Slack channel name. Without <code>#</code>.
+              </p>
+            </td>
+            <td>
+              <input type="submit" value="Add" class="btn btn-primary">
+            </td>
+          </tr>
+          </form>
+
+          {% for notif in settings %}
+          <tr class="admin-notif-row" data-updatepost-id="{{ notif._id.toString() }}">
+            <td>
+              {{ notif.pathPattern }}
+            </td>
+            <td>
+              {{ notif.channel }}
+            </td>
+            <td>
+              <form class="admin-remove-updatepost">
+                <input type="hidden" name="id" value="{{ notif._id.toString() }}">
+                <input type="submit" value="Delete" class="btn btn-default">
+              </form>
+            </td>
+          </tr>
+          {% endfor %}
+        </tbody>
+      </table>
+
+
+      {% endif %}
+
+      {% if not hasSlackConfig %}
+      <h3>How to configure Slack app for Crowi</h3>
+      <p>
+      Register Crowi as a Slack application, the notification feature for Slack can be enabled.
+      </p>
+      <h4>1. Register Slack App</h4>
+      <p>
+      Create App from this link, and fill the form out as below:
+      </p>
+      <dl class="dl-horizontal">
+        <dt>App Name</dt> <dd><code>Crowi</code> </dd>
+        <dt>Icon</dt> <dd>Upload this image as the icon (Free to download and use it) =&gt; <img src=""></dd>
+        <dt>Short description</dt> <dd><code>Crowi's Slack Notification Integration</code> </dd>
+        <dt>Long description</dt> <dd><code>Crowi's Slack Notification Integration</code> </dd>
+      </dl>
+      <p>
+      and <strong>Save</strong> it.
+      </p>
+
+      <h4>2. Get <code>clientId</code> and <code>clientSecret</code></h4>
+      <h4>3. Configure Slack on this notification setting screen</h4>
+      {% endif %}
+
+
+
+    </div>
+  </div>
+
+</div>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock content_footer %}
+
+
+

+ 0 - 0
lib/views/admin/slackauth.html


+ 1 - 0
lib/views/admin/slackauthstart.html

@@ -0,0 +1 @@
+<a href="{{ url }}">auth</a>

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

@@ -1,4 +1,4 @@
-{% extends '../layout/2column.html' %}
+{% extends '../layout/admin.html' %}
 
 {% block html_title %}ユーザー管理 · {% endblock %}
 
@@ -31,6 +31,7 @@
       <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>
     </div>
@@ -215,8 +216,4 @@
 {% block content_footer %}
 {% endblock content_footer %}
 
-{% block footer %}
-{% endblock footer %}
-
-
 

+ 6 - 0
lib/views/layout/admin.html

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

+ 16 - 18
lib/views/page.html

@@ -143,7 +143,7 @@
               <a class="diff-view" data-revision-id="{{ t._id.toString() }}">
                 <i id="diff-icon-{{ t._id.toString() }}" class="fa fa-arrow-circle-right"></i> 差分を見る
               </a>
-              <pre class="fk-hide" id="diff-display-{{ t._id.toString()}}"></pre>
+              <pre class="" id="diff-display-{{ t._id.toString()}}" style="display: none"></pre>
             </div>
           </div>
         </div>
@@ -155,6 +155,21 @@
 
   </div>
   {% endif %}
+
+<div id="notifPageEdited" class="fk-notif fk-notif-danger"><i class="fa fa-exclamation-triangle"></i> <span class="edited-user"></span>さんがこのページを編集しました。 <a href="javascript:location.reload();"><i class="fa fa-angle-double-right"></i> 最新版を読み込む</a></div>
+<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>
 
 {% block content_main_after %}
@@ -188,23 +203,6 @@
 {% endblock %}
 
 {% block footer %}
-<div id="notifPageEdited" class="fk-hide fk-notif fk-notif-danger"><i class="fa fa-exclamation-triangle"></i> <span class="edited-user"></span>さんがこのページを編集しました。 <a href="javascript:location.reload();"><i class="fa fa-angle-double-right"></i> 最新版を読み込む</a></div>
-<div id="notifPageEditing" class="fk-hide fk-notif fk-notif-warning"><i class="fa fa-exclamation-triangle"></i> 他の人がこのページの編集を開始しました。</div>
-
-
-<script>
-  $(function() {
-    var me = {{ user|json|safe }};
-    var socket = io();
-    socket.on('page edited', function (data) {
-      if (data.user._id != me._id
-        && data.page.path == {{ page.path|json|safe }}) {
-        $('#notifPageEdited').removeClass('fk-hide').css({bottom: 0});
-        $('#notifPageEdited .edited-user').html(data.user.name);
-      }
-    });
-  });
-</script>
 {% endblock %}
 
 {% block body_end %}

+ 1 - 0
package.json

@@ -34,6 +34,7 @@
     "bluebird": "~3.0.5",
     "body-parser": "~1.14.1",
     "bootstrap-sass": "~3.3.6",
+    "botkit": "~0.1.1",
     "browserify": "~12.0.1",
     "cli": "~0.6.0",
     "connect-flash": "~0.1.1",

+ 8 - 0
resource/css/_form.scss

@@ -78,6 +78,14 @@
             content: "Preview";
           }
         }
+
+        .page-form-setting {
+          .extended-setting {
+            label {
+              margin-bottom: 0;
+            }
+          }
+        }
       }
     }
 

+ 1 - 1
resource/css/_wiki.scss

@@ -41,7 +41,7 @@ div.body {
 
   .revision-head-link {
     display: none;
-    font-size: 15px;
+    font-size: 0.6em;
     padding-top: 8px;
     padding-left: 10px;
   }

+ 9 - 6
resource/css/crowi.scss

@@ -257,16 +257,15 @@ input.searching {
   }
 }
 
-.fk-hide {
-  display: none;
-}
-
 // notification
 .fk-notif {
+  display: none;
+
+  bottom: 0;
+  left: 0;
   width: 100%;
   position: fixed;
-  bottom: -80px;
-  z-index: 8;
+  z-index: 1029;
   padding: 10px;
   box-shadow: -1px 0 3px 0px #666;
   font-weight: bold;
@@ -290,6 +289,10 @@ input.searching {
     color: #8a6d3b;
   }
 }
+.on-edit .fk-notif {
+  bottom: 50px;
+  z-index: 1061;
+}
 
 // external-services
 .crowi {

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

@@ -0,0 +1,24 @@
+$(function() {
+  var UpdatePost = {};
+
+  $('#slackNotificationForm').on('submit', function(e) {
+    $.post('/_api/admin/notification.add', $(this).serialize(), function(res) {
+      if (res.ok) {
+        // TODO Fix
+        location.reload();
+      }
+    });
+
+    return false;
+  });
+
+  $('form.admin-remove-updatepost').on('submit', function(e) {
+    $.post('/_api/admin/notification.remove', $(this).serialize(), function(res) {
+      if (res.ok) {
+        // TODO Fix
+        location.reload();
+      }
+    });
+    return false;
+  });
+});

+ 62 - 0
resource/js/crowi-form.js

@@ -1,4 +1,46 @@
 $(function() {
+  var pageId = $('#content-main').data('page-id');
+  var pagePath= $('#content-main').data('path');
+
+  // show/hide
+  function FetchPagesUpdatePostAndInsert(path) {
+    $.get('/_api/pages.updatePost', {path: path}, function(res) {
+      if (res.ok) {
+        var $slackChannels = $('#page-form-slack-channel');
+        $slackChannels.val(res.updatePost.join(','));
+      }
+    });
+  }
+
+  var slackConfigured = $('#page-form-setting').data('slack-configured');
+
+  // for new page
+  if (!pageId) {
+    if (slackConfigured) {
+      FetchPagesUpdatePostAndInsert(pagePath);
+    }
+  }
+
+  $('a[data-toggle="tab"][href="#edit-form"]').on('show.bs.tab', function() {
+    $('.content-main').addClass('on-edit');
+
+    if (slackConfigured) {
+      var $slackChannels = $('#page-form-slack-channel');
+      var slackChannels = $slackChannels.val();
+      // if slackChannels is empty, then fetch default (admin setting)
+      // if not empty, it means someone specified this setting for the page.
+      if (slackChannels === '') {
+        FetchPagesUpdatePostAndInsert(pagePath);
+      }
+    }
+  });
+
+  $('a[data-toggle="tab"][href="#edit-form"]').on('hide.bs.tab', function() {
+    $('.content-main').removeClass('on-edit');
+  });
+
+  $('[data-toggle="popover"]').popover();
+
   // preview watch
   var originalContent = $('#form-body').val();
   var prevContent = "";
@@ -12,6 +54,26 @@ $(function() {
     }
   }, 500);
 
+  // edit detection
+  var isFormChanged = false;
+  $(window).on('beforeunload', function(e) {
+    if (isFormChanged) {
+      return '編集中の内容があります。内容を破棄してページを移動しますか?';
+    }
+  });
+  $('#form-body').on('keyup change', function(e) {
+    var content = $('#form-body').val();
+    if (originalContent != content) {
+      isFormChanged = true;
+    } else {
+      isFormChanged = false;
+    }
+  });
+  $('#page-form').on('submit', function(e) {
+    // avoid message
+    isFormChanged = false;
+  });
+
   var getCurrentLine = function(event) {
     var $target = $(event.target);
 

+ 1 - 7
resource/js/crowi.js

@@ -225,19 +225,13 @@ $(function() {
   var revisionCreatedAt = $('#content-main').data('page-revision-created');
   var currentUser = $('#content-main').data('current-user');
   var isSeen = $('#content-main').data('page-is-seen');
+  var pagePath= $('#content-main').data('path');
 
   Crowi.linkPath();
 
   $('[data-toggle="tooltip"]').tooltip();
   $('[data-tooltip-stay]').tooltip('show');
 
-  $('a[data-toggle="tab"][href="#edit-form"]').on('show.bs.tab', function() {
-    $('.content-main').addClass('on-edit');
-  });
-  $('a[data-toggle="tab"][href="#edit-form"]').on('hide.bs.tab', function() {
-    $('.content-main').removeClass('on-edit');
-  });
-
 
   $('.copy-link').on('click', function () {
     $(this).select();

+ 35 - 2
test/models/page.test.js

@@ -52,8 +52,14 @@ describe('Page', function () {
           path: '/grant/owner',
           grant: Page.GRANT_OWNER,
           grantedUsers: [testUser0],
-          creator: testUser0
-        }
+          creator: testUser0,
+        },
+        {
+          path: '/page/for/extended',
+          grant: Page.GRANT_PUBLIC,
+          creator: testUser0,
+          extended: {hoge: 1}
+        },
       ];
 
       testDBUtil.generateFixture(conn, 'Page', fixture)
@@ -161,4 +167,31 @@ describe('Page', function () {
       });
     });
   });
+
+  describe('Extended field', function () {
+    context('Slack Channel.', function() {
+      it('should be empty', function(done) {
+        Page.findOne({path: '/page/for/extended'}, function(err, page) {
+          expect(page.extended.hoge).to.be.equal(1);
+          expect(page.getSlackChannel()).to.be.equal('');
+          done();
+        })
+      });
+
+      it('set slack channel and should get it and should keep hoge ', function(done) {
+        Page.findOne({path: '/page/for/extended'}, function(err, page) {
+          page.updateSlackChannel('slack-channel1')
+          .then(function(data) {
+            Page.findOne({path: '/page/for/extended'}, function(err, page) {
+              expect(page.extended.hoge).to.be.equal(1);
+              expect(page.getSlackChannel()).to.be.equal('slack-channel1');
+              done();
+            });
+          })
+        });
+      });
+
+    });
+  });
+
 });

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

@@ -0,0 +1,54 @@
+var chai = require('chai')
+  , expect = chai.expect
+  , sinon = require('sinon')
+  , sinonChai = require('sinon-chai')
+  , Promise = require('bluebird')
+  , utils = require('../utils.js')
+  ;
+chai.use(sinonChai);
+
+describe('UpdatePost', function () {
+  var UpdatePost = utils.models.UpdatePost,
+    conn   = utils.mongoose.connection;
+
+  describe('.createPrefixesByPathPattern', function () {
+    context('with a path', function() {
+      it('should return right patternPrfixes', function(done) {
+        expect(UpdatePost.createPrefixesByPathPattern('/*')).to.deep.equal(['*', '*']);
+        expect(UpdatePost.createPrefixesByPathPattern('/user/*/日報*')).to.deep.equal(['user', '*']);
+        expect(UpdatePost.createPrefixesByPathPattern('/project/hoge/*')).to.deep.equal(['project', 'hoge']);
+        expect(UpdatePost.createPrefixesByPathPattern('/*/MTG/*')).to.deep.equal(['*', 'MTG']);
+        expect(UpdatePost.createPrefixesByPathPattern('自己紹介')).to.deep.equal(['*', '*']);
+        expect(UpdatePost.createPrefixesByPathPattern('/user/aoi/メモ/2016/02/10/xxx')).to.deep.equal(['user', 'aoi']);
+
+        done();
+      });
+    });
+  });
+
+  describe('.getRegExpByPattern', function () {
+    context('with a pattern', function() {
+      it('should return right regexp', function(done) {
+        expect(UpdatePost.getRegExpByPattern('/*')).to.deep.equal(/^\/.*/);
+        expect(UpdatePost.getRegExpByPattern('/user/*/日報*')).to.deep.equal(/^\/user\/.*\/日報.*/);
+        expect(UpdatePost.getRegExpByPattern('/project/hoge/*')).to.deep.equal(/^\/project\/hoge\/.*/);
+        expect(UpdatePost.getRegExpByPattern('/*/MTG/*')).to.deep.equal(/^\/.*\/MTG\/.*/);
+        expect(UpdatePost.getRegExpByPattern('自己紹介')).to.deep.equal(/^\/.*自己紹介.*/);
+        expect(UpdatePost.getRegExpByPattern('\/user\/aoi\/メモ\/2016\/02\/10\/xxx')).to.deep.equal(/^\/user\/aoi\/メモ\/2016\/02\/10\/xxx/);
+        done();
+      });
+    });
+  });
+
+  describe('.normalizeChannelName', function () {
+    context('with a channel name', function() {
+      it('should return true', function(done) {
+        expect(UpdatePost.normalizeChannelName('#pj-hoge')).to.be.equal('pj-hoge');
+        expect(UpdatePost.normalizeChannelName('pj-hoge')).to.be.equal('pj-hoge');
+
+        done();
+      });
+    });
+  });
+});
+

+ 18 - 0
test/util/slack.test.js

@@ -0,0 +1,18 @@
+var chai = require('chai')
+  , expect = chai.expect
+  , sinon = require('sinon')
+  , sinonChai = require('sinon-chai')
+  , utils = require('../utils.js')
+  ;
+chai.use(sinonChai);
+
+describe('Slack Util', function () {
+  var crowi = new (require(ROOT_DIR + '/lib/crowi'))(ROOT_DIR, process.env);
+  var slack = require(crowi.libDir + '/util/slack')(crowi);
+
+  it('convert markdown', function() {
+    var markdown = '# ほげほげ\n\n* aaa\n* bbb\n* ccc\n\n## ほげほげほげ\n\n[Yahoo! Japan](http://www.yahoo.co.jp/) is here\n**Bold** and *Italic*';
+    var markdownConverted = '\n*ほげほげ*\n\n• aaa\n• bbb\n• ccc\n\n\n*ほげほげほげ*\n\n<http://www.yahoo.co.jp/|Yahoo! Japan> is here\n**Bold** and *Italic*';
+    expect(slack.convertMarkdownToMrkdwn(markdown)).to.be.equal(markdownConverted);
+  });
+});

+ 1 - 0
test/utils.js

@@ -47,6 +47,7 @@ models.Page     = require(MODEL_DIR + '/page.js')(crowi);
 models.User     = require(MODEL_DIR + '/user.js')(crowi);
 models.Config   = require(MODEL_DIR + '/config.js')(crowi);
 models.Revision = require(MODEL_DIR + '/revision.js')(crowi);
+models.UpdatePost = require(MODEL_DIR + '/updatePost.js')(crowi);
 
 crowi.models = models;