diff --git a/api/app/lib/controllers/latestMetadata/index.js b/api/app/lib/controllers/latestMetadata/index.js
index 705a249..686dfdd 100644
--- a/api/app/lib/controllers/latestMetadata/index.js
+++ b/api/app/lib/controllers/latestMetadata/index.js
@@ -114,13 +114,15 @@ async function getMetadataDatabases(ctx) {
const { catalog, limit, offset, keywords, orderBy = 'createAt', orderDirection = 'desc' } = ctx.query;
const where = { catalog: catalog };
if (keywords) {
- where[$or] = [{ name: { $like: keywords } }, { code: { $like: keywords } }, { type: { $like: keywords } }]
+ where['$or'] = [{ name: { $iLike: `%${keywords}%` } },
+ { code: { $iLike: `%${keywords}%` } },
+ { type: { $iLike: `%${keywords}%` } }]
}
- const rslt = await models.MetadataDatabase.findAll({
+ const findObj = {
include: [
{
model: models.User,
- attributes: ['id', 'name'],
+ attributes: ['id', 'name', 'username'],
},
{
model: models.TagDatabase,
@@ -130,10 +132,13 @@ async function getMetadataDatabases(ctx) {
}],
where: where,
order: [[orderBy, orderDirection]],
- offset: Number(offset) * Number(limit),
- limit: Number(limit),
distinct: true
- });
+ }
+ if (Number(limit) > 0 && Number(offset) >= 0) {
+ findObj.offset = Number(offset) * Number(limit);
+ findObj.limit = Number(limit);
+ }
+ const rslt = await models.MetadataDatabase.findAndCountAll(findObj);
ctx.status = 200;
ctx.body = rslt;
} catch (error) {
@@ -151,13 +156,13 @@ async function getMetadataFiles(ctx) {
const { catalog, limit, offset, keywords, orderBy = 'createAt', orderDirection = 'desc' } = ctx.query;
const where = { catalog: catalog };
if (keywords) {
- where[$or] = [{ name: { $like: keywords } }, { type: { $like: keywords } }]
+ where['$or'] = [{ name: { $iLike: `%${keywords}%` } }, { type: { $iLike: `%${keywords}%` } }]
}
- const rslt = await models.MetadataFile.findAll({
+ const findObj = {
include: [
{
model: models.User,
- attributes: ['id', 'name'],
+ attributes: ['id', 'name', 'username'],
},
{
model: models.TagFile,
@@ -167,10 +172,13 @@ async function getMetadataFiles(ctx) {
}],
where: where,
order: [[orderBy, orderDirection]],
- offset: Number(offset) * Number(limit),
- limit: Number(limit),
distinct: true
- });
+ };
+ if (Number(limit) > 0 && Number(offset) >= 0) {
+ findObj.offset = Number(offset) * Number(limit);
+ findObj.limit = Number(limit);
+ }
+ const rslt = await models.MetadataFile.findAndCountAll(findObj);
ctx.status = 200;
ctx.body = rslt;
} catch (error) {
@@ -188,13 +196,13 @@ async function getMetadataRestapis(ctx) {
const { catalog, limit, offset, keywords, orderBy = 'createAt', orderDirection = 'desc' } = ctx.query;
const where = { catalog: catalog };
if (keywords) {
- where.name = { $like: keywords };
+ where.name = { $iLike: `%${keywords}%` };
}
- const rslt = await models.MetadataRestapi.findAll({
+ const findObj = {
include: [
{
model: models.User,
- attributes: ['id', 'name'],
+ attributes: ['id', 'name', 'username'],
},
{
model: models.TagRestapi,
@@ -204,10 +212,13 @@ async function getMetadataRestapis(ctx) {
}],
where: where,
order: [[orderBy, orderDirection]],
- offset: Number(offset) * Number(limit),
- limit: Number(limit),
distinct: true
- });
+ };
+ if (Number(limit) > 0 && Number(offset) >= 0) {
+ findObj.offset = Number(offset) * Number(limit);
+ findObj.limit = Number(limit);
+ }
+ const rslt = await models.MetadataRestapi.findAndCountAll(findObj);
ctx.status = 200;
ctx.body = rslt;
} catch (error) {
diff --git a/scripts/0.0.4/01_alter_t_metadata_database&t_resource_consumption.sql b/scripts/0.0.4/01_alter_t_metadata_database&t_resource_consumption.sql
new file mode 100644
index 0000000..303dcd9
--- /dev/null
+++ b/scripts/0.0.4/01_alter_t_metadata_database&t_resource_consumption.sql
@@ -0,0 +1,2 @@
+alter table t_metadata_database alter column type type varchar(255) using type::varchar(255);
+alter table t_resource_consumption alter column approve_state type varchar(20) using approve_state::varchar(20);
diff --git a/web/client/src/components/buttonGroup/index.js b/web/client/src/components/buttonGroup/index.js
new file mode 100644
index 0000000..d0335ad
--- /dev/null
+++ b/web/client/src/components/buttonGroup/index.js
@@ -0,0 +1,51 @@
+'use strict';
+
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { Button, Popover, Icon } from 'antd';
+import { EllipsisOutlined } from '@ant-design/icons';
+
+class ButtonGroup extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+
+ };
+ }
+
+ content = () => {
+
+ }
+
+ render_ = () => {
+ const { children } = this.props
+ return (
+
+ )
+ }
+
+ render() {
+ const { children } = this.props
+ if (children) {
+ if (Array.isArray(children)) {
+ if (children.some(c => c)) {
+ return this.render_()
+ }
+ } else {
+ return this.render_()
+ }
+ }
+ return ''
+ }
+}
+
+function mapStateToProps(state) {
+ return {
+ }
+}
+
+export default connect(mapStateToProps)(ButtonGroup);
\ No newline at end of file
diff --git a/web/client/src/components/index.js b/web/client/src/components/index.js
index 13a1672..c20bafa 100644
--- a/web/client/src/components/index.js
+++ b/web/client/src/components/index.js
@@ -4,11 +4,13 @@
import Upload from './Upload';
import Uploads from './Uploads';
import NoResource from './no-resource';
-import ExportAndImport from './export'
+import ExportAndImport from './export';
+import ButtonGroup from './buttonGroup';
export {
Upload,
Uploads,
NoResource,
ExportAndImport,
+ ButtonGroup
};
diff --git a/web/client/src/sections/metadataManagement/actions/metadata.js b/web/client/src/sections/metadataManagement/actions/metadata.js
index bafacd9..653cc43 100644
--- a/web/client/src/sections/metadataManagement/actions/metadata.js
+++ b/web/client/src/sections/metadataManagement/actions/metadata.js
@@ -50,4 +50,40 @@ export function delResourceCatalog(id) {
option: '删除资源目录',
}
});
+}
+
+export function getMetadataDatabases(params) {
+ return dispatch => basicAction({
+ type: 'get',
+ dispatch: dispatch,
+ query: params,
+ actionType: 'GET_METADATA_DATABASES_LIST',
+ url: ApiTable.getMetadataDatabases,
+ msg: { error: '获取库表元数据列表失败' },
+ reducer: { name: 'metadataDatabases' }
+ });
+}
+
+export function getMetadataFiles(params) {
+ return dispatch => basicAction({
+ type: 'get',
+ dispatch: dispatch,
+ query: params,
+ actionType: 'GET_METADATA_FILES_LIST',
+ url: ApiTable.getMetadataFiles,
+ msg: { error: '获取文件元数据列表失败' },
+ reducer: { name: 'metadataFiles' }
+ });
+}
+
+export function getMetadataRestapis(params) {
+ return dispatch => basicAction({
+ type: 'get',
+ dispatch: dispatch,
+ query: params,
+ actionType: 'GET_METADATA_RESTAPIS_LIST',
+ url: ApiTable.getMetadataRestapis,
+ msg: { error: '获取接口元数据列表失败' },
+ reducer: { name: 'metadataRestapis' }
+ });
}
\ No newline at end of file
diff --git a/web/client/src/sections/metadataManagement/containers/databasesTable.js b/web/client/src/sections/metadataManagement/containers/databasesTable.js
new file mode 100644
index 0000000..1446c40
--- /dev/null
+++ b/web/client/src/sections/metadataManagement/containers/databasesTable.js
@@ -0,0 +1,232 @@
+import React, { useEffect, useState } from 'react';
+import { connect } from 'react-redux';
+import { Spin, Table, Popconfirm, Button, Input } from 'antd';
+import { ButtonGroup } from '$components';
+import moment from 'moment';
+import FileSaver from 'file-saver';
+
+const DatabaseTable = (props) => {
+ const { user, dispatch, actions, clientHeight, resourceCatalogId, isRequesting } = props;
+ const { metadataManagement } = actions;
+ const SortValues = { 'ascend': 'asc', 'descend': 'desc' };
+ const [tableData, setTableData] = useState([]);
+ const [tableDataCount, setTableDataCount] = useState(0);//Table数据
+ const [createAtSort, setCreateAtSort] = useState('descend');
+ const [keywords, setKeywords] = useState('');
+ const [limit, setLimit] = useState(10);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [selectedRowKeys, setSelectedRowKeys] = useState([]);
+ const [selectedRows, setSelectedRows] = useState([]);
+
+ useEffect(() => {
+ setCreateAtSort('descend');
+ initData({ limit, offset: currentPage - 1, orderDirection: SortValues[createAtSort] });
+ }, [resourceCatalogId]);
+
+ const initData = (query = {}) => {
+ dispatch(metadataManagement.getMetadataDatabases({ catalog: resourceCatalogId, keywords, orderBy: 'createAt', ...query })).then(res => {
+ if (res.success) {
+ setTableData(res.payload.data.rows);
+ setTableDataCount(res.payload.data.count);
+ }
+ })
+ }
+ const onView = (id) => { }
+ const onEdit = (record) => { }
+ const confirmDelete = (id) => { }
+ const marking = (id) => { }
+ const applyResources = (id) => { }
+
+ const onTableChange = (pagination, filters, sorter) => {
+ let limit = Number.parseInt(pagination.pageSize);
+ let offset = Number.parseInt(pagination.current) - 1;
+ setCurrentPage(pagination.current);
+ setLimit(limit);
+ let query = { offset, limit, orderDirection: SortValues[createAtSort] };
+ if (sorter.columnKey === 'createAt') {
+ query.orderDirection = SortValues[sorter.order];
+ setCreateAtSort(sorter.order);
+ }
+ setSelectedRowKeys([]);
+ setSelectedRows([]);
+ initData(query);
+ }
+ const columns = [{
+ title: '名称',
+ dataIndex: 'name',
+ key: 'name',
+ width: '16%',
+ ellipsis: true
+ }, {
+ title: '代码',
+ dataIndex: 'code',
+ key: 'code',
+ width: '16%',
+ ellipsis: true
+ }, {
+ title: '元数据类型',
+ dataIndex: 'type',
+ key: 'type',
+ width: '10%'
+ }, {
+ title: '标签',
+ dataIndex: 'tags',
+ key: 'tags',
+ width: '18%',
+ ellipsis: true,
+ render: (text, record, index) => {
+ let tagName = record.tagDatabases.map(tagSet => tagSet.tag.name);
+ return tagName.join(',');
+ }
+ }, {
+ title: '创建者',
+ dataIndex: 'createBy',
+ key: 'createBy',
+ width: '14%',
+ render: (text, record, index) => {
+ return record.user.username
+ }
+ }, {
+ title: '创建时间',
+ dataIndex: 'createAt',
+ key: 'createAt',
+ width: '18%',
+ sortOrder: createAtSort,
+ sorter: (a, b) => moment(a.createAt).valueOf() - moment(b.createAt).valueOf(),
+ sortDirections: ['descend', 'ascend', 'descend'],
+ render: (text, record, index) => {
+ return moment(text).format('YYYY-MM-DD HH:mm:ss')
+ }
+ }, {
+ title: '操作',
+ dataIndex: 'action',
+ width: '8%',
+ render: (text, record) => {
+ return
+ onView(id)}>查看
+ onEdit(record)}>编辑
+ confirmDelete(record.id)}
+ > 删除
+ marking(record.id)}>打标
+ applyResources(record.id)}>申请资源
+
+ }
+ }];
+
+ const onSearch = () => {
+ setSelectedRowKeys([]);
+ setSelectedRows([]);
+ setCurrentPage(1);
+ initData({ limit, offset: 0, orderDirection: SortValues[createAtSort] });
+ }
+ const handleExport = (isAll = false) => {
+ let tableHeader = ``;
+ columns.filter(c => c.dataIndex != 'action').map(c => { tableHeader += `${c.title} | `; });
+ tableHeader += '
';
+ if (isAll) {
+ dispatch(metadataManagement.getMetadataDatabases({ catalog: resourceCatalogId })).then(res => {
+ if (res.success) {
+ handleExportTable(tableHeader, res.payload.data.rows, isAll);
+ }
+ })
+ } else {
+ let data = []
+ if (createAtSort === 'descend') {
+ data = selectedRows.sort((a, b) => moment(b.createAt).valueOf() - moment(a.createAt).valueOf());
+ } else {
+ data = selectedRows.sort((a, b) => moment(a.createAt).valueOf() - moment(b.createAt).valueOf());
+ }
+ handleExportTable(tableHeader, data);
+ }
+ }
+ const handleExportTable = (tableHeader, contentData, isAll = false) => {
+ let tableContent = '';
+ contentData.map(cd => {
+ tableContent += ``;
+ tableContent += `${cd.name} | `;
+ tableContent += `${cd.code} | `;
+ tableContent += `${cd.type} | `;
+ let tagName = cd.tagDatabases.map(tagSet => tagSet.tag.name);
+ tableContent += `${tagName.join(',')} | `;
+ tableContent += `${cd.user.username} | `;
+ tableContent += `${moment(cd.createAt).format('YYYY-MM-DD HH:mm:ss')} | `;
+ tableContent += `
`;
+ })
+ let exportTable = `\uFEFF
+ ${tableHeader}
+ ${tableContent}
+
`;
+ let tempStr = new Blob([exportTable], { type: 'text/plain;charset=utf-8' });
+ FileSaver.saveAs(tempStr, `库表元数据导出.xls`);
+ }
+ return
+
+
+ {tableDataCount == 0 ?
:
+ selectedRowKeys && selectedRowKeys.length ?
+
+ :
handleExport(true)} okText="确定" cancelText="取消">
+
+
+ }
+
+
setKeywords(e.target.value || '')} />
+
+ { return {`共${Math.ceil(total / limit)}页,${total}项`} },
+ onShowSizeChange: (currentPage, pageSize) => {
+ setCurrentPage(currentPage);
+ setLimit(pageSize);
+ },
+ onChange: (page, pageSize) => {
+ setSelectedRowKeys([]);
+ setSelectedRows([]);
+ setCurrentPage(page);
+ setLimit(pageSize);
+ let queryParams = {
+ orderDirection: SortValues[createAtSort],
+ page: page - 1,
+ size: pageSize
+ };
+ initData(queryParams);
+ }
+ }}
+ rowSelection={{
+ onChange: (selectedRowKeys, selectedRows) => {
+ setSelectedRowKeys(selectedRowKeys)
+ setSelectedRows(selectedRows);
+ },
+ selectedRowKeys: selectedRowKeys
+ }}
+ >
+
+
+
+}
+function mapStateToProps(state) {
+ const { global, auth, metadataDatabases } = state;
+ return {
+ user: auth.user,
+ actions: global.actions,
+ clientHeight: global.clientHeight,
+ isRequesting: metadataDatabases.isRequesting,
+ };
+}
+export default connect(mapStateToProps)(DatabaseTable)
\ No newline at end of file
diff --git a/web/client/src/sections/metadataManagement/containers/filesTable.js b/web/client/src/sections/metadataManagement/containers/filesTable.js
new file mode 100644
index 0000000..1bbd6e3
--- /dev/null
+++ b/web/client/src/sections/metadataManagement/containers/filesTable.js
@@ -0,0 +1,105 @@
+import React, { useEffect, useState } from 'react';
+import { connect } from 'react-redux';
+import { Spin, Table, Popconfirm } from 'antd';
+import moment from 'moment';
+
+const FilesTable = (props) => {
+ const { user, dispatch, actions, clientHeight, resourceCatalogId, isRequesting } = props;
+ const { metadataManagement } = actions;
+ const [resourceCatalogData, setResourceCatalogData] = useState([]);
+ const [limit, setLimit] = useState(10)
+ const [offset, setOffset] = useState(0)
+
+ useEffect(() => {
+ initData(resourceCatalogId);
+ }, []);
+
+ const initData = (resourceCatalogId) => {
+ dispatch(metadataManagement.getMetadataFiles({ catalog: resourceCatalogId, limit, offset })).then(res => {
+ const { data } = res.payload;
+ if (res.success) {
+
+ }
+ })
+ }
+ const onEdit = (record) => { }
+ const confirmDelete = (id) => { }
+ const marking = (id) => { }
+ const applyResources = (id) => { }
+ const columns = [{
+ title: '文件名称',
+ dataIndex: 'name',
+ key: 'name',
+ width: '20%'
+ }, {
+ title: '文件描述',
+ dataIndex: 'description',
+ key: 'description',
+ width: '20%'
+ }, {
+ title: '文件类型',
+ dataIndex: 'type',
+ key: 'type',
+ width: '20%'
+ }, {
+ title: '标签',
+ dataIndex: 'tags',
+ key: 'tags',
+ width: '20%'
+ }, {
+ title: '大小',
+ dataIndex: 'size',
+ key: 'size',
+ width: '20%'
+ }, {
+ title: '创建者',
+ dataIndex: 'createBy',
+ key: 'createBy',
+ width: '10%',
+ }, {
+ title: '修改时间',
+ dataIndex: 'updateAt',
+ key: 'updateAt',
+ width: '20%',
+ render: (text, record, index) => {
+ return text ? moment(text).format('YYYY-MM-DD HH:mm') : ''
+ }
+ }, {
+ title: '操作',
+ dataIndex: 'action',
+ width: '20%',
+ render: (text, record) => {
+ return
+ }
+ }];
+
+ return
+
+
+
+}
+function mapStateToProps(state) {
+ const { global, auth, metadataFiles } = state;
+ return {
+ user: auth.user,
+ actions: global.actions,
+ clientHeight: global.clientHeight,
+ isRequesting: metadataFiles.isRequesting
+ };
+}
+export default connect(mapStateToProps)(FilesTable)
\ No newline at end of file
diff --git a/web/client/src/sections/metadataManagement/containers/latestMetadata.js b/web/client/src/sections/metadataManagement/containers/latestMetadata.js
index 4f9a791..12f8850 100644
--- a/web/client/src/sections/metadataManagement/containers/latestMetadata.js
+++ b/web/client/src/sections/metadataManagement/containers/latestMetadata.js
@@ -4,6 +4,7 @@ import { Spin, Row, Col, Tree, Button, Tooltip, Popconfirm } from 'antd';
import { PlusCircleOutlined, EditOutlined, MinusCircleOutlined } from '@ant-design/icons';
import theStyle from './style.css';
import ResourceCatalogModal from '../components/resourceCatalogModal';
+import MetadataTab from './metadataTab';
let expandedKeysData = [];
const LatestMetadata = (props) => {
@@ -14,6 +15,7 @@ const LatestMetadata = (props) => {
const [expandedKeys, setExpandedKeys] = useState([]);
const [modalVisible, setModalVisible] = useState(false);
const [editData, setEditData] = useState({});
+ const [resourceCatalogId, setResourceCatalogId] = useState('');
useEffect(() => {
initData(true);
@@ -28,6 +30,8 @@ const LatestMetadata = (props) => {
if (configRefresh) {
setExpandedKeys(expandedKeysData);
setSelectedKeys(expandedKeysData);
+ let id = expandedKeysData[0].split('-').pop();
+ setResourceCatalogId(id);
}
} else {
setExpandedKeys([]);
@@ -120,11 +124,12 @@ const LatestMetadata = (props) => {
selectedKeys={selectedKeys}
onSelect={(keys, e) => {
if (e.selected) {
- // setCurrentPage(1);
setSelectedKeys(keys);
// let keyArr = allTreeNodeKeys.filter(ak => ak.key.includes(keys[0]));
// let ids = keyArr.map(key => key.id);
// console.log('all-' + JSON.stringify(ids))
+ let id = keys[0].split('-').pop();
+ setResourceCatalogId(id);
}
}}
onExpand={(keys) => {
@@ -134,7 +139,7 @@ const LatestMetadata = (props) => {
/>
-
+
{
diff --git a/web/client/src/sections/metadataManagement/containers/metadataTab.js b/web/client/src/sections/metadataManagement/containers/metadataTab.js
new file mode 100644
index 0000000..01bd5ad
--- /dev/null
+++ b/web/client/src/sections/metadataManagement/containers/metadataTab.js
@@ -0,0 +1,51 @@
+import React, { useEffect, useState } from 'react';
+import { connect } from 'react-redux';
+import { Tabs } from 'antd';
+import DatabaseTable from './databasesTable';
+import FilesTable from './filesTable';
+import RestapisTable from './restapisTable';
+
+const MetadataTab = (props) => {
+ const { resourceCatalogId, actions, dispatch } = props;
+ const [activeKey, setActiveKey] = useState('databases');
+ useEffect(() => {
+ setActiveKey('databases');
+ }, [resourceCatalogId]);
+
+ const onTabChange = (key) => {
+ setActiveKey(key)
+ }
+ return <>
+ 库表 ,
+ key: 'databases'
+ },
+ {
+ label: 文件 ,
+ key: 'files'
+ },
+ {
+ label: 接口 ,
+ key: 'restapis'
+ }
+ ]}>
+
+ {
+ activeKey === 'databases' && resourceCatalogId ? :
+ activeKey === 'files' && resourceCatalogId ? :
+ activeKey === 'restapis' && resourceCatalogId ? < RestapisTable resourceCatalogId={resourceCatalogId} /> : null
+ }
+ >
+}
+function mapStateToProps(state) {
+ const { global, auth } = state;
+ return {
+ user: auth.user,
+ actions: global.actions
+ };
+}
+export default connect(mapStateToProps)(MetadataTab)
\ No newline at end of file
diff --git a/web/client/src/sections/metadataManagement/containers/restapisTable.js b/web/client/src/sections/metadataManagement/containers/restapisTable.js
new file mode 100644
index 0000000..c7904d0
--- /dev/null
+++ b/web/client/src/sections/metadataManagement/containers/restapisTable.js
@@ -0,0 +1,102 @@
+import React, { useEffect, useState } from 'react';
+import { connect } from 'react-redux';
+import { Spin, Table, Popconfirm } from 'antd';
+import moment from 'moment';
+
+const RestapisTable = (props) => {
+ const { user, dispatch, actions, clientHeight, resourceCatalogId, isRequesting } = props;
+ const { metadataManagement } = actions;
+ const [resourceCatalogData, setResourceCatalogData] = useState([]);
+ const [limit, setLimit] = useState(10)
+ const [offset, setOffset] = useState(0)
+
+ useEffect(() => {
+ initData();
+ }, []);
+
+ const initData = () => {
+ dispatch(metadataManagement.getMetadataRestapis({ catalog: resourceCatalogId, limit, offset })).then(res => {
+ const { data } = res.payload;
+ if (res.success) {
+
+ }
+ })
+ }
+ const onEdit = (record) => { }
+ const confirmDelete = (id) => { }
+ const marking = (id) => { }
+ const applyResources = (id) => { }
+ const columns = [{
+ title: '接口名称',
+ dataIndex: 'name',
+ key: 'name',
+ width: '20%'
+ }, {
+ title: '接口路由',
+ dataIndex: 'url',
+ key: 'url',
+ width: '20%'
+ }, {
+ title: '接口类型',
+ dataIndex: 'method',
+ key: 'method',
+ width: '10%'
+ }, {
+ title: '传参',
+ dataIndex: 'queryParam',
+ key: 'queryParam',
+ width: '20%'
+ }, {
+ title: '返回值',
+ dataIndex: 'return',
+ key: 'return',
+ width: '20%'
+ }, {
+ title: '标签',
+ dataIndex: 'tags',
+ key: 'tags',
+ width: '20%'
+ }, {
+ title: '状态',
+ dataIndex: 'enabled',
+ key: 'enabled',
+ width: '10%'
+ }, {
+ title: '操作',
+ dataIndex: 'action',
+ width: '20%',
+ render: (text, record) => {
+ return
+ }
+ }];
+
+ return
+
+
+
+}
+function mapStateToProps(state) {
+ const { global, auth, metadataRestapis } = state;
+ return {
+ user: auth.user,
+ actions: global.actions,
+ clientHeight: global.clientHeight,
+ isRequesting: metadataRestapis.isRequesting
+ };
+}
+export default connect(mapStateToProps)(RestapisTable)
\ No newline at end of file