diff --git a/frontend/config/webpack.config.dev.js b/frontend/config/webpack.config.dev.js
index ffd44e4b6e..1490a28119 100644
--- a/frontend/config/webpack.config.dev.js
+++ b/frontend/config/webpack.config.dev.js
@@ -73,7 +73,12 @@ module.exports = {
require.resolve('./polyfills'),
require.resolve('react-dev-utils/webpackHotDevClient'),
paths.appSrc + "/file-history.js",
- ]
+ ],
+ drafts: [
+ require.resolve('./polyfills'),
+ require.resolve('react-dev-utils/webpackHotDevClient'),
+ paths.appSrc + "/drafts.js",
+ ]
},
output: {
diff --git a/frontend/src/components/constance.js b/frontend/src/components/constance.js
index c8503ca2e4..5284c45fd6 100644
--- a/frontend/src/components/constance.js
+++ b/frontend/src/components/constance.js
@@ -9,6 +9,7 @@ export const siteTitle = window.app.config.siteTitle;
export const logoWidth = window.app.config.logoWidth;
export const logoHeight = window.app.config.logoHeight;
export const isPro = window.app.config.isPro === "True";
+export const lang = window.app.config.lang;
// wiki
export const slug = window.wiki ? window.wiki.config.slug : '';
diff --git a/frontend/src/components/list-view/list-item.js b/frontend/src/components/list-view/list-item.js
new file mode 100644
index 0000000000..1b856d7598
--- /dev/null
+++ b/frontend/src/components/list-view/list-item.js
@@ -0,0 +1,82 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { siteRoot, lang } from '../constance';
+import NodeMenuControl from '../menu-component/node-menu-control';
+import moment from 'moment';
+
+moment.locale(lang);
+const propTypes = {
+ isItemFreezed: PropTypes.bool.isRequired,
+ onMenuToggleClick: PropTypes.func.isRequired,
+}
+class ListItem extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ isMenuControlShow: false,
+ highlight: '',
+ };
+ }
+
+ onMouseEnter = () => {
+ if (!this.props.isItemFreezed) {
+ this.setState({
+ isMenuControlShow: true,
+ highlight: 'tr-highlight'
+ });
+ }
+ }
+
+ onMouseLeave = () => {
+ if (!this.props.isItemFreezed) {
+ this.setState({
+ isMenuControlShow: false,
+ highlight: ''
+ });
+ }
+ }
+
+ onMenuToggleClick = (e) => {
+ e.nativeEvent.stopImmediatePropagation();
+ let draft = this.props.draft;
+ this.props.onMenuToggleClick(e, draft);
+ }
+
+ onDraftEditClick = () => {
+ let draft = this.props.draft;
+ let filePath = draft.draft_file_path;
+ let repoID = draft.draft_repo_id;
+ window.location.href= siteRoot + 'lib/' + repoID + '/file' + filePath + '?mode=edit';
+ }
+
+ getFileName(filePath) {
+ let lastIndex = filePath.lastIndexOf("/");
+ return filePath.slice(lastIndex+1);
+ }
+
+ render() {
+ let draft = this.props.draft;
+ let fileName = this.getFileName(draft.draft_file_path);
+ let localTime = moment.utc(draft.updated_at).toDate();
+ localTime = moment(localTime).fromNow();
+ return (
+
+  |
+ {fileName} |
+ {draft.owner} |
+ {localTime} |
+
+
+ |
+
+ );
+ }
+}
+
+ListItem.propTypes = propTypes;
+
+export default ListItem;
diff --git a/frontend/src/components/list-view/list-menu.js b/frontend/src/components/list-view/list-menu.js
new file mode 100644
index 0000000000..093d0b85ac
--- /dev/null
+++ b/frontend/src/components/list-view/list-menu.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { gettext } from '../constance';
+
+const propTypes = {
+ isMenuShow: PropTypes.bool.isRequired,
+ menuPosition: PropTypes.object.isRequired,
+ onDeleteHandler: PropTypes.func.isRequired,
+ onPublishHandler: PropTypes.func.isRequired
+};
+
+class ListMenu extends React.Component {
+
+ render() {
+ let style = '';
+ let {isMenuShow, menuPosition} = this.props;
+ if (isMenuShow) {
+ style = {position: 'fixed', top: menuPosition.top, left: menuPosition.left, display: 'block'}
+ }
+ return (
+
+
+ - {gettext('Delete')}
+ - {gettext('Publish')}
+
+
+ );
+ }
+}
+
+ListMenu.propTypes = propTypes;
+
+export default ListMenu;
diff --git a/frontend/src/components/list-view/list-view.js b/frontend/src/components/list-view/list-view.js
new file mode 100644
index 0000000000..a4e177916a
--- /dev/null
+++ b/frontend/src/components/list-view/list-view.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { gettext } from '../constance';
+import ListItem from './list-item';
+
+const propTypes = {
+ isItemFreezed: PropTypes.bool.isRequired,
+ draftList: PropTypes.array.isRequired,
+ onMenuToggleClick: PropTypes.func.isRequired,
+};
+
+class ListView extends React.Component {
+
+ render() {
+ let drafts = this.props.draftList;
+ return (
+
+
+
+ {/*img*/} |
+ {gettext('Name')} |
+ {gettext('Owner')} |
+ {gettext('Update time')} |
+ |
+
+
+
+ { drafts && drafts.map((draft) => {
+ return (
+
+ );
+ })}
+
+
+ );
+ }
+}
+
+ListView.propTypes = propTypes;
+
+export default ListView;
diff --git a/frontend/src/components/main-side-nav.js b/frontend/src/components/main-side-nav.js
index 1c2f79d849..3a8844e4a7 100644
--- a/frontend/src/components/main-side-nav.js
+++ b/frontend/src/components/main-side-nav.js
@@ -114,6 +114,11 @@ class MainSideNav extends React.Component {
Share Admin
{this.renderSharedAdmin()}
+
+
+ Drafts
+
+
diff --git a/frontend/src/css/common.css b/frontend/src/css/common.css
new file mode 100644
index 0000000000..f406855083
--- /dev/null
+++ b/frontend/src/css/common.css
@@ -0,0 +1,100 @@
+.panel-heading {
+ position: relative;
+ padding: .5rem 1rem;
+ width: 100%;
+ border-bottom: 1px solid #e8e8e8;
+ font-size: 1rem;
+ font-weight: normal;
+ line-height: 1.5;
+ height: 36px;
+ text-align: center;
+}
+
+.text-left {
+ text-align: left;
+}
+
+.text-right {
+ text-align: right;
+}
+
+.a-simulate {
+ color: #eb8205 !important;
+ text-decoration: none;
+ font-weight: normal;
+ cursor: pointer;
+}
+
+.a-simulate:hover {
+ text-decoration: underline;
+}
+
+.flex-right {
+ justify-content: flex-end;
+}
+/* begin main table list style */
+
+.table-container {
+ flex: 1;
+ padding: 10px 1rem;
+ overflow: auto;
+}
+
+.table-container table {
+ width: 100%;
+}
+
+.table-container table th {
+ text-align: left;
+ font-weight: normal;
+ color: #9c9c9c;
+}
+
+.table-container table td {
+ color: #333;
+ font-size: 14px;
+ word-break: break-all;
+}
+
+.table-container table th, .table-container table td {
+ padding-top: 5px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid #eee;
+}
+
+.table-container table tr img {
+ display: block;
+ width: 24px;
+ height: 24px;
+}
+/* specific handler */
+.table-container table .menu-toggle {
+ text-align: center;
+}
+
+.tr-highlight {
+ background-color: #f8f8f8;
+}
+
+/* end main list style */
+
+/* begin dropdown-menu style */
+.dropdown-menu {
+ min-width: 8rem;
+}
+/* end dropdown-menu style */
+
+/* begin tip */
+.empty-tip {
+ padding: 30px 40px;
+ background-color: #FAFAFA;
+ border: solid 1px #DDD;
+ border-radius: 3px;
+ box-shadow: inset 0 0 8px #EEE;
+ margin-top: 5.5em;
+}
+
+.empty-tip h2 {
+ text-align: center;
+}
+/* end tip */
diff --git a/frontend/src/dashboard.js b/frontend/src/dashboard.js
index e9c546c906..2daac88a96 100644
--- a/frontend/src/dashboard.js
+++ b/frontend/src/dashboard.js
@@ -8,7 +8,6 @@ import Notification from './components/notification';
import { SeafileAPI } from 'seafile-js';
import cookie from 'react-cookies';
-
import 'seafile-ui';
import './assets/css/fa-solid.css';
import './assets/css/fa-regular.css';
diff --git a/frontend/src/drafts.js b/frontend/src/drafts.js
new file mode 100644
index 0000000000..02fdea4dc5
--- /dev/null
+++ b/frontend/src/drafts.js
@@ -0,0 +1,67 @@
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import editUtilties from './utils/editor-utilties';
+import SidePanel from './pages/drafts/side-panel';
+import MainPanel from './pages/drafts/main-panel';
+
+import 'seafile-ui';
+import './assets/css/fa-solid.css';
+import './assets/css/fa-regular.css';
+import './assets/css/fontawesome.css';
+import './css/layout.css';
+import './css/common.css';
+
+class Drafts extends Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ draftList: [],
+ isLoadingDraft: true,
+ };
+ }
+
+ componentDidMount() {
+ this.initDraftList();
+ }
+
+ initDraftList() {
+ editUtilties.listDrafts().then(res => {
+ this.setState({
+ draftList: res.data.data,
+ isLoadingDraft: false,
+ });
+ });
+ }
+
+ deleteDraft = (draft) => {
+ editUtilties.deleteDraft(draft.id).then(res => {
+ this.initDraftList();
+ })
+ }
+
+ publishDraft = (draft) => {
+ editUtilties.publishDraft(draft.id).then(res => {
+ this.initDraftList();
+ })
+ }
+
+ render() {
+ return (
+
+
+
+
+ );
+ }
+}
+
+ReactDOM.render(
+ ,
+ document.getElementById('wrapper')
+);
diff --git a/frontend/src/pages/drafts/main-panel.js b/frontend/src/pages/drafts/main-panel.js
new file mode 100644
index 0000000000..ba626cd46f
--- /dev/null
+++ b/frontend/src/pages/drafts/main-panel.js
@@ -0,0 +1,113 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { gettext } from '../../components/constance';
+import Loading from '../../components/loading';
+import Account from '../../components/account';
+import { seafileAPI } from '../../utils/editor-utilties';
+import Notification from '../../components/notification';
+import ListView from '../../components/list-view/list-view';
+import ListMenu from '../../components/list-view/list-menu';
+
+const propTypes = {
+ draftList: PropTypes.array.isRequired,
+};
+
+class MainPanel extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ isMenuShow: false,
+ menuPosition: {top:'', left: ''},
+ currentDraft: null,
+ isItemFreezed: false,
+ };
+ }
+
+ componentDidMount() {
+ document.addEventListener('click', this.onHideContextMenu);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('click', this.onHideContextMenu);
+ }
+
+ onMenuToggleClick = (e, draft) => {
+ if (this.state.isMenuShow) {
+ this.onHideContextMenu();
+ } else {
+ this.onShowContextMenu(e, draft);
+ }
+ }
+
+ onShowContextMenu = (e, draft) => {
+ let left = e.clientX - 8*16;
+ let top = e.clientY + 10;
+ let position = {top: top, left: left};
+ this.setState({
+ isMenuShow: true,
+ menuPosition: position,
+ currentDraft: draft,
+ isItemFreezed: true
+ });
+ }
+
+ onHideContextMenu = () => {
+ this.setState({
+ isMenuShow: false,
+ currentDraft: null,
+ isItemFreezed: false
+ });
+ }
+
+ onPublishHandler = () => {
+ this.props.publishDraft(this.state.currentDraft);
+ }
+
+ onDeleteHandler = () => {
+ this.props.deleteDraft(this.state.currentDraft);
+ }
+
+ render() {
+ return (
+
+
+
+
{gettext('Drafts')}
+ {this.props.isLoadingDraft ?
+
:
+
+ {this.props.draftList.length ?
+
:
+
+
{gettext('There is no draft file existing')}
+
+ }
+
+ }
+ {
+ this.state.isMenuShow &&
+
+ }
+
+
+ );
+ }
+}
+
+MainPanel.propTypes = propTypes;
+
+export default MainPanel;
diff --git a/frontend/src/pages/drafts/side-panel.js b/frontend/src/pages/drafts/side-panel.js
new file mode 100644
index 0000000000..fe702704a7
--- /dev/null
+++ b/frontend/src/pages/drafts/side-panel.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import Logo from '../../components/logo';
+import MainSideNav from '../../components/main-side-nav';
+import SideNavFooter from '../../components/side-nav-footer';
+
+
+class SidePanel extends React.Component {
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+export default SidePanel;
diff --git a/frontend/src/utils/editor-utilties.js b/frontend/src/utils/editor-utilties.js
index ef3ab92fcf..15467b3e16 100644
--- a/frontend/src/utils/editor-utilties.js
+++ b/frontend/src/utils/editor-utilties.js
@@ -97,9 +97,21 @@ class EditorUtilities {
revertFile(filePath, commitID) {
return seafileAPI.revertFile(historyRepoID, filePath, commitID);
}
+
+ listDrafts() {
+ return seafileAPI.listDrafts();
+ }
+
+ deleteDraft(id) {
+ return seafileAPI.deleteDraft(id);
+ }
+
+ publishDraft(id) {
+ return seafileAPI.publishDraft(id);
+ }
}
const editorUtilities = new EditorUtilities();
export default editorUtilities;
-export { seafileAPI };
+export { seafileAPI };
diff --git a/media/css/seahub_react.css b/media/css/seahub_react.css
index b20149bd04..403b33565c 100644
--- a/media/css/seahub_react.css
+++ b/media/css/seahub_react.css
@@ -34,6 +34,7 @@
.sf2-icon-x3:before { content:"\e035"; }
.sf2-icon-grid-view:before { content:"\e025"; }
.sf2-icon-list-view:before { content:"\e026"; }
+.sf2-icon-edit:before { content:"\e018"; }
/* common class and element style*/
a { color:#eb8205; }
diff --git a/seahub/api2/endpoints/drafts.py b/seahub/api2/endpoints/drafts.py
new file mode 100644
index 0000000000..01ab0a044d
--- /dev/null
+++ b/seahub/api2/endpoints/drafts.py
@@ -0,0 +1,122 @@
+# Copyright (c) 2012-2016 Seafile Ltd.
+import json
+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, edit_repo
+from pysearpc import SearpcError
+from django.core.urlresolvers import reverse
+from django.db import IntegrityError
+from django.db.models import Count
+from django.http import HttpResponse
+from django.utils.translation import ugettext as _
+
+from seahub.api2.authentication import TokenAuthentication
+from seahub.api2.endpoints.utils import add_org_context
+from seahub.api2.throttling import UserRateThrottle
+from seahub.api2.utils import api_error
+from seahub.constants import PERMISSION_READ_WRITE
+from seahub.drafts.models import Draft, DraftFileExist, DraftFileConflict
+from seahub.views import check_folder_permission
+
+logger = logging.getLogger(__name__)
+
+
+class DraftsView(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated, )
+ throttle_classes = (UserRateThrottle, )
+
+ def get(self, request, format=None):
+ """List all user drafts.
+ """
+ username = request.user.username
+ data = [x.to_dict() for x in Draft.objects.filter(username=username)]
+
+ return Response({'data': data})
+
+ @add_org_context
+ def post(self, request, org_id, format=None):
+ """Create a file draft.
+ """
+ repo_id = request.POST.get('repo_id', '')
+ file_path = request.POST.get('file_path', '')
+
+ repo = seafile_api.get_repo(repo_id)
+ if not repo:
+ error_msg = 'Library %s not found.' % repo_id
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ file_id = seafile_api.get_file_id_by_path(repo.id, file_path)
+ if not file_id:
+ return api_error(status.HTTP_404_NOT_FOUND,
+ "File %s not found" % file_path)
+
+ # perm check
+ perm = check_folder_permission(request, repo.id, file_path)
+ if perm != PERMISSION_READ_WRITE:
+ error_msg = 'Permission denied.'
+ return api_error(status.HTTP_403_FORBIDDEN, error_msg)
+
+ username = request.user.username
+
+ try:
+ d = Draft.objects.add(username, repo, file_path, file_id, org_id=org_id)
+
+ return Response(d.to_dict())
+ except (DraftFileExist, IntegrityError):
+ return api_error(status.HTTP_409_CONFLICT, 'Draft already exists.')
+
+
+class DraftView(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated, )
+ throttle_classes = (UserRateThrottle, )
+
+ def put(self, request, pk, format=None):
+ """Publish a draft.
+ """
+ op = request.data.get('operation', '')
+ if op != 'publish':
+ return api_error(status.HTTP_400_BAD_REQUEST,
+ 'Operation %s invalid.')
+
+ try:
+ d = Draft.objects.get(pk=pk)
+ except Draft.DoesNotExist:
+ return api_error(status.HTTP_404_NOT_FOUND,
+ 'Draft %s not found.' % pk)
+
+ # perm check
+ if d.username != request.user.username:
+ return api_error(status.HTTP_403_FORBIDDEN,
+ 'Permission denied.')
+
+ try:
+ d.publish()
+ return Response(status.HTTP_200_OK)
+ except (DraftFileConflict, IntegrityError):
+ return api_error(status.HTTP_409_CONFLICT,
+ 'There is a conflict between the draft and the original file')
+
+ def delete(self, request, pk, format=None):
+ """Delete a draft.
+ """
+ try:
+ d = Draft.objects.get(pk=pk)
+ except Draft.DoesNotExist:
+ return api_error(status.HTTP_404_NOT_FOUND,
+ 'Draft %s not found.' % pk)
+
+ # perm check
+ if d.username != request.user.username:
+ return api_error(status.HTTP_403_FORBIDDEN,
+ 'Permission denied.')
+
+ d.delete()
+
+ return Response(status.HTTP_200_OK)
diff --git a/seahub/base/models.py b/seahub/base/models.py
index 9f6dc8ce8e..dcbcdf9997 100644
--- a/seahub/base/models.py
+++ b/seahub/base/models.py
@@ -21,6 +21,23 @@ from fields import LowerCaseCharField
logger = logging.getLogger(__name__)
+class TimestampedModel(models.Model):
+ # A timestamp representing when this object was created.
+ created_at = models.DateTimeField(auto_now_add=True, db_index=True)
+
+ # A timestamp reprensenting when this object was last updated.
+ updated_at = models.DateTimeField(auto_now=True, db_index=True)
+
+ class Meta:
+ abstract = True
+
+ # By default, any model that inherits from `TimestampedModel` should
+ # be ordered in reverse-chronological order. We can override this on a
+ # per-model basis as needed, but reverse-chronological is a good
+ # default ordering for most models.
+ ordering = ['-created_at', '-updated_at']
+
+
class FileDiscuss(models.Model):
"""
Model used to represents the relationship between group message and file/dir.
diff --git a/seahub/drafts/__init__.py b/seahub/drafts/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/seahub/drafts/apps.py b/seahub/drafts/apps.py
new file mode 100644
index 0000000000..1ff055c4d7
--- /dev/null
+++ b/seahub/drafts/apps.py
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.apps import AppConfig
+
+
+class DraftsConfig(AppConfig):
+ name = 'drafts'
diff --git a/seahub/drafts/migrations/0001_initial.py b/seahub/drafts/migrations/0001_initial.py
new file mode 100644
index 0000000000..c659e3d712
--- /dev/null
+++ b/seahub/drafts/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.11 on 2018-09-03 08:34
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import seahub.base.fields
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('tags', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Draft',
+ 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)),
+ ('username', seahub.base.fields.LowerCaseCharField(db_index=True, max_length=255)),
+ ('origin_repo_id', models.CharField(max_length=36)),
+ ('origin_file_version', models.CharField(max_length=100)),
+ ('draft_repo_id', models.CharField(max_length=36)),
+ ('draft_file_path', models.CharField(max_length=2048)),
+ ('origin_file_uuid', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tags.FileUUIDMap')),
+ ],
+ ),
+ migrations.AlterUniqueTogether(
+ name='draft',
+ unique_together=set([('username', 'draft_repo_id')]),
+ ),
+ ]
diff --git a/seahub/drafts/migrations/__init__.py b/seahub/drafts/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/seahub/drafts/models.py b/seahub/drafts/models.py
new file mode 100644
index 0000000000..18651e6953
--- /dev/null
+++ b/seahub/drafts/models.py
@@ -0,0 +1,131 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+import os
+import posixpath
+
+from django.db import models
+from seaserv import seafile_api
+
+from seahub.base.fields import LowerCaseCharField
+from seahub.base.models import TimestampedModel
+from seahub.base.templatetags.seahub_tags import email2nickname
+from seahub.tags.models import FileUUIDMap
+from seahub.utils import normalize_file_path
+from seahub.utils.timeutils import datetime_to_isoformat_timestr
+from .utils import create_user_draft_repo, get_draft_file_name
+
+
+class DraftFileExist(Exception):
+ pass
+
+
+class DraftFileConflict(Exception):
+ pass
+
+
+class DraftManager(models.Manager):
+ def get_user_draft_repo_id(self, username):
+ r = self.filter(username=username).first()
+ if r is None:
+ return None
+ else:
+ return r.draft_repo_id
+
+ def add(self, username, repo, file_path, file_id=None, org_id=-1):
+ file_path = normalize_file_path(file_path)
+ parent_path = os.path.dirname(file_path)
+ filename = os.path.basename(file_path)
+ file_uuid = FileUUIDMap.objects.get_or_create_fileuuidmap(
+ repo.id, parent_path, filename, is_dir=False)
+
+ if file_id is None:
+ file_id = seafile_api.get_file_id_by_path(repo.id, file_path)
+
+ # create draft repo if any
+ draft_repo_id = self.get_user_draft_repo_id(username)
+ if draft_repo_id is None:
+ draft_repo_id = create_user_draft_repo(username)
+
+ # check draft file does not exists and copy origin file content to
+ # draft file
+ draft_file_name = get_draft_file_name(repo.id, file_path)
+ draft_file_path = '/' + draft_file_name
+
+ if seafile_api.get_file_id_by_path(draft_repo_id, draft_file_path):
+ raise DraftFileExist
+
+ seafile_api.copy_file(repo.id, file_uuid.parent_path, file_uuid.filename,
+ draft_repo_id, '/', draft_file_name,
+ username=username, need_progress=0, synchronous=1)
+
+ draft = self.model(username=username,
+ origin_repo_id=repo.id, origin_file_uuid=file_uuid,
+ origin_file_version=file_id,
+ draft_repo_id=draft_repo_id,
+ draft_file_path=draft_file_path)
+ draft.save(using=self._db)
+ return draft
+
+
+class Draft(TimestampedModel):
+ """Draft models enable user save file as drafts, and publish later.
+ """
+ username = LowerCaseCharField(max_length=255, db_index=True)
+ origin_repo_id = models.CharField(max_length=36)
+ origin_file_uuid = models.ForeignKey(FileUUIDMap, on_delete=models.CASCADE)
+ origin_file_version = models.CharField(max_length=100)
+ draft_repo_id = models.CharField(max_length=36)
+ draft_file_path = models.CharField(max_length=2048)
+
+ objects = DraftManager()
+
+ # class Meta:
+ # unique_together = (('username', 'draft_repo_id'), )
+
+ def delete(self):
+ seafile_api.del_file(self.draft_repo_id, '/',
+ self.draft_file_path.lstrip('/'), self.username)
+
+ super(Draft, self).delete()
+
+ def publish(self):
+ # check whether origin file is updated
+ r_repo = seafile_api.get_repo(self.origin_repo_id)
+ if not r_repo:
+ raise DraftFileConflict
+
+ origin_file_path = self.origin_file_uuid.parent_path + self.origin_file_uuid.filename
+ file_id = seafile_api.get_file_id_by_path(self.origin_repo_id,
+ origin_file_path)
+ if not file_id:
+ raise DraftFileConflict
+
+ if file_id != self.origin_file_version:
+ raise DraftFileConflict
+
+ # move draft file to origin file
+ seafile_api.move_file(
+ self.draft_repo_id, '/', self.draft_file_path.lstrip('/'),
+ self.origin_repo_id, self.origin_file_uuid.parent_path,
+ self.origin_file_uuid.filename, replace=1,
+ username=self.username, need_progress=0, synchronous=1
+ )
+
+ self.delete()
+
+ def to_dict(self):
+ uuid = self.origin_file_uuid
+ file_path = posixpath.join(uuid.parent_path, uuid.filename) # TODO: refactor uuid
+
+ return {
+ 'id': self.pk,
+ 'owner': self.username,
+ 'owner_nickname': email2nickname(self.username),
+ 'origin_repo_id': self.origin_repo_id,
+ 'origin_file_path': file_path,
+ 'origin_file_version': self.origin_file_version,
+ 'draft_repo_id': self.draft_repo_id,
+ 'draft_file_path': self.draft_file_path,
+ 'created_at': datetime_to_isoformat_timestr(self.created_at),
+ 'updated_at': datetime_to_isoformat_timestr(self.updated_at),
+ }
diff --git a/seahub/drafts/utils.py b/seahub/drafts/utils.py
new file mode 100644
index 0000000000..cfe5973281
--- /dev/null
+++ b/seahub/drafts/utils.py
@@ -0,0 +1,23 @@
+import hashlib
+import os
+
+from seaserv import seafile_api
+
+from seahub.utils import normalize_file_path
+
+def create_user_draft_repo(username, org_id=-1):
+ repo_name = 'Drafts'
+ if org_id > 0:
+ repo_id = seafile_api.create_org_repo(repo_name, '', username,
+ passwd=None, org_id=org_id)
+ else:
+ repo_id = seafile_api.create_repo(repo_name, '', username,
+ passwd=None)
+ return repo_id
+
+def get_draft_file_name(repo_id, file_path):
+ file_path = normalize_file_path(file_path)
+ file_name, file_ext = os.path.splitext(os.path.basename(file_path))
+ md5 = hashlib.md5((repo_id + file_path).encode('utf-8')).hexdigest()[:10]
+
+ return "%s-%s%s" % (file_name, md5, file_ext)
diff --git a/seahub/settings.py b/seahub/settings.py
index e861e79e3f..474ddca25b 100644
--- a/seahub/settings.py
+++ b/seahub/settings.py
@@ -226,6 +226,7 @@ INSTALLED_APPS = (
'seahub.api2',
'seahub.avatar',
'seahub.contacts',
+ 'seahub.drafts',
'seahub.institutions',
'seahub.invitations',
'seahub.wiki',
diff --git a/seahub/templates/drafts.html b/seahub/templates/drafts.html
new file mode 100644
index 0000000000..f474d199a4
--- /dev/null
+++ b/seahub/templates/drafts.html
@@ -0,0 +1,7 @@
+{% extends "base_for_react.html" %}
+{% load render_bundle from webpack_loader %}
+
+{% block extra_script %}
+{% render_bundle 'drafts' %}
+{% endblock %}
+
diff --git a/seahub/templates/js/templates.html b/seahub/templates/js/templates.html
index 82e5894d0d..2e2a1704f2 100644
--- a/seahub/templates/js/templates.html
+++ b/seahub/templates/js/templates.html
@@ -770,6 +770,7 @@
{% trans "Lock" %}
<% } %>
<% } %>
+ {% trans "New Draft" %}
@@ -1577,6 +1579,9 @@
<% } %>
+
+ {% trans "Drafts" %}
+