mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-22 18:30:09 +00:00
feat(sync): split up webdav model stuff into different files
This commit is contained in:
parent
6858ce3c91
commit
8201f745ba
26 changed files with 151 additions and 144 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { WebdavApi } from './src/app/pfapi/api/sync/providers/webdav/webdav-api';
|
||||
import { WebdavPrivateCfg } from './src/app/pfapi/api/sync/providers/webdav/webdav';
|
||||
import { createMockResponse } from './src/app/pfapi/api/sync/providers/webdav/webdav-api-test-utils';
|
||||
import { WebdavPrivateCfg } from './src/app/pfapi/api/sync/providers/webdav/webdav.model';
|
||||
|
||||
describe('Debug Headers', () => {
|
||||
let mockFetch: jasmine.Spy;
|
||||
|
|
|
|||
|
|
@ -6,3 +6,7 @@ export * from './sync/providers/dropbox/dropbox';
|
|||
export * from './sync/providers/webdav/webdav';
|
||||
export * from './sync/sync-provider.interface';
|
||||
export * from './errors/errors';
|
||||
export { WebdavServerType } from './sync/providers/webdav/webdav.model';
|
||||
export { WebdavPrivateCfg } from './sync/providers/webdav/webdav.model';
|
||||
export { WebdavServerCapabilities } from './sync/providers/webdav/webdav.model';
|
||||
export { getRecommendedServerCapabilities } from './sync/providers/webdav/getRecommendedServerCapabilities';
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { DatabaseAdapter } from './db/database-adapter.model';
|
|||
import { ModelCtrl } from './model-ctrl/model-ctrl';
|
||||
import { ConflictReason, SyncProviderId, SyncStatus } from './pfapi.const';
|
||||
import { DropboxPrivateCfg } from './sync/providers/dropbox/dropbox';
|
||||
import { WebdavPrivateCfg } from './sync/providers/webdav/webdav';
|
||||
import { IValidation } from 'typia';
|
||||
import { WebdavPrivateCfg } from './sync/providers/webdav/webdav.model';
|
||||
|
||||
type JSONPrimitive = string | number | boolean | null;
|
||||
type Serializable = JSONPrimitive | SerializableObject | SerializableArray;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
import { createMockResponse } from './webdav-api-test-utils';
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
describe('Debug Headers', () => {
|
||||
let mockFetch: jasmine.Spy;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import { WebdavServerCapabilities, WebdavServerType } from './webdav.model';
|
||||
|
||||
/**
|
||||
* Helper function to get recommended server capabilities for common WebDAV server types
|
||||
*/
|
||||
export const getRecommendedServerCapabilities = (
|
||||
serverType: WebdavServerType,
|
||||
): WebdavServerCapabilities => {
|
||||
switch (serverType) {
|
||||
case WebdavServerType.NEXTCLOUD:
|
||||
case WebdavServerType.OWNCLOUD:
|
||||
return {
|
||||
supportsETags: true,
|
||||
supportsIfHeader: true,
|
||||
supportsLocking: true,
|
||||
supportsLastModified: true,
|
||||
};
|
||||
|
||||
case WebdavServerType.APACHE_MOD_DAV:
|
||||
return {
|
||||
supportsETags: true,
|
||||
supportsIfHeader: false, // mod_dav has limited If header support
|
||||
supportsLocking: true,
|
||||
supportsLastModified: true,
|
||||
};
|
||||
|
||||
case WebdavServerType.NGINX_DAV:
|
||||
return {
|
||||
supportsETags: false, // nginx dav module has limited ETag support
|
||||
supportsIfHeader: false,
|
||||
supportsLocking: false,
|
||||
supportsLastModified: true,
|
||||
};
|
||||
|
||||
case WebdavServerType.BASIC_WEBDAV:
|
||||
return {
|
||||
supportsETags: false,
|
||||
supportsIfHeader: false,
|
||||
supportsLocking: false,
|
||||
supportsLastModified: true,
|
||||
};
|
||||
|
||||
case WebdavServerType.CUSTOM:
|
||||
default:
|
||||
// Return null to trigger auto-detection
|
||||
return {
|
||||
supportsETags: false,
|
||||
supportsIfHeader: false,
|
||||
supportsLocking: false,
|
||||
supportsLastModified: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
import {
|
||||
RemoteFileNotFoundAPIError,
|
||||
NoEtagAPIError,
|
||||
HttpNotOkAPIError,
|
||||
} from '../../../errors/errors';
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
describe('WebdavApi - Additional Coverage Tests', () => {
|
||||
let api: WebdavApi;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
// import { HttpNotOkAPIError, RemoteFileNotFoundAPIError } from '../../../errors/errors';
|
||||
import { CapacitorHttp } from '@capacitor/core';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../../../util/is-android-web-view';
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
describe('WebdavApi - Android WebView', () => {
|
||||
let mockGetCfgOrError: jasmine.Spy;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
import { createMockResponse, createPropfindResponse } from './webdav-api-test-utils';
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
describe('WebdavApi Capability Detection', () => {
|
||||
let api: WebdavApi;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { WebdavApi } from './webdav-api';
|
||||
import {
|
||||
WebdavPrivateCfg,
|
||||
WebdavServerType,
|
||||
getRecommendedServerCapabilities,
|
||||
} from './webdav';
|
||||
import { createMockResponse } from './webdav-api-test-utils';
|
||||
import { WebdavPrivateCfg, WebdavServerType } from './webdav.model';
|
||||
import { getRecommendedServerCapabilities } from './getRecommendedServerCapabilities';
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
describe('WebdavApi - Connection Testing', () => {
|
||||
let api: WebdavApi;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
import { HttpNotOkAPIError, RemoteFileNotFoundAPIError } from '../../../errors/errors';
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
describe('WebdavApi - Download Operations', () => {
|
||||
let api: WebdavApi;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
import { createMockResponse, createMockResponseFactory } from './webdav-api-test-utils';
|
||||
import {
|
||||
NoRevAPIError,
|
||||
|
|
@ -7,6 +6,7 @@ import {
|
|||
FileExistsAPIError,
|
||||
RemoteFileNotFoundAPIError,
|
||||
} from '../../../errors/errors';
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
import {
|
||||
createMockResponse,
|
||||
createMockResponseFactory,
|
||||
createPropfindResponse,
|
||||
} from './webdav-api-test-utils';
|
||||
import { RemoteFileNotFoundAPIError } from '../../../errors/errors';
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
import { NoEtagAPIError, RemoteFileNotFoundAPIError } from '../../../errors/errors';
|
||||
import { createMockResponseFactory } from './webdav-api-test-utils';
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
describe('WebdavApi - Fallback Paths', () => {
|
||||
let api: WebdavApi;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
import { HttpNotOkAPIError, RemoteFileNotFoundAPIError } from '../../../errors/errors';
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
describe('WebdavApi - File Operations', () => {
|
||||
let api: WebdavApi;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
import { AuthFailSPError } from '../../../errors/errors';
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
describe('WebdavApi - Helper Methods', () => {
|
||||
let api: WebdavApi;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
import { createMockResponse } from './webdav-api-test-utils';
|
||||
import { RemoteFileNotFoundAPIError, NoEtagAPIError } from '../../../errors/errors';
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
import { RemoteFileNotFoundAPIError, NoEtagAPIError } from '../../../errors/errors';
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
describe('WebdavApi - Metadata Operations', () => {
|
||||
let api: WebdavApi;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
describe('WebdavApi Safe Creation Simple Test', () => {
|
||||
it('should be created', () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
import { createMockResponse } from './webdav-api-test-utils';
|
||||
import { FileExistsAPIError } from '../../../errors/errors';
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
export const createMockResponse = (
|
||||
status: number,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { WebdavApi } from './webdav-api';
|
||||
import { WebdavPrivateCfg } from './webdav';
|
||||
import {
|
||||
FileExistsAPIError,
|
||||
HttpNotOkAPIError,
|
||||
NoEtagAPIError,
|
||||
RemoteFileNotFoundAPIError,
|
||||
} from '../../../errors/errors';
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
describe('WebdavApi - Upload Operations', () => {
|
||||
let api: WebdavApi;
|
||||
|
|
|
|||
63
src/app/pfapi/api/sync/providers/webdav/webdav.model.ts
Normal file
63
src/app/pfapi/api/sync/providers/webdav/webdav.model.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { SyncProviderPrivateCfgBase } from '../../../pfapi.model';
|
||||
|
||||
export interface WebdavServerCapabilities {
|
||||
/** Whether the server supports ETag headers for versioning */
|
||||
supportsETags: boolean;
|
||||
/** Whether the server supports WebDAV If headers (RFC 4918) */
|
||||
supportsIfHeader: boolean;
|
||||
/** Whether the server supports WebDAV LOCK/UNLOCK operations */
|
||||
supportsLocking: boolean;
|
||||
/** Whether the server supports Last-Modified headers for versioning */
|
||||
supportsLastModified: boolean;
|
||||
}
|
||||
|
||||
export interface WebdavPrivateCfg extends SyncProviderPrivateCfgBase {
|
||||
baseUrl: string;
|
||||
userName: string;
|
||||
password: string;
|
||||
syncFolderPath: string;
|
||||
|
||||
/**
|
||||
* Server capabilities configuration. If not provided, capabilities will be
|
||||
* detected automatically on first use. Providing this configuration can
|
||||
* improve performance by skipping detection and ensure consistent behavior.
|
||||
*
|
||||
* Recommended settings for common servers:
|
||||
* - Nextcloud/ownCloud: { supportsETags: true, supportsIfHeader: true, supportsLocking: true, supportsLastModified: true }
|
||||
* - Apache mod_dav: { supportsETags: true, supportsIfHeader: false, supportsLocking: true, supportsLastModified: true }
|
||||
* - Basic WebDAV: { supportsETags: false, supportsIfHeader: false, supportsLocking: false, supportsLastModified: true }
|
||||
*/
|
||||
serverCapabilities?: WebdavServerCapabilities;
|
||||
|
||||
/**
|
||||
* Force the use of Last-Modified headers instead of ETags, even if ETags are available.
|
||||
* This can be useful for testing fallback behavior or working with servers that have
|
||||
* unreliable ETag implementations.
|
||||
*/
|
||||
preferLastModified?: boolean;
|
||||
|
||||
/**
|
||||
* Compatibility mode for servers with limited WebDAV support.
|
||||
* When enabled, disables conditional operations and safe creation mechanisms.
|
||||
* Use only for very basic WebDAV servers that don't support any conditional headers.
|
||||
*/
|
||||
basicCompatibilityMode?: boolean;
|
||||
|
||||
/**
|
||||
* Maximum number of retry attempts for capability detection and fallback operations.
|
||||
* Default: 2
|
||||
*/
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server type enum for common WebDAV implementations
|
||||
*/
|
||||
export enum WebdavServerType {
|
||||
NEXTCLOUD = 'nextcloud',
|
||||
OWNCLOUD = 'owncloud',
|
||||
APACHE_MOD_DAV = 'apache_mod_dav',
|
||||
NGINX_DAV = 'nginx_dav',
|
||||
BASIC_WEBDAV = 'basic_webdav',
|
||||
CUSTOM = 'custom',
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Webdav, WebdavPrivateCfg } from './webdav';
|
||||
import { Webdav } from './webdav';
|
||||
import { WebdavApi } from './webdav-api';
|
||||
import { SyncProviderId } from '../../../pfapi.const';
|
||||
import { SyncProviderPrivateCfgStore } from '../../sync-provider-private-cfg-store';
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
MissingCredentialsSPError,
|
||||
NoRevAPIError,
|
||||
} from '../../../errors/errors';
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
describe('Webdav', () => {
|
||||
let webdav: Webdav;
|
||||
|
|
|
|||
|
|
@ -7,121 +7,7 @@ import {
|
|||
MissingCredentialsSPError,
|
||||
NoRevAPIError,
|
||||
} from '../../../errors/errors';
|
||||
import { SyncProviderPrivateCfgBase } from '../../../pfapi.model';
|
||||
|
||||
export interface WebdavServerCapabilities {
|
||||
/** Whether the server supports ETag headers for versioning */
|
||||
supportsETags: boolean;
|
||||
/** Whether the server supports WebDAV If headers (RFC 4918) */
|
||||
supportsIfHeader: boolean;
|
||||
/** Whether the server supports WebDAV LOCK/UNLOCK operations */
|
||||
supportsLocking: boolean;
|
||||
/** Whether the server supports Last-Modified headers for versioning */
|
||||
supportsLastModified: boolean;
|
||||
}
|
||||
|
||||
export interface WebdavPrivateCfg extends SyncProviderPrivateCfgBase {
|
||||
baseUrl: string;
|
||||
userName: string;
|
||||
password: string;
|
||||
syncFolderPath: string;
|
||||
|
||||
/**
|
||||
* Server capabilities configuration. If not provided, capabilities will be
|
||||
* detected automatically on first use. Providing this configuration can
|
||||
* improve performance by skipping detection and ensure consistent behavior.
|
||||
*
|
||||
* Recommended settings for common servers:
|
||||
* - Nextcloud/ownCloud: { supportsETags: true, supportsIfHeader: true, supportsLocking: true, supportsLastModified: true }
|
||||
* - Apache mod_dav: { supportsETags: true, supportsIfHeader: false, supportsLocking: true, supportsLastModified: true }
|
||||
* - Basic WebDAV: { supportsETags: false, supportsIfHeader: false, supportsLocking: false, supportsLastModified: true }
|
||||
*/
|
||||
serverCapabilities?: WebdavServerCapabilities;
|
||||
|
||||
/**
|
||||
* Force the use of Last-Modified headers instead of ETags, even if ETags are available.
|
||||
* This can be useful for testing fallback behavior or working with servers that have
|
||||
* unreliable ETag implementations.
|
||||
*/
|
||||
preferLastModified?: boolean;
|
||||
|
||||
/**
|
||||
* Compatibility mode for servers with limited WebDAV support.
|
||||
* When enabled, disables conditional operations and safe creation mechanisms.
|
||||
* Use only for very basic WebDAV servers that don't support any conditional headers.
|
||||
*/
|
||||
basicCompatibilityMode?: boolean;
|
||||
|
||||
/**
|
||||
* Maximum number of retry attempts for capability detection and fallback operations.
|
||||
* Default: 2
|
||||
*/
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server type enum for common WebDAV implementations
|
||||
*/
|
||||
export enum WebdavServerType {
|
||||
NEXTCLOUD = 'nextcloud',
|
||||
OWNCLOUD = 'owncloud',
|
||||
APACHE_MOD_DAV = 'apache_mod_dav',
|
||||
NGINX_DAV = 'nginx_dav',
|
||||
BASIC_WEBDAV = 'basic_webdav',
|
||||
CUSTOM = 'custom',
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get recommended server capabilities for common WebDAV server types
|
||||
*/
|
||||
export const getRecommendedServerCapabilities = (
|
||||
serverType: WebdavServerType,
|
||||
): WebdavServerCapabilities => {
|
||||
switch (serverType) {
|
||||
case WebdavServerType.NEXTCLOUD:
|
||||
case WebdavServerType.OWNCLOUD:
|
||||
return {
|
||||
supportsETags: true,
|
||||
supportsIfHeader: true,
|
||||
supportsLocking: true,
|
||||
supportsLastModified: true,
|
||||
};
|
||||
|
||||
case WebdavServerType.APACHE_MOD_DAV:
|
||||
return {
|
||||
supportsETags: true,
|
||||
supportsIfHeader: false, // mod_dav has limited If header support
|
||||
supportsLocking: true,
|
||||
supportsLastModified: true,
|
||||
};
|
||||
|
||||
case WebdavServerType.NGINX_DAV:
|
||||
return {
|
||||
supportsETags: false, // nginx dav module has limited ETag support
|
||||
supportsIfHeader: false,
|
||||
supportsLocking: false,
|
||||
supportsLastModified: true,
|
||||
};
|
||||
|
||||
case WebdavServerType.BASIC_WEBDAV:
|
||||
return {
|
||||
supportsETags: false,
|
||||
supportsIfHeader: false,
|
||||
supportsLocking: false,
|
||||
supportsLastModified: true,
|
||||
};
|
||||
|
||||
case WebdavServerType.CUSTOM:
|
||||
default:
|
||||
// Return null to trigger auto-detection
|
||||
return {
|
||||
supportsETags: false,
|
||||
supportsIfHeader: false,
|
||||
supportsLocking: false,
|
||||
supportsLastModified: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
import { WebdavPrivateCfg } from './webdav.model';
|
||||
|
||||
export class Webdav implements SyncProviderServiceInterface<SyncProviderId.WebDAV> {
|
||||
private static readonly L = 'Webdav';
|
||||
|
|
@ -205,14 +91,13 @@ export class Webdav implements SyncProviderServiceInterface<SyncProviderId.WebDA
|
|||
}
|
||||
|
||||
return { rev, dataStr };
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
// Handle 304 Not Modified by retrying without localRev
|
||||
if (e?.status === 304) {
|
||||
if (e && typeof e === 'object' && 'status' in e && e.status === 304) {
|
||||
const { rev, dataStr } = await this._api.download({
|
||||
path: filePath,
|
||||
localRev: null,
|
||||
});
|
||||
|
||||
if (!dataStr && dataStr !== '') {
|
||||
throw new InvalidDataSPError(targetPath);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue