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

Merge pull request #210 from crowi/wip-v1.6.1

Prepare v1.6.1
Sotaro KARASAWA 9 лет назад
Родитель
Сommit
33adcd02e7

+ 13 - 0
CHANGES.md

@@ -1,6 +1,19 @@
 CHANGES
 ========
 
+## 1.6.1
+
+- Feature: Attachment remove #205
+- Feature: Attachment redirector (and proxy).
+- Fix: Not render Emoji in code block #202
+- Fix: Order of parsing access token order
+- Changes: Page name with spaces around `/` is now not creatable
+- API Changes:
+    - Changed `attachments.add`
+    - Add `attachments.remove`
+- Library Update: Now using webpack2, React.js 15.5
+- And some bug fixes, internal fixes. (Thank you @yuki-takei)
+
 ## 1.6.0
 
 - I18N

+ 0 - 1
README.md

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

+ 1 - 0
lib/crowi/express-init.js

@@ -71,6 +71,7 @@ module.exports = function(crowi, app) {
         language:   User.getLanguageLabels(),
         registrationMode: Config.getRegistrationModeLabels(),
     };
+    res.locals.local_config = Config.getLocalconfig(config); // config for browser context
 
     next();
   });

+ 2 - 1
lib/form/admin/app.js

@@ -5,6 +5,7 @@ var form = require('express-form')
 
 module.exports = form(
   field('settingForm[app:title]').required(),
-  field('settingForm[app:confidential]')
+  field('settingForm[app:confidential]'),
+  field('settingForm[app:fileUpload]').isInt()
 );
 

+ 16 - 10
lib/models/attachment.js

@@ -108,16 +108,22 @@ module.exports = function(crowi) {
   attachmentSchema.statics.removeAttachmentsByPageId = function(pageId) {
     var Attachment = this;
 
-    // todo
-    return Promise.resolve();
-    //return new Promise(function(resolve, reject) {
-    //  // target find
-    //  Attachment.getListByPageId(pageId)
-    //  .then(function(attachments) {
-    //  }).then(function(done) {
-    //  }).catch(function(err) {
-    //  });
-    //});
+    return new Promise((resolve, reject) => {
+      Attachment.getListByPageId(pageId)
+      .then((attachments) => {
+        for (attachment of attachments) {
+          Attachment.removeAttachment(attachment).then((res) => {
+            // do nothing
+          }).catch((err) => {
+            debug('Attachment remove error', err);
+          });
+        }
+
+        resolve(attachments);
+      }).catch((err) => {
+        reject(err);
+      });
+    });
 
   };
 

+ 31 - 2
lib/models/config.js

@@ -23,6 +23,8 @@ module.exports = function(crowi) {
       'app:title'         : 'Crowi',
       'app:confidential'  : '',
 
+      'app:fileUpload'    : false,
+
       'security:registrationMode'      : 'Open',
       'security:registrationWhiteList' : [],
 
@@ -37,8 +39,6 @@ module.exports = function(crowi) {
       'mail:smtpUser'     : '',
       'mail:smtpPassword' : '',
 
-      'searcher:url': '',
-
       'google:clientId'     : '',
       'google:clientSecret' : '',
 
@@ -180,6 +180,17 @@ module.exports = function(crowi) {
     return method != 'none';
   };
 
+  configSchema.statics.fileUploadEnabled = function(config)
+  {
+    const Config = this;
+
+    if (!Config.isUploadable(config)) {
+      return false;
+    }
+
+    return config.crowi['app:fileUpload'] || false;
+  };
+
   configSchema.statics.hasSlackConfig = function(config)
   {
     if (!config.notification) {
@@ -206,6 +217,24 @@ module.exports = function(crowi) {
     return true;
   };
 
+  configSchema.statics.getLocalconfig = function(config)
+  {
+    const Config = this;
+
+    const local_config = {
+      crowi: {
+        title: config.crowi['app:title'],
+        url: config.crowi['app:url'] || '',
+      },
+      upload: {
+        image: Config.isUploadable(config),
+        file: Config.fileUploadEnabled(config),
+      },
+    };
+
+    return local_config;
+  }
+
   /*
   configSchema.statics.isInstalled = function(config)
   {

+ 57 - 1
lib/models/page.js

@@ -342,6 +342,8 @@ module.exports = function(crowi) {
       path = '/' + path;
     }
 
+    path = path.replace(/\/\s+?/g, '/').replace(/\s+\//g, '/');
+
     return path;
   };
 
@@ -384,6 +386,7 @@ module.exports = function(crowi) {
       /^\/user\/[^\/]+\/(bookmarks|comments|activities|pages|recent-create|recent-edit)/, // reserved
       /^\/?https?:\/\/.+$/, // avoid miss in renaming
       /\/{2,}/,             // avoid miss in renaming
+      /\s+\/\s+/,           // avoid miss in renaming
       /.+\/edit$/,
       /.+\.md$/,
       /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments)(\/.*|$)/,
@@ -536,6 +539,20 @@ module.exports = function(crowi) {
     });
   };
 
+  pageSchema.statics.findPageByRedirectTo = function(path) {
+    var Page = this;
+
+    return new Promise(function(resolve, reject) {
+      Page.findOne({redirectTo: path}, function(err, pageData) {
+        if (err || pageData === null) {
+          return reject(err);
+        }
+
+        return resolve(pageData);
+      });
+    });
+  };
+
   pageSchema.statics.findListByCreator = function(user, option, currentUser) {
     var Page = this;
     var User = crowi.model('User');
@@ -905,9 +922,11 @@ module.exports = function(crowi) {
         return Revision.removeRevisionsByPath(pageData.path);
       }).then(function(done) {
         return Page.removePageById(pageId);
+      }).then(function(done) {
+        return Page.removeRedirectOriginPageByPath(pageData.path);
       }).then(function(done) {
         pageEvent.emit('delete', pageData, user); // update as renamed page
-        resolve();
+        resolve(pageData);
       }).catch(reject);
     });
   };
@@ -927,6 +946,43 @@ module.exports = function(crowi) {
     });
   };
 
+  pageSchema.statics.removePageByPath = function(pagePath) {
+    var Page = this;
+
+    return Page.findPageByPath(redirectPath)
+      .then(function(pageData) {
+        return Page.removePageById(pageData.id);
+      });
+  };
+
+  /**
+   * remove the page that is redirecting to specified `pagePath` recursively
+   *  ex: when
+   *    '/page1' redirects to '/page2' and
+   *    '/page2' redirects to '/page3'
+   *    and given '/page3',
+   *    '/page1' and '/page2' will be removed
+   *
+   * @param {string} pagePath
+   */
+  pageSchema.statics.removeRedirectOriginPageByPath = function(pagePath) {
+    var Page = this;
+
+    return Page.findPageByRedirectTo(pagePath)
+      .then((redirectOriginPageData) => {
+        // remove
+        return Page.removePageById(redirectOriginPageData.id)
+          // remove recursive
+          .then(() => {
+            return Page.removeRedirectOriginPageByPath(redirectOriginPageData.path)
+          });
+      })
+      .catch((err) => {
+        // do nothing if origin page doesn't exist
+        return Promise.resolve();
+      })
+  };
+
   pageSchema.statics.rename = function(pageData, newPagePath, user, options) {
     var Page = this
       , Revision = crowi.model('Revision')

+ 0 - 1
lib/routes/attachment.js

@@ -145,7 +145,6 @@ module.exports = function(crowi, app) {
             page: page.toObject(),
             attachment: data.toObject(),
             url: fileUrl,
-            filename: fileUrl, // this is for inline-attachemnets plugin http://inlineattachment.readthedocs.io/en/latest/pages/configuration.html
             pageCreated: pageCreated,
           };
 

+ 1 - 0
lib/routes/index.js

@@ -104,6 +104,7 @@ module.exports = function(crowi, app) {
   app.post('/_api/pages.rename'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.rename);
   app.post('/_api/pages.remove'       , loginRequired(crowi, app) , csrf, page.api.remove); // (Avoid from API Token)
   app.post('/_api/pages.revertRemove' , loginRequired(crowi, app) , csrf, page.api.revertRemove); // (Avoid from API Token)
+  app.post('/_api/pages.unlink'       , loginRequired(crowi, app) , csrf, page.api.unlink); // (Avoid from API Token)
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(crowi, app) , comment.api.get);
   app.post('/_api/comments.add'       , form.comment, accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.add);
   app.get( '/_api/bookmarks.get'      , accessTokenParser , loginRequired(crowi, app) , bookmark.api.get);

+ 45 - 32
lib/routes/page.js

@@ -189,36 +189,6 @@ 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);
-    });
-  };
-
   function renderPage(pageData, req, res) {
     // create page
     if (!pageData) {
@@ -229,7 +199,7 @@ module.exports = function(crowi, app) {
     }
 
     if (pageData.redirectTo) {
-      return res.redirect(encodeURI(pageData.redirectTo + '?renamed=' + pageData.path));
+      return res.redirect(encodeURI(pageData.redirectTo + '?redirectFrom=' + pageData.path));
     }
 
     var renderVars = {
@@ -307,6 +277,11 @@ module.exports = function(crowi, app) {
       return renderPage(page, req, res);
     }).catch(function(err) {
 
+      const normalizedPath = Page.normalizePath(path);
+      if (normalizedPath !== path) {
+        return res.redirect(normalizedPath);
+      }
+
       // pageShow は /* にマッチしてる最後の砦なので、creatableName でない routing は
       // これ以前に定義されているはずなので、こうしてしまって問題ない。
       if (!Page.isCreatableName(path)) {
@@ -384,7 +359,7 @@ module.exports = function(crowi, app) {
 
       if (data && !data.isUpdatable(currentRevision)) {
         debug('Conflict occured');
-        req.form.errors.push('すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。');
+        req.form.errors.push('page_edit.notice.conflict');
         throw new Error('Conflict.');
       }
 
@@ -804,10 +779,19 @@ module.exports = function(crowi, app) {
     var pageId = req.body.page_id;
     var previousRevision = req.body.revision_id || null;
 
+    // get completely flag
+    const isCompletely = (req.body.completely !== undefined);
+
     Page.findPageByIdAndGrantedUser(pageId, req.user)
     .then(function(pageData) {
       debug('Delete page', pageData._id, pageData.path);
 
+      if (isCompletely) {
+        return Page.completelyDeletePage(pageData, req.user);
+      }
+
+      // else
+
       if (!pageData.isUpdatable(previousRevision)) {
         throw new Error('Someone could update this page, so couldn\'t delete.');
       }
@@ -901,5 +885,34 @@ module.exports = function(crowi, app) {
     });
   };
 
+  /**
+   * @api {post} /pages.unlink Remove the redirecting page
+   * @apiName UnlinkPage
+   * @apiGroup Page
+   *
+   * @apiParam {String} page_id Page Id.
+   * @apiParam {String} revision_id
+   */
+  api.unlink = function(req, res){
+    var pageId = req.body.page_id;
+
+    Page.findPageByIdAndGrantedUser(pageId, req.user)
+    .then(function(pageData) {
+      debug('Unlink page', pageData._id, pageData.path);
+
+      return Page.removeRedirectOriginPageByPath(pageData.path)
+        .then(() => pageData);
+    }).then(function(data) {
+      debug('Redirect Page deleted', data.path);
+      var result = {};
+      result.page = data;
+
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      debug('Error occured while get setting', err, err.stack);
+      return res.json(ApiResponse.error('Failed to delete redirect page.'));
+    });
+  };
+
   return actions;
 };

+ 1 - 1
lib/views/_form.html

@@ -2,7 +2,7 @@
 <div class="alert alert-danger">
   <ul>
   {% for error in req.form.errors %}
-    <li>{{ error }}</li>
+    <li>{{ t(error) }}</li>
   {% endfor %}
 
   </ul>

+ 20 - 0
lib/views/admin/app.html

@@ -52,6 +52,26 @@
           </div>
         </div>
 
+        <div class="form-group">
+          <div class="col-xs-offset-3 col-xs-6">
+            <input type="checkbox" name="settingForm[app:fileUpload]" value="1"
+              {% if settingForm['app:fileUpload'] %}
+              checked
+              {% endif %}
+              {% if not isUploadable() %}
+              disabled="disabled"
+              {% else %}
+              {% endif %}
+              >
+              <label for="settingForm[app:fileUpload]" class="">画像以外のファイルアップロードを許可</label>
+
+              <p class="help-block">
+                ファイルアップロードの設定を有効にしている場合にのみ、選択可能です。<br>
+                許可をしている場合、画像以外のファイルをページに添付可能になります。
+              </p>
+          </div>
+        </div>
+
         <div class="form-group">
           <div class="col-xs-offset-3 col-xs-6">
             <input type="hidden" name="_csrf" value="{{ csrf() }}">

+ 4 - 0
lib/views/layout/layout.html

@@ -142,6 +142,10 @@
 </body>
 {% endblock %}
 
+<script type="application/json" id="crowi-context-hydrate">
+{{ local_config|json|safe }}
+</script>
+
 <script src="{{ assets('/js/crowi.js') }}"></script>
 <script src="{{ assets('/js/app.js') }}"></script>
 </html>

+ 1 - 7
lib/views/modal/rename.html

@@ -20,13 +20,6 @@
                 <input type="text" class="form-control" name="new_path" id="newPageName" value="{{ page.path }}">
               </div>
             </div>
-            <div class="checkbox">
-               <label>
-                 <input name="create_redirect" value="1"  type="checkbox"> {{ t('Redirect') }}
-               </label>
-               <p class="help-block"> {{ t('modal_rename.help.redirect', page.path) }}
-               </p>
-            </div>
             {# <div class="checkbox"> #}
             {#    <label> #}
             {#      <input name="moveUnderTrees" value="1" type="checkbox"> 下層ページも全部移動する #}
@@ -37,6 +30,7 @@
         </div>
         <div class="modal-footer">
           <p><small class="pull-left" id="newPageNameCheck"></small></p>
+          <input type="hidden" name="create_redirect" value="1">
           <input type="hidden" name="_csrf" value="{{ csrf() }}">
           <input type="hidden" name="path" value="{{ page.path }}">
           <input type="hidden" name="page_id" value="{{ page._id.toString() }}">

+ 67 - 15
lib/views/page.html

@@ -17,7 +17,11 @@
       <h1 class="title flex-item-title" id="revision-path">{{ path|insertSpaceToEachSlashes }}</h1>
       {% if page %}
       <div class="flex-item-action">
-        <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-csrftoken="{{ csrf() }}" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+        <span id="bookmark-button">
+          <p class="bookmark-link">
+            <i class="fa fa-star-o"></i>
+          </p>
+        </span>
       </div>
       <div class="flex-item-action visible-xs visible-sm">
         <button
@@ -35,7 +39,11 @@
     <div class="flex-title-line">
       <h1 class="title flex-item-title">{{ path|insertSpaceToEachSlashes }}</h1>
       <div class="flex-item-action">
-        <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-csrftoken="{{ csrf() }}" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+        <span id="bookmark-button">
+          <p class="bookmark-link">
+            <i class="fa fa-star-o"></i>
+          </a>
+        </span>
       </div>
     </div>
   </header>
@@ -80,20 +88,37 @@
   {% else %}
 
   {% if page.isDeleted() %}
-  <div class="alert alert-danger">
-    <form role="form" class="pull-right" id="revert-delete-page-form" onsubmit="return false;">
-      <input type="hidden" name="_csrf" value="{{ csrf() }}">
-      <input type="hidden" name="path" value="{{ page.path }}">
-      <input type="hidden" name="page_id" value="{{ page._id.toString() }}">
-      <input type="submit" class="btn btn-danger btn-inverse btn-sm" value="Put Back!">
-    </form>
-    <p>
+  <div class="alert alert-danger alert-trash">
+    <div>
+      <ul class="list-inline pull-right">
+        <li>
+          <form role="form" id="revert-delete-page-form" onsubmit="return false;">
+            <input type="hidden" name="_csrf" value="{{ csrf() }}">
+            <input type="hidden" name="path" value="{{ page.path }}">
+            <input type="hidden" name="page_id" value="{{ page._id.toString() }}">
+            <button type="submit" class="btn btn-default btn-sm">
+              <i class="fa fa-undo" aria-hidden="true"></i>
+              Put Back
+            </button>
+          </form>
+        </li>
+        <li>
+          <form role="form" id="delete-page-form" onsubmit="return false;">
+            <input type="hidden" name="_csrf" value="{{ csrf() }}">
+            <input type="hidden" name="path" value="{{ page.path }}">
+            <input type="hidden" name="page_id" value="{{ page._id.toString() }}">
+            <input type="hidden" name="completely" value="true">
+            <button type="submit" class="btn btn-danger btn-sm">
+              <i class="fa fa-times-circle" aria-hidden="true"></i>
+              Delete Completely
+            </button>
+          </form>
+        </li>
+      </ul>{# /.pull-right #}
       <i class="fa fa-trash-o" aria-hidden="true"></i>
       This page is in the trash.<br>
-    </p>
-    <p>
-    Deleted by <img src="{{ page.lastUpdateUser|picture }}" class="picture picture-sm picture-rounded"> {{ page.lastUpdateUser.name }} at {{ page.updatedAt|datetz('Y-m-d H:i:s') }}
-    </p>
+      Deleted by <img src="{{ page.lastUpdateUser|picture }}" class="picture picture-sm picture-rounded"> {{ page.lastUpdateUser.name }} at {{ page.updatedAt|datetz('Y-m-d H:i:s') }}
+    </div>
   </div>
   {% endif %}
 
@@ -132,10 +157,37 @@
 
   <div class="tab-content wiki-content">
   {% if req.query.renamed and not page.isDeleted() %}
+  <div class="alert alert-info alert-moved">
+    <span>
+      <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.query.renamed) }}
+    </span>
+  </div>
+  {% endif %}
+  {% if req.query.redirectFrom and not page.isDeleted() %}
+  <div class="alert alert-info alert-moved">
+    <div>
+      <form role="form" id="unlink-page-form" onsubmit="return false;">
+        <input type="hidden" name="_csrf" value="{{ csrf() }}">
+        <input type="hidden" name="path" value="{{ page.path }}">
+        <input type="hidden" name="page_id" value="{{ page._id.toString() }}">
+        <button type="submit" class="btn btn-default btn-sm pull-right">
+          <i class="fa fa-unlink" aria-hidden="true"></i>
+          Unlink
+        </button>
+      </form>
+      <span>
+        <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.query.redirectFrom) }}
+      </span>
+    </div>
+  </div>
+  {% endif %}
+  {% if req.query.unlinked %}
   <div class="alert alert-info">
-    <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.query.renamed) }}
+    <strong>{{ t('Unlinked') }}: </strong> {{ t('page_page.notice.unlinked') }}
   </div>
   {% endif %}
+
+
   {% if not page.isLatestRevision() %}
   <div class="alert alert-warning">
     <strong>{{ t('Warning') }}: </strong> {{ t('page_page.notice.version') }} <i class="fa fa-magic"></i> <a href="{{ page.path }}">{{ t('Show latest') }}</a>

+ 1 - 1
lib/views/page_list.html

@@ -31,7 +31,7 @@
       </h1>
       {% if page %}
       <div class="flex-item-action">
-        <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-csrftoken="{{ csrf() }}" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+        <span id="bookmark-button" data-csrftoken="{{ csrf() }}"></span>
       </div>
       <div class="flex-item-action visible-xs visible-sm">
         <button

+ 1 - 1
lib/views/user_page.html

@@ -10,7 +10,7 @@
   <h1 class="title" id="revision-path">{{ path|insertSpaceToEachSlashes }}</h1>
   <div class="user-page-header">
   {% if page %}
-    <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+    <span id="bookmark-button" data-csrftoken="{{ csrf() }}"></span>
   {% endif %}
     <div class="pull-left user-page-picture">
       <img src="{{ pageUser|picture }}" class="picture picture-rounded">

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

@@ -4,6 +4,7 @@
   "Delete": "Delete",
   "Move": "Move",
   "Moved": "Moved",
+  "Unlinked": "Unlinked",
   "Like!": "Like!",
   "Seen by": "Seen by",
   "Cancel": "Cancel",
@@ -134,10 +135,17 @@
       "notice": {
           "version": "This is not the current version.",
           "moved": "This page was moved from <code>%s</code>",
+          "unlinked": "Redirect pages to this page have been deleted.",
           "restricted": "Access to this page is restricted"
       }
   },
 
+  "page_edit": {
+      "notice": {
+          "conflict": "Couldn't save the changes you made because someone else was editing this page. Please re-edit the affected section after reloading the page."
+      }
+  },
+
   "Rename page": "Rename page",
   "New page name": "New page name",
   "Current page name": "Current page name",

+ 8 - 0
locales/ja/translation.json

@@ -4,6 +4,7 @@
   "Delete": "削除",
   "Move": "移動",
   "Moved": "移動しました",
+  "Unlinked": "リダイレクト削除",
   "Like!": "いいね!",
   "Seen by": "見た人",
   "Cancel": "キャンセル",
@@ -134,10 +135,17 @@
       "notice": {
           "version": "これは現在の版ではありません。",
           "moved": "このページは <code>%s</code> から移動しました。",
+          "unlinked": "このページへのリダイレクトは削除されました。",
           "restricted": "このページの閲覧は制限されています"
       }
   },
 
+  "page_edit": {
+      "notice": {
+          "conflict": "すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。"
+      }
+  },
+
   "Rename page": "ページを移動する",
   "New page name": "移動先のページ名",
   "Current page name": "現在のページ名",

+ 1 - 1
npm-shrinkwrap.json

@@ -1,6 +1,6 @@
 {
   "name": "crowi",
-  "version": "1.6.0",
+  "version": "1.6.1",
   "dependencies": {
     "abbrev": {
       "version": "1.1.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "crowi",
-  "version": "1.6.0",
+  "version": "1.6.1",
   "description": "The simple & powerful Wiki",
   "tags": [
     "wiki",

+ 7 - 0
resource/css/_attachments.scss

@@ -2,6 +2,13 @@
 .page-attachments {
   .attachment-in-use {
     margin: 0 0 0 4px;
+    padding: 1px 5px;
+  }
+
+  .attachment-filetype {
+    margin: 0 0 0 4px;
+    padding: 1px 5px;
+    font-weight: normal;
   }
 
   .attachment-delete {

+ 16 - 0
resource/css/_page.scss

@@ -100,6 +100,22 @@
       .tab-content {
         margin-top: 30px;
       }
+
+      // alert component settings for trash page and moved page
+      // see: https://jsfiddle.net/me420sky/2/
+      .alert-trash, .alert-moved {
+        padding: 10px 15px;
+
+        span {
+          line-height: 25px;
+        }
+
+        >div:after {
+          clear: both;
+          content: '';
+          display: table;
+        }
+      }
     }
   } // }}}
 

+ 3 - 0
resource/js/app.js

@@ -10,6 +10,7 @@ import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
 import PageAttachment   from './components/PageAttachment';
 import SeenUserList     from './components/SeenUserList';
+import BookmarkButton   from './components/BookmarkButton';
 //import PageComment  from './components/PageComment';
 
 if (!window) {
@@ -33,6 +34,7 @@ const crowi = new Crowi({
   csrfToken: $('#content-main').data('csrftoken'),
 }, window);
 window.crowi = crowi;
+crowi.setConfig(JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}'));
 crowi.fetchUsers();
 
 const crowiRenderer = new CrowiRenderer();
@@ -47,6 +49,7 @@ const componentMappings = {
   //'revision-history': <PageHistory pageId={pageId} />,
   //'page-comment': <PageComment />,
   'seen-user-list': <SeenUserList />,
+  'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
 };
 
 Object.keys(componentMappings).forEach((key) => {

+ 67 - 0
resource/js/components/BookmarkButton.js

@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Icon from './Common/Icon'
+
+export default class BookmarkButton extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      bookmarked: false,
+    };
+
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  componentDidMount() {
+    this.props.crowi.apiGet('/bookmarks.get', {page_id: this.props.pageId})
+    .then(res => {
+      if (res.bookmark) {
+        this.markBookmarked();
+      }
+    });
+  }
+
+  handleClick(event) {
+    event.preventDefault();
+
+    const pageId = this.props.pageId;
+
+    if (!this.state.bookmarked) {
+      this.props.crowi.apiPost('/bookmarks.add', {page_id: pageId})
+      .then(res => {
+        this.markBookmarked();
+      });
+    } else {
+      this.props.crowi.apiPost('/bookmarks.remove', {page_id: pageId})
+      .then(res => {
+        this.markUnBookmarked();
+      });
+    }
+  }
+
+  markBookmarked() {
+    this.setState({bookmarked: true});
+  }
+
+  markUnBookmarked() {
+    this.setState({bookmarked: false});
+  }
+
+  render() {
+    const iconName = this.state.bookmarked ? 'star' : 'star-o';
+
+    return (
+      <a href="#" title="Bookmark" className="bookmark-link" onClick={this.handleClick}>
+        <Icon name={iconName} />
+      </a>
+    );
+  }
+}
+
+BookmarkButton.propTypes = {
+  pageId: PropTypes.string,
+  crowi: PropTypes.object.isRequired,
+};

+ 4 - 0
resource/js/components/PageAttachment/Attachment.js

@@ -33,6 +33,8 @@ export default class Attachment extends React.Component {
       fileInUse = <span className="attachment-in-use label label-info">In Use</span>;
     }
 
+    const fileType = <span className="attachment-filetype label label-default">{attachment.fileFormat}</span>;
+
     return (
       <li>
           <User user={attachment.creator} />
@@ -40,6 +42,8 @@ export default class Attachment extends React.Component {
 
           <a href={attachment.url}> {attachment.originalName}</a>
 
+          {fileType}
+
           {fileInUse}
 
           <a className="text-danger attachment-delete" onClick={this._onAttachmentDeleteClicked}><Icon name="trash-o" /></a>

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

@@ -425,12 +425,31 @@ $(function() {
         _csrf: csrfToken
       },
       progressText: '(Uploading file...)',
-      urlText: "\n![file]({filename})\n"
+      jsonFieldName: 'url',
     };
 
+    // if files upload is set
+    var config = crowi.getConfig();
+    if (config.upload.file) {
+      attachmentOption.allowedTypes = '*';
+    }
+
+    attachmentOption.remoteFilename = function(file) {
+      return file.name;
+    };
+
+    attachmentOption.onFileReceived = function(file) {
+      // if not image
+      if (!file.type.match(/^image\/.+$/)) {
+        // modify urlText with 'a' tag
+        this.settings.urlText = `[${file.name}]({filename})\n`;
+      } else {
+        this.settings.urlText = `![${file.name}]({filename})\n`;
+      }
+    }
+
     attachmentOption.onFileUploadResponse = function(res) {
       var result = JSON.parse(res.response);
-      console.log(result);
 
       if (result.ok && result.pageCreated) {
         var page = result.page,

+ 18 - 46
resource/js/crowi.js

@@ -320,6 +320,24 @@ $(function() {
 
     return false;
   });
+  $('#unlink-page-form').submit(function(e) {
+    $.ajax({
+      type: 'POST',
+      url: '/_api/pages.unlink',
+      data: $('#unlink-page-form').serialize(),
+      dataType: 'json'
+    }).done(function(res) {
+      if (!res.ok) {
+        $('#delete-errors').html('<i class="fa fa-times-circle"></i> ' + res.error);
+        $('#delete-errors').addClass('alert-danger');
+      } else {
+        var page = res.page;
+        top.location.href = page.path + '?unlinked=true';
+      }
+    });
+
+    return false;
+  });
 
   $('#create-portal-button').on('click', function(e) {
     $('.portal').removeClass('hide');
@@ -558,52 +576,6 @@ $(function() {
       return false;
     });
 
-    // bookmark
-    var $bookmarkButton = $('#bookmark-button');
-    $.get('/_api/bookmarks.get', {page_id: pageId}, function(res) {
-      if (res.ok) {
-        if (res.bookmark) {
-          MarkBookmarked();
-        }
-      }
-    });
-
-    $bookmarkButton.click(function() {
-      var bookmarked = $bookmarkButton.data('bookmarked');
-      var token = $bookmarkButton.data('csrftoken');
-      if (!bookmarked) {
-        $.post('/_api/bookmarks.add', {_csrf: token, page_id: pageId}, function(res) {
-          if (res.ok && res.bookmark) {
-            MarkBookmarked();
-          }
-        });
-      } else {
-        $.post('/_api/bookmarks.remove', {_csrf: token, page_id: pageId}, function(res) {
-          if (res.ok) {
-            MarkUnBookmarked();
-          }
-        });
-      }
-
-      return false;
-    });
-
-    function MarkBookmarked()
-    {
-      $('i', $bookmarkButton)
-        .removeClass('fa-star-o')
-        .addClass('fa-star');
-      $bookmarkButton.data('bookmarked', 1);
-    }
-
-    function MarkUnBookmarked()
-    {
-      $('i', $bookmarkButton)
-        .removeClass('fa-star')
-        .addClass('fa-star-o');
-      $bookmarkButton.data('bookmarked', 0);
-    }
-
     // Like
     var $likeButton = $('.like-button');
     var $likeCount = $('#like-count');

+ 9 - 0
resource/js/util/Crowi.js

@@ -7,6 +7,7 @@ import axios from 'axios'
 export default class Crowi {
   constructor(context, window) {
     this.context = context;
+    this.config = {};
     this.csrfToken = context.csrfToken;
 
     this.location = window.location || {};
@@ -33,6 +34,14 @@ export default class Crowi {
     return context;
   }
 
+  setConfig(config) {
+    this.config = config;
+  }
+
+  getConfig() {
+    return this.config;
+  }
+
   recoverData() {
     const keys = [
       'userByName',

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

@@ -145,6 +145,7 @@ describe('Page', function () {
       expect(Page.isCreatableName('http://demo.crowi.wiki/user/sotarok/hoge')).to.be.false;
       expect(Page.isCreatableName('https://demo.crowi.wiki/user/sotarok/hoge')).to.be.false;
 
+      expect(Page.isCreatableName('/ the / path / with / space')).to.be.false;
 
       var forbidden = ['installer', 'register', 'login', 'logout', 'admin', 'files', 'trash', 'paste', 'comments'];
       for (var i = 0; i < forbidden.length ; i++) {
@@ -264,4 +265,18 @@ describe('Page', function () {
     });
   });
 
+  describe('Normalize path', function () {
+    context('Normalize', function() {
+      it('should start with slash', function(done) {
+        expect(Page.normalizePath('hoge/fuga')).to.equal('/hoge/fuga');
+        done();
+      });
+
+      it('should trim spaces of slash', function(done) {
+        expect(Page.normalizePath('/ hoge / fuga')).to.equal('/hoge/fuga');
+        done();
+      });
+    });
+  });
+
 });