mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-09-10 22:14:48 -05:00
feat(xml-rpc): new lib to parse/format XML-RPC
This commit is contained in:
committed by
Pierre Donias
parent
798049c20e
commit
f8fc4aa551
128
@vates/xml-rpc/.USAGE.md
Normal file
128
@vates/xml-rpc/.USAGE.md
Normal file
@@ -0,0 +1,128 @@
|
||||
### Formatting
|
||||
|
||||
```js
|
||||
import { xmlRpcFormatter } from '@vates/xml-rpc/formatter'
|
||||
|
||||
// There is a dedicated method per XML-RPC tag (`array`, `string`, etc.)
|
||||
xmlRpcFormatter.format_methodCall({
|
||||
methodName: 'examples.getStateName',
|
||||
params: [40],
|
||||
})
|
||||
// → {
|
||||
// name: 'methodCall',
|
||||
// children: [
|
||||
// {
|
||||
// name: 'methodName',
|
||||
// children: [ 'examples.getStateName' ]
|
||||
// },
|
||||
// {
|
||||
// name: 'params',
|
||||
// children: [
|
||||
// {
|
||||
// name: 'param',
|
||||
// children: [ { name: 'value', children: [ '40' ] } ]
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
|
||||
// There is a dedicated method to format a JS value
|
||||
xmlRpcFormatter.format_js(new Date(900684535000))
|
||||
// → {
|
||||
// name: 'dateTime.iso8601',
|
||||
// children: [ '1998-07-17T14:08:55.000Z' ]
|
||||
// }
|
||||
```
|
||||
|
||||
Formatting XML is out of this library's scope, but you can achieve it easily with
|
||||
`@vates/xml`:
|
||||
|
||||
```js
|
||||
import { formatXml } from '@vates/xml/format'
|
||||
|
||||
// it can now be passed to the XML-RPC parser
|
||||
const tree = xmlRpcFormatter.format_methodCall({
|
||||
methodName: 'examples.getStateName',
|
||||
params: [40],
|
||||
})
|
||||
|
||||
// avoid any indentation or new lines as it will confuse many XML-RPC parsers.
|
||||
const xml = formatXml(tree, { indent: 0 })
|
||||
```
|
||||
|
||||
### Parsing
|
||||
|
||||
```js
|
||||
import { xmlRpcParser } from '@vates/xml-rpc/parser'
|
||||
|
||||
xmlRpcParser.parse({
|
||||
name: 'methodResponse',
|
||||
children: [
|
||||
{
|
||||
name: 'params',
|
||||
children: [
|
||||
{
|
||||
name: 'param',
|
||||
children: [
|
||||
{
|
||||
name: 'value',
|
||||
children: [
|
||||
{
|
||||
name: 'string',
|
||||
children: ['South Dakota'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
// { params: [ 'South Dakota' ] }
|
||||
|
||||
// There is a dedicated method per XML-RPC tag (`array`, `string`, etc.)
|
||||
xmlRpcParser.parse_array({
|
||||
name: 'array',
|
||||
children: [
|
||||
{
|
||||
name: 'data',
|
||||
children: [
|
||||
{
|
||||
name: 'value',
|
||||
children: [{ name: 'boolean', children: ['1'] }],
|
||||
},
|
||||
{
|
||||
name: 'value',
|
||||
children: [{ name: 'int', children: ['42'] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
// [ true, 42 ]
|
||||
```
|
||||
|
||||
Parsing XML is out of this library's scope, but you can achieve it easily with
|
||||
`@vates/xml`:
|
||||
|
||||
```js
|
||||
import { parseXml } from '@vates/xml/parse'
|
||||
|
||||
const xml = `
|
||||
<?xml version="1.0"?>
|
||||
<methodResponse>
|
||||
<params>
|
||||
<param>
|
||||
<value><string>South Dakota</string></value>
|
||||
</param>
|
||||
</params>
|
||||
</methodResponse>
|
||||
`
|
||||
|
||||
const tree = parseXml(xml)
|
||||
|
||||
// it can now be passed to the XML-RPC parser
|
||||
xmlRpcParser.parse(tree)
|
||||
```
|
||||
1
@vates/xml-rpc/.npmignore
Symbolic link
1
@vates/xml-rpc/.npmignore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
161
@vates/xml-rpc/README.md
Normal file
161
@vates/xml-rpc/README.md
Normal file
@@ -0,0 +1,161 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @vates/xml-rpc
|
||||
|
||||
[](https://npmjs.org/package/@vates/xml-rpc)  [](https://bundlephobia.com/result?p=@vates/xml-rpc) [](https://npmjs.org/package/@vates/xml-rpc)
|
||||
|
||||
> Parse and format XML-RPC messages, do not handle transport or XML
|
||||
|
||||
## Install
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/xml-rpc):
|
||||
|
||||
```sh
|
||||
npm install --save @vates/xml-rpc
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Formatting
|
||||
|
||||
```js
|
||||
import { xmlRpcFormatter } from '@vates/xml-rpc/formatter'
|
||||
|
||||
// There is a dedicated method per XML-RPC tag (`array`, `string`, etc.)
|
||||
xmlRpcFormatter.format_methodCall({
|
||||
methodName: 'examples.getStateName',
|
||||
params: [40],
|
||||
})
|
||||
// → {
|
||||
// name: 'methodCall',
|
||||
// children: [
|
||||
// {
|
||||
// name: 'methodName',
|
||||
// children: [ 'examples.getStateName' ]
|
||||
// },
|
||||
// {
|
||||
// name: 'params',
|
||||
// children: [
|
||||
// {
|
||||
// name: 'param',
|
||||
// children: [ { name: 'value', children: [ '40' ] } ]
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
|
||||
// There is a dedicated method to format a JS value
|
||||
xmlRpcFormatter.format_js(new Date(900684535000))
|
||||
// → {
|
||||
// name: 'dateTime.iso8601',
|
||||
// children: [ '1998-07-17T14:08:55.000Z' ]
|
||||
// }
|
||||
```
|
||||
|
||||
Formatting XML is out of this library's scope, but you can achieve it easily with
|
||||
`@vates/xml`:
|
||||
|
||||
```js
|
||||
import { formatXml } from '@vates/xml/format'
|
||||
|
||||
// it can now be passed to the XML-RPC parser
|
||||
const tree = xmlRpcFormatter.format_methodCall({
|
||||
methodName: 'examples.getStateName',
|
||||
params: [40],
|
||||
})
|
||||
|
||||
// avoid any indentation or new lines as it will confuse many XML-RPC parsers.
|
||||
const xml = formatXml(tree, { indent: 0 })
|
||||
```
|
||||
|
||||
### Parsing
|
||||
|
||||
```js
|
||||
import { xmlRpcParser } from '@vates/xml-rpc/parser'
|
||||
|
||||
xmlRpcParser.parse({
|
||||
name: 'methodResponse',
|
||||
children: [
|
||||
{
|
||||
name: 'params',
|
||||
children: [
|
||||
{
|
||||
name: 'param',
|
||||
children: [
|
||||
{
|
||||
name: 'value',
|
||||
children: [
|
||||
{
|
||||
name: 'string',
|
||||
children: ['South Dakota'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
// { params: [ 'South Dakota' ] }
|
||||
|
||||
// There is a dedicated method per XML-RPC tag (`array`, `string`, etc.)
|
||||
xmlRpcParser.parse_array({
|
||||
name: 'array',
|
||||
children: [
|
||||
{
|
||||
name: 'data',
|
||||
children: [
|
||||
{
|
||||
name: 'value',
|
||||
children: [{ name: 'boolean', children: ['1'] }],
|
||||
},
|
||||
{
|
||||
name: 'value',
|
||||
children: [{ name: 'int', children: ['42'] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
// [ true, 42 ]
|
||||
```
|
||||
|
||||
Parsing XML is out of this library's scope, but you can achieve it easily with
|
||||
`@vates/xml`:
|
||||
|
||||
```js
|
||||
import { parseXml } from '@vates/xml/parse'
|
||||
|
||||
const xml = `
|
||||
<?xml version="1.0"?>
|
||||
<methodResponse>
|
||||
<params>
|
||||
<param>
|
||||
<value><string>South Dakota</string></value>
|
||||
</param>
|
||||
</params>
|
||||
</methodResponse>
|
||||
`
|
||||
|
||||
const tree = parseXml(xml)
|
||||
|
||||
// it can now be passed to the XML-RPC parser
|
||||
xmlRpcParser.parse(tree)
|
||||
```
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
the code.
|
||||
|
||||
You may:
|
||||
|
||||
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
|
||||
you've encountered;
|
||||
- fork and create a pull request.
|
||||
|
||||
## License
|
||||
|
||||
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)
|
||||
104
@vates/xml-rpc/formatter.js
Normal file
104
@vates/xml-rpc/formatter.js
Normal file
@@ -0,0 +1,104 @@
|
||||
'use strict'
|
||||
|
||||
function node(name, attributes, children) {
|
||||
if (Array.isArray(attributes)) {
|
||||
children = attributes
|
||||
attributes = undefined
|
||||
}
|
||||
|
||||
return { name, attributes, children }
|
||||
}
|
||||
|
||||
class XmlRpcFormatter {
|
||||
format_js(value) {
|
||||
const type = typeof value
|
||||
if (type === 'object') {
|
||||
return value === null
|
||||
? this.format_nil()
|
||||
: value instanceof Date
|
||||
? this['format_dateTime.iso8601'](value)
|
||||
: Array.isArray(value)
|
||||
? this.format_array(value)
|
||||
: Buffer.isBuffer(value)
|
||||
? this.format_base64(value)
|
||||
: this.format_struct(value)
|
||||
}
|
||||
|
||||
return this['format_' + type](value)
|
||||
}
|
||||
|
||||
format_array(values) {
|
||||
return node('array', [node('data', values.map(this.format_value, this))])
|
||||
}
|
||||
|
||||
format_base64(buf) {
|
||||
return node('base64', [buf.toString('base64')])
|
||||
}
|
||||
|
||||
format_boolean(bool) {
|
||||
return node('boolean', [bool ? '1' : '0'])
|
||||
}
|
||||
|
||||
'format_dateTime.iso8601'(date) {
|
||||
return node('dateTime.iso8601', [date.toISOString()])
|
||||
}
|
||||
|
||||
format_double(str) {
|
||||
return node('double', [String(str)])
|
||||
}
|
||||
|
||||
format_fault(val) {
|
||||
return node('fault', [this.format_value(val)])
|
||||
}
|
||||
|
||||
format_i4(str) {
|
||||
return node('i4', [String(str)])
|
||||
}
|
||||
|
||||
format_int(str) {
|
||||
return node('int', [String(str)])
|
||||
}
|
||||
|
||||
format_member([name, value]) {
|
||||
return node('member', [node('name', [name]), this.format_value(value)])
|
||||
}
|
||||
|
||||
format_methodCall({ methodName, params }) {
|
||||
return node('methodCall', [node('methodName', [methodName]), this.format_params(params)])
|
||||
}
|
||||
|
||||
format_methodResponse({ fault, params }) {
|
||||
return node('methodResponse', [fault !== undefined ? this.format_fault(fault) : this.format_params(params)])
|
||||
}
|
||||
|
||||
format_nil() {
|
||||
return node('nil')
|
||||
}
|
||||
|
||||
format_number(num) {
|
||||
return Number.isInteger(num) ? this.format_int(num) : this.format_double(num)
|
||||
}
|
||||
|
||||
format_param(value) {
|
||||
return node('param', [this.format_value(value)])
|
||||
}
|
||||
|
||||
format_params(params) {
|
||||
return node('params', params.map(this.format_param, this))
|
||||
}
|
||||
|
||||
format_string(str) {
|
||||
return str
|
||||
}
|
||||
|
||||
format_struct(struct) {
|
||||
return node('struct', Object.entries(struct).map(this.format_member, this))
|
||||
}
|
||||
|
||||
format_value(value) {
|
||||
return node('value', [this.format_js(value)])
|
||||
}
|
||||
}
|
||||
exports.Formatter = XmlRpcFormatter
|
||||
|
||||
exports.xmlRpcFormatter = new XmlRpcFormatter()
|
||||
33
@vates/xml-rpc/package.json
Normal file
33
@vates/xml-rpc/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@vates/xml-rpc",
|
||||
"description": "Parse and format XML-RPC messages, do not handle transport or XML",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/xml-rpc",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@vates/xml-rpc",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.0.0",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vates/xml": "0.0.0",
|
||||
"test": "^3.3.0"
|
||||
},
|
||||
"exports": {
|
||||
"./formatter": "./formatter.js",
|
||||
"./parser": "./parser.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
}
|
||||
}
|
||||
133
@vates/xml-rpc/parser.js
Normal file
133
@vates/xml-rpc/parser.js
Normal file
@@ -0,0 +1,133 @@
|
||||
'use strict'
|
||||
|
||||
function toValue(node) {
|
||||
const obj = { __proto__: null }
|
||||
for (const child of node.children) {
|
||||
if (typeof child === 'string') {
|
||||
return child
|
||||
}
|
||||
|
||||
const { name } = child
|
||||
if (Object.hasOwn(obj, name)) {
|
||||
const prev = obj[name]
|
||||
if (Array.isArray(obj, name)) {
|
||||
prev.push(child)
|
||||
} else {
|
||||
obj[name] = [prev, child]
|
||||
}
|
||||
} else {
|
||||
obj[name] = child
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// https://en.wikipedia.org/wiki/XML-RPC#Data_types
|
||||
class XmlRpcParser {
|
||||
parse(node) {
|
||||
return this['parse_' + node.name](node)
|
||||
}
|
||||
|
||||
parse_array({ children: [data] }) {
|
||||
return data.children.map(this.parse_value, this)
|
||||
}
|
||||
|
||||
parse_base64({ children: [str] }) {
|
||||
return Buffer.from(str, 'base64')
|
||||
}
|
||||
|
||||
parse_boolean({ children: [str] }) {
|
||||
return Boolean(Number(str))
|
||||
}
|
||||
|
||||
'parse_dateTime.iso8601'({ children: [str] }) {
|
||||
const date = new Date(str)
|
||||
|
||||
// date successfully parsed
|
||||
if (!Number.isNaN(+date)) {
|
||||
return date
|
||||
}
|
||||
|
||||
// XML-RPC dates may use YYYYMMDD instead of YYYY-MM-DD which ECMAScript
|
||||
// does not parse
|
||||
return new Date(str.slice(0, 4) + '-' + str.slice(4, 6) + '-' + str.slice(6))
|
||||
}
|
||||
|
||||
parse_double({ children: [str] }) {
|
||||
return Number(str)
|
||||
}
|
||||
|
||||
parse_fault({ children: [value] }) {
|
||||
return this.parse_value(value)
|
||||
}
|
||||
|
||||
parse_i4({ children: [str] }) {
|
||||
return Number(str)
|
||||
}
|
||||
|
||||
parse_int({ children: [str] }) {
|
||||
return Number(str)
|
||||
}
|
||||
|
||||
parse_member(member) {
|
||||
member = toValue(member)
|
||||
return [member.name.children[0], this.parse_value(member.value)]
|
||||
}
|
||||
|
||||
parse_methodCall(methodCall) {
|
||||
methodCall = toValue(methodCall)
|
||||
return {
|
||||
methodName: methodCall.methodName.children[0],
|
||||
params: this.parse_params(methodCall.params),
|
||||
}
|
||||
}
|
||||
|
||||
parse_methodResponse(methodResponse) {
|
||||
methodResponse = toValue(methodResponse)
|
||||
|
||||
const { fault } = methodResponse
|
||||
if (fault !== undefined) {
|
||||
return { fault: this.parse_fault(fault) }
|
||||
}
|
||||
|
||||
return { params: this.parse_params(methodResponse.params) }
|
||||
}
|
||||
|
||||
parse_nil() {
|
||||
return null
|
||||
}
|
||||
|
||||
parse_param(param) {
|
||||
return this.parse_value(param.children[0])
|
||||
}
|
||||
|
||||
parse_params(params) {
|
||||
return params.children.map(this.parse_param, this)
|
||||
}
|
||||
|
||||
parse_string({ children: [str] }) {
|
||||
return str
|
||||
}
|
||||
|
||||
parse_struct(struct) {
|
||||
return Object.fromEntries(struct.children.map(this.parse_member, this))
|
||||
}
|
||||
|
||||
parse_value({ children }) {
|
||||
if (children.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const child = children[0]
|
||||
|
||||
// can be a plain string
|
||||
if (typeof child === 'string') {
|
||||
return child
|
||||
}
|
||||
|
||||
return this.parse(child)
|
||||
}
|
||||
}
|
||||
exports.XmlRpcParser = XmlRpcParser
|
||||
|
||||
exports.xmlRpcParser = new XmlRpcParser()
|
||||
19
@vates/xml-rpc/test/formatter.js
Normal file
19
@vates/xml-rpc/test/formatter.js
Normal file
@@ -0,0 +1,19 @@
|
||||
'use strict'
|
||||
|
||||
const { formatXml } = require('@vates/xml/format')
|
||||
const { join } = require('node:path')
|
||||
const { parseXml } = require('@vates/xml/parse')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { xmlRpcFormatter } = require('@vates/xml-rpc/formatter')
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('test')
|
||||
|
||||
const methodCall = require('./methodCall.js')
|
||||
|
||||
test(function () {
|
||||
const xmlTree = parseXml(readFileSync(join(__dirname, 'methodCall.xml')))
|
||||
|
||||
const xmlRpc = xmlRpcFormatter.format_methodCall(methodCall)
|
||||
|
||||
assert.deepEqual(parseXml(formatXml(xmlRpc)), xmlTree)
|
||||
})
|
||||
16
@vates/xml-rpc/test/methodCall.js
Normal file
16
@vates/xml-rpc/test/methodCall.js
Normal file
@@ -0,0 +1,16 @@
|
||||
'use strict'
|
||||
|
||||
module.exports = {
|
||||
methodName: 'examples.getStateName',
|
||||
params: [
|
||||
[1404, 'Something here'],
|
||||
Buffer.from([121, 111, 117, 32, 99, 97, 110, 39, 116, 32, 114, 101, 97, 100, 32, 116, 104, 105, 115, 33]),
|
||||
true,
|
||||
new Date('1998-07-17T14:08:55.000Z'),
|
||||
-12.53,
|
||||
42,
|
||||
'Hello world!',
|
||||
{ foo: 1, bar: 2 },
|
||||
null,
|
||||
],
|
||||
}
|
||||
73
@vates/xml-rpc/test/methodCall.xml
Normal file
73
@vates/xml-rpc/test/methodCall.xml
Normal file
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<methodCall>
|
||||
<methodName>examples.getStateName</methodName>
|
||||
<params>
|
||||
<param>
|
||||
<value>
|
||||
<array>
|
||||
<data>
|
||||
<value>
|
||||
<int>1404</int>
|
||||
</value>
|
||||
<value>
|
||||
Something here
|
||||
</value>
|
||||
</data>
|
||||
</array>
|
||||
</value>
|
||||
</param>
|
||||
<param>
|
||||
<value>
|
||||
<base64>eW91IGNhbid0IHJlYWQgdGhpcyE=</base64>
|
||||
</value>
|
||||
</param>
|
||||
<param>
|
||||
<value>
|
||||
<boolean>1</boolean>
|
||||
</value>
|
||||
</param>
|
||||
<param>
|
||||
<value>
|
||||
<dateTime.iso8601>1998-07-17T14:08:55.000Z</dateTime.iso8601>
|
||||
</value>
|
||||
</param>
|
||||
<param>
|
||||
<value>
|
||||
<double>-12.53</double>
|
||||
</value>
|
||||
</param>
|
||||
<param>
|
||||
<value>
|
||||
<int>42</int>
|
||||
</value>
|
||||
</param>
|
||||
<param>
|
||||
<value>
|
||||
Hello world!
|
||||
</value>
|
||||
</param>
|
||||
<param>
|
||||
<value>
|
||||
<struct>
|
||||
<member>
|
||||
<name>foo</name>
|
||||
<value>
|
||||
<int>1</int>
|
||||
</value>
|
||||
</member>
|
||||
<member>
|
||||
<name>bar</name>
|
||||
<value>
|
||||
<int>2</int>
|
||||
</value>
|
||||
</member>
|
||||
</struct>
|
||||
</value>
|
||||
</param>
|
||||
<param>
|
||||
<value>
|
||||
<nil />
|
||||
</value>
|
||||
</param>
|
||||
</params>
|
||||
</methodCall>
|
||||
32
@vates/xml-rpc/test/parser.js
Normal file
32
@vates/xml-rpc/test/parser.js
Normal file
@@ -0,0 +1,32 @@
|
||||
'use strict'
|
||||
|
||||
const { join } = require('node:path')
|
||||
const { parseXml } = require('@vates/xml/parse')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { xmlRpcParser } = require('@vates/xml-rpc/parser')
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('test')
|
||||
|
||||
const methodCall = require('./methodCall.js')
|
||||
|
||||
test('parser.parse()', function () {
|
||||
const xml = String(readFileSync(join(__dirname, 'methodCall.xml')))
|
||||
const xmlRpc = parseXml(xml)
|
||||
|
||||
assert.deepEqual(xmlRpcParser.parse(xmlRpc), methodCall)
|
||||
})
|
||||
|
||||
test('datetime without dashes nor milliseconds', function () {
|
||||
assert.deepEqual(
|
||||
xmlRpcParser.parse({ name: 'dateTime.iso8601', children: ['19980717T14:08:55Z'] }),
|
||||
new Date('1998-07-17T14:08:55Z')
|
||||
)
|
||||
})
|
||||
|
||||
test('i4 support', function () {
|
||||
assert.equal(xmlRpcParser.parse({ name: 'i4', children: ['42'] }), 42)
|
||||
})
|
||||
|
||||
test('string support', function () {
|
||||
assert.equal(xmlRpcParser.parse({ name: 'string', children: ['foo'] }), 'foo')
|
||||
})
|
||||
@@ -28,5 +28,6 @@
|
||||
<!--packages-start-->
|
||||
|
||||
- @vates/xml major
|
||||
- @vates/xml-rpc major
|
||||
|
||||
<!--packages-end-->
|
||||
|
||||
Reference in New Issue
Block a user