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