From 0a7aeec2e202d216452623f2b47d6a3a1667837a Mon Sep 17 00:00:00 2001
From: Alex Happy <1223408988@qq.com>
Date: Thu, 27 Jul 2023 15:11:35 +0800
Subject: [PATCH] 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>
---
.gitignore | 2 +
frontend/package-lock.json | 68 ++--
frontend/package.json | 3 +-
.../column/column-name.js | 22 ++
.../extra-attributes-dialog/column/index.css | 7 +
.../extra-attributes-dialog/column/index.js | 37 ++
.../editor/ctime-formatter.js | 22 ++
.../editor/date-editor.js | 28 ++
.../editor/formula-formatter.js | 31 ++
.../extra-attributes-dialog/editor/index.js | 20 +
.../editor/number-editor.js | 90 +++++
.../editor/search-input.js | 108 ++++++
.../editor/simple-text.js | 92 +++++
.../editor/single-select/index.css | 101 +++++
.../editor/single-select/index.js | 83 +++++
.../single-select/single-select-editor.js | 126 +++++++
.../dialog/extra-attributes-dialog/index.css | 17 +
.../dialog/extra-attributes-dialog/index.js | 250 +++++++++++++
.../dirent-detail/detail-list-view.js | 29 +-
frontend/src/constants/index.js | 112 ++++++
frontend/src/constants/keyCodes.js | 104 ++++++
frontend/src/constants/zIndexes.js | 1 +
frontend/src/css/dirent-detail.css | 21 ++
.../sdoc-file-history/history-version.js | 2 +-
frontend/src/utils/extra-attributes.js | 351 ++++++++++++++++++
frontend/src/utils/number-precision.js | 122 ++++++
frontend/src/utils/utils.js | 7 +-
scripts/init_extended_props_table.py | 64 ++++
seahub/api2/endpoints/extended_properties.py | 317 ++++++++++++++++
seahub/settings.py | 7 +
seahub/urls.py | 5 +
seahub/utils/seatable_api.py | 117 ++++++
32 files changed, 2330 insertions(+), 36 deletions(-)
create mode 100644 frontend/src/components/dialog/extra-attributes-dialog/column/column-name.js
create mode 100644 frontend/src/components/dialog/extra-attributes-dialog/column/index.css
create mode 100644 frontend/src/components/dialog/extra-attributes-dialog/column/index.js
create mode 100644 frontend/src/components/dialog/extra-attributes-dialog/editor/ctime-formatter.js
create mode 100644 frontend/src/components/dialog/extra-attributes-dialog/editor/date-editor.js
create mode 100644 frontend/src/components/dialog/extra-attributes-dialog/editor/formula-formatter.js
create mode 100644 frontend/src/components/dialog/extra-attributes-dialog/editor/index.js
create mode 100644 frontend/src/components/dialog/extra-attributes-dialog/editor/number-editor.js
create mode 100644 frontend/src/components/dialog/extra-attributes-dialog/editor/search-input.js
create mode 100644 frontend/src/components/dialog/extra-attributes-dialog/editor/simple-text.js
create mode 100644 frontend/src/components/dialog/extra-attributes-dialog/editor/single-select/index.css
create mode 100644 frontend/src/components/dialog/extra-attributes-dialog/editor/single-select/index.js
create mode 100644 frontend/src/components/dialog/extra-attributes-dialog/editor/single-select/single-select-editor.js
create mode 100644 frontend/src/components/dialog/extra-attributes-dialog/index.css
create mode 100644 frontend/src/components/dialog/extra-attributes-dialog/index.js
create mode 100644 frontend/src/constants/index.js
create mode 100644 frontend/src/constants/keyCodes.js
create mode 100644 frontend/src/constants/zIndexes.js
create mode 100644 frontend/src/utils/extra-attributes.js
create mode 100644 frontend/src/utils/number-precision.js
create mode 100644 scripts/init_extended_props_table.py
create mode 100644 seahub/api2/endpoints/extended_properties.py
create mode 100644 seahub/utils/seatable_api.py
diff --git a/.gitignore b/.gitignore
index 928f6bf9ae5..bc560d9747b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -64,3 +64,5 @@ frontend/package-lock.json
frontend/.eslintcache
/.idea
+
+.vscode
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3604a16cc22..aec68219b64 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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": {
diff --git a/frontend/package.json b/frontend/package.json
index f2688496385..00892f0c417 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
diff --git a/frontend/src/components/dialog/extra-attributes-dialog/column/column-name.js b/frontend/src/components/dialog/extra-attributes-dialog/column/column-name.js
new file mode 100644
index 00000000000..2252e283c29
--- /dev/null
+++ b/frontend/src/components/dialog/extra-attributes-dialog/column/column-name.js
@@ -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 (
+
+
+ {name || ''}
+
+
+ );
+}
+
+ColumnName.propTypes = {
+ column: PropTypes.object.isRequired,
+};
+
+export default ColumnName;
diff --git a/frontend/src/components/dialog/extra-attributes-dialog/column/index.css b/frontend/src/components/dialog/extra-attributes-dialog/column/index.css
new file mode 100644
index 00000000000..fe3e8a1d61d
--- /dev/null
+++ b/frontend/src/components/dialog/extra-attributes-dialog/column/index.css
@@ -0,0 +1,7 @@
+.extra-attributes-dialog .column-name {
+ padding-top: 9px;
+}
+
+.extra-attributes-dialog .column-item {
+ min-height: 56px;
+}
diff --git a/frontend/src/components/dialog/extra-attributes-dialog/column/index.js b/frontend/src/components/dialog/extra-attributes-dialog/column/index.js
new file mode 100644
index 00000000000..164a7a2a439
--- /dev/null
+++ b/frontend/src/components/dialog/extra-attributes-dialog/column/index.js
@@ -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 (
+
+
+
+
+
+
+ );
+ }
+}
+
+Column.propTypes = {
+ column: PropTypes.object,
+ row: PropTypes.object,
+ columns: PropTypes.array,
+ onCommit: PropTypes.func,
+};
+
+export default Column;
diff --git a/frontend/src/components/dialog/extra-attributes-dialog/editor/ctime-formatter.js b/frontend/src/components/dialog/extra-attributes-dialog/editor/ctime-formatter.js
new file mode 100644
index 00000000000..43334ade82d
--- /dev/null
+++ b/frontend/src/components/dialog/extra-attributes-dialog/editor/ctime-formatter.js
@@ -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 (
+ {value}
+ );
+ }
+}
+
+CtimeFormatter.propTypes = {
+ column: PropTypes.object,
+ row: PropTypes.object,
+};
+
+export default CtimeFormatter;
diff --git a/frontend/src/components/dialog/extra-attributes-dialog/editor/date-editor.js b/frontend/src/components/dialog/extra-attributes-dialog/editor/date-editor.js
new file mode 100644
index 00000000000..e72abd3e0ed
--- /dev/null
+++ b/frontend/src/components/dialog/extra-attributes-dialog/editor/date-editor.js
@@ -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 (
+
+ );
+ }
+}
+
+DateEditor.propTypes = {
+ column: PropTypes.object,
+ row: PropTypes.object,
+};
+
+export default DateEditor;
diff --git a/frontend/src/components/dialog/extra-attributes-dialog/editor/formula-formatter.js b/frontend/src/components/dialog/extra-attributes-dialog/editor/formula-formatter.js
new file mode 100644
index 00000000000..d901a6648cf
--- /dev/null
+++ b/frontend/src/components/dialog/extra-attributes-dialog/editor/formula-formatter.js
@@ -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 (
+ {getDateDisplayString(value, format)}
+ );
+ }
+ 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;
diff --git a/frontend/src/components/dialog/extra-attributes-dialog/editor/index.js b/frontend/src/components/dialog/extra-attributes-dialog/editor/index.js
new file mode 100644
index 00000000000..63cd1f2dd4f
--- /dev/null
+++ b/frontend/src/components/dialog/extra-attributes-dialog/editor/index.js
@@ -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;
diff --git a/frontend/src/components/dialog/extra-attributes-dialog/editor/number-editor.js b/frontend/src/components/dialog/extra-attributes-dialog/editor/number-editor.js
new file mode 100644
index 00000000000..63b7e2ea254
--- /dev/null
+++ b/frontend/src/components/dialog/extra-attributes-dialog/editor/number-editor.js
@@ -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 (
+
+ );
+ }
+}
+
+NumberEditor.propTypes = {
+ column: PropTypes.object,
+ row: PropTypes.object,
+ onCommit: PropTypes.func,
+};
+
+export default NumberEditor;
diff --git a/frontend/src/components/dialog/extra-attributes-dialog/editor/search-input.js b/frontend/src/components/dialog/extra-attributes-dialog/editor/search-input.js
new file mode 100644
index 00000000000..f6659e6cde6
--- /dev/null
+++ b/frontend/src/components/dialog/extra-attributes-dialog/editor/search-input.js
@@ -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 (
+ 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;
diff --git a/frontend/src/components/dialog/extra-attributes-dialog/editor/simple-text.js b/frontend/src/components/dialog/extra-attributes-dialog/editor/simple-text.js
new file mode 100644
index 00000000000..03e4ac3f200
--- /dev/null
+++ b/frontend/src/components/dialog/extra-attributes-dialog/editor/simple-text.js
@@ -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 (
+
+ );
+ }
+}
+
+SimpleText.propTypes = {
+ column: PropTypes.object,
+ row: PropTypes.object,
+ onCommit: PropTypes.func.isRequired,
+};
+
+export default SimpleText;
diff --git a/frontend/src/components/dialog/extra-attributes-dialog/editor/single-select/index.css b/frontend/src/components/dialog/extra-attributes-dialog/editor/single-select/index.css
new file mode 100644
index 00000000000..d698b4b470f
--- /dev/null
+++ b/frontend/src/components/dialog/extra-attributes-dialog/editor/single-select/index.css
@@ -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;
+}
+
diff --git a/frontend/src/components/dialog/extra-attributes-dialog/editor/single-select/index.js b/frontend/src/components/dialog/extra-attributes-dialog/editor/single-select/index.js
new file mode 100644
index 00000000000..ead9f50421e
--- /dev/null
+++ b/frontend/src/components/dialog/extra-attributes-dialog/editor/single-select/index.js
@@ -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 (
+ <>
+
+
+
+ {currentOptionID && (
+
{optionName}
+ )}
+
+ {column.editable && (
+
+ )}
+
+
+ {column.editable && (
+
+ )}
+ >
+ );
+ }
+}
+
+SingleSelect.propTypes = {
+ column: PropTypes.object,
+ row: PropTypes.object,
+ columns: PropTypes.array,
+ onCommit: PropTypes.func,
+};
+
+export default SingleSelect;
diff --git a/frontend/src/components/dialog/extra-attributes-dialog/editor/single-select/single-select-editor.js b/frontend/src/components/dialog/extra-attributes-dialog/editor/single-select/single-select-editor.js
new file mode 100644
index 00000000000..0e6060b5cd5
--- /dev/null
+++ b/frontend/src/components/dialog/extra-attributes-dialog/editor/single-select/single-select-editor.js
@@ -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 (
+
+
+
+
+
+
+ {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 (
+
+
{option.name}
+
+ {isSelected && ()}
+
+
+ );
+ })}
+
+
+
+ );
+ }
+}
+
+SingleSelectEditor.propTypes = {
+ value: PropTypes.string,
+ row: PropTypes.object,
+ column: PropTypes.object,
+ columns: PropTypes.array,
+ onUpdateState: PropTypes.func,
+ onCommit: PropTypes.func,
+};
+
+export default SingleSelectEditor;
diff --git a/frontend/src/components/dialog/extra-attributes-dialog/index.css b/frontend/src/components/dialog/extra-attributes-dialog/index.css
new file mode 100644
index 00000000000..f96ae21d00e
--- /dev/null
+++ b/frontend/src/components/dialog/extra-attributes-dialog/index.css
@@ -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;
+}
diff --git a/frontend/src/components/dialog/extra-attributes-dialog/index.js b/frontend/src/components/dialog/extra-attributes-dialog/index.js
new file mode 100644
index 00000000000..18bec37bb3a
--- /dev/null
+++ b/frontend/src/components/dialog/extra-attributes-dialog/index.js
@@ -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 (
+
+
+
+ );
+ }
+
+ if (errorMsg) {
+ return (
+
+ {gettext(errorMsg)}
+
+ );
+ }
+
+ const newRow = { ...row, ...update };
+
+ return (
+ <>
+ {columns.map(column => {
+ return (
+
+ );
+ })}
+ >
+ );
+
+ }
+
+ renderContent = () => {
+ if (!this.state.animationEnd) return null;
+
+ return (
+ <>
+ {gettext('Edit extra attributes')}
+
+ {this.renderColumns()}
+
+ >
+ );
+ }
+
+ render() {
+ const { animationEnd } = this.state;
+
+ return (
+
+ {this.renderContent()}
+
+ );
+ }
+}
+
+ExtraAttributesDialog.propTypes = {
+ repoID: PropTypes.string,
+ filePath: PropTypes.string,
+ direntDetail: PropTypes.object,
+ onToggle: PropTypes.func,
+};
+
+export default ExtraAttributesDialog;
diff --git a/frontend/src/components/dirent-detail/detail-list-view.js b/frontend/src/components/dirent-detail/detail-list-view.js
index 0f606eadb05..59f93d0ab3e 100644
--- a/frontend/src/components/dirent-detail/detail-list-view.js
+++ b/frontend/src/components/dirent-detail/detail-list-view.js
@@ -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 {
+ {direntDetail.permission === 'rw' && (
+
+
+
+ {gettext('Edit extra attributes')}
+
+ |
+
+ )}
{this.state.isEditFileTagShow &&
@@ -113,6 +128,14 @@ class DetailListView extends React.Component {
/>
}
+ {this.state.isShowExtraAttributes && (
+
+ )}
);
}
diff --git a/frontend/src/constants/index.js b/frontend/src/constants/index.js
new file mode 100644
index 00000000000..90a8254bbcd
--- /dev/null
+++ b/frontend/src/constants/index.js
@@ -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,
+};
diff --git a/frontend/src/constants/keyCodes.js b/frontend/src/constants/keyCodes.js
new file mode 100644
index 00000000000..ec93aa7345b
--- /dev/null
+++ b/frontend/src/constants/keyCodes.js
@@ -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;
diff --git a/frontend/src/constants/zIndexes.js b/frontend/src/constants/zIndexes.js
new file mode 100644
index 00000000000..9091cc82525
--- /dev/null
+++ b/frontend/src/constants/zIndexes.js
@@ -0,0 +1 @@
+export const EXTRA_ATTRIBUTES_DIALOG_MODAL = 1048;
diff --git a/frontend/src/css/dirent-detail.css b/frontend/src/css/dirent-detail.css
index ef9b6de4404..79ba8a23927 100644
--- a/frontend/src/css/dirent-detail.css
+++ b/frontend/src/css/dirent-detail.css
@@ -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;
+}
diff --git a/frontend/src/pages/sdoc-file-history/history-version.js b/frontend/src/pages/sdoc-file-history/history-version.js
index 2c51f3a30ef..2b48cf7000e 100644
--- a/frontend/src/pages/sdoc-file-history/history-version.js
+++ b/frontend/src/pages/sdoc-file-history/history-version.js
@@ -107,7 +107,7 @@ class HistoryVersion extends React.Component {
alt={gettext('More Operations')}
/>
- {(this.props.index !== 0) && {gettext('Restore')}}
+ {/* {(this.props.index !== 0) && {gettext('Restore')}} */}
{gettext('Download')}
{(this.props.index !== 0) && {gettext('Copy')}}
{gettext('Rename')}
diff --git a/frontend/src/utils/extra-attributes.js b/frontend/src/utils/extra-attributes.js
new file mode 100644
index 00000000000..af1de17f4b1
--- /dev/null
+++ b/frontend/src/utils/extra-attributes.js
@@ -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');
+};
diff --git a/frontend/src/utils/number-precision.js b/frontend/src/utils/number-precision.js
new file mode 100644
index 00000000000..1178d338730
--- /dev/null
+++ b/frontend/src/utils/number-precision.js
@@ -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.699999999999999,1.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 };
+
diff --git a/frontend/src/utils/utils.js b/frontend/src/utils/utils.js
index 96a8acbd33b..2bc1f8d7bd5 100644
--- a/frontend/src/utils/utils.js
+++ b/frontend/src/utils/utils.js
@@ -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]';
+ },
};
diff --git a/scripts/init_extended_props_table.py b/scripts/init_extended_props_table.py
new file mode 100644
index 00000000000..b1c88bf09ff
--- /dev/null
+++ b/scripts/init_extended_props_table.py
@@ -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)
diff --git a/seahub/api2/endpoints/extended_properties.py b/seahub/api2/endpoints/extended_properties.py
new file mode 100644
index 00000000000..614826352aa
--- /dev/null
+++ b/seahub/api2/endpoints/extended_properties.py
@@ -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})
diff --git a/seahub/settings.py b/seahub/settings.py
index 1b8f9fe5515..244ed3eaa94 100644
--- a/seahub/settings.py
+++ b/seahub/settings.py
@@ -862,6 +862,13 @@ def genpassword():
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',
diff --git a/seahub/urls.py b/seahub/urls.py
index 52d89a7e891..14e566fe50c 100644
--- a/seahub/urls.py
+++ b/seahub/urls.py
@@ -118,6 +118,8 @@
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 @@
re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/file/participant/$', FileParticipantView.as_view(), name='api-v2.1-file-participant'),
re_path(r'^api/v2.1/repos/(?P[-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[-0-9a-f]{36})/extended-properties/$', ExtendedPropertiesView.as_view(), name='api-v2.1-extended-properties'),
+
re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/auto-delete/$', RepoAutoDeleteView.as_view(), name='api-v2.1-repo-auto-delete'),
re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/share-links/$', RepoShareLinks.as_view(), name='api-v2.1-repo-share-links'),
diff --git a/seahub/utils/seatable_api.py b/seahub/utils/seatable_api.py
new file mode 100644
index 00000000000..d191d706d9f
--- /dev/null
+++ b/seahub/utils/seatable_api.py
@@ -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