mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Worked on 'update' action
This commit is contained in:
parent
6f0c98289b
commit
5780596cb1
|
@ -3,7 +3,7 @@ import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
|
||||||
import { containerFields, containerOperations } from './descriptions/ContainerDescription';
|
import { containerFields, containerOperations } from './descriptions/ContainerDescription';
|
||||||
import { itemFields, itemOperations } from './descriptions/ItemDescription';
|
import { itemFields, itemOperations } from './descriptions/ItemDescription';
|
||||||
import { searchCollections, searchItems } from './GenericFunctions';
|
import { getDynamicFields, searchCollections, searchItems } from './GenericFunctions';
|
||||||
|
|
||||||
export class CosmosDb implements INodeType {
|
export class CosmosDb implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
|
@ -64,6 +64,7 @@ export class CosmosDb implements INodeType {
|
||||||
listSearch: {
|
listSearch: {
|
||||||
searchCollections,
|
searchCollections,
|
||||||
searchItems,
|
searchItems,
|
||||||
|
getDynamicFields,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -118,6 +118,41 @@ export async function handlePagination(
|
||||||
return aggregatedResult.map((result) => ({ json: result }));
|
return aggregatedResult.map((result) => ({ json: result }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handleErrorPostReceive(
|
||||||
|
this: IExecuteSingleFunctions,
|
||||||
|
data: INodeExecutionData[],
|
||||||
|
response: IN8nHttpFullResponse,
|
||||||
|
): Promise<INodeExecutionData[]> {
|
||||||
|
if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) {
|
||||||
|
const responseBody = response.body as IDataObject;
|
||||||
|
|
||||||
|
let errorMessage = 'Unknown error occurred';
|
||||||
|
|
||||||
|
if (typeof responseBody.message === 'string') {
|
||||||
|
try {
|
||||||
|
const jsonMatch = responseBody.message.match(/Message: (\{.*\})/);
|
||||||
|
|
||||||
|
if (jsonMatch && jsonMatch[1]) {
|
||||||
|
const parsedMessage = JSON.parse(jsonMatch[1]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsedMessage.Errors &&
|
||||||
|
Array.isArray(parsedMessage.Errors) &&
|
||||||
|
parsedMessage.Errors.length > 0
|
||||||
|
) {
|
||||||
|
errorMessage = parsedMessage.Errors[0].split(' Learn more:')[0].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = 'Failed to extract error message';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApplicationError(errorMessage);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function microsoftCosmosDbRequest(
|
export async function microsoftCosmosDbRequest(
|
||||||
this: ILoadOptionsFunctions,
|
this: ILoadOptionsFunctions,
|
||||||
opts: IHttpRequestOptions,
|
opts: IHttpRequestOptions,
|
||||||
|
@ -133,6 +168,7 @@ export async function microsoftCosmosDbRequest(
|
||||||
...opts,
|
...opts,
|
||||||
baseURL: `${credentials.baseUrl}`,
|
baseURL: `${credentials.baseUrl}`,
|
||||||
headers: {
|
headers: {
|
||||||
|
...opts.headers,
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
@ -298,35 +334,69 @@ export async function validateOperations(
|
||||||
requestOptions: IHttpRequestOptions,
|
requestOptions: IHttpRequestOptions,
|
||||||
): Promise<IHttpRequestOptions> {
|
): Promise<IHttpRequestOptions> {
|
||||||
const rawOperations = this.getNodeParameter('operations', []) as IDataObject;
|
const rawOperations = this.getNodeParameter('operations', []) as IDataObject;
|
||||||
console.log('Operations', rawOperations);
|
|
||||||
if (!rawOperations || !Array.isArray(rawOperations.operations)) {
|
if (!rawOperations || !Array.isArray(rawOperations.operations)) {
|
||||||
throw new ApplicationError('The "operations" field must contain at least one operation.');
|
throw new ApplicationError('The "operations" field must contain at least one operation.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const operations = rawOperations.operations as Array<{
|
const operations = rawOperations.operations as Array<{
|
||||||
op: string;
|
op: string;
|
||||||
path: string;
|
path?: { mode: string; value: string };
|
||||||
value?: string;
|
toPath?: { mode: string; value: string };
|
||||||
|
from?: { mode: string; value: string };
|
||||||
|
value?: string | number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
for (const operation of operations) {
|
const transformedOperations = operations.map((operation) => {
|
||||||
if (!['add', 'increment', 'move', 'remove', 'replace', 'set'].includes(operation.op)) {
|
if (
|
||||||
throw new ApplicationError(
|
operation.op !== 'move' &&
|
||||||
`Invalid operation type "${operation.op}". Allowed values are "add", "increment", "move", "remove", "replace", and "set".`,
|
(!operation.path?.value ||
|
||||||
);
|
typeof operation.path.value !== 'string' ||
|
||||||
}
|
operation.path.value.trim() === '')
|
||||||
|
) {
|
||||||
if (!operation.path || operation.path.trim() === '') {
|
|
||||||
throw new ApplicationError('Each operation must have a valid "path".');
|
throw new ApplicationError('Each operation must have a valid "path".');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
['set', 'replace', 'add', 'increment'].includes(operation.op) &&
|
['set', 'replace', 'add', 'incr'].includes(operation.op) &&
|
||||||
(operation.value === undefined || operation.value === null)
|
(operation.value === undefined || operation.value === null)
|
||||||
) {
|
) {
|
||||||
throw new ApplicationError(`The operation "${operation.op}" must include a valid "value".`);
|
throw new ApplicationError(`The "${operation.op}" operation must include a valid "value".`);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if (operation.op === 'move') {
|
||||||
|
if (
|
||||||
|
!operation.from?.value ||
|
||||||
|
typeof operation.from.value !== 'string' ||
|
||||||
|
operation.from.value.trim() === ''
|
||||||
|
) {
|
||||||
|
throw new ApplicationError('The "move" operation must have a valid "from" path.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!operation.toPath?.value ||
|
||||||
|
typeof operation.toPath.value !== 'string' ||
|
||||||
|
operation.toPath.value.trim() === ''
|
||||||
|
) {
|
||||||
|
throw new ApplicationError('The "move" operation must have a valid "toPath".');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation.op === 'incr' && isNaN(Number(operation.value))) {
|
||||||
|
throw new ApplicationError('The "increment" operation must have a numeric value.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: operation.op,
|
||||||
|
path: operation.op === 'move' ? operation.toPath?.value : operation.path?.value,
|
||||||
|
...(operation.from ? { from: operation.from.value } : {}),
|
||||||
|
...(operation.op === 'incr'
|
||||||
|
? { value: Number(operation.value) }
|
||||||
|
: { value: isNaN(Number(operation.value)) ? operation.value : Number(operation.value) }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
requestOptions.body = transformedOperations;
|
||||||
|
|
||||||
return requestOptions;
|
return requestOptions;
|
||||||
}
|
}
|
||||||
|
@ -488,3 +558,85 @@ export async function processResponseContainers(
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractFieldPaths(obj: any, prefix = ''): string[] {
|
||||||
|
let paths: string[] = [];
|
||||||
|
|
||||||
|
Object.entries(obj).forEach(([key, value]) => {
|
||||||
|
if (key.startsWith('_') || key === 'id') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newPath = prefix ? `${prefix}/${key}` : `/${key}`;
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
value.forEach((item, index) => {
|
||||||
|
if (typeof item === 'object' && item !== null) {
|
||||||
|
paths = paths.concat(extractFieldPaths(item, `${newPath}/${index}`));
|
||||||
|
} else {
|
||||||
|
paths.push(`${newPath}/${index}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
|
paths = paths.concat(extractFieldPaths(value, newPath));
|
||||||
|
} else {
|
||||||
|
paths.push(newPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchItemById(
|
||||||
|
this: ILoadOptionsFunctions,
|
||||||
|
itemId: string,
|
||||||
|
): Promise<IDataObject | null> {
|
||||||
|
const collection = this.getNodeParameter('collId') as { mode: string; value: string };
|
||||||
|
|
||||||
|
if (!collection?.value) {
|
||||||
|
throw new ApplicationError('Collection ID is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemId) {
|
||||||
|
throw new ApplicationError('Item ID is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts: IHttpRequestOptions = {
|
||||||
|
method: 'GET',
|
||||||
|
url: `/colls/${collection.value}/docs/${itemId}`,
|
||||||
|
headers: {
|
||||||
|
'x-ms-documentdb-partitionkey': `["${itemId}"]`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const responseData: IDataObject = await microsoftCosmosDbRequest.call(this, opts);
|
||||||
|
|
||||||
|
if (!responseData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDynamicFields(
|
||||||
|
this: ILoadOptionsFunctions,
|
||||||
|
): Promise<INodeListSearchResult> {
|
||||||
|
const itemId = this.getNodeParameter('id', '') as { mode: string; value: string };
|
||||||
|
|
||||||
|
if (!itemId) {
|
||||||
|
throw new ApplicationError('Item ID is required to fetch fields.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemData = await searchItemById.call(this, itemId.value);
|
||||||
|
|
||||||
|
if (!itemData) {
|
||||||
|
throw new ApplicationError(`Item with ID "${itemId.value}" not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldPaths = extractFieldPaths(itemData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
results: fieldPaths.map((path) => ({
|
||||||
|
name: path,
|
||||||
|
value: path,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import type { INodeProperties } from 'n8n-workflow';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatCustomProperties,
|
formatCustomProperties,
|
||||||
|
handleErrorPostReceive,
|
||||||
handlePagination,
|
handlePagination,
|
||||||
processResponseItems,
|
processResponseItems,
|
||||||
validateOperations,
|
validateOperations,
|
||||||
|
@ -145,6 +146,9 @@ export const itemOperations: INodeProperties[] = [
|
||||||
'x-ms-documentdb-partitionkey': '=["{{$parameter["id"]}}"]',
|
'x-ms-documentdb-partitionkey': '=["{{$parameter["id"]}}"]',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
output: {
|
||||||
|
postReceive: [handleErrorPostReceive],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
action: 'Update item',
|
action: 'Update item',
|
||||||
},
|
},
|
||||||
|
@ -155,7 +159,7 @@ export const itemOperations: INodeProperties[] = [
|
||||||
|
|
||||||
export const createFields: INodeProperties[] = [
|
export const createFields: INodeProperties[] = [
|
||||||
{
|
{
|
||||||
displayName: 'Container ID',
|
displayName: 'Container',
|
||||||
name: 'collId',
|
name: 'collId',
|
||||||
type: 'resourceLocator',
|
type: 'resourceLocator',
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -184,7 +188,7 @@ export const createFields: INodeProperties[] = [
|
||||||
displayName: 'By ID',
|
displayName: 'By ID',
|
||||||
name: 'containerId',
|
name: 'containerId',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
hint: 'Enter the container ID',
|
hint: 'Enter the container Id',
|
||||||
validation: [
|
validation: [
|
||||||
{
|
{
|
||||||
type: 'regex',
|
type: 'regex',
|
||||||
|
@ -239,7 +243,7 @@ export const createFields: INodeProperties[] = [
|
||||||
|
|
||||||
export const deleteFields: INodeProperties[] = [
|
export const deleteFields: INodeProperties[] = [
|
||||||
{
|
{
|
||||||
displayName: 'Container ID',
|
displayName: 'Container',
|
||||||
name: 'collId',
|
name: 'collId',
|
||||||
type: 'resourceLocator',
|
type: 'resourceLocator',
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -274,7 +278,7 @@ export const deleteFields: INodeProperties[] = [
|
||||||
type: 'regex',
|
type: 'regex',
|
||||||
properties: {
|
properties: {
|
||||||
regex: '^[\\w+=,.@-]+$',
|
regex: '^[\\w+=,.@-]+$',
|
||||||
errorMessage: 'The container name must follow the allowed pattern.',
|
errorMessage: 'The container id must follow the allowed pattern.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -330,7 +334,7 @@ export const deleteFields: INodeProperties[] = [
|
||||||
|
|
||||||
export const getFields: INodeProperties[] = [
|
export const getFields: INodeProperties[] = [
|
||||||
{
|
{
|
||||||
displayName: 'Container ID',
|
displayName: 'Container',
|
||||||
name: 'collId',
|
name: 'collId',
|
||||||
type: 'resourceLocator',
|
type: 'resourceLocator',
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -365,7 +369,7 @@ export const getFields: INodeProperties[] = [
|
||||||
type: 'regex',
|
type: 'regex',
|
||||||
properties: {
|
properties: {
|
||||||
regex: '^[\\w+=,.@-]+$',
|
regex: '^[\\w+=,.@-]+$',
|
||||||
errorMessage: 'The container name must follow the allowed pattern.',
|
errorMessage: 'The container id must follow the allowed pattern.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -421,7 +425,7 @@ export const getFields: INodeProperties[] = [
|
||||||
|
|
||||||
export const getAllFields: INodeProperties[] = [
|
export const getAllFields: INodeProperties[] = [
|
||||||
{
|
{
|
||||||
displayName: 'Container ID',
|
displayName: 'Container',
|
||||||
name: 'collId',
|
name: 'collId',
|
||||||
type: 'resourceLocator',
|
type: 'resourceLocator',
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -456,7 +460,7 @@ export const getAllFields: INodeProperties[] = [
|
||||||
type: 'regex',
|
type: 'regex',
|
||||||
properties: {
|
properties: {
|
||||||
regex: '^[\\w+=,.@-]+$',
|
regex: '^[\\w+=,.@-]+$',
|
||||||
errorMessage: 'The container name must follow the allowed pattern.',
|
errorMessage: 'The container id must follow the allowed pattern.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -506,7 +510,7 @@ export const getAllFields: INodeProperties[] = [
|
||||||
|
|
||||||
export const queryFields: INodeProperties[] = [
|
export const queryFields: INodeProperties[] = [
|
||||||
{
|
{
|
||||||
displayName: 'Container ID',
|
displayName: 'Container',
|
||||||
name: 'collId',
|
name: 'collId',
|
||||||
type: 'resourceLocator',
|
type: 'resourceLocator',
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -621,7 +625,7 @@ export const queryFields: INodeProperties[] = [
|
||||||
|
|
||||||
export const updateFields: INodeProperties[] = [
|
export const updateFields: INodeProperties[] = [
|
||||||
{
|
{
|
||||||
displayName: 'Container ID',
|
displayName: 'Container',
|
||||||
name: 'collId',
|
name: 'collId',
|
||||||
type: 'resourceLocator',
|
type: 'resourceLocator',
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -656,7 +660,7 @@ export const updateFields: INodeProperties[] = [
|
||||||
type: 'regex',
|
type: 'regex',
|
||||||
properties: {
|
properties: {
|
||||||
regex: '^[\\w+=,.@-]+$',
|
regex: '^[\\w+=,.@-]+$',
|
||||||
errorMessage: 'The container name must follow the allowed pattern.',
|
errorMessage: 'The container id must follow the allowed pattern.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -736,29 +740,112 @@ export const updateFields: INodeProperties[] = [
|
||||||
type: 'options',
|
type: 'options',
|
||||||
options: [
|
options: [
|
||||||
{ name: 'Add', value: 'add' },
|
{ name: 'Add', value: 'add' },
|
||||||
{ name: 'Increment', value: 'increment' },
|
{ name: 'Increment', value: 'incr' },
|
||||||
{ name: 'Move', value: 'move' },
|
{ name: 'Move', value: 'move' },
|
||||||
{ name: 'Remove', value: 'remove' },
|
{ name: 'Remove', value: 'remove' },
|
||||||
|
{ name: 'Replace', value: 'replace' },
|
||||||
{ name: 'Set', value: 'set' },
|
{ name: 'Set', value: 'set' },
|
||||||
],
|
],
|
||||||
default: 'set',
|
default: 'set',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'From',
|
displayName: 'From Path',
|
||||||
name: 'from',
|
name: 'from',
|
||||||
type: 'string',
|
type: 'resourceLocator',
|
||||||
default: '',
|
description: 'Select a field from the list or enter it manually',
|
||||||
displayOptions: {
|
displayOptions: {
|
||||||
show: {
|
show: {
|
||||||
op: ['move'],
|
op: ['move'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
default: {
|
||||||
|
mode: 'list',
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
modes: [
|
||||||
|
{
|
||||||
|
displayName: 'From List',
|
||||||
|
name: 'list',
|
||||||
|
type: 'list',
|
||||||
|
typeOptions: {
|
||||||
|
searchListMethod: 'getDynamicFields',
|
||||||
|
searchable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'By Name',
|
||||||
|
name: 'manual',
|
||||||
|
type: 'string',
|
||||||
|
hint: 'Enter the field name manually',
|
||||||
|
placeholder: 'e.g. /Parents/0/FamilyName',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'To Path',
|
||||||
|
name: 'toPath',
|
||||||
|
type: 'resourceLocator',
|
||||||
|
description: 'Select a field from the list or enter it manually',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
op: ['move'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
mode: 'list',
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
modes: [
|
||||||
|
{
|
||||||
|
displayName: 'From List',
|
||||||
|
name: 'list',
|
||||||
|
type: 'list',
|
||||||
|
typeOptions: {
|
||||||
|
searchListMethod: 'getDynamicFields',
|
||||||
|
searchable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'By Name',
|
||||||
|
name: 'manual',
|
||||||
|
type: 'string',
|
||||||
|
hint: 'Enter the field name manually',
|
||||||
|
placeholder: 'e.g. /Parents/0/FamilyName',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Path',
|
displayName: 'Path',
|
||||||
name: 'path',
|
name: 'path',
|
||||||
type: 'string',
|
type: 'resourceLocator',
|
||||||
default: '',
|
description: 'Select a field from the list or enter it manually',
|
||||||
|
default: {
|
||||||
|
mode: 'list',
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
op: ['add', 'remove', 'set', 'incr', 'replace'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modes: [
|
||||||
|
{
|
||||||
|
displayName: 'From List',
|
||||||
|
name: 'list',
|
||||||
|
type: 'list',
|
||||||
|
typeOptions: {
|
||||||
|
searchListMethod: 'getDynamicFields',
|
||||||
|
searchable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'By Name',
|
||||||
|
name: 'manual',
|
||||||
|
type: 'string',
|
||||||
|
hint: 'Enter the field name manually',
|
||||||
|
placeholder: 'e.g. /Parents/0/FamilyName',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Value',
|
displayName: 'Value',
|
||||||
|
@ -767,20 +854,13 @@ export const updateFields: INodeProperties[] = [
|
||||||
default: '',
|
default: '',
|
||||||
displayOptions: {
|
displayOptions: {
|
||||||
show: {
|
show: {
|
||||||
op: ['add', 'set', 'increment'],
|
op: ['add', 'set', 'replace', 'incr'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
routing: {
|
|
||||||
send: {
|
|
||||||
type: 'body',
|
|
||||||
property: 'operations',
|
|
||||||
value: '={{ $parameter["operations"].operations }}',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue