feat: add formula field type (#457)

* feat: add formula field type

* feat: issue for useCollectionField

* feat: add formula field type

* feat: add formula field type
This commit is contained in:
金昶 2022-06-02 14:33:37 +08:00 committed by GitHub
parent b8fac535f2
commit efc4301be6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 494 additions and 3 deletions

View File

@ -27,6 +27,7 @@
"file-saver": "^2.0.5",
"i18next": "^21.6.0",
"marked": "^4.0.12",
"mathjs": "^10.6.0",
"react-beautiful-dnd": "^13.1.0",
"react-big-calendar": "^0.38.7",
"react-drag-listview": "^0.1.9",
@ -34,6 +35,7 @@
"react-i18next": "^11.15.1",
"react-image-lightbox": "^5.1.4",
"react-quill": "^1.3.5",
"react-contenteditable": "^3.3.6",
"react-router-dom": "^5.2.0",
"slate": "^0.76.1",
"slate-history": "^0.66.0",

View File

@ -0,0 +1,55 @@
import { defaultProps, operators } from './properties';
import { IField } from './types';
export const formula: IField = {
name: 'formula',
type: 'object',
group: 'basic',
order: 9,
title: '{{t("Formula")}}',
sortable: true,
default: {
type: 'formula',
// name,
uiSchema: {
type: 'number',
// title,
"x-disabled": true,
'x-component': 'Formula.Result',
'x-component-props': {
stringMode: true,
step: '1',
},
},
},
properties: {
...defaultProps,
'expression': {
type: 'string',
title: '{{t("Expression")}}',
required: true,
description: '{{t("Input +、-、*、/、( ) to calculate, input @ to open field variables.")}}',
'x-component': 'Formula.Expression',
'x-decorator': 'FormItem',
},
'uiSchema.x-component-props.step': {
type: 'string',
title: '{{t("Precision")}}',
'x-component': 'Select',
'x-decorator': 'FormItem',
required: true,
default: '0',
enum: [
{ value: '0', label: '1' },
{ value: '0.1', label: '1.0' },
{ value: '0.01', label: '1.00' },
{ value: '0.001', label: '1.000' },
{ value: '0.0001', label: '1.0000' },
{ value: '0.00001', label: '1.00000' },
],
},
},
filterable: {
operators: operators.number,
},
};

View File

@ -24,4 +24,5 @@ export * from './textarea';
export * from './time';
export * from './updatedAt';
export * from './updatedBy';
export * from './formula';

View File

@ -118,6 +118,7 @@ export default {
"Number": "数字",
"Percent": "百分比",
"Password": "密码",
"Formula": "公式",
"Choices": "选择类型",
"Checkbox": "勾选",
"Single select": "下拉菜单(单选)",
@ -345,6 +346,9 @@ export default {
'Role UID': '角色标识',
'Precision': '精确度',
'Formula mode': '计算方式',
'Expression': '表达式',
'Input +、-、*、/、( ) to calculate, input @ to open field variables.': '英文输入+、-、*、/、( ) 进行运算,输入@打开可用字段变量。',
'Accept': '文件格式',
'Rich Text': '富文本',
'Junction collection': '中间表',

View File

@ -0,0 +1,46 @@
import { onFormValuesChange } from '@formily/core';
import { connect, mapReadPretty, useFieldSchema, useFormEffects } from '@formily/react';
import { InputNumber } from 'antd';
import React, { useState } from 'react';
import * as math from 'mathjs';
import _ from 'lodash';
import { ReadPretty } from '../input-number/ReadPretty';
import { useCollection, useCollectionField } from '../../../collection-manager/hooks';
const AntdCompute = (props) => {
const { value, onChange, step } = props;
// const { expression } = useCollectionField();
const { getField } = useCollection();
const fieldSchema = useFieldSchema();
const options = getField(fieldSchema.name);
const { expression } = options;
const [computeValue, setComputeValue] = useState(value);
useFormEffects(() => {
onFormValuesChange((form) => {
const scope = _.cloneDeep(form.values);
let result;
try {
result = math.evaluate(expression, scope);
result = math.round(result, 9);
} catch {}
if (result) {
setComputeValue(result);
if (onChange) {
onChange(result);
}
}
})
})
return (
<InputNumber readOnly value={computeValue} stringMode={true} step={step} />
);
}
export const Compute = connect(
AntdCompute,
mapReadPretty(ReadPretty)
);
export default Compute;

View File

@ -0,0 +1 @@
export * from './Compute';

View File

@ -0,0 +1,46 @@
import { onFormValuesChange } from '@formily/core';
import { connect, mapReadPretty, useFieldSchema, useFormEffects } from '@formily/react';
import { InputNumber } from 'antd';
import React, { useState } from 'react';
import * as math from 'mathjs';
import _ from 'lodash';
import { ReadPretty } from '../input-number/ReadPretty';
import { useCollection, useCollectionField } from '../../../collection-manager/hooks';
const AntdCompute = (props) => {
const { value, onChange, step } = props;
// const { expression } = useCollectionField();
const { getField } = useCollection();
const fieldSchema = useFieldSchema();
const options = getField(fieldSchema.name);
const { expression } = options;
const [computeValue, setComputeValue] = useState(value);
useFormEffects(() => {
onFormValuesChange((form) => {
const scope = _.cloneDeep(form.values);
let result;
try {
result = math.evaluate(expression, scope);
result = math.round(result, 9);
} catch {}
if (result) {
setComputeValue(result);
if (onChange) {
onChange(result);
}
}
})
})
return (
<InputNumber readOnly value={computeValue} stringMode={true} step={step} />
);
}
export const Compute = connect(
AntdCompute,
mapReadPretty(ReadPretty)
);
export default Compute;

View File

@ -0,0 +1,104 @@
import { CloseOutlined, LoadingOutlined } from '@ant-design/icons';
import { useFormLayout } from '@formily/antd';
import { connect, mapProps, mapReadPretty, useFieldSchema } from '@formily/react';
import { isValid } from '@formily/shared';
import { useRecord } from '@nocobase/client';
import { Button, Input, Popover, Tag, Menu, Dropdown } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import ContentEditable from 'react-contenteditable';
import { useTranslation } from 'react-i18next';
import { useCollectionManager } from '../../../collection-manager/hooks';
import { hasIcon, Icon, icons } from '../../../icon';
const AntdFormulaInput = (props) => {
const { value, onChange } = props;
const record = useRecord();
const { getCollectionFields } = useCollectionManager();
const fields = getCollectionFields(record.collectionName || record.name) as any[];
const inputRef = useRef();
const [dropdownVisible, setDropdownVisible] = useState(false);
const [formula, setFormula] = useState(null);
const [html, setHtml] = useState(null);
const numColumns = new Map<string, string>();
fields.filter(field => field.interface === 'number').forEach(field => {
numColumns.set(field.name, field.uiSchema.title);
})
const keys = Array.from(numColumns.keys());
let initHtml;
if (value) {
initHtml = value;
numColumns.forEach((value, key) => {
initHtml = initHtml.replaceAll(key, `<span contentEditable="false" style="border: 1px solid #aaa; padding: 2px 5px;">${value}</span>`)
})
}
useEffect(() => {
if (onChange && formula) {
let v = formula;
numColumns.forEach((value, key) => {
v = v.replaceAll(value, key);
})
if (v != value) {
onChange(v);
}
}
}, [formula])
const menu = (
<Menu onClick={async (args) => {
const replaceFormula = formula.replace('@', numColumns.get(args.key));
const replaceHtml = html.replace('@', `<span contentEditable="false" style="border: 1px solid #aaa; padding: 2px 5px;">${numColumns.get(args.key)}</span>`);
setFormula(replaceFormula);
setHtml(replaceHtml);
setDropdownVisible(false);
}}>
{
keys.map(key => (<Menu.Item key={key}>{numColumns.get(key)}</Menu.Item>))
}
</Menu>
);
const handleChange = (e) => {
const current = inputRef.current as any;
setFormula(e.currentTarget.textContent);
setHtml(current.innerHTML);
}
const handleKeyDown = (e) => {
const {key} = e;
switch (key) {
case 'Enter':
e.preventDefault();
break;
case '@':
case 'Process':
setDropdownVisible(true);
break;
default:
setDropdownVisible(false);
break;
}
}
return (
<Dropdown overlay={menu} visible={dropdownVisible}>
<ContentEditable
innerRef={inputRef}
className="ant-input"
onChange={handleChange}
onKeyDown={handleKeyDown}
html={html || initHtml || ''}
/>
</Dropdown>
)
}
export const FormulaInput = connect(
AntdFormulaInput,
mapProps(),
);
export default FormulaInput;

View File

@ -0,0 +1,6 @@
import { FormulaInput } from './FormulaInput';
import { Compute } from './Compute';
export const Formula: any = () => null;
Formula.Expression = FormulaInput;
Formula.Result = Compute;

View File

@ -32,4 +32,6 @@ export * from './tabs';
export * from './time-picker';
export * from './tree-select';
export * from './upload';
export * from './formula-input';
export * from './compute';
import './index.less';

View File

@ -18,7 +18,8 @@
"flat": "^5.0.2",
"glob": "^7.1.6",
"sequelize": "^6.9.0",
"umzug": "^3.1.1"
"umzug": "^3.1.1",
"mathjs": "^10.6.1"
},
"repository": {
"type": "git",

View File

@ -0,0 +1,62 @@
import { Database } from '../../database';
import { mockDatabase } from '..';
import { FormulaField } from '../../fields';
describe('formula field', () => {
let db: Database;
beforeEach(async () => {
db = mockDatabase();
});
afterEach(async () => {
await db.close();
});
it('add formula field with old table, already has data.', async() => {
const Test = db.collection({
name: 'tests',
fields: [{ type: 'float', name: 'price' }, { type: 'float', name: 'count' }],
});
await db.sync();
const test = await Test.model.create<any>({
price: '1.2',
count: '2',
});
const expression = 'price*count';
const field = Test.addField('sum', { type: 'formula', expression });
await field.sync({});
const updatedTest = await Test.model.findByPk(test.id);
const sum = updatedTest.get('sum');
const sumField = Test.getField<FormulaField>('sum');
expect(sum).toEqual(sumField.caculate(expression, updatedTest.toJSON()));
});
it('auto set formula field with create or update data', async () => {
const expression = 'price*count';
const Test = db.collection({
name: 'tests',
fields: [{ type: 'float', name: 'price' }, { type: 'float', name: 'count' }, {name: 'sum', type: 'formula', expression}],
});
await db.sync();
const test = await Test.model.create<any>({
price: '1.2',
count: '2',
});
const sumField = Test.getField<FormulaField>('sum');
expect(test.get('sum')).toEqual(sumField.caculate(expression, test.toJSON()));
test.set('count', '6');
await test.save();
expect(test.get('sum')).toEqual(sumField.caculate(expression, test.toJSON()));
})
});

View File

@ -0,0 +1,78 @@
import { DataTypes } from 'sequelize';
import { BaseColumnFieldOptions, Field } from './field';
import * as math from 'mathjs';
export class FormulaField extends Field {
get dataType() {
return DataTypes.FLOAT;
}
caculate(expression, scope) {
let result;
try {
result = math.evaluate(expression, scope);
result = math.round(result, 9);
} catch {}
return result;
}
async initFieldData({ transaction }) {
const { expression, name } = this.options;
console.log('initFieldData', expression, name);
const records = await this.collection.repository.find({
order: [this.collection.model.primaryKeyAttribute],
transaction,
});
for (const record of records) {
const scope = record.toJSON();
const result = this.caculate(expression, scope);
if (result) {
await record.update(
{
[name]: result,
},
{
transaction,
silent: true,
hooks: false,
},
);
}
}
}
async caculateField(instance) {
const { expression, name } = this.options;
const scope = instance.toJSON();
let result;
try {
result = math.evaluate(expression, scope);
result = math.round(result, 9);
} catch {}
if (result) {
instance.set(name, result);
}
}
bind() {
super.bind();
this.on('afterSync', this.initFieldData.bind(this));
this.on('beforeCreate', this.caculateField.bind(this));
this.on('beforeUpdate', this.caculateField.bind(this));
}
unbind() {
super.unbind();
this.off('beforeCreate', this.caculateField.bind(this));
this.off('beforeUpdate', this.caculateField.bind(this));
this.off('afterSync', this.initFieldData.bind(this));
}
}
export interface FormulaFieldOptions extends BaseColumnFieldOptions {
type: 'formula';
expression: string;
}

View File

@ -24,6 +24,7 @@ import { TimeFieldOptions } from './time-field';
import { UidFieldOptions } from './uid-field';
import { UUIDFieldOptions } from './uuid-field';
import { VirtualFieldOptions } from './virtual-field';
import { FormulaFieldOptions } from './formula-field'
export * from './array-field';
export * from './belongs-to-field';
@ -46,6 +47,7 @@ export * from './time-field';
export * from './uid-field';
export * from './uuid-field';
export * from './virtual-field';
export * from './formula-field';
export type FieldOptions =
| BaseFieldOptions
@ -62,6 +64,7 @@ export type FieldOptions =
| SortFieldOptions
| TextFieldOptions
| VirtualFieldOptions
| FormulaFieldOptions
| ArrayFieldOptions
| TimeFieldOptions
| DateFieldOptions

View File

@ -2361,6 +2361,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.17.9", "@babel/runtime@^7.18.3":
version "7.18.3"
resolved "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.18.3.tgz#c7b654b57f6f63cf7f8b418ac9ca04408c4579f4"
integrity sha512-38Y8f7YUhce/K7RMwTp7m0uCumpv9hZkitCbBClqQIow1qSbCvGkcegKOXpEWCQLfWmevgRiWokZ1GkpfhbZug==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.10.4", "@babel/template@^7.16.7", "@babel/template@^7.4.0", "@babel/template@^7.4.4":
version "7.16.7"
resolved "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz#8d126c8701fde4d66b264b3eba3d96f07666d155"
@ -7780,6 +7787,11 @@ compare-func@^2.0.0:
array-ify "^1.0.0"
dot-prop "^5.1.0"
complex.js@^2.1.1:
version "2.1.1"
resolved "https://registry.npmmirror.com/complex.js/-/complex.js-2.1.1.tgz#0675dac8e464ec431fb2ab7d30f41d889fb25c31"
integrity sha512-8njCHOTtFFLtegk6zQo0kkVX1rngygb/KQI6z1qZxlFI3scluC+LVTCFbrkWjBv4vvLlbQ9t88IPMC6k95VTTg==
component-emitter@^1.2.1, component-emitter@^1.3.0:
version "1.3.0"
resolved "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
@ -8692,7 +8704,7 @@ decamelize@^1.0.0, decamelize@^1.1.0, decamelize@^1.1.2, decamelize@^1.2.0:
resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
decimal.js@^10.2.1:
decimal.js@^10.2.1, decimal.js@^10.3.1:
version "10.3.1"
resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783"
integrity sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==
@ -9501,6 +9513,11 @@ escape-html@^1.0.3:
resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
escape-latex@^1.2.0:
version "1.2.0"
resolved "https://registry.npmmirror.com/escape-latex/-/escape-latex-1.2.0.tgz#07c03818cf7dac250cce517f4fda1b001ef2bca1"
integrity sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==
escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
version "1.0.5"
resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
@ -10384,6 +10401,11 @@ formstream@^1.1.0:
mime "^2.5.2"
pause-stream "~0.0.11"
fraction.js@^4.2.0:
version "4.2.0"
resolved "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950"
integrity sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==
fragment-cache@^0.2.1:
version "0.2.1"
resolved "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"
@ -12475,6 +12497,11 @@ istanbul-reports@^3.0.2:
html-escaper "^2.0.0"
istanbul-lib-report "^3.0.0"
javascript-natural-sort@^0.7.1:
version "0.7.1"
resolved "https://registry.npmmirror.com/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz#f9e2303d4507f6d74355a73664d1440fb5a0ef59"
integrity sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==
javascript-stringify@^2.0.1:
version "2.1.0"
resolved "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz#27c76539be14d8bd128219a2d731b09337904e79"
@ -14341,6 +14368,36 @@ mathjax-full@^3.0.0:
mj-context-menu "^0.6.1"
speech-rule-engine "^3.3.3"
mathjs@^10.6.0:
version "10.6.0"
resolved "https://registry.npmmirror.com/mathjs/-/mathjs-10.6.0.tgz#7d9a36fa98c727d05fadadf1f4dba99fff071ab9"
integrity sha512-4oI0CSX7LtcyexTSLV8uo+llj8hB5LvVE9ApjN6rBjBplQaZ4/Gr3jh0zEla9+KaCig5wonZ9oFKD+GKXFL8hg==
dependencies:
"@babel/runtime" "^7.17.9"
complex.js "^2.1.1"
decimal.js "^10.3.1"
escape-latex "^1.2.0"
fraction.js "^4.2.0"
javascript-natural-sort "^0.7.1"
seedrandom "^3.0.5"
tiny-emitter "^2.1.0"
typed-function "^2.1.0"
mathjs@^10.6.1:
version "10.6.1"
resolved "https://registry.npmmirror.com/mathjs/-/mathjs-10.6.1.tgz#95b34178eed65cbf7a63d35c468ad3ac912f7ddf"
integrity sha512-8iZp6uUKKBoCFoUHze9ydsrSji9/IOEzMhwURyoQXaLL1+ILEZnraw4KzZnUBt/XN6lPJPV+7JO94oil3AmosQ==
dependencies:
"@babel/runtime" "^7.18.3"
complex.js "^2.1.1"
decimal.js "^10.3.1"
escape-latex "^1.2.0"
fraction.js "^4.2.0"
javascript-natural-sort "^0.7.1"
seedrandom "^3.0.5"
tiny-emitter "^2.1.0"
typed-function "^2.1.0"
md5.js@^1.3.4:
version "1.3.5"
resolved "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f"
@ -17520,7 +17577,7 @@ prop-types-exact@^1.2.0:
object.assign "^4.1.0"
reflect.ownkeys "^0.2.0"
prop-types@^15.5.10, prop-types@^15.5.8:
prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.7.1:
version "15.8.1"
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@ -18276,6 +18333,14 @@ react-big-calendar@^0.38.7:
react-overlays "^4.1.1"
uncontrollable "^7.0.0"
react-contenteditable@^3.3.6:
version "3.3.6"
resolved "https://registry.npmmirror.com/react-contenteditable/-/react-contenteditable-3.3.6.tgz#4dac0eeaff268ca1614b52d4290d7b21f3bfb997"
integrity sha512-61+Anbmzggel1sP7nwvxq3d2woD3duR5R89RoLGqKan1A+nruFIcmLjw2F+qqk70AyABls0BDKzE1vqS1UIF1g==
dependencies:
fast-deep-equal "^3.1.3"
prop-types "^15.7.1"
react-docgen-typescript-dumi-tmp@^1.22.1-0:
version "1.22.1-0"
resolved "https://registry.npmjs.org/react-docgen-typescript-dumi-tmp/-/react-docgen-typescript-dumi-tmp-1.22.1-0.tgz#6f452de05c5c114a6e1dd60b34930afaa7ae39a0"
@ -19613,6 +19678,11 @@ sdk-base@^2.0.1:
dependencies:
get-ready "~1.0.0"
seedrandom@^3.0.5:
version "3.0.5"
resolved "https://registry.npmmirror.com/seedrandom/-/seedrandom-3.0.5.tgz#54edc85c95222525b0c7a6f6b3543d8e0b3aa0a7"
integrity sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==
semver-diff@^2.0.0:
version "2.1.0"
resolved "https://registry.npmjs.org/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36"
@ -20933,6 +21003,11 @@ timsort@^0.3.0:
resolved "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4"
integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=
tiny-emitter@^2.1.0:
version "2.1.0"
resolved "https://registry.npmmirror.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423"
integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==
tiny-invariant@1.0.6:
version "1.0.6"
resolved "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.0.6.tgz#b3f9b38835e36a41c843a3b0907a5a7b3755de73"
@ -21340,6 +21415,11 @@ type-is@^1.6.16, type-is@^1.6.4:
media-typer "0.3.0"
mime-types "~2.1.24"
typed-function@^2.1.0:
version "2.1.0"
resolved "https://registry.npmmirror.com/typed-function/-/typed-function-2.1.0.tgz#ded6f8a442ba8749ff3fe75bc41419c8d46ccc3f"
integrity sha512-bctQIOqx2iVbWGDGPWwIm18QScpu2XRmkC19D8rQGFsjKSgteq/o1hTZvIG/wuDq8fanpBDrLkLq+aEN/6y5XQ==
typedarray-to-buffer@^3.1.5:
version "3.1.5"
resolved "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"