1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-15 14:49:09 +00:00

add file ledger apis (#5507)

* add file ledger apis

* remove apis about export ledgers

* opt code struct

* feat: update api

* feat: update code

* rename init-ledger script -> init-extended-props

* remove useless code

* POST/PUT extended-props return row

* return default some fields when extended-row not exists

* feat: update seafile-js version

---------

Co-authored-by: er-pai-r <18335219360@163.com>
This commit is contained in:
Alex Happy
2023-07-27 15:11:35 +08:00
committed by GitHub
parent e97b0c264c
commit 0a7aeec2e2
32 changed files with 2330 additions and 36 deletions

2
.gitignore vendored
View File

@@ -64,3 +64,5 @@ frontend/package-lock.json
frontend/.eslintcache
/.idea
.vscode

View File

@@ -25,6 +25,7 @@
"i18next": "22.4.6",
"i18next-browser-languagedetector": "7.0.1",
"i18next-xhr-backend": "3.2.2",
"is-hotkey": "0.2.0",
"MD5": "^1.3.0",
"moment": "^2.22.2",
"object-assign": "4.1.1",
@@ -43,7 +44,7 @@
"react-select": "5.7.0",
"react-transition-group": "4.4.5",
"reactstrap": "8.9.0",
"seafile-js": "0.2.205",
"seafile-js": "0.2.206",
"socket.io-client": "^2.2.0",
"svg-sprite-loader": "^6.0.11",
"svgo-loader": "^3.0.1",
@@ -5297,11 +5298,6 @@
"node": ">=10.0.0"
}
},
"node_modules/@seafile/sdoc-editor/node_modules/is-hotkey": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="
},
"node_modules/@seafile/sdoc-editor/node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
@@ -5436,11 +5432,6 @@
"xtend": "^4.0.1"
}
},
"node_modules/@seafile/seafile-editor/node_modules/is-hotkey": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="
},
"node_modules/@seafile/seafile-editor/node_modules/unified": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/unified/-/unified-7.0.0.tgz",
@@ -5524,6 +5515,11 @@
"slate": ">=0.50.0"
}
},
"node_modules/@seafile/slate-react/node_modules/is-hotkey": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.1.8.tgz",
"integrity": "sha512-qs3NZ1INIS+H+yeo7cD9pDfwYV/jqRh1JG9S9zYrNudkoUQg7OL7ziXqRKu+InFjUIDoP2o6HIkLYMh1pcWgyQ=="
},
"node_modules/@seafile/slate/node_modules/immer": {
"version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
@@ -14616,8 +14612,9 @@
}
},
"node_modules/is-hotkey": {
"version": "0.1.8",
"license": "MIT"
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="
},
"node_modules/is-in-browser": {
"version": "1.1.3",
@@ -24946,9 +24943,9 @@
}
},
"node_modules/seafile-js": {
"version": "0.2.205",
"resolved": "https://registry.npmjs.org/seafile-js/-/seafile-js-0.2.205.tgz",
"integrity": "sha512-RmupPDRFTRcgT3jU5phU/2kcOG+e3ZHAxN4RZ7oj28dPa3HN2fwkxgHQfhJK6suLqCQhjs0v6cLFRnEsWpL3aA==",
"version": "0.2.206",
"resolved": "https://registry.npmjs.org/seafile-js/-/seafile-js-0.2.206.tgz",
"integrity": "sha512-D0mq1nNxx1kB0RZoDvlEgF5L/W/fDUOG1OnM/RcRQSwuTDh6FgOLQNpC/Ombb5EMiLsLnFZxRU6D6ImGmrlgXg==",
"dependencies": {
"@babel/polyfill": "7.12.1",
"axios": "1.2.1",
@@ -25331,6 +25328,11 @@
"slate-dev-environment": "^0.2.0"
}
},
"node_modules/slate-hotkeys/node_modules/is-hotkey": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.1.8.tgz",
"integrity": "sha512-qs3NZ1INIS+H+yeo7cD9pDfwYV/jqRh1JG9S9zYrNudkoUQg7OL7ziXqRKu+InFjUIDoP2o6HIkLYMh1pcWgyQ=="
},
"node_modules/slate-html-serializer": {
"version": "0.7.39",
"resolved": "https://registry.npmjs.org/slate-html-serializer/-/slate-html-serializer-0.7.39.tgz",
@@ -33493,11 +33495,6 @@
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.6.tgz",
"integrity": "sha512-tjuoZDMAdEhVnSFleYPCtdL2GXwVTGtNjoeJd9IhIG3C1xs9uwxqRNEu5WpnDZCaozwVlK/nuQhpodhXSIMaxw=="
},
"is-hotkey": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="
},
"is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
@@ -33602,11 +33599,6 @@
"xtend": "^4.0.1"
},
"dependencies": {
"is-hotkey": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="
},
"unified": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/unified/-/unified-7.0.0.tgz",
@@ -33689,6 +33681,13 @@
"is-hotkey": "^0.1.6",
"is-plain-object": "^3.0.0",
"scroll-into-view-if-needed": "^2.2.20"
},
"dependencies": {
"is-hotkey": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.1.8.tgz",
"integrity": "sha512-qs3NZ1INIS+H+yeo7cD9pDfwYV/jqRh1JG9S9zYrNudkoUQg7OL7ziXqRKu+InFjUIDoP2o6HIkLYMh1pcWgyQ=="
}
}
},
"@sideway/address": {
@@ -39970,7 +39969,9 @@
"version": "1.0.4"
},
"is-hotkey": {
"version": "0.1.8"
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="
},
"is-in-browser": {
"version": "1.1.3"
@@ -47338,9 +47339,9 @@
}
},
"seafile-js": {
"version": "0.2.205",
"resolved": "https://registry.npmjs.org/seafile-js/-/seafile-js-0.2.205.tgz",
"integrity": "sha512-RmupPDRFTRcgT3jU5phU/2kcOG+e3ZHAxN4RZ7oj28dPa3HN2fwkxgHQfhJK6suLqCQhjs0v6cLFRnEsWpL3aA==",
"version": "0.2.206",
"resolved": "https://registry.npmjs.org/seafile-js/-/seafile-js-0.2.206.tgz",
"integrity": "sha512-D0mq1nNxx1kB0RZoDvlEgF5L/W/fDUOG1OnM/RcRQSwuTDh6FgOLQNpC/Ombb5EMiLsLnFZxRU6D6ImGmrlgXg==",
"requires": {
"@babel/polyfill": "7.12.1",
"axios": "1.2.1",
@@ -47636,6 +47637,13 @@
"requires": {
"is-hotkey": "^0.1.3",
"slate-dev-environment": "^0.2.0"
},
"dependencies": {
"is-hotkey": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.1.8.tgz",
"integrity": "sha512-qs3NZ1INIS+H+yeo7cD9pDfwYV/jqRh1JG9S9zYrNudkoUQg7OL7ziXqRKu+InFjUIDoP2o6HIkLYMh1pcWgyQ=="
}
}
},
"slate-html-serializer": {

View File

@@ -20,6 +20,7 @@
"i18next": "22.4.6",
"i18next-browser-languagedetector": "7.0.1",
"i18next-xhr-backend": "3.2.2",
"is-hotkey": "0.2.0",
"MD5": "^1.3.0",
"moment": "^2.22.2",
"object-assign": "4.1.1",
@@ -38,7 +39,7 @@
"react-select": "5.7.0",
"react-transition-group": "4.4.5",
"reactstrap": "8.9.0",
"seafile-js": "0.2.205",
"seafile-js": "0.2.206",
"socket.io-client": "^2.2.0",
"svg-sprite-loader": "^6.0.11",
"svgo-loader": "^3.0.1",

View File

@@ -0,0 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Col } from 'reactstrap';
function ColumnName(props) {
const { column } = props;
const { name } = column;
return (
<Col md={3} className="d-flex column-name">
<div className="w-100 text-truncate">
{name || ''}
</div>
</Col>
);
}
ColumnName.propTypes = {
column: PropTypes.object.isRequired,
};
export default ColumnName;

View File

@@ -0,0 +1,7 @@
.extra-attributes-dialog .column-name {
padding-top: 9px;
}
.extra-attributes-dialog .column-item {
min-height: 56px;
}

View File

@@ -0,0 +1,37 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Col } from 'reactstrap';
import ColumnName from './column-name';
import CONFIG from '../editor';
import './index.css';
class Column extends Component {
render() {
const { column, row, columns } = this.props;
const Editor = CONFIG[column.type] || CONFIG['text'];
return (
<div className="pb-4 row column-item">
<ColumnName column={column} />
<Col md={9} className='d-flex align-items-center extra-attribute-item-info'>
<Editor
column={column}
row={row}
columns={columns}
onCommit={this.props.onCommit}
/>
</Col>
</div>
);
}
}
Column.propTypes = {
column: PropTypes.object,
row: PropTypes.object,
columns: PropTypes.array,
onCommit: PropTypes.func,
};
export default Column;

View File

@@ -0,0 +1,22 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { getDateDisplayString } from '../../../../utils/extra-attributes';
class CtimeFormatter extends Component {
render() {
const { column, row } = this.props;
const { key } = column;
const value = getDateDisplayString(row[key], 'YYYY-MM-DD HH:mm:ss') || '';
return (
<div className="form-control" style={{ width: 320 }}>{value}</div>
);
}
}
CtimeFormatter.propTypes = {
column: PropTypes.object,
row: PropTypes.object,
};
export default CtimeFormatter;

View File

@@ -0,0 +1,28 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { getDateDisplayString } from '../../../../utils/extra-attributes';
class DateEditor extends Component {
render() {
const { column, row } = this.props;
const { data, key } = column;
const value = getDateDisplayString(row[key], data ? data.format : '');
return (
<input
type="text"
className="form-control"
value={value}
disabled={true}
/>
);
}
}
DateEditor.propTypes = {
column: PropTypes.object,
row: PropTypes.object,
};
export default DateEditor;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FORMULA_RESULT_TYPE } from '../../../../constants';
import { getDateDisplayString } from '../../../../utils/extra-attributes';
function FormulaFormatter(props) {
const { column, row } = props;
const value = row[column.key];
const { data } = column;
const { result_type, format } = data || {};
if (result_type === FORMULA_RESULT_TYPE.DATE) {
return (
<div className="form-control disabled">{getDateDisplayString(value, format)}</div>
);
}
if (result_type === FORMULA_RESULT_TYPE.STRING) {
return value;
}
if (typeof value === 'object') {
return null;
}
return <></>;
}
FormulaFormatter.propTypes = {
column: PropTypes.object,
row: PropTypes.object,
};
export default FormulaFormatter;

View File

@@ -0,0 +1,20 @@
import SimpleText from './simple-text';
import FormulaFormatter from './formula-formatter';
import SingleSelect from './single-select';
import NumberEditor from './number-editor';
import DateEditor from './date-editor';
import CtimeFormatter from './ctime-formatter';
import { EXTRA_ATTRIBUTES_COLUMN_TYPE } from '../../../../constants';
const CONFIG = {
[EXTRA_ATTRIBUTES_COLUMN_TYPE.TEXT]: SimpleText,
[EXTRA_ATTRIBUTES_COLUMN_TYPE.FORMULA]: FormulaFormatter,
[EXTRA_ATTRIBUTES_COLUMN_TYPE.SINGLE_SELECT]: SingleSelect,
[EXTRA_ATTRIBUTES_COLUMN_TYPE.NUMBER]: NumberEditor,
[EXTRA_ATTRIBUTES_COLUMN_TYPE.DATE]: DateEditor,
[EXTRA_ATTRIBUTES_COLUMN_TYPE.CTIME]: CtimeFormatter,
[EXTRA_ATTRIBUTES_COLUMN_TYPE.MTIME]: CtimeFormatter,
};
export default CONFIG;

View File

@@ -0,0 +1,90 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getNumberDisplayString, replaceNumberNotAllowInput, formatStringToNumber, isMac } from '../../../../utils/extra-attributes';
import { KeyCodes, DEFAULT_NUMBER_FORMAT } from '../../../../constants';
class NumberEditor extends React.Component {
constructor(props) {
super(props);
const { row, column } = props;
const value = row[column.key];
this.state = {
value: getNumberDisplayString(value, column.data),
};
}
onChange = (event) => {
const { data } = this.props.column; // data maybe 'null'
const format = (data && data.format) ? data.format : DEFAULT_NUMBER_FORMAT;
let currency_symbol = null;
if (data && data.format === 'custom_currency') {
currency_symbol = data['currency_symbol'];
}
const initValue = event.target.value.trim();
//Prevent the repetition of periods bug in the Chinese input method of the Windows system
if (!isMac() && initValue.indexOf('.。') > -1) return;
let value = replaceNumberNotAllowInput(initValue, format, currency_symbol);
if (value === this.state.value) return;
this.setState({ value });
}
onKeyDown = (event) => {
let { selectionStart, selectionEnd, value } = event.currentTarget;
if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Esc) {
event.preventDefault();
this.input.blur();
} else if ((event.keyCode === KeyCodes.LeftArrow && selectionStart === 0) ||
(event.keyCode === KeyCodes.RightArrow && selectionEnd === value.length)
) {
event.stopPropagation();
}
}
onBlur = () => {
const { value } = this.state;
const { column } = this.props;
this.props.onCommit({ [column.key]: formatStringToNumber(value, column.data) }, column);
}
setInputRef = (input) => {
this.input = input;
return this.input;
};
onPaste = (e) => {
e.stopPropagation();
}
onCut = (e) => {
e.stopPropagation();
}
render() {
const { column } = this.props;
return (
<input
ref={this.setInputRef}
type="text"
className="form-control"
value={this.state.value}
onBlur={this.onBlur}
onPaste={this.onPaste}
onCut={this.onCut}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
disabled={!column.editable}
/>
);
}
}
NumberEditor.propTypes = {
column: PropTypes.object,
row: PropTypes.object,
onCommit: PropTypes.func,
};
export default NumberEditor;

View File

@@ -0,0 +1,108 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
class SearchInput extends Component {
constructor(props) {
super(props);
this.state = {
searchValue: props.value,
};
this.isInputtingChinese = false;
this.timer = null;
this.inputRef = null;
}
componentDidMount() {
if (this.props.autoFocus && this.inputRef && this.inputRef !== document.activeElement) {
setTimeout(() => {
this.inputRef.focus();
}, 0);
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.value !== this.props.value) {
this.setState({searchValue: nextProps.value});
}
}
componentWillUnmount() {
this.timer && clearTimeout(this.timer);
this.timer = null;
this.inputRef = null;
}
onCompositionStart = () => {
this.isInputtingChinese = true;
}
onChange = (e) => {
this.timer && clearTimeout(this.timer);
const { onChange, wait } = this.props;
let text = e.target.value;
this.setState({searchValue: text || ''}, () => {
if (this.isInputtingChinese) return;
this.timer = setTimeout(() => {
onChange && onChange(this.state.searchValue.trim());
}, wait);
});
}
onCompositionEnd = (e) => {
this.isInputtingChinese = false;
this.onChange(e);
}
setFocus = (isSelectAllText) => {
if (this.inputRef === document.activeElement) return;
this.inputRef.focus();
if (isSelectAllText) {
const txtLength = this.state.searchValue.length;
this.inputRef.setSelectionRange(0, txtLength);
}
}
render() {
const { placeholder, autoFocus, className, onKeyDown, disabled, style } = this.props;
const { searchValue } = this.state;
return (
<input
type="text"
value={searchValue}
className={classnames('form-control', className)}
onChange={this.onChange}
autoFocus={autoFocus}
placeholder={placeholder}
onCompositionStart={this.onCompositionStart}
onCompositionEnd={this.onCompositionEnd}
onKeyDown={onKeyDown}
disabled={disabled}
style={style}
ref={ref => this.inputRef = ref}
/>
);
}
}
SearchInput.propTypes = {
placeholder: PropTypes.string,
autoFocus: PropTypes.bool,
className: PropTypes.string,
onChange: PropTypes.func.isRequired,
onKeyDown: PropTypes.func,
wait: PropTypes.number,
disabled: PropTypes.bool,
style: PropTypes.object,
value: PropTypes.string,
};
SearchInput.defaultProps = {
wait: 100,
disabled: false,
value: '',
};
export default SearchInput;

View File

@@ -0,0 +1,92 @@
import React from 'react';
import PropTypes from 'prop-types';
import { KeyCodes } from '../../../../constants';
class SimpleText extends React.Component {
constructor(props) {
super(props);
this.state = {
value: props.row[props.column.key] || '',
};
this.inputRef = React.createRef();
}
UNSAFE_componentWillReceiveProps(nextProps) {
const nextValue = nextProps.row[nextProps.column.key];
if (nextValue !== this.state.value) {
this.setState({ value: nextValue });
}
}
blurInput = () => {
setTimeout(() => {
this.inputRef.current && this.inputRef.current.blur();
}, 1);
}
onBlur = () => {
let { column, onCommit } = this.props;
const updated = {};
updated[column.key] = this.state.value.trim();
onCommit(updated, column);
}
onChange = (e) => {
let value = e.target.value;
if (value === this.state.value) return;
this.setState({value});
}
onCut = (e) => {
e.stopPropagation();
}
onPaste = (e) => {
e.stopPropagation();
}
onKeyDown = (e) => {
if (e.keyCode === KeyCodes.Esc) {
e.stopPropagation();
this.blurInput();
return;
}
let { selectionStart, selectionEnd, value } = e.currentTarget;
if (
(e.keyCode === KeyCodes.ChineseInputMethod) ||
(e.keyCode === KeyCodes.LeftArrow && selectionStart === 0) ||
(e.keyCode === KeyCodes.RightArrow && selectionEnd === value.length)
) {
e.stopPropagation();
}
}
render() {
const { column } = this.props;
const { value } = this.state;
return (
<input
type="text"
onBlur={this.onBlur}
onCut={this.onCut}
onPaste={this.onPaste}
onChange={this.onChange}
className="form-control"
value={value || ''}
onKeyDown={this.onKeyDown}
disabled={!column.editable}
ref={this.inputRef}
/>
);
}
}
SimpleText.propTypes = {
column: PropTypes.object,
row: PropTypes.object,
onCommit: PropTypes.func.isRequired,
};
export default SimpleText;

View File

@@ -0,0 +1,101 @@
.extra-attributes-dialog .selected-single-select-container {
height: 38px;
width: 100%;
padding: 0 10px;
border-radius: 3px;
user-select: none;
border: 1px solid rgba(0, 40, 100, .12);
appearance: none;
background: #fff;
}
.extra-attributes-dialog .selected-single-select-container.disable {
background-color: #f8f9fa;
}
.extra-attributes-dialog .selected-single-select-container.focus {
border-color: #1991eb!important;
box-shadow: 0 0 0 2px rgba(70, 127, 207, .25);
}
.extra-attributes-dialog .selected-single-select-container:not(.disable):hover {
cursor: pointer;
}
.extra-attributes-dialog .selected-single-select-container .fa-caret-down {
font-size: 16px;
color: #949494;
}
.extra-attributes-dialog .selected-single-select-container .single-select-option {
text-align: center;
width: min-content;
max-width: 250px;
line-height: 20px;
border-radius: 10px;
padding: 0 10px;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* editor */
.single-select-editor-container {
min-height: 160px;
width: 320px;
overflow: hidden;
background-color: #fff;
}
.single-select-editor-container .search-single-selects {
padding: 10px 10px 0;
}
.single-select-editor-container .search-single-selects input {
max-height: 30px;
font-size: 14px;
}
.single-select-editor-container .single-select-editor-content {
max-height: 200px;
min-height: 100px;
padding: 10px;
overflow-x: hidden;
overflow-y: scroll;
}
.single-select-editor-container .single-select-editor-content .single-select-option-container {
width: 100%;
height: 30px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: #212529;
padding-left: 12px;
}
.single-select-editor-container .single-select-editor-content .single-select-option-container:hover {
background-color: #f5f5f5;
cursor: pointer;
}
.single-select-editor-container .single-select-editor-content .single-select-option {
padding: 0 10px;
height: 20px;
line-height: 20px;
text-align: center;
border-radius: 10px;
margin-right: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.single-select-editor-container .single-select-editor-content .single-select-option-selected {
width: 20px;
text-align: center;
}

View File

@@ -0,0 +1,83 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { DELETED_OPTION_BACKGROUND_COLOR, DELETED_OPTION_TIPS } from '../../../../../constants';
import { gettext } from '../../../../../utils/constants';
import SingleSelectEditor from './single-select-editor';
import { getSelectColumnOptions } from '../../../../../utils/extra-attributes';
import './index.css';
class SingleSelect extends Component {
constructor(props) {
super(props);
const { column } = props;
this.options = getSelectColumnOptions(column);
this.state = {
isShowSingleSelect: false,
};
this.editorKey = `single-select-editor-${column.key}`;
}
updateState = () => {
// this.setState({ isShowSingleSelect: !this.state.isShowSingleSelect });
}
onCommit = (value, column) => {
this.props.onCommit(value, column);
}
render() {
const { isShowSingleSelect } = this.state;
const { column, row } = this.props;
const currentOptionID = row[column.key];
const option = this.options.find(option => option.id === currentOptionID);
const optionStyle = option ?
{ backgroundColor: option.color, color: option.textColor || null } :
{ backgroundColor: DELETED_OPTION_BACKGROUND_COLOR };
const optionName = option ? option.name : gettext(DELETED_OPTION_TIPS);
return (
<>
<div
id={this.editorKey}
className={classnames('selected-single-select-container', { 'disable': !column.editable, 'focus': isShowSingleSelect })}
>
<div className="single-select-inner w-100 h-100 d-flex align-items-center justify-content-between">
<div>
{currentOptionID && (
<div
className="single-select-option"
style={optionStyle}
title={optionName}
>{optionName}</div>
)}
</div>
{column.editable && (
<i className="fas fa-caret-down"></i>
)}
</div>
</div>
{column.editable && (
<SingleSelectEditor
column={column}
row={this.props.row}
columns={this.props.columns}
onCommit={this.onCommit}
onUpdateState={this.updateState}
/>
)}
</>
);
}
}
SingleSelect.propTypes = {
column: PropTypes.object,
row: PropTypes.object,
columns: PropTypes.array,
onCommit: PropTypes.func,
};
export default SingleSelect;

View File

@@ -0,0 +1,126 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { UncontrolledPopover } from 'reactstrap';
import { gettext } from '../../../../../utils/constants';
import SearchInput from '../search-input';
import { getSelectColumnOptions } from '../../../../../utils/extra-attributes';
class SingleSelectEditor extends Component {
constructor(props) {
super(props);
const options = this.getSelectColumnOptions();
this.state = {
value: props.row[props.column.key],
searchVal: '',
highlightIndex: -1,
maxItemNum: 0,
itemHeight: 0
};
this.options = options;
this.filteredOptions = options;
this.timer = null;
this.editorKey = `single-select-editor-${props.column.key}`;
}
getSelectColumnOptions = () => {
const { column, row, columns } = this.props;
let options = getSelectColumnOptions(column);
const { data } = column;
const { cascade_column_key, cascade_settings } = data || {};
if (cascade_column_key) {
const cascadeColumn = columns.find(item => item.key === cascade_column_key);
if (cascadeColumn) {
const cascadeColumnValue = row[cascade_column_key];
if (!cascadeColumnValue) return [];
const cascadeSetting = cascade_settings[cascadeColumnValue];
if (!cascadeSetting || !Array.isArray(cascadeSetting) || cascadeSetting.length === 0) return [];
return options.filter(option => cascadeSetting.includes(option.id));
}
}
return options;
}
setRef = (ref) => {
this.ref = ref;
if (!this.ref) return;
const { toggle } = this.ref;
this.ref.toggle = () => {
toggle && toggle();
this.props.onUpdateState();
};
}
onChangeSearch = (searchVal) => {
const { searchVal: oldSearchVal } = this.state;
if (oldSearchVal === searchVal) return;
const val = searchVal.toLowerCase();
this.filteredOptions = val ?
this.options.filter((item) => item.name && item.name.toLowerCase().indexOf(val) > -1) : this.options;
this.setState({ searchVal });
}
onSelectOption = (optionID) => {
const { column } = this.props;
this.setState({ value: optionID }, () => {
this.props.onCommit({ [column.key]: optionID }, column);
this.ref.toggle();
});
}
render() {
const { value } = this.state;
const { column } = this.props;
return (
<UncontrolledPopover
target={this.editorKey}
className="single-select-editor-popover"
trigger="legacy"
placement="bottom-start"
hideArrow={true}
ref={this.setRef}
>
<div className="single-select-editor-container">
<div className="search-single-selects">
<SearchInput
placeholder={gettext('Find an option')}
onKeyDown={this.onKeyDown}
onChange={this.onChangeSearch}
autoFocus={true}
/>
</div>
<div className="single-select-editor-content">
{this.filteredOptions.map(option => {
const isSelected = value === option.id;
const style = {
backgroundColor: option.color,
color: option.textColor || null,
maxWidth: Math.max(200 - 62, column.width ? column.width -62 : 0)
};
return (
<div className="single-select-option-container" key={option.id} onClick={this.onSelectOption.bind(this, isSelected ? null : option.id)}>
<div className="single-select-option" style={style}>{option.name}</div>
<div className="single-select-option-selected">
{isSelected && (<i ></i>)}
</div>
</div>
);
})}
</div>
</div>
</UncontrolledPopover>
);
}
}
SingleSelectEditor.propTypes = {
value: PropTypes.string,
row: PropTypes.object,
column: PropTypes.object,
columns: PropTypes.array,
onUpdateState: PropTypes.func,
onCommit: PropTypes.func,
};
export default SingleSelectEditor;

View File

@@ -0,0 +1,17 @@
.extra-attributes-dialog {
margin: 28px 0 0 0;
}
.extra-attributes-dialog .extra-attributes-content-container {
height: 100%;
overflow: hidden;
}
.extra-attributes-dialog .modal-body {
overflow-y: scroll;
padding: 30px;
}
.extra-attributes-dialog .modal-body .form-control.disabled {
background-color: #f8f9fa;
}

View File

@@ -0,0 +1,250 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Modal, ModalHeader, ModalBody } from 'reactstrap';
import isHotkey from 'is-hotkey';
import { zIndexes, DIALOG_MAX_HEIGHT, EXTRA_ATTRIBUTES_COLUMN_TYPE } from '../../../constants';
import { gettext } from '../../../utils/constants';
import { seafileAPI } from '../../../utils/seafile-api';
import { Utils } from '../../../utils/utils';
import { getSelectColumnOptions, getValidColumns } from '../../../utils/extra-attributes';
import Column from './column';
import Loading from '../../loading';
import toaster from '../../toast';
import './index.css';
class ExtraAttributesDialog extends Component {
constructor(props) {
super(props);
const { direntDetail } = props;
this.state = {
animationEnd: false,
isLoading: true,
update: {},
row: {},
columns: [],
errorMsg: '',
};
const direntDetailId = direntDetail.id;
this.isEmptyFile = direntDetailId === '0'.repeat(direntDetailId.length);
this.isExist = false;
this.modalRef = React.createRef();
}
componentDidMount() {
this.startAnimation(this.getData);
window.addEventListener('keydown', this.onHotKey);
}
componentWillUnmount() {
window.removeEventListener('keydown', this.onHotKey);
}
startAnimation = (callback) => {
if (this.state.animationEnd === true) {
callback && callback();
}
// use setTimeout to make sure real dom rendered
setTimeout(() => {
let dom = this.modalRef.current.firstChild;
const { width, maxWidth, marginLeft, height } = this.getDialogStyle();
dom.style.width = `${width}px`;
dom.style.maxWidth = `${maxWidth}px`;
dom.style.marginLeft = `${marginLeft}px`;
dom.style.height = `${height}px`;
dom.style.marginRight = 'unset';
dom.style.marginTop = '28px';
// after animation, change style and run callback
setTimeout(() => {
this.setState({ animationEnd: true }, () => {
dom.style.transition = 'none';
callback && callback();
});
}, 280);
}, 1);
}
getFormatUpdateData = (update = {}) => {
const { columns } = this.state;
const updateData = {};
for (let key in update) {
const column = columns.find(column => column.key === key);
if (column && column.editable) {
const { type, name } = column;
const value = update[key];
if (type === EXTRA_ATTRIBUTES_COLUMN_TYPE.SINGLE_SELECT) {
const options = getSelectColumnOptions(column);
const option = options.find(item => item.id === value);
updateData[name] = option ? option.name : '';
} else {
updateData[column.name] = update[key];
}
}
}
return updateData;
}
getData = () => {
const { repoID, filePath } = this.props;
seafileAPI.getFileExtendedProperties(repoID, filePath).then(res => {
const { row, metadata, editable_columns } = res.data;
this.isExist = Boolean(row._id);
this.setState({ row: row, columns: getValidColumns(metadata, editable_columns, this.isEmptyFile), isLoading: false, errorMsg: '' });
}).catch(error => {
const errorMsg =Utils.getErrorMsg(error);
this.setState({ isLoading: false, errorMsg });
});
}
createData = (data) => {
const { repoID, filePath } = this.props;
seafileAPI.newFileExtendedProperties(repoID, filePath, data).then(res => {
this.isExist = true;
const { row } = res.data;
this.setState({ row: row, isLoading: false, errorMsg: '' });
}).catch(error => {
const errorMsg =Utils.getErrorMsg(error);
toaster.danger(gettext(errorMsg));
});
};
updateData = (update, column) => {
const newRow = { ...this.state.row, ...update };
this.setState({ row: newRow }, () => {
const data = this.getFormatUpdateData(update);
const { repoID, filePath } = this.props;
if (this.isExist) {
seafileAPI.updateFileExtendedProperties(repoID, filePath, data).then(res => {
this.setState({ update: {}, row: res.data.row });
}).catch(error => {
const errorMsg = Utils.getErrorMsg(error);
toaster.danger(gettext(errorMsg));
});
} else {
this.createData(data);
}
});
}
onHotKey = (event) => {
if (isHotkey('esc', event)) {
this.onToggle();
return;
}
}
onToggle = () => {
this.props.onToggle();
}
getDialogStyle = () => {
const width = 800;
return {
width,
maxWidth: width,
marginLeft: (window.innerWidth - width) / 2,
height: DIALOG_MAX_HEIGHT,
};
}
getInitStyle = () => {
const transition = 'all .3s';
const defaultMargin = 80; // sequence cell width
const defaultHeight = 100;
const marginTop = '30%';
const width = window.innerWidth;
return {
width: `${width - defaultMargin}px`,
maxWidth: `${width - defaultMargin}px`,
marginLeft: `${defaultMargin}px`,
height: `${defaultHeight}px`,
marginRight: `${defaultMargin}px`,
marginTop,
transition,
};
}
renderColumns = () => {
const { isLoading, errorMsg, columns, row, update } = this.state;
if (isLoading) {
return (
<div className="w-100 h-100 d-flex align-items-center justify-content-center">
<Loading />
</div>
);
}
if (errorMsg) {
return (
<div className="w-100 h-100 d-flex align-items-center justify-content-center error-message">
{gettext(errorMsg)}
</div>
);
}
const newRow = { ...row, ...update };
return (
<>
{columns.map(column => {
return (
<Column
key={column.key}
column={column}
row={newRow}
columns={columns}
onCommit={this.updateData}
/>
);
})}
</>
);
}
renderContent = () => {
if (!this.state.animationEnd) return null;
return (
<>
<ModalHeader toggle={this.onToggle}>{gettext('Edit extra attributes')}</ModalHeader>
<ModalBody>
{this.renderColumns()}
</ModalBody>
</>
);
}
render() {
const { animationEnd } = this.state;
return (
<Modal
isOpen={true}
className="extra-attributes-dialog"
style={animationEnd ? this.getDialogStyle() : this.getInitStyle()}
zIndex={zIndexes.EXTRA_ATTRIBUTES_DIALOG_MODAL}
contentClassName="extra-attributes-content-container"
modalClassName="extra-attributes-modal"
wrapClassName="extra-attributes"
fade={false}
innerRef={this.modalRef}
toggle={this.onToggle}
>
{this.renderContent()}
</Modal>
);
}
}
ExtraAttributesDialog.propTypes = {
repoID: PropTypes.string,
filePath: PropTypes.string,
direntDetail: PropTypes.object,
onToggle: PropTypes.func,
};
export default ExtraAttributesDialog;

View File

@@ -5,6 +5,7 @@ import { gettext } from '../../utils/constants';
import { Utils } from '../../utils/utils';
import EditFileTagDialog from '../dialog/edit-filetag-dialog';
import ModalPortal from '../modal-portal';
import ExtraAttributesDialog from '../dialog/extra-attributes-dialog';
const propTypes = {
repoInfo: PropTypes.object.isRequired,
@@ -22,11 +23,12 @@ class DetailListView extends React.Component {
constructor(props) {
super(props);
this.state = {
isEditFileTagShow: false
isEditFileTagShow: false,
isShowExtraAttributes: false,
};
}
getDirentPostion = () => {
getDirentPosition = () => {
let { repoInfo } = this.props;
let direntPath = this.getDirentPath();
let position = repoInfo.repo_name;
@@ -57,9 +59,13 @@ class DetailListView extends React.Component {
return Utils.joinPath(path, dirent.name);
}
toggleExtraAttributesDialog = () => {
this.setState({ isShowExtraAttributes: !this.state.isShowExtraAttributes });
}
render() {
let { direntType, direntDetail, fileTagList } = this.props;
let position = this.getDirentPostion();
let position = this.getDirentPosition();
let direntPath = this.getDirentPath();
if (direntType === 'dir') {
return (
@@ -100,6 +106,15 @@ class DetailListView extends React.Component {
<i className='fa fa-pencil-alt attr-action-icon' onClick={this.onEditFileTagToggle}></i>
</td>
</tr>
{direntDetail.permission === 'rw' && (
<tr className="file-extra-attributes">
<th colSpan={2}>
<div className="edit-file-extra-attributes-btn" onClick={this.toggleExtraAttributesDialog}>
{gettext('Edit extra attributes')}
</div>
</th>
</tr>
)}
</tbody>
</table>
{this.state.isEditFileTagShow &&
@@ -113,6 +128,14 @@ class DetailListView extends React.Component {
/>
</ModalPortal>
}
{this.state.isShowExtraAttributes && (
<ExtraAttributesDialog
repoID={this.props.repoID}
filePath={direntPath}
direntDetail={direntDetail}
onToggle={this.toggleExtraAttributesDialog}
/>
)}
</Fragment>
);
}

View File

@@ -0,0 +1,112 @@
import * as zIndexes from './zIndexes';
import KeyCodes from './keyCodes';
export const DIALOG_MAX_HEIGHT = window.innerHeight - 56; // Dialog margin is 3.5rem (56px)
export const EXTRA_ATTRIBUTES_COLUMN_TYPE = {
TEXT: 'text',
NUMBER: 'number',
DATE: 'date',
FORMULA: 'formula',
SINGLE_SELECT: 'single-select',
CTIME: 'ctime',
MTIME: 'mtime'
};
export const EXTRA_ATTRIBUTES_NOT_DISPLAY_COLUMN_KEY = [
'_id',
'_locked',
'_locked_by',
'_archived',
'_creator',
'_last_modifier',
'_ctime',
'_mtime',
];
export const EXTRA_ATTRIBUTES_NOT_DISPLAY_COLUMN_NAME = [
'Repo ID',
'UUID',
];
export const FORMULA_RESULT_TYPE = {
NUMBER: 'number',
STRING: 'string',
DATE: 'date',
BOOL: 'bool',
ARRAY: 'array',
};
export const DELETED_OPTION_BACKGROUND_COLOR = '#eaeaea';
export const DELETED_OPTION_TIPS = 'Deleted option';
export const DEFAULT_NUMBER_FORMAT = 'number';
export const ERROR = 'ERROR';
export const ERROR_DIV_ZERO = 'DIV/0';
export const ERROR_NAME = 'NAME';
export const ERROR_NOT_AVAILABLE = 'N/A';
export const ERROR_NULL = 'NULL';
export const ERROR_NUM = 'NUM';
export const ERROR_REF = 'REF';
export const ERROR_VALUE = 'VALUE';
export const GETTING_DATA = 'GETTING_DATA';
const errors = {
[ERROR]: '#ERROR!',
[ERROR_DIV_ZERO]: '#DIV/0!',
[ERROR_NAME]: '#NAME?',
[ERROR_NOT_AVAILABLE]: '#N/A',
[ERROR_NULL]: '#NULL!',
[ERROR_NUM]: '#NUM!',
[ERROR_REF]: '#REF!',
[ERROR_VALUE]: '#VALUE!',
[GETTING_DATA]: '#GETTING_DATA',
};
export const DISPLAY_INTERNAL_ERRORS = [
errors[ERROR],
errors[ERROR_DIV_ZERO],
errors[ERROR_NAME],
errors[ERROR_NOT_AVAILABLE],
errors[ERROR_NULL],
errors[ERROR_NUM],
errors[ERROR_REF],
errors[ERROR_VALUE],
errors[GETTING_DATA],
];
export const DURATION_FORMATS_MAP = {
H_MM: 'h:mm',
H_MM_SS: 'h:mm:ss',
H_MM_SS_S: 'h:mm:ss.s',
H_MM_SS_SS: 'h:mm:ss.ss',
H_MM_SS_SSS: 'h:mm:ss.sss'
};
export const DURATION_FORMATS = [
{ name: DURATION_FORMATS_MAP.H_MM, type: DURATION_FORMATS_MAP.H_MM },
{ name: DURATION_FORMATS_MAP.H_MM_SS, type: DURATION_FORMATS_MAP.H_MM_SS }
];
export const DURATION_ZERO_DISPLAY = {
[DURATION_FORMATS_MAP.H_MM]: '0:00',
[DURATION_FORMATS_MAP.H_MM_SS]: '0:00',
[DURATION_FORMATS_MAP.H_MM_SS_S]: '0:00.0',
[DURATION_FORMATS_MAP.H_MM_SS_SS]: '0:00.00',
[DURATION_FORMATS_MAP.H_MM_SS_SSS]: '0:00.000',
};
export const DURATION_DECIMAL_DIGITS = {
[DURATION_FORMATS_MAP.H_MM]: 0,
[DURATION_FORMATS_MAP.H_MM_SS]: 0,
[DURATION_FORMATS_MAP.H_MM_SS_S]: 1,
[DURATION_FORMATS_MAP.H_MM_SS_SS]: 2,
[DURATION_FORMATS_MAP.H_MM_SS_SSS]: 3,
};
export {
KeyCodes,
zIndexes,
};

View File

@@ -0,0 +1,104 @@
const KeyCodes = {
Backspace: 8,
Tab: 9,
Enter: 13,
Shift: 16,
Ctrl: 17,
Alt: 18,
PauseBreak: 19,
CapsLock: 20,
Escape: 27,
Esc: 27,
Space: 32,
PageUp: 33,
PageDown: 34,
End: 35,
Home: 36,
LeftArrow: 37,
UpArrow: 38,
RightArrow: 39,
DownArrow: 40,
Insert: 45,
Delete: 46,
0: 48,
1: 49,
2: 50,
3: 51,
4: 52,
5: 53,
6: 54,
7: 55,
8: 56,
9: 57,
a: 65,
b: 66,
c: 67,
d: 68,
e: 69,
f: 70,
g: 71,
h: 72,
i: 73,
j: 74,
k: 75,
l: 76,
m: 77,
n: 78,
o: 79,
p: 80,
q: 81,
r: 82,
s: 83,
t: 84,
u: 85,
v: 86,
w: 87,
x: 88,
y: 89,
z: 90,
LeftWindowKey: 91,
RightWindowKey: 92,
SelectKey: 93,
NumPad0: 96,
NumPad1: 97,
NumPad2: 98,
NumPad3: 99,
NumPad4: 100,
NumPad5: 101,
NumPad6: 102,
NumPad7: 103,
NumPad8: 104,
NumPad9: 105,
Multiply: 106,
Add: 107,
Subtract: 109,
DecimalPoint: 110,
Divide: 111,
F1: 112,
F2: 113,
F3: 114,
F4: 115,
F5: 116,
F6: 117,
F7: 118,
F8: 119,
F9: 120,
F10: 121,
F12: 123,
NumLock: 144,
ScrollLock: 145,
SemiColon: 186,
EqualSign: 187,
Comma: 188,
Dash: 189,
Period: 190,
ForwardSlash: 191,
GraveAccent: 192,
OpenBracket: 219,
BackSlash: 220,
CloseBracket: 221,
SingleQuote: 222,
ChineseInputMethod: 229,
};
export default KeyCodes;

View File

@@ -0,0 +1 @@
export const EXTRA_ATTRIBUTES_DIALOG_MODAL = 1048;

View File

@@ -174,3 +174,24 @@
.detail-container .nav-item .nav-link, .detail-container .nav-item .nav-link i {
margin: 0 auto;
}
.detail-container .edit-file-extra-attributes-btn {
min-width: 80px;
width: fit-content;
max-width: 100%;
height: 28px;
line-height: 28px;
padding: 0 10px;
background-color: #f0f0f0;
border-radius: 3px;
color: #929292;
font-size: 14px;
text-align: center;
cursor: pointer;
}
.detail-container .edit-file-extra-attributes-btn:hover {
cursor: pointer;
background-color: #dbdbdb;
color: #666;
}

View File

@@ -107,7 +107,7 @@ class HistoryVersion extends React.Component {
alt={gettext('More Operations')}
/>
<DropdownMenu>
{(this.props.index !== 0) && <DropdownItem onClick={this.onItemRestore}>{gettext('Restore')}</DropdownItem>}
{/* {(this.props.index !== 0) && <DropdownItem onClick={this.onItemRestore}>{gettext('Restore')}</DropdownItem>} */}
<DropdownItem tag='a' href={url} onClick={this.onItemDownLoad}>{gettext('Download')}</DropdownItem>
{(this.props.index !== 0) && <DropdownItem onClick={this.onItemCopy}>{gettext('Copy')}</DropdownItem>}
<DropdownItem onClick={this.toggleRename}>{gettext('Rename')}</DropdownItem>

View File

@@ -0,0 +1,351 @@
import moment from 'moment';
import { EXTRA_ATTRIBUTES_NOT_DISPLAY_COLUMN_KEY, DEFAULT_NUMBER_FORMAT, DISPLAY_INTERNAL_ERRORS, DURATION_FORMATS_MAP,
DURATION_FORMATS, DURATION_ZERO_DISPLAY, DURATION_DECIMAL_DIGITS, EXTRA_ATTRIBUTES_NOT_DISPLAY_COLUMN_NAME } from '../constants';
import NP from './number-precision';
NP.enableBoundaryChecking(false);
export const getValidColumns = (columns, editableColumns = [], isEmptyFile = false) => {
if (!Array.isArray(columns) || columns.length === 0) return [];
return columns
.map(column => {
let validColumn = column;
const canEdit = isEmptyFile ? false : editableColumns.includes(column.name);
if (column.type === 'single-select') {
if (!(column.data && column.data.options)) {
validColumn.data = { options: [] };
}
}
validColumn.editable = canEdit;
return validColumn;
})
.filter(column => !EXTRA_ATTRIBUTES_NOT_DISPLAY_COLUMN_KEY.includes(column.key))
.filter(column => !EXTRA_ATTRIBUTES_NOT_DISPLAY_COLUMN_NAME.includes(column.name));
};
export const getDateDisplayString = (value, format) => {
if (value === '' || !value || typeof value !== 'string') {
return '';
}
// Compatible with older versions: if format is null, use defaultFormat
const validValue = value.replace(/-/g, '/').replace('T', ' ').replace('Z', '');
const date = moment(validValue);
if (!date.isValid()) return value;
switch(format) {
case 'D/M/YYYY':
case 'DD/MM/YYYY': {
const formatValue = date.format('YYYY-MM-DD');
const formatValueList = formatValue.split('-');
return `${formatValueList[2]}/${formatValueList[1]}/${formatValueList[0]}`;
}
case 'D/M/YYYY HH:mm':
case 'DD/MM/YYYY HH:mm': {
const formatValues = date.format('YYYY-MM-DD HH:mm');
const formatValuesList = formatValues.split(' ');
const formatDateList = formatValuesList[0].split('-');
return `${formatDateList[2]}/${formatDateList[1]}/${formatDateList[0]} ${formatValuesList[1]}`;
}
case 'M/D/YYYY':
return date.format('M/D/YYYY');
case 'M/D/YYYY HH:mm':
return date.format('M/D/YYYY HH:mm');
case 'YYYY-MM-DD':
return date.format('YYYY-MM-DD');
case 'YYYY-MM-DD HH:mm':
return date.format('YYYY-MM-DD HH:mm');
case 'YYYY-MM-DD HH:mm:ss': {
return date.format('YYYY-MM-DD HH:mm:ss');
}
case 'DD.MM.YYYY':
return date.format('DD.MM.YYYY');
case 'DD.MM.YYYY HH:mm':
return date.format('DD.MM.YYYY HH:mm');
default:
return date.format('YYYY-MM-DD');
}
};
export const getSelectColumnOptions = (column) => {
if (!column || !column.data || !Array.isArray(column.data.options)) {
return [];
}
return column.data.options;
};
const _getMathRoundedDuration = (num, duration_format) => {
const decimalDigits = DURATION_DECIMAL_DIGITS[duration_format];
if (decimalDigits < 1) {
return num;
}
const ratio = Math.pow(10, decimalDigits);
return Math.round(num * ratio) / ratio;
};
const _getDurationDecimalSuffix = (duration_format, decimal) => {
if (duration_format === DURATION_FORMATS_MAP.H_MM_SS_S) {
return decimal === 0 ? '.0' : '';
} else if (duration_format === DURATION_FORMATS_MAP.H_MM_SS_SS) {
if (decimal === 0) {
return '.00';
} else if (decimal < 10) {
return '0';
}
} else if (duration_format === DURATION_FORMATS_MAP.H_MM_SS_SSS) {
if (decimal === 0) {
return '.000';
} else if (decimal < 10) {
return '00';
} else if (decimal < 100) {
return '0';
}
}
return '';
};
export const getDurationDisplayString = (value, data) => {
if (!value && value !== 0) return '';
let { duration_format } = data || {};
duration_format = duration_format || DURATION_FORMATS_MAP.H_MM;
if (DURATION_FORMATS.findIndex((format) => format.type === duration_format) < 0) {
return '';
}
if (value === 0) {
return DURATION_ZERO_DISPLAY[duration_format];
}
const includeDecimal = duration_format.indexOf('.') > -1;
let positiveValue = Math.abs(value);
if (!includeDecimal) {
positiveValue = Math.round(positiveValue);
}
positiveValue = _getMathRoundedDuration(positiveValue, duration_format);
const decimalParts = (positiveValue + '').split('.');
const decimalPartsLen = decimalParts.length;
let decimal = 0;
if (decimalPartsLen > 1) {
decimal = decimalParts[decimalPartsLen - 1];
decimal = decimal ? decimal - 0 : 0;
}
const decimalDigits = DURATION_DECIMAL_DIGITS[duration_format];
const decimalSuffix = _getDurationDecimalSuffix(duration_format, decimal);
let displayString = value < 0 ? '-' : '';
let hours = parseInt(positiveValue / 3600);
let minutes = parseInt((positiveValue - hours * 3600) / 60);
if (duration_format === DURATION_FORMATS_MAP.H_MM) {
displayString += `${hours}:${minutes > 9 ? minutes : '0' + minutes}`;
return displayString;
}
let seconds = Number.parseFloat((positiveValue - hours * 3600 - minutes * 60).toFixed(decimalDigits));
minutes = minutes > 9 ? minutes : `0${minutes}`;
seconds = seconds > 9 ? seconds : `0${seconds}`;
displayString += `${hours}:${minutes}:${seconds}${decimalSuffix}`;
return displayString;
};
const _separatorMap = {
'comma': ',',
'dot': '.',
'no': '',
'space': ' ',
};
const _toThousands = (num, isCurrency, formatData) => {
let { decimal = 'dot', thousands = 'no', precision = 2, enable_precision = false } = formatData || {};
const decimalString = _separatorMap[decimal];
const thousandsString = _separatorMap[thousands];
if ((num + '').indexOf('e') > -1) {
if (num < 1 && num > -1) {
// 1.convert to non-scientific number
let numericString = num.toFixed(enable_precision ? precision : 8);
// 2.remove 0 from end of the number which not set precision. e.g. 0.100000
if (!enable_precision) {
numericString = removeZerosFromEnd(numericString);
}
// 3.remove minus from number which equal to 0. e.g. '-0.00'
if (parseFloat(numericString) === 0) {
return numericString.startsWith('-') ? numericString.substring(1) : numericString;
}
return numericString;
}
return num;
}
const decimalDigits = enable_precision ? precision : _getDecimalDigits(num);
let value = parseFloat(num.toFixed(decimalDigits));
const isMinus = value < 0;
let integer = Math.trunc(value);
// format decimal value
let decimalValue = String(Math.abs(NP.minus(value, integer)).toFixed(decimalDigits)).slice(1);
if (!enable_precision) {
decimalValue = removeZerosFromEnd(decimalValue);
}
if (isCurrency) {
if (!enable_precision) {
if (decimalValue.length === 2) {
decimalValue = decimalValue.padEnd(3, '0');
} else {
decimalValue = (decimalValue.substring(0, 3) || '.').padEnd(3, '0');
}
}
}
decimalValue = decimalValue.replace(/./, decimalString);
// format integer value
let result = [], counter = 0;
integer = Math.abs(integer).toString();
for (var i = integer.length - 1; i >= 0; i--) {
counter++;
result.unshift(integer[i]);
if (!(counter % 3) && i !== 0) {
result.unshift(thousandsString);
}
}
return (isMinus ? '-' : '') + result.join('') + decimalValue;
};
const _getDecimalDigits = (num) => {
if (Number.isInteger(num)) {
return 0;
}
let valueArr = (num + '').split('.');
let digitsLength = valueArr[1] ? valueArr[1].length : 8;
return digitsLength > 8 ? 8 : digitsLength;
};
/**
* @param {string} value
* e.g. removeZerosFromEnd('0.0100') // '0.01'
*/
const removeZerosFromEnd = (value) => {
if (value.endsWith('0')) {
return value.replace(/(?:\.0*|(\.\d+?)0+)$/, '$1');
}
return value;
};
export const getPrecisionNumber = (num, formatData) => {
let { precision = 2, enable_precision = false } = formatData || {};
let type = Object.prototype.toString.call(num);
if (type !== '[object Number]') {
if (type === '[object String]' && DISPLAY_INTERNAL_ERRORS.includes(num)) {
return num;
}
return null;
}
let decimalDigits = enable_precision ? precision : _getDecimalDigits(num);
return num.toFixed(decimalDigits);
};
export const getNumberDisplayString = (value, formatData) => {
// formatData: old version maybe 'null'
const type = Object.prototype.toString.call(value);
if (type !== '[object Number]') {
// return formula internal errors directly.
if (type === '[object String]' && value.startsWith('#')) {
return value;
}
return '';
}
if (isNaN(value) || value === Infinity || value === -Infinity) return value + '';
const { format = DEFAULT_NUMBER_FORMAT } = formatData || {};
switch(format) {
case 'number': {
return _toThousands(value, false, formatData);
}
case 'percent': {
return `${_toThousands(Number.parseFloat((value * 100).toFixed(8)), false, formatData)}%`;
}
case 'yuan': {
return `${_toThousands(value, true, formatData)}`;
}
case 'dollar': {
return `$${_toThousands(value, true, formatData)}`;
}
case 'euro': {
return `${_toThousands(value, true, formatData)}`;
}
case 'duration': {
return getDurationDisplayString(value, formatData);
}
case 'custom_currency': {
if (formatData.currency_symbol_position === 'after') {
return `${_toThousands(value, true, formatData)}${formatData.currency_symbol || ''}`;
} else {
return `${formatData.currency_symbol || ''}${_toThousands(value, true, formatData)}`;
}
}
default:
return '' + value;
}
};
export const replaceNumberNotAllowInput = (value, format = DEFAULT_NUMBER_FORMAT, currency_symbol = null) => {
if (!value) {
return '';
}
value = value.replace(/。/g, '.');
switch(format) {
case 'number': {
return value.replace(/[^.-\d,]/g,'');
}
case 'percent': {
return value.replace(/[^.-\d,%]/g, '');
}
case 'yuan': {
return value.replace(/[^.-\d¥¥,]/g, '');
}
case 'dollar': {
return value.replace(/[^.-\d$,]/g, '');
}
case 'euro': {
return value.replace(/[^.-\d€,]/g, '');
}
case 'custom_currency': {
// eslint-disable-next-line
const reg = new RegExp('[^.-\d' + currency_symbol + ',]', 'g');
return value.replace(reg, '');
}
default:
return value.replace(/[^.-\d,]/g, '');
}
};
export const getFloatNumber = (data, format) => {
if (!data && data !== 0) {
return null;
}
let newData = parseFloat(data.replace(/[^.-\d]/g, ''));
if (format === 'percent' && !isNaN(newData)) {
return NP.divide(newData, 100);
}
return isNaN(newData) ? null : newData;
};
export const formatStringToNumber = (numberString, formatData) => {
let { format, decimal, thousands, enable_precision, precision } = formatData || {};
let value = numberString;
if (decimal && thousands && decimal === 'comma') {
if (thousands === 'dot') {
value = value.replace(/,/, '@');
value = value.replace(/\./g, ',');
value = value.replace(/@/, '.');
} else {
value = value.replace(/\./g, '');
value = value.replace(/,/, '.');
}
}
value = getFloatNumber(value, format);
if (enable_precision && value) {
if (format === 'percent') {
precision += 2;
}
value = Number(parseFloat(value).toFixed(precision));
}
return value;
};
export const isMac = () => {
const platform = navigator.platform;
return (platform == 'Mac68K') || (platform == 'MacPPC') || (platform == 'Macintosh') || (platform == 'MacIntel');
};

View File

@@ -0,0 +1,122 @@
/**
* @desc Solve the problem of floating calculation, avoid multiple digits after the decimal point and loss of calculation accuracy.
* example: 3 + 2.4 = 4.6999999999999991.0 - 0.9 = 0.09999999999999998
*/
/**
* Correct wrong data
* strip(0.09999999999999998)=0.1
*/
function strip(num, precision = 12) {
return +parseFloat(num.toPrecision(precision));
}
/**
* Return digits length of a number
* @param {*number} num Input number
*/
function digitLength(num) {
// Get digit length of e
const eSplit = num.toString().split(/[eE]/);
const len = (eSplit[0].split('.')[1] || '').length - (+(eSplit[1] || 0));
return len > 0 ? len : 0;
}
/**
* Convert decimals to integers and support scientific notation. If it is a decimal, it is enlarged to an integer
* @param {*number} num Number of inputs
*/
function float2Fixed(num) {
if (num.toString().indexOf('e') === -1) {
return Number(num.toString().replace('.', ''));
}
const dLen = digitLength(num);
return dLen > 0 ? strip(num * Math.pow(10, dLen)) : num;
}
/**
* Check whether the number is out of range, and give a prompt if it is out of range
* @param {*number} num Number of inputs
*/
function checkBoundary(num) {
if (_boundaryCheckingState) {
if (num > Number.MAX_SAFE_INTEGER || num < Number.MIN_SAFE_INTEGER) {
// eslint-disable-next-line no-console
console.warn(`${num} is beyond boundary when transfer to integer, the results may not be accurate`);
}
}
}
/**
* Exact multiplication
*/
function times(num1, num2, ...others) {
if (others.length > 0) {
return times(times(num1, num2), others[0], ...others.slice(1));
}
const num1Changed = float2Fixed(num1);
const num2Changed = float2Fixed(num2);
const baseNum = digitLength(num1) + digitLength(num2);
const leftValue = num1Changed * num2Changed;
checkBoundary(leftValue);
return leftValue / Math.pow(10, baseNum);
}
/**
* Exact addition
*/
function plus(num1, num2, ...others) {
if (others.length > 0) {
return plus(plus(num1, num2), others[0], ...others.slice(1));
}
const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2)));
return (times(num1, baseNum) + times(num2, baseNum)) / baseNum;
}
/**
* Exact subtraction
*/
function minus(num1, num2, ...others) {
if (others.length > 0) {
return minus(minus(num1, num2), others[0], ...others.slice(1));
}
const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2)));
return (times(num1, baseNum) - times(num2, baseNum)) / baseNum;
}
/**
* Exact division
*/
function divide(num1, num2, ...others) {
if (others.length > 0) {
return divide(divide(num1, num2), others[0], ...others.slice(1));
}
const num1Changed = float2Fixed(num1);
const num2Changed = float2Fixed(num2);
checkBoundary(num1Changed);
checkBoundary(num2Changed);
// fix: Similar to 10 ** -4 is 0.00009999999999999999, strip correction
return times((num1Changed / num2Changed), strip(Math.pow(10, digitLength(num2) - digitLength(num1))));
}
/**
* rounding
*/
function round(num, ratio) {
const base = Math.pow(10, ratio);
return divide(Math.round(times(num, base)), base);
}
let _boundaryCheckingState = true;
/**
* Whether to perform boundary check, default true
* @param flag Mark switch, true is on, false is off, default is true
*/
function enableBoundaryChecking(flag = true) {
_boundaryCheckingState = flag;
}
export { strip, plus, minus, times, divide, round, digitLength, float2Fixed, enableBoundaryChecking };
export default { strip, plus, minus, times, divide, round, digitLength, float2Fixed, enableBoundaryChecking };

View File

@@ -1560,6 +1560,11 @@ export const Utils = {
if (!siteRoot || !repoID || !path) return '';
console.log(siteRoot + 'repo/sdoc_revisions/' + repoID + '/?p=' + this.encodePath(path))
return siteRoot + 'repo/sdoc_revisions/' + repoID + '/?p=' + this.encodePath(path);
}
},
isFunction: function(functionToCheck) {
const getType = {};
return functionToCheck && getType.toString.call(functionToCheck) === '[object Function]';
},
};

View File

@@ -0,0 +1,64 @@
import logging
import requests
LEDGER_COLUMNS = [
{'column_key': '0000', 'column_name': 'Repo ID', 'column_type': 'text', 'column_data': None},
{'column_key': 'GqGh', 'column_name': 'File', 'column_type': 'text', 'column_data': {'enable_fill_default_value': False, 'enable_check_format': False, 'format_specification_value': None, 'default_value': '', 'format_check_type': 'chinese_id_card'}},
{'column_key': 'l76s', 'column_name': 'UUID', 'column_type': 'text', 'column_data': {'enable_fill_default_value': False, 'enable_check_format': False, 'format_specification_value': None, 'default_value': '', 'format_check_type': 'chinese_id_card'}},
{'column_key': '1fUd', 'column_name': 'Path', 'column_type': 'text', 'column_data': {'enable_fill_default_value': False, 'enable_check_format': False, 'format_specification_value': None, 'default_value': '', 'format_check_type': 'chinese_id_card'}},
{'column_key': 'IFzK', 'column_name': '文件大分类', 'column_type': 'single-select', 'column_data': {'enable_fill_default_value': False, 'default_value': None, 'options': []}},
{'column_key': 'qc3L', 'column_name': '文件中分类', 'column_type': 'single-select', 'column_data': {'enable_fill_default_value': False, 'default_value': None, 'options': []}},
{'column_key': 'k93T', 'column_name': '文件小分类', 'column_type': 'single-select', 'column_data': {'enable_fill_default_value': False, 'default_value': None, 'options': []}},
{'column_key': 'sysV', 'column_name': '文件负责人', 'column_type': 'text', 'column_data': {'enable_fill_default_value': False, 'enable_check_format': False, 'format_specification_value': None, 'default_value': '', 'format_check_type': 'chinese_id_card'}},
{'column_key': 'TZw3', 'column_name': '密级', 'column_type': 'single-select', 'column_data': {'enable_fill_default_value': False, 'default_value': None, 'options': []}},
{'column_key': 'uFNa', 'column_name': '保密期限', 'column_type': 'number', 'column_data': {'format': 'number', 'precision': 2, 'enable_precision': False, 'enable_fill_default_value': False, 'enable_check_format': False, 'decimal': 'dot', 'thousands': 'no', 'format_min_value': 0, 'format_max_value': 1000}},
{'column_key': 'BeVA', 'column_name': '创建日期', 'column_type': 'date', 'column_data': {'format': 'YYYY-MM-DD HH:mm', 'enable_fill_default_value': False, 'default_value': '', 'default_date_type': 'specific_date'}},
{'column_key': 'ngbE', 'column_name': '废弃日期', 'column_type': 'formula', 'column_data': {'format': 'YYYY-MM-DD', 'formula': "dateAdd({创建日期}, {保密期限}, 'days')", 'operated_columns': ['BeVA', 'uFNa'], 'result_type': 'date'}}
]
DTABLE_WEB_SERVER = ''
SEATABLE_EXTENDED_PROPS_BASE_API_TOKEN = ''
EXTENDED_PROPS_TABLE_NAME = ''
# auth
url = f"{DTABLE_WEB_SERVER.strip('/')}/api/v2.1/dtable/app-access-token/?from=dtable_web"
resp = requests.get(url, headers={'Authorization': f'Token {SEATABLE_EXTENDED_PROPS_BASE_API_TOKEN}'})
dtable_uuid = resp.json()['dtable_uuid']
access_token = resp.json()['access_token']
dtable_server_url = resp.json()['dtable_server']
headers = {'Authorization': f'Token {access_token}'}
# query metadata
url = f"{dtable_server_url.strip('/')}/api/v1/dtables/{dtable_uuid}/metadata/?from=dtable_web"
resp = requests.get(url, headers=headers)
metadata = resp.json()['metadata']
existed_table = None
for table in metadata['tables']:
if table['name'] == EXTENDED_PROPS_TABLE_NAME:
existed_table = table
break
# check table or add table
if existed_table:
logging.info('table %s exists', EXTENDED_PROPS_TABLE_NAME)
for col in LEDGER_COLUMNS:
target_col = None
for table_col in existed_table['columns']:
if col['column_name'] == table_col['name']:
target_col = table_col
break
if not target_col:
logging.error('Column %s not found', col['column_name'])
exit(1)
if target_col['type'] != col['column_type']:
logging.error('Column %s type should be %s', col['column_name'], col['column_type'])
exit(1)
else:
# add table
url = f"{dtable_server_url.strip('/')}/api/v1/dtables/{dtable_uuid}/tables/?from=dtable_web"
data = {
'table_name': EXTENDED_PROPS_TABLE_NAME,
'columns': LEDGER_COLUMNS
}
resp = requests.post(url, headers=headers, json=data)

View File

@@ -0,0 +1,317 @@
import json
import logging
import os
from datetime import datetime
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from seaserv import seafile_api
from seahub.api2.authentication import TokenAuthentication
from seahub.api2.throttling import UserRateThrottle
from seahub.api2.utils import api_error
from seahub.base.templatetags.seahub_tags import email2nickname
from seahub.settings import DTABLE_WEB_SERVER, SEATABLE_EX_PROPS_BASE_API_TOKEN, \
EX_PROPS_TABLE, EX_EDITABLE_COLUMNS
from seahub.tags.models import FileUUIDMap
from seahub.utils import normalize_file_path, EMPTY_SHA1
from seahub.utils.repo import parse_repo_perm
from seahub.utils.seatable_api import SeaTableAPI
from seahub.views import check_folder_permission
logger = logging.getLogger(__name__)
def check_table(seatable_api: SeaTableAPI):
"""check EX_PROPS_TABLE is invalid or not
:return: error_msg -> str or None
"""
table = seatable_api.get_table_by_name(EX_PROPS_TABLE)
if not table:
return 'Table %s not found' % EX_PROPS_TABLE
return None
class ExtendedPropertiesView(APIView):
authentication_classes = (TokenAuthentication, SessionAuthentication)
permission_classes = (IsAuthenticated,)
throttle_classes = (UserRateThrottle,)
def post(self, request, repo_id):
if not all((DTABLE_WEB_SERVER, SEATABLE_EX_PROPS_BASE_API_TOKEN, EX_PROPS_TABLE)):
return api_error(status.HTTP_403_FORBIDDEN, 'Feature not enabled')
# arguments check
path = request.data.get('path')
if not path:
return api_error(status.HTTP_400_BAD_REQUEST, 'path invalid')
path = normalize_file_path(path)
parent_dir = os.path.dirname(path)
file_name = os.path.basename(path)
props_data_str = request.data.get('props_data')
if not props_data_str or not isinstance(props_data_str, str):
return api_error(status.HTTP_400_BAD_REQUEST, 'props_data invalid')
try:
props_data = json.loads(props_data_str)
except:
return api_error(status.HTTP_400_BAD_REQUEST, 'props_data invalid')
# resource check
repo = seafile_api.get_repo(repo_id)
if not repo:
return api_error(status.HTTP_404_NOT_FOUND, 'Library not found')
dirent = seafile_api.get_dirent_by_path(repo_id, path)
if not dirent:
return api_error(status.HTTP_404_NOT_FOUND, 'File %s not found' % path)
if dirent.obj_id == EMPTY_SHA1:
return api_error(status.HTTP_400_BAD_REQUEST, 'File %s is empty' % path)
# permission check
if not parse_repo_perm(check_folder_permission(request, repo_id, parent_dir)).can_edit_on_web:
return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.')
# check base
try:
seatable_api = SeaTableAPI(SEATABLE_EX_PROPS_BASE_API_TOKEN, DTABLE_WEB_SERVER)
except:
logger.error('server: %s token: %s seatable-api fail', DTABLE_WEB_SERVER, SEATABLE_EX_PROPS_BASE_API_TOKEN)
return api_error(status.HTTP_400_BAD_REQUEST, 'Props table invalid')
## props table
try:
error_msg = check_table(seatable_api)
except Exception as e:
logger.exception('check ex-props table %s error: %s', EX_PROPS_TABLE, e)
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
if error_msg:
return api_error(status.HTTP_400_BAD_REQUEST, 'Props table invalid: %s' % error_msg)
## check existed props row
file_uuid = FileUUIDMap.objects.get_or_create_fileuuidmap(repo_id, parent_dir, file_name, False).uuid.hex
sql = f"SELECT COUNT(1) as `count` FROM `{EX_PROPS_TABLE}` WHERE `UUID`='{file_uuid}'"
result = seatable_api.query(sql)
count = result['results'][0]['count']
if count > 0:
return api_error(status.HTTP_400_BAD_REQUEST, 'The props of the file exists')
## append props row
props_data = {column_name: value for column_name, value in props_data.items() if column_name in EX_EDITABLE_COLUMNS}
props_data.update({
'Repo ID': repo_id,
'File': file_name,
'Path': path,
'UUID': file_uuid,
'创建日期': str(datetime.fromtimestamp(dirent.mtime)),
'文件负责人': email2nickname(request.user.username)
})
try:
seatable_api.append_row(EX_PROPS_TABLE, props_data)
except Exception as e:
logger.error('update props table error: %s', e)
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
## query
sql = f"SELECT * FROM {EX_PROPS_TABLE} WHERE `UUID`='{file_uuid}'"
try:
result = seatable_api.query(sql)
except Exception as e:
logger.exception('query sql: %s error: %s', sql, e)
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
rows = result.get('results')
row = rows[0] if rows else {}
return Response({
'row': row,
'metadata': result['metadata'],
'editable_columns': EX_EDITABLE_COLUMNS
})
def get(self, request, repo_id):
if not all((DTABLE_WEB_SERVER, SEATABLE_EX_PROPS_BASE_API_TOKEN, EX_PROPS_TABLE)):
return api_error(status.HTTP_403_FORBIDDEN, 'Feature not enabled')
# arguments check
path = request.GET.get('path')
if not path:
return api_error(status.HTTP_400_BAD_REQUEST, 'path invalid')
path = normalize_file_path(path)
parent_dir = os.path.dirname(path)
# resource check
repo = seafile_api.get_repo(repo_id)
if not repo:
return api_error(status.HTTP_404_NOT_FOUND, 'Library not found')
file_id = seafile_api.get_file_id_by_path(repo_id, path)
if not file_id:
return api_error(status.HTTP_404_NOT_FOUND, 'File %s not found' % path)
# permission check
if not parse_repo_perm(check_folder_permission(request, repo_id, parent_dir)).can_edit_on_web:
return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.')
# check base
try:
seatable_api = SeaTableAPI(SEATABLE_EX_PROPS_BASE_API_TOKEN, DTABLE_WEB_SERVER)
except:
logger.error('server: %s token: %s seatable-api fail', DTABLE_WEB_SERVER, SEATABLE_EX_PROPS_BASE_API_TOKEN)
return api_error(status.HTTP_400_BAD_REQUEST, 'Props table invalid')
## props table
try:
error_msg = check_table(seatable_api)
except Exception as e:
logger.exception('check ex-props table %s error: %s', EX_PROPS_TABLE, e)
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
if error_msg:
return api_error(status.HTTP_400_BAD_REQUEST, 'Props table invalid: %s' % error_msg)
## query
file_name = os.path.basename(path)
file_uuid = FileUUIDMap.objects.get_or_create_fileuuidmap(repo_id, parent_dir, file_name, False).uuid.hex
sql = f"SELECT * FROM {EX_PROPS_TABLE} WHERE `UUID`='{file_uuid}'"
try:
result = seatable_api.query(sql)
except Exception as e:
logger.exception('query sql: %s error: %s', sql, e)
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
rows = result.get('results')
if rows:
row = rows[0]
else:
row = {
'Repo ID': repo_id,
'File': file_name,
'Path': path,
'UUID': file_uuid
}
for name in ['Repo ID', 'File', 'Path', 'UUID']:
for column in result['metadata']:
if name == column['name']:
row[column['key']] = row[name]
row.pop(name, None)
break
return Response({
'row': row,
'metadata': result['metadata'],
'editable_columns': EX_EDITABLE_COLUMNS
})
def put(self, request, repo_id):
if not all((DTABLE_WEB_SERVER, SEATABLE_EX_PROPS_BASE_API_TOKEN, EX_PROPS_TABLE)):
return api_error(status.HTTP_403_FORBIDDEN, 'Feature not enabled')
# arguments check
path = request.data.get('path')
if not path:
return api_error(status.HTTP_400_BAD_REQUEST, 'path invalid')
path = normalize_file_path(path)
parent_dir = os.path.dirname(path)
props_data_str = request.data.get('props_data')
if not props_data_str or not isinstance(props_data_str, str):
return api_error(status.HTTP_400_BAD_REQUEST, 'props_data invalid')
try:
props_data = json.loads(props_data_str)
except:
return api_error(status.HTTP_400_BAD_REQUEST, 'props_data invalid')
# resource check
repo = seafile_api.get_repo(repo_id)
if not repo:
return api_error(status.HTTP_404_NOT_FOUND, 'Library not found')
file_id = seafile_api.get_file_id_by_path(repo_id, path)
if not file_id:
return api_error(status.HTTP_404_NOT_FOUND, 'File %s not found' % path)
# permission check
if not parse_repo_perm(check_folder_permission(request, repo_id, parent_dir)).can_edit_on_web:
return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.')
# check base
try:
seatable_api = SeaTableAPI(SEATABLE_EX_PROPS_BASE_API_TOKEN, DTABLE_WEB_SERVER)
except:
logger.error('server: %s token: %s seatable-api fail', DTABLE_WEB_SERVER, SEATABLE_EX_PROPS_BASE_API_TOKEN)
return api_error(status.HTTP_400_BAD_REQUEST, 'Props table invalid')
## props table
try:
error_msg = check_table(seatable_api)
except Exception as e:
logger.exception('check ex-props table %s error: %s', EX_PROPS_TABLE, e)
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
if error_msg:
return api_error(status.HTTP_400_BAD_REQUEST, 'Props table invalid: %s' % error_msg)
## check existed props row
file_name = os.path.basename(path)
file_uuid = FileUUIDMap.objects.get_or_create_fileuuidmap(repo_id, parent_dir, file_name, False).uuid.hex
sql = f"SELECT * FROM `{EX_PROPS_TABLE}` WHERE `UUID`='{file_uuid}'"
result = seatable_api.query(sql)
results = result['results']
if not results:
return api_error(status.HTTP_404_NOT_FOUND, 'The props of the file not found')
row_id = results[0]['_id']
## update props row
props_data = {col_name: value for col_name, value in props_data.items() if col_name in EX_EDITABLE_COLUMNS}
try:
seatable_api.update_row(EX_PROPS_TABLE, row_id, props_data)
except Exception as e:
logger.error('update props table error: %s', e)
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
## query
sql = f"SELECT * FROM {EX_PROPS_TABLE} WHERE `UUID`='{file_uuid}'"
try:
result = seatable_api.query(sql)
except Exception as e:
logger.exception('query sql: %s error: %s', sql, e)
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
rows = result.get('results')
row = rows[0] if rows else {}
return Response({
'row': row,
'metadata': result['metadata'],
'editable_columns': EX_EDITABLE_COLUMNS
})
def delete(self, request, repo_id):
if not all((DTABLE_WEB_SERVER, SEATABLE_EX_PROPS_BASE_API_TOKEN, EX_PROPS_TABLE)):
return api_error(status.HTTP_403_FORBIDDEN, 'Feature not enabled')
# arguments check
path = request.GET.get('path')
if not path:
return api_error(status.HTTP_400_BAD_REQUEST, 'path invalid')
path = normalize_file_path(path)
parent_dir = os.path.dirname(path)
# resource check
repo = seafile_api.get_repo(repo_id)
if not repo:
return api_error(status.HTTP_404_NOT_FOUND, 'Library not found')
file_id = seafile_api.get_file_id_by_path(repo_id, path)
if not file_id:
return api_error(status.HTTP_404_NOT_FOUND, 'File %s not found' % path)
# permission check
if not parse_repo_perm(check_folder_permission(request, repo_id, parent_dir)).can_edit_on_web:
return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.')
# check base
try:
seatable_api = SeaTableAPI(SEATABLE_EX_PROPS_BASE_API_TOKEN, DTABLE_WEB_SERVER)
except:
logger.error('server: %s token: %s seatable-api fail', DTABLE_WEB_SERVER, SEATABLE_EX_PROPS_BASE_API_TOKEN)
return api_error(status.HTTP_400_BAD_REQUEST, 'Props table invalid')
## props table
try:
error_msg = check_table(seatable_api)
except Exception as e:
logger.exception('check ex-props table %s error: %s', EX_PROPS_TABLE, e)
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
if error_msg:
return api_error(status.HTTP_400_BAD_REQUEST, 'Props table invalid: %s' % error_msg)
file_name = os.path.basename(path)
file_uuid = FileUUIDMap.objects.get_or_create_fileuuidmap(repo_id, parent_dir, file_name, False).uuid.hex
sql = f"DELETE FROM `{EX_PROPS_TABLE}` WHERE `UUID`='{file_uuid}'"
try:
seatable_api.query(sql)
except Exception as e:
logger.exception('delete props record error: %s', e)
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
return Response({'success': True})

View File

@@ -862,6 +862,13 @@ if os.environ.get('SEAFILE_DOCS', None):
LOGO_WIDTH = ''
ENABLE_WIKI = True
#######################
# extended properties #
#######################
SEATABLE_EX_PROPS_BASE_API_TOKEN = ''
EX_PROPS_TABLE = ''
EX_EDITABLE_COLUMNS = []
d = os.path.dirname
EVENTS_CONFIG_FILE = os.environ.get(
'EVENTS_CONFIG_FILE',

View File

@@ -118,6 +118,8 @@ from seahub.ocm_via_webdav.ocm_api import OCMProviderView
from seahub.api2.endpoints.repo_share_links import RepoShareLinks, RepoShareLink
from seahub.api2.endpoints.repo_upload_links import RepoUploadLinks, RepoUploadLink
from seahub.api2.endpoints.extended_properties import ExtendedPropertiesView
# Admin
from seahub.api2.endpoints.admin.abuse_reports import AdminAbuseReportsView, AdminAbuseReportView
from seahub.api2.endpoints.admin.revision_tag import AdminTaggedItemsView
@@ -420,6 +422,9 @@ urlpatterns = [
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/file/participant/$', FileParticipantView.as_view(), name='api-v2.1-file-participant'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/related-users/$', RepoRelatedUsersView.as_view(), name='api-v2.1-related-user'),
## user:file:extended-props
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/extended-properties/$', ExtendedPropertiesView.as_view(), name='api-v2.1-extended-properties'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/auto-delete/$', RepoAutoDeleteView.as_view(), name='api-v2.1-repo-auto-delete'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/share-links/$', RepoShareLinks.as_view(), name='api-v2.1-repo-share-links'),

View File

@@ -0,0 +1,117 @@
import requests
class ColumnTypes:
COLLABORATOR = 'collaborator'
NUMBER = 'number'
DATE = 'date'
GEOLOCATION = 'geolocation'
CREATOR = 'creator'
LAST_MODIFIER = 'last-modifier'
TEXT = 'text'
IMAGE = 'image'
LONG_TEXT = 'long-text'
CHECKBOX = 'checkbox'
SINGLE_SELECT = 'single-select'
MULTIPLE_SELECT = 'multiple-select'
URL = 'url'
DURATION = 'duration'
FILE = 'file'
EMAIL = 'email'
RATE = 'rate'
FORMULA = 'formula'
LINK_FORMULA = 'link-formula'
AUTO_NUMBER = 'auto-number'
LINK = 'link'
CTIME = 'ctime'
MTIME = 'mtime'
BUTTON = 'button'
DIGITAL_SIGN = 'digital-sign'
def parse_response(response):
if response.status_code >= 400:
raise ConnectionError(response.status_code, response.text)
else:
try:
return response.json()
except:
pass
class SeaTableAPI:
def __init__(self, api_token, server_url):
self.api_token = api_token
self.server_url = server_url
self.dtable_uuid = None
self.access_token = None
self.dtable_server_url = None
self.dtable_db_url = None
self.headers = None
self.auth()
def auth(self):
url = f"{self.server_url.strip('/')}/api/v2.1/dtable/app-access-token/?from=dtable_web"
resp = requests.get(url, headers={'Authorization': f'Token {self.api_token}'})
self.dtable_uuid = resp.json()['dtable_uuid']
self.access_token = resp.json()['access_token']
self.dtable_server_url = resp.json()['dtable_server']
self.dtable_db_url = resp.json()['dtable_db']
self.headers = {'Authorization': f'Token {self.access_token}'}
def get_metadata(self):
url = f"{self.dtable_server_url.strip('/')}/api/v1/dtables/{self.dtable_uuid}/metadata/?from=dtable_web"
resp = requests.get(url, headers=self.headers)
return parse_response(resp)['metadata']
def query(self, sql, convert=None, server_only=None):
url = f"{self.dtable_db_url.strip('/')}/api/v1/query/{self.dtable_uuid}/?from=dtable_web"
data = {'sql': sql}
if convert is not None:
data['convert_keys'] = convert
if server_only is not None:
data['server_only'] = server_only
resp = requests.post(url, json=data, headers=self.headers)
return parse_response(resp)
def add_table(self, table_name, columns=None):
url = f"{self.dtable_server_url.strip('/')}/api/v1/dtables/{self.dtable_uuid}/tables/?from=dtable_web"
data = {'table_name': table_name}
if columns:
data['columns'] = columns
resp = requests.post(url, headers=self.headers, json=data)
return parse_response(resp)
def insert_column(self, table_name, column):
url = f"{self.dtable_server_url.strip('/')}/api/v1/dtables/{self.dtable_uuid}/columns/?from=dtable_web"
data = {'table_name': table_name}
data.update(column)
resp = requests.post(url, headers=self.headers, json=data)
return parse_response(resp)
def append_row(self, table_name, row):
url = f"{self.dtable_server_url.strip('/')}/api/v1/dtables/{self.dtable_uuid}/rows/?from=dtable_web"
data = {
'table_name': table_name,
'row': row
}
resp = requests.post(url, headers=self.headers, json=data)
return parse_response(resp)
def update_row(self, table_name, row_id, row):
url = f"{self.dtable_server_url.strip('/')}/api/v1/dtables/{self.dtable_uuid}/rows/?from=dtable_web"
data = {
'table_name': table_name,
'row': row,
"row_id": row_id
}
resp = requests.put(url, headers=self.headers, json=data)
return parse_response(resp)
def get_table_by_name(self, table_name):
metadata = self.get_metadata()
for table in metadata['tables']:
if table['name'] == table_name:
return table
return None