1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-16 07:08:55 +00:00

Review comment (#2459)

This commit is contained in:
MichaelAn
2018-10-23 13:13:44 +08:00
committed by Daniel Pan
parent 68377bf495
commit 26f9d7d9d7
16 changed files with 885 additions and 46 deletions

View File

@@ -98,6 +98,30 @@
"react-popper": "^0.8.3",
"react-transition-group": "^2.2.1"
}
},
"unified": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/unified/-/unified-6.2.0.tgz",
"integrity": "sha512-1k+KPhlVtqmG99RaTbAv/usu85fcSRu3wY8X+vnsEhIxNP5VbVIDiXnLqyKIG+UMdyTg0ZX9EI6k2AfjJkHPtA==",
"requires": {
"bail": "^1.0.0",
"extend": "^3.0.0",
"is-plain-obj": "^1.1.0",
"trough": "^1.0.0",
"vfile": "^2.0.0",
"x-is-string": "^0.1.0"
}
},
"vfile": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-2.3.0.tgz",
"integrity": "sha512-ASt4mBUHcTpMKD/l5Q+WJXNtshlWxOogYyGYYrg4lt/vuRjC1EFQtlAofL5VmtVNIZJzWYFJjzGWZ0Gw8pzW1w==",
"requires": {
"is-buffer": "^1.1.4",
"replace-ext": "1.0.0",
"unist-util-stringify-position": "^1.0.0",
"vfile-message": "^1.0.0"
}
}
}
},
@@ -1896,7 +1920,7 @@
},
"browserify-rsa": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz",
"resolved": "http://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz",
"integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=",
"dev": true,
"requires": {
@@ -1947,7 +1971,7 @@
},
"buffer": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz",
"resolved": "http://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz",
"integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=",
"dev": true,
"requires": {
@@ -3838,7 +3862,7 @@
},
"events": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz",
"resolved": "http://registry.npmjs.org/events/-/events-1.1.1.tgz",
"integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=",
"dev": true
},
@@ -9775,6 +9799,32 @@
"remark-parse": "^5.0.0",
"remark-stringify": "^5.0.0",
"unified": "^6.0.0"
},
"dependencies": {
"unified": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/unified/-/unified-6.2.0.tgz",
"integrity": "sha512-1k+KPhlVtqmG99RaTbAv/usu85fcSRu3wY8X+vnsEhIxNP5VbVIDiXnLqyKIG+UMdyTg0ZX9EI6k2AfjJkHPtA==",
"requires": {
"bail": "^1.0.0",
"extend": "^3.0.0",
"is-plain-obj": "^1.1.0",
"trough": "^1.0.0",
"vfile": "^2.0.0",
"x-is-string": "^0.1.0"
}
},
"vfile": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-2.3.0.tgz",
"integrity": "sha512-ASt4mBUHcTpMKD/l5Q+WJXNtshlWxOogYyGYYrg4lt/vuRjC1EFQtlAofL5VmtVNIZJzWYFJjzGWZ0Gw8pzW1w==",
"requires": {
"is-buffer": "^1.1.4",
"replace-ext": "1.0.0",
"unist-util-stringify-position": "^1.0.0",
"vfile-message": "^1.0.0"
}
}
}
},
"remark-breaks": {
@@ -10175,9 +10225,9 @@
}
},
"seafile-js": {
"version": "0.2.26",
"resolved": "https://registry.npmjs.org/seafile-js/-/seafile-js-0.2.26.tgz",
"integrity": "sha512-iKP2nLBCDE2G4MoiVdtQ4JlKY3vUByb7dwbwOV/pC8OdSxYcpu1zmCFl45TLnhz8B6wOlc7EC00ByZAvyTaZOQ==",
"version": "0.2.28",
"resolved": "https://registry.npmjs.org/seafile-js/-/seafile-js-0.2.28.tgz",
"integrity": "sha512-+QA16BLpNVrvYIHfvrU/0D0x90bb1gCzW31U2BeFdL1CT3vgdLXaVc+j0XAtDahBYTyKtu7YKGPR6UO0ZdlXdw==",
"requires": {
"axios": "^0.18.0",
"form-data": "^2.3.2"
@@ -11183,7 +11233,7 @@
},
"yargs": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz",
"resolved": "http://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz",
"integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=",
"dev": true,
"requires": {
@@ -11210,15 +11260,15 @@
}
},
"unified": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/unified/-/unified-6.2.0.tgz",
"integrity": "sha512-1k+KPhlVtqmG99RaTbAv/usu85fcSRu3wY8X+vnsEhIxNP5VbVIDiXnLqyKIG+UMdyTg0ZX9EI6k2AfjJkHPtA==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/unified/-/unified-7.0.0.tgz",
"integrity": "sha512-j+Sm7upmmt3RXPBeA+KFGYBlHBxClnby2DtxezFKwMfhWTAklY4WbEdhwRo6c6GpuHdi04YDsyPKY/kh5a/xnQ==",
"requires": {
"bail": "^1.0.0",
"extend": "^3.0.0",
"is-plain-obj": "^1.1.0",
"trough": "^1.0.0",
"vfile": "^2.0.0",
"vfile": "^3.0.0",
"x-is-string": "^0.1.0"
}
},
@@ -11499,14 +11549,21 @@
}
},
"vfile": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-2.3.0.tgz",
"integrity": "sha512-ASt4mBUHcTpMKD/l5Q+WJXNtshlWxOogYyGYYrg4lt/vuRjC1EFQtlAofL5VmtVNIZJzWYFJjzGWZ0Gw8pzW1w==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-3.0.0.tgz",
"integrity": "sha512-X2DiPHL9Nxgfyu5DNVgtTkZtD4d4Zzf7rVBVI+uXP2pWWIQG8Ri+xAP9KdH/sB6SS0a1niWp5bRF88n4ciwhoA==",
"requires": {
"is-buffer": "^1.1.4",
"is-buffer": "^2.0.0",
"replace-ext": "1.0.0",
"unist-util-stringify-position": "^1.0.0",
"vfile-message": "^1.0.0"
},
"dependencies": {
"is-buffer": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz",
"integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw=="
}
}
},
"vfile-location": {
@@ -11671,7 +11728,7 @@
},
"load-json-file": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
"resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
"integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=",
"dev": true,
"requires": {
@@ -11794,13 +11851,13 @@
"dependencies": {
"ansi-regex": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-1.1.1.tgz",
"resolved": "http://registry.npmjs.org/ansi-regex/-/ansi-regex-1.1.1.tgz",
"integrity": "sha1-QchHGUZGN15qGl0Qw8oFTvn8mA0=",
"dev": true
},
"strip-ansi": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-2.0.1.tgz",
"resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-2.0.1.tgz",
"integrity": "sha1-32LBqpTtLxFOHQ8h/R1QSCt5pg4=",
"dev": true,
"requires": {
@@ -11961,7 +12018,7 @@
},
"yargs": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-6.6.0.tgz",
"resolved": "http://registry.npmjs.org/yargs/-/yargs-6.6.0.tgz",
"integrity": "sha1-eC7CHvQDNF+DCoCMo9UTr1YGUgg=",
"dev": true,
"requires": {

View File

@@ -25,11 +25,13 @@
"react-dom": "^16.5.2",
"react-moment": "^0.7.9",
"reactstrap": "^6.4.0",
"seafile-js": "^0.2.26",
"seafile-js": "^0.2.28",
"seafile-ui": "^0.1.10",
"sw-precache-webpack-plugin": "0.11.4",
"unified": "^7.0.0",
"url-loader": "0.6.2",
"url-parse": "^1.4.3",
"vfile": "^3.0.0",
"whatwg-fetch": "2.0.3"
},
"scripts": {

View File

@@ -0,0 +1,198 @@
import React from 'react';
import { processor } from "../../utils/seafile-markdown2html";
import { Button, Input, Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap';
import { seafileAPI } from '../../utils/seafile-api';
import { draftID, reviewID, gettext } from '../../utils/constants';
import moment from 'moment';
import Loading from '../../components/loading.js';
import '../../css/review-comments.css';
class ReviewComments extends React.Component {
constructor(props) {
super(props);
this.state = {
commentsList: [],
userAvatar: `${window.location.host}media/avatars/default.png`,
}
this.accountInfo = {};
}
listComments = () => {
seafileAPI.listReviewComments(reviewID).then((response) => {
this.setState({
commentsList: response.data.comments
});
});
}
getUserAvatar = () => {
seafileAPI.getAccountInfo().then((res) => {
this.accountInfo = res.data;
this.setState({
userAvatar: res.data.avatar_url,
})
})
}
handleCommentChange = (event) => {
this.setState({
comment: event.target.value,
});
}
submitComment = () => {
let comment = this.refs.commentTextarea.value;
if (comment.trim().length > 0) {
seafileAPI.addReviewComment(reviewID, comment.trim()).then((res) => {
this.listComments();
this.props.getCommentsNumber();
});
this.refs.commentTextarea.value = '';
}
}
resolveComment = (event) => {
seafileAPI.updateReviewComment(reviewID, event.target.id, 'true').then((res) => {
this.listComments();
});
}
deleteComment = (event) => {
seafileAPI.deleteReviewComment(reviewID, event.target.id).then((res) => {
this.props.getCommentsNumber();
this.listComments();
});
}
componentWillMount() {
this.getUserAvatar();
this.listComments();
}
render() {
return (
<div className="seafile-comment">
<div className="seafile-comment-title">
<div onClick={this.props.toggleCommentList} className={'seafile-comment-title-close'}>
<i className={'fa fa-times-circle'}/>
</div>
<div className={'seafile-comment-title-text'}>{gettext('Comments')}</div>
</div>
{ this.props.commentsNumber == 0 &&
<div className={"seafile-comment-list"}>
<div className="comment-vacant">{gettext('No comment yet.')}</div>
</div>
}
{ (this.state.commentsList.length == 0 && this.props.commentsNumber > 0) &&
<div><Loading/></div>
}
<ul className={"seafile-comment-list"}>
{ (this.state.commentsList.length > 0 && this.props.commentsNumber > 0) &&
this.state.commentsList.map((item, index = 0, arr) => {
if (item.resolved) {
return
} else {
let oldTime = (new Date(item.created_at)).getTime();
let time = moment(oldTime).format("YYYY-MM-DD HH:mm");
return (
<CommentItem id={item.id} time={time} headUrl={item.avatar_url}
editorUtilities={this.props.editorUtilities}
comment={item.comment} name={item.user_name}
user_email={item.user_email} key={index}
deleteComment={this.deleteComment}
resolveComment={this.resolveComment}
commentsList={this.state.commentsList}
accountInfo={this.accountInfo}
/>
)
}
})
}
</ul>
<div className="seafile-comment-footer">
<div className="user-header">
<img className="avatar" src={this.state.userAvatar}/>
</div>
<div className="seafile-add-comment">
<textarea className="add-comment-input" ref="commentTextarea"
placeholder={gettext('Add a comment.')}
clos="100" rows="3" warp="virtual"></textarea>
<Button className="submit-comment" color="success"
size="sm" onClick={this.submitComment}>
{gettext('Submit')}</Button>
</div>
</div>
</div>
)
}
}
class CommentItem extends React.Component {
constructor(props) {
super(props);
this.state = {
dropdownOpen: false,
html: '',
}
}
toggleDropDownMenu = () => {
this.setState({
dropdownOpen: !this.state.dropdownOpen,
})
}
convertComment = (mdFile) => {
processor.process(mdFile).then(
(result) => {
let html = String(result);
this.setState({
html: html
})
}
);
}
componentWillMount() {
this.convertComment(this.props.comment);
}
componentWillReceiveProps(nextProps) {
this.convertComment(nextProps.comment);
}
render() {
return (
<li className="seafile-comment-item" id={this.props.id}>
<div className="seafile-comment-info">
<img className="avatar reviewer-head" src={this.props.headUrl} />
<div className="reviewer-info">
<div className="reviewer-name">{this.props.name}</div>
<div className="review-time">{this.props.time}</div>
</div>
<Dropdown isOpen={this.state.dropdownOpen} size="sm"
className="seafile-comment-dropdown" toggle={this.toggleDropDownMenu}>
<DropdownToggle className="seafile-comment-dropdown-btn">
<i className="fas fa-ellipsis-v"></i>
</DropdownToggle>
<DropdownMenu>
{
(this.props.user_email === this.props.accountInfo.email) &&
<DropdownItem onClick={this.props.deleteComment}
className="delete-comment" id={this.props.id}>{gettext('Delete')}</DropdownItem>
}
<DropdownItem onClick={this.props.resolveComment}
className="seafile-comment-resolved" id={this.props.id}>{gettext('Mark as resolved')}</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
<div className="seafile-comment-content" dangerouslySetInnerHTML={{ __html: this.state.html }}></div>
</li>
)
}
}
export default ReviewComments;

View File

@@ -0,0 +1,35 @@
.header .button-group {
display: flex;
}
.header .common-list-btn {
margin-right: .25em;
}
.header .common-list-btn .common-list-btn-number {
margin-left: .5em;
}
.main .cur-view-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
background-color: #fafaf9;
}
.main .cur-view-container .cur-view-content {
flex: auto;
}
.main .cur-view-container .cur-view-content-commenton {
overflow: auto;
margin-right: 30em;
}
.main .cur-view-container .seafile-comment {
position: absolute;
right: 0;
height: calc(100% - 4.5em);
}

View File

@@ -0,0 +1,123 @@
.seafile-comment {
border-left: 1px solid #e6e6dd;
background-color: #fff;
display: flex;
flex-direction: column;
flex: 0 0 auto;
min-height: 18.5em;
width: 30em;
}
.seafile-comment-title {
border-bottom: 1px solid #e5e5e5;
min-height: 3em;
line-height: 3em;
padding: 0 1em;
display: flex;
flex-direction: row;
justify-content: space-between;
background-color: #fafaf9;
}
.seafile-comment-title .seafile-comment-title-text {
width: 100%;
text-align: center;
font-weight: 700;
}
.seafile-comment-title .seafile-comment-title-close {
color: #b9b9b9;
}
.seafile-comment-title .seafile-comment-title-close:hover {
color: #888;
}
.seafile-comment-list {
height: calc(100% - 40px);
overflow-y: auto;
margin-bottom: 0;
}
.seafile-comment .loading-icon {
margin-top: 20px;
}
.seafile-comment-list .comment-vacant {
padding: 1em;
text-align: center;
}
.seafile-comment-item {
overflow-y: auto;
padding: 15px 10px;
overflow-y: hidden;
}
.seafile-comment-item .seafile-comment-info {
padding-bottom: 0.5em;
height: 3em;
display: flex;
justify-content: flex-start;
}
.seafile-comment-item .seafile-comment-info .reviewer-head {
margin-top: .3em;
}
.seafile-comment-item .seafile-comment-info .reviewer-info {
padding-left: 10px;
}
.seafile-comment-item .seafile-comment-info .review-time {
font-size: .6em;
color: #777;
}
.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown {
margin-left: auto;
}
.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown button,
.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown button:hover,
.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown button:focus
{
border: none;
box-shadow: none;
background-color: #fff;
height: 100%;
}
.seafile-comment-item .seafile-comment-info.seafile-comment-dropdown .seafile-comment-dropdown-btn {
color: #999;
}
.seafile-comment-item .seafile-comment-info .seafile-comment-dropdown:hover .seafile-comment-dropdown-btn {
color: #555;
}
.seafile-comment-item .seafile-comment-content {
margin-left: 42px;
}
.seafile-comment-item .seafile-comment-content ol,
.seafile-comment-item .seafile-comment-content ul,
.seafile-comment-item .seafile-comment-content li {
margin-left: 10px;
}
.seafile-comment-item .seafile-comment-content table,
.seafile-comment-item .seafile-comment-content th,
.seafile-comment-item .seafile-comment-content td {
border: 1px solid #333;
}
.seafile-comment-footer {
background-color: #fafaf9;
padding: 10px;
border-top: 1px solid #e5e5e5;
display: flex;
justify-content: flex-start;
min-height: 150px;
}
.seafile-comment-footer .seafile-add-comment {
margin-left: 10px;
overflow: hidden;
width: 25em;
display: flex;
flex-direction: column;
}
.seafile-comment-footer .seafile-add-comment .add-comment-input {
box-sizing: border-box;
background-color: #fff;
border: 1px solid #e6e6dd;
padding: 5px;
width: 25em;
min-height: 90px;
}
.seafile-comment-footer .seafile-add-comment .submit-comment {
margin-top: 5px;
width: 60px;
height: 28px;
min-height: 28px;
}

View File

@@ -3,12 +3,13 @@ import ReactDOM from 'react-dom';
/* eslint-disable */
import Prism from 'prismjs';
/* eslint-enable */
import { siteRoot, gettext, reviewID, draftOriginFilePath, draftFilePath, draftOriginRepoID, draftFileName, opStatus, publishFileVersion, originFileVersion } from './utils/constants';
import { siteRoot, gettext, reviewID, draftOriginFilePath, draftFilePath, draftOriginRepoID, draftFileName, opStatus, publishFileVersion, originFileVersion } from './utils/constants';
import { seafileAPI } from './utils/seafile-api';
import axios from 'axios';
import DiffViewer from '@seafile/seafile-editor/dist/viewer/diff-viewer';
import Loading from './components/loading';
import Toast from './components/toast';
import ReviewComments from './components/review-list-view/review-comments';
import 'seafile-ui';
import './assets/css/fa-solid.css';
@@ -17,6 +18,7 @@ import './assets/css/fontawesome.css';
import './css/layout.css';
import './css/initial-style.css';
import './css/toolbar.css';
import './css/draft-review.css';
require('@seafile/seafile-editor/dist/editor/code-hight-package');
@@ -28,6 +30,8 @@ class DraftReview extends React.Component {
draftOriginContent: '',
reviewStatus: opStatus,
isLoading: true,
commentsNumber: null,
isShowComments: false,
};
}
@@ -82,6 +86,25 @@ class DraftReview extends React.Component {
});
}
toggleCommentList = () => {
this.setState({
isShowComments: !this.state.isShowComments
});
}
getCommentsNumber = () => {
seafileAPI.listReviewComments(reviewID).then((res) => {
let number = res.data.total_count;
this.setState({
commentsNumber: number,
});
});
}
componentWillMount() {
this.getCommentsNumber();
}
render() {
return(
<div className="wrapper">
@@ -95,25 +118,35 @@ class DraftReview extends React.Component {
<span className="file-copywriting">{gettext('review')}</span>
</div>
</div>
{
this.state.reviewStatus === 'open' &&
<div className="cur-file-operation">
<button className="btn btn-secondary file-operation-btn" title={gettext('Close Review')} onClick={this.onCloseReview}>{gettext('Close')}</button>
<button className="btn btn-success file-operation-btn" title={gettext('Publish Review')} onClick={this.onPublishReview}>{gettext('Publish')}</button>
</div>
}
{
this.state.reviewStatus === 'finished' &&
<div className="review-state review-state-finished">{gettext('Finished')}</div>
}
{
this.state.reviewStatus === 'closed' &&
<div className="review-state review-state-closed">{gettext('Closed')}</div>
}
<div className="button-group">
<button className="btn btn-icon btn-secondary btn-active common-list-btn"
id="commentsNumber" type="button" data-active="false"
onMouseDown={this.toggleCommentList}>
<i className="fa fa-comments"></i>
{ this.state.commentsNumber > 0 &&
<span>&nbsp;{this.state.commentsNumber}</span>
}
</button>
{
this.state.reviewStatus === 'open' &&
<div className="cur-file-operation">
<button className="btn btn-secondary file-operation-btn" title={gettext('Close Review')} onClick={this.onCloseReview}>{gettext("Close")}</button>
<button className="btn btn-success file-operation-btn" title={gettext('Publish Review')} onClick={this.onPublishReview}>{gettext("Publish")}</button>
</div>
}
{
this.state.reviewStatus === 'finished' &&
<div className="review-state review-state-finished">{gettext('Finished')}</div>
}
{
this.state.reviewStatus === 'closed' &&
<div className="review-state review-state-closed">{gettext('Closed')}</div>
}
</div>
</div>
<div id="main" className="main">
<div className="cur-view-container content-container">
<div className="cur-view-content">
<div className={!this.state.isShowComments ? "cur-view-content" : "cur-view-content cur-view-content-commenton"}>
<div className="markdown-viewer-render-content article">
{this.state.isLoading ?
<Loading /> :
@@ -121,6 +154,13 @@ class DraftReview extends React.Component {
}
</div>
</div>
{ this.state.isShowComments &&
<ReviewComments
toggleCommentList={this.toggleCommentList}
commentsNumber={this.state.commentsNumber}
getCommentsNumber={this.getCommentsNumber}
/>
}
</div>
</div>
</div>
@@ -131,4 +171,4 @@ class DraftReview extends React.Component {
ReactDOM.render (
<DraftReview />,
document.getElementById('wrapper')
);
);

View File

@@ -0,0 +1,54 @@
var unified = require('unified');
var markdown = require('remark-parse');
var slug = require('remark-slug');
var breaks = require('remark-breaks');
var remark2rehype = require('remark-rehype');
var format = require('rehype-format');
var raw = require('rehype-raw');
var xtend = require('xtend');
var toHTML = require('hast-util-to-html');
var sanitize = require('hast-util-sanitize');
var gh = require('hast-util-sanitize/lib/github');
var deepmerge = require('deepmerge').default;
function stringify(config) {
var settings = xtend(config, this.data('settings'));
var schema = deepmerge(gh, {
"attributes":{
"input": [
"type",
],
"li": [
"className"
],
"code":[
"className",
],
},
"tagNames": [
"input",
"code"
]
});
this.Compiler = compiler;
function compiler(tree) {
var hast = sanitize(tree, schema);
return toHTML(hast, settings);
}
}
var processor = unified()
.use(markdown, {commonmark: true})
.use(breaks)
.use(slug)
.use(remark2rehype, {allowDangerousHTML: true})
.use(raw)
.use(format)
.use(stringify);
var processorGetAST = unified()
.use(markdown, {commonmark: true})
.use(slug)
export { processor, processorGetAST };

View File

@@ -0,0 +1,211 @@
# Copyright (c) 2012-2016 Seafile Ltd.
import logging
from rest_framework import status
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from seaserv import seafile_api
from django.core.urlresolvers import reverse
from seahub.api2.authentication import TokenAuthentication
from seahub.api2.throttling import UserRateThrottle
from seahub.api2.utils import api_error, user_to_dict, to_python_boolean
from seahub.avatar.settings import AVATAR_DEFAULT_SIZE
from seahub.utils.repo import get_repo_owner
from seahub.api2.endpoints.utils import generate_links_header_for_paginator
from seahub.views import check_folder_permission
from seahub.drafts.models import DraftReview, ReviewComment
from seahub.drafts.signals import comment_review_successful
logger = logging.getLogger(__name__)
class ReviewCommentsView(APIView):
authentication_classes = (TokenAuthentication, SessionAuthentication)
permission_classes = (IsAuthenticated, )
throttle_classes = (UserRateThrottle, )
def get(self, request, review_id, format=None):
"""List all comments of a review.
"""
# resource check
try:
r = DraftReview.objects.get(pk=review_id)
except DraftReview.DoesNotExist:
return api_error(status.HTTP_404_NOT_FOUND,
'Review %s not found' % review_id)
resolved = request.GET.get('resolved', None)
if resolved not in ('true', 'false', None):
error_msg = 'resolved invalid.'
return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
# permission check
if check_folder_permission(request, r.origin_repo_id, '/') is None:
return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.')
try:
avatar_size = int(request.GET.get('avatar_size',
AVATAR_DEFAULT_SIZE))
page = int(request.GET.get('page', '1'))
per_page = int(request.GET.get('per_page', '25'))
except ValueError:
avatar_size = AVATAR_DEFAULT_SIZE
page = 1
per_page = 25
start = (page - 1) * per_page
end = page * per_page
total_count = ReviewComment.objects.filter(review_id=r).count()
comments = []
if resolved is None:
file_comments = ReviewComment.objects.filter(review_id=r)
else:
comment_resolved = to_python_boolean(resolved)
file_comments = ReviewComment.objects.filter(review_id=r, resolved=comment_resolved)[start: end]
for file_comment in file_comments:
comment = file_comment.to_dict()
comment.update(user_to_dict(file_comment.author, request=request, avatar_size=avatar_size))
comments.append(comment)
result = {'comments': comments, 'total_count': total_count}
resp = Response(result)
base_url = reverse('api2-review-comments', args=[review_id])
links_header = generate_links_header_for_paginator(base_url, page,
per_page, total_count)
resp['Links'] = links_header
return resp
def post(self, request, review_id, format=None):
"""Post a comments of a review.
"""
# resource check
try:
r = DraftReview.objects.get(pk=review_id)
except DraftReview.DoesNotExist:
return api_error(status.HTTP_404_NOT_FOUND,
'Review %s not found' % review_id)
# permission check
if check_folder_permission(request, r.origin_repo_id, '/') is None:
return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.')
# argument check
comment = request.data.get('comment', '')
if not comment:
return api_error(status.HTTP_400_BAD_REQUEST, 'Comment can not be empty.')
try:
avatar_size = int(request.GET.get('avatar_size',
AVATAR_DEFAULT_SIZE))
except ValueError:
avatar_size = AVATAR_DEFAULT_SIZE
detail = request.data.get('detail', '')
username = request.user.username
review_comment = ReviewComment.objects.add(comment, detail, username, r)
# Send notification to review creator
comment_review_successful.send(sender=None, review=r, comment=comment, author=username)
comment = review_comment.to_dict()
comment.update(user_to_dict(username, request=request, avatar_size=avatar_size))
return Response(comment)
class ReviewCommentView(APIView):
authentication_classes = (TokenAuthentication, SessionAuthentication)
permission_classes = (IsAuthenticated, )
throttle_classes = (UserRateThrottle, )
def delete(self, request, review_id, comment_id, format=None):
"""Delete a comment, only comment author or review creator can perform this op.
"""
# resource check
try:
r = DraftReview.objects.get(pk=review_id)
except DraftReview.DoesNotExist:
return api_error(status.HTTP_404_NOT_FOUND,
'Review %s not found' % review_id)
try:
review_comment = ReviewComment.objects.get(pk=comment_id)
except ReviewComment.DoesNotExist:
return api_error(status.HTTP_404_NOT_FOUND,
'Review comment %s not found' % comment_id)
username = request.user.username
if username != (review_comment.author and r.creator):
return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.')
review_comment.delete()
return Response(status.HTTP_200_OK)
def put(self, request, review_id, comment_id, format=None):
"""Update a comment, only comment author or review creator can perform
this op
1.Change resolved of comment
2.Add comment_detail
"""
# argument check
resolved = request.data.get('resolved')
if resolved not in ('true', 'false', None):
error_msg = 'resolved invalid.'
return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
detail = request.data.get('detail')
# resource check
try:
r = DraftReview.objects.get(pk=review_id)
except DraftReview.DoesNotExist:
return api_error(status.HTTP_404_NOT_FOUND,
'Review %s not found' % review_id)
try:
review_comment = ReviewComment.objects.get(pk=comment_id)
except ReviewComment.DoesNotExist:
error_msg = 'Review comment %s not found.' % comment_id
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
# permission check
username = request.user.username
if username != (review_comment.author and r.creator):
error_msg = 'Permission denied.'
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
if resolved is not None:
comment_resolved = to_python_boolean(resolved)
try:
review_comment.resolved = comment_resolved
review_comment.save()
except Exception as e:
logger.error(e)
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal error.')
if detail is not None:
try:
review_comment.detail = detail
review_comment.save()
except Exception as e:
logger.error(e)
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal error.')
try:
avatar_size = int(request.GET.get('avatar_size', AVATAR_DEFAULT_SIZE))
except ValueError:
avatar_size = AVATAR_DEFAULT_SIZE
comment = review_comment.to_dict()
comment.update(user_to_dict(username, request=request, avatar_size=avatar_size))
return Response(comment)

View File

@@ -17,6 +17,7 @@ from .endpoints.group_discussions import GroupDiscussions
from .endpoints.group_discussion import GroupDiscussion
from .endpoints.send_share_link_email import SendShareLinkView
from .endpoints.send_upload_link_email import SendUploadLinkView
from .endpoints.review_comments import ReviewCommentsView, ReviewCommentView
urlpatterns = [
url(r'^ping/$', Ping.as_view()),
@@ -36,6 +37,8 @@ urlpatterns = [
url(r'^regdevice/$', RegDevice.as_view(), name="regdevice"),
url(r'^search/$', Search.as_view(), name='api_search'),
url(r'^search-user/$', SearchUser.as_view(), name='search-user'),
url(r'^review/(?P<review_id>\d+)/comments/$', ReviewCommentsView.as_view(), name='api2-review-comments'),
url(r'^review/(?P<review_id>\d+)/comment/(?P<comment_id>\d+)/$', ReviewCommentView.as_view(), name='api2-review-comment'),
url(r'^repos/$', Repos.as_view(), name="api2-repos"),
url(r'^repos/public/$', PubRepos.as_view(), name="api2-pub-repos"),
url(r'^repos/(?P<repo_id>[-0-9a-f]{36})/$', Repo.as_view(), name="api2-repo"),

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.15 on 2018-10-16 07:04
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import seahub.base.fields
class Migration(migrations.Migration):
dependencies = [
('drafts', '0002_auto_20181011_1207'),
]
operations = [
migrations.CreateModel(
name='ReviewComment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('author', seahub.base.fields.LowerCaseCharField(db_index=True, max_length=255)),
('resolved', models.BooleanField(db_index=True, default=False)),
('comment', models.TextField()),
('detail', models.TextField()),
('review_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='drafts.DraftReview')),
],
options={
'ordering': ['-created_at', '-updated_at'],
'abstract': False,
},
),
]

View File

@@ -201,13 +201,34 @@ class DraftReview(TimestampedModel):
}
###### signal handlers
from django.dispatch import receiver
from seahub.signals import repo_deleted
class ReviewCommentManager(models.Manager):
def add(self, comment, detail, author, review_id):
review_comment = self.model(author=author, comment=comment,
detail=detail, review_id=review_id)
review_comment.save(using=self._db)
@receiver(repo_deleted)
def remove_drafts(sender, **kwargs):
repo_owner = kwargs['repo_owner']
repo_id = kwargs['repo_id']
return review_comment
Draft.objects.filter(username=repo_owner, draft_repo_id=repo_id).delete()
class ReviewComment(TimestampedModel):
"""
Model used to record file comments.
"""
author = LowerCaseCharField(max_length=255, db_index=True)
resolved = models.BooleanField(default=False, db_index=True)
review_id = models.ForeignKey('DraftReview', on_delete=models.CASCADE)
comment = models.TextField()
detail = models.TextField()
objects = ReviewCommentManager()
def to_dict(self):
return {
'id': self.pk,
'review_id': self.review_id_id,
'comment': self.comment,
'created_at': datetime_to_isoformat_timestr(self.created_at),
'updated_at': datetime_to_isoformat_timestr(self.updated_at),
'resolved': self.resolved,
'detail': self.detail,
}

4
seahub/drafts/signals.py Normal file
View File

@@ -0,0 +1,4 @@
# Copyright (c) 2012-2018 Seafile Ltd.
import django.dispatch
comment_review_successful = django.dispatch.Signal(providing_args=["review", "comment", "author"])

View File

@@ -53,6 +53,7 @@ MSG_TYPE_REPO_SHARE = 'repo_share'
MSG_TYPE_REPO_SHARE_TO_GROUP = 'repo_share_to_group'
MSG_TYPE_USER_MESSAGE = 'user_message'
MSG_TYPE_FILE_COMMENT = 'file_comment'
MSG_TYPE_REVIEW_COMMENT = 'review_comment'
MSG_TYPE_GUEST_INVITATION_ACCEPTED = 'guest_invitation_accepted'
USER_NOTIFICATION_COUNT_CACHE_PREFIX = 'USER_NOTIFICATION_COUNT_'
@@ -92,6 +93,11 @@ def file_comment_msg_to_json(repo_id, file_path, author, comment):
'author': author,
'comment': comment})
def review_comment_msg_to_json(review_id, author, comment):
return json.dumps({'review_id': review_id,
'author': author,
'comment': comment})
def guest_invitation_accepted_msg_to_json(invitation_id):
return json.dumps({'invitation_id': invitation_id})
@@ -287,6 +293,11 @@ class UserNotificationManager(models.Manager):
"""
return self._add_user_notification(to_user, MSG_TYPE_FILE_COMMENT, detail)
def add_review_comment_msg(self, to_user, detail):
"""Notify ``to_user`` that review creator
"""
return self._add_user_notification(to_user, MSG_TYPE_REVIEW_COMMENT, detail)
def add_guest_invitation_accepted_msg(self, to_user, detail):
"""Nofity ``to_user`` that a guest has accpeted an invitation.
"""
@@ -385,6 +396,9 @@ class UserNotification(models.Model):
def is_file_comment_msg(self):
return self.msg_type == MSG_TYPE_FILE_COMMENT
def is_review_comment_msg(self):
return self.msg_type == MSG_TYPE_REVIEW_COMMENT
def is_guest_invitation_accepted_msg(self):
return self.msg_type == MSG_TYPE_GUEST_INVITATION_ACCEPTED
@@ -729,6 +743,23 @@ class UserNotification(models.Model):
}
return msg
def format_review_comment_msg(self):
try:
d = json.loads(self.detail)
except Exception as e:
logger.error(e)
return _(u"Internal error")
review_id = d['review_id']
author = d['author']
msg = _("Review <a href='%(file_url)s'>%(review_id)s</a> has a new comment from user %(author)s") % {
'review_id': review_id,
'file_url': reverse('drafts:review', args=[review_id]),
'author': escape(email2nickname(author)),
}
return msg
def format_guest_invitation_accepted_msg(self):
try:
d = json.loads(self.detail)
@@ -763,6 +794,7 @@ from seahub.group.signals import grpmsg_added, group_join_request, add_user_to_g
from seahub.share.signals import share_repo_to_user_successful, \
share_repo_to_group_successful
from seahub.invitations.signals import accept_guest_invitation_successful
from seahub.drafts.signals import comment_review_successful
@receiver(upload_file_successful)
def add_upload_file_msg_cb(sender, **kwargs):
@@ -867,6 +899,16 @@ def comment_file_successful_cb(sender, **kwargs):
detail = file_comment_msg_to_json(repo.id, file_path, author, comment)
UserNotification.objects.add_file_comment_msg(u, detail)
@receiver(comment_review_successful)
def comment_review_successful_cb(sender, **kwargs):
review = kwargs['review']
comment = kwargs['comment']
author = kwargs['author']
detail = review_comment_msg_to_json(review.id, author, comment)
UserNotification.objects.add_review_comment_msg(review.creator, detail)
@receiver(accept_guest_invitation_successful)
def accept_guest_invitation_successful_cb(sender, **kwargs):
inv_obj = kwargs['invitation_obj']

View File

@@ -31,9 +31,13 @@
{% elif notice.is_group_join_request %}
<p class="brief">{{ notice.format_group_join_request|safe }}</p>
{% elif notice.is_file_comment_msg %}
<p class="brief">{{ notice.format_file_comment_msg|safe }}</p>
{% elif notice.is_review_comment_msg %}
<p class="brief">{{ notice.format_review_comment_msg|safe }}</p>
{% elif notice.is_guest_invitation_accepted_msg %}
<p class="brief">{{ notice.format_guest_invitation_accepted_msg|safe }}</p>

View File

@@ -185,4 +185,12 @@ def add_notice_from_info(notices):
except Exception as e:
logger.error(e)
elif notice.is_review_comment_msg():
try:
d = json.loads(notice.detail)
notice.msg_from = d['author']
except Exception as e:
logger.error(e)
return notices

View File

@@ -37,6 +37,9 @@
{% elif notice.is_file_comment_msg %}
<p class="brief">{{ notice.format_file_comment_msg|safe }}</p>
{% elif notice.is_review_comment_msg %}
<p class="brief">{{ notice.format_review_comment_msg|safe }}</p>
{% elif notice.is_guest_invitation_accepted_msg %}
<p class="brief">{{ notice.format_guest_invitation_accepted_msg|safe }}</p>