parent
f6ba121c40
commit
b02bd2aaef
|
@ -13,6 +13,7 @@
|
||||||
"@nextcloud/paths": "^2.1.0",
|
"@nextcloud/paths": "^2.1.0",
|
||||||
"@nextcloud/sharing": "^0.1.0",
|
"@nextcloud/sharing": "^0.1.0",
|
||||||
"@nextcloud/vue": "7.12.1",
|
"@nextcloud/vue": "7.12.1",
|
||||||
|
"dexie": "^3.2.4",
|
||||||
"filerobot-image-editor": "^4.5.1",
|
"filerobot-image-editor": "^4.5.1",
|
||||||
"fuse.js": "^6.6.2",
|
"fuse.js": "^6.6.2",
|
||||||
"hammerjs": "^2.0.8",
|
"hammerjs": "^2.0.8",
|
||||||
|
@ -4552,6 +4553,14 @@
|
||||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/dexie": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/dexie/-/dexie-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-VKoTQRSv7+RnffpOJ3Dh6ozknBqzWw/F3iqMdsZg958R0AS8AnY9x9d1lbwENr0gzeGJHXKcGhAMRaqys6SxqA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/diff": {
|
"node_modules/diff": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
|
||||||
|
@ -15026,6 +15035,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
|
||||||
},
|
},
|
||||||
|
"dexie": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/dexie/-/dexie-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-VKoTQRSv7+RnffpOJ3Dh6ozknBqzWw/F3iqMdsZg958R0AS8AnY9x9d1lbwENr0gzeGJHXKcGhAMRaqys6SxqA=="
|
||||||
|
},
|
||||||
"diff": {
|
"diff": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
|
||||||
|
|
|
@ -34,6 +34,7 @@
|
||||||
"@nextcloud/paths": "^2.1.0",
|
"@nextcloud/paths": "^2.1.0",
|
||||||
"@nextcloud/sharing": "^0.1.0",
|
"@nextcloud/sharing": "^0.1.0",
|
||||||
"@nextcloud/vue": "7.12.1",
|
"@nextcloud/vue": "7.12.1",
|
||||||
|
"dexie": "^3.2.4",
|
||||||
"filerobot-image-editor": "^4.5.1",
|
"filerobot-image-editor": "^4.5.1",
|
||||||
"fuse.js": "^6.6.2",
|
"fuse.js": "^6.6.2",
|
||||||
"hammerjs": "^2.0.8",
|
"hammerjs": "^2.0.8",
|
||||||
|
|
|
@ -160,6 +160,7 @@ import { defineComponent } from 'vue';
|
||||||
import UserConfig from '../mixins/UserConfig';
|
import UserConfig from '../mixins/UserConfig';
|
||||||
import * as utils from '../services/utils';
|
import * as utils from '../services/utils';
|
||||||
import * as nativex from '../native';
|
import * as nativex from '../native';
|
||||||
|
import * as nativeSync from '../native/sync';
|
||||||
|
|
||||||
import NcButton from '@nextcloud/vue/dist/Components/NcButton';
|
import NcButton from '@nextcloud/vue/dist/Components/NcButton';
|
||||||
const NcAppSettingsDialog = () => import('@nextcloud/vue/dist/Components/NcAppSettingsDialog');
|
const NcAppSettingsDialog = () => import('@nextcloud/vue/dist/Components/NcAppSettingsDialog');
|
||||||
|
@ -169,6 +170,7 @@ const NcCheckboxRadioSwitch = () => import('@nextcloud/vue/dist/Components/NcChe
|
||||||
import MultiPathSelectionModal from './modal/MultiPathSelectionModal.vue';
|
import MultiPathSelectionModal from './modal/MultiPathSelectionModal.vue';
|
||||||
|
|
||||||
import type { IConfig } from '../types';
|
import type { IConfig } from '../types';
|
||||||
|
import type { LocalFolderConfig } from '../native/types';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
|
@ -184,7 +186,7 @@ export default defineComponent({
|
||||||
mixins: [UserConfig],
|
mixins: [UserConfig],
|
||||||
|
|
||||||
data: () => ({
|
data: () => ({
|
||||||
localFolders: [] as nativex.LocalFolderConfig[],
|
localFolders: [] as LocalFolderConfig[],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
|
@ -289,11 +291,11 @@ export default defineComponent({
|
||||||
|
|
||||||
// --------------- Native APIs start -----------------------------
|
// --------------- Native APIs start -----------------------------
|
||||||
async refreshNativeConfig() {
|
async refreshNativeConfig() {
|
||||||
this.localFolders = await nativex.getLocalFolders();
|
this.localFolders = await nativeSync.getLocalFolders();
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateDeviceFolders() {
|
async updateDeviceFolders() {
|
||||||
await nativex.setLocalFolders(this.localFolders);
|
await nativeSync.setLocalFolders(this.localFolders);
|
||||||
},
|
},
|
||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
/**
|
||||||
|
* Add current origin to URL if doesn't have any protocol or origin.
|
||||||
|
*/
|
||||||
|
export function addOrigin(url: string) {
|
||||||
|
return url.match(/^(https?:)?\/\//)
|
||||||
|
? url
|
||||||
|
: url.startsWith('/')
|
||||||
|
? `${location.origin}${url}`
|
||||||
|
: `${location.origin}/${url}`;
|
||||||
|
}
|
|
@ -1,7 +1,13 @@
|
||||||
import axios from '@nextcloud/axios';
|
import axios from '@nextcloud/axios';
|
||||||
import { generateUrl } from '@nextcloud/router';
|
import { generateUrl } from '@nextcloud/router';
|
||||||
import type { IDay, IPhoto } from './types';
|
|
||||||
import { API as SAPI } from './services/API';
|
import type { IDay, IPhoto } from '../types';
|
||||||
|
import { API as SAPI } from '../services/API';
|
||||||
|
import { addOrigin } from './helpers';
|
||||||
|
|
||||||
|
import type { LocalFolderConfig, ISystemImage } from './types';
|
||||||
|
import * as sync from './sync';
|
||||||
|
|
||||||
const euc = encodeURIComponent;
|
const euc = encodeURIComponent;
|
||||||
|
|
||||||
/** Access NativeX over localhost */
|
/** Access NativeX over localhost */
|
||||||
|
@ -9,20 +15,6 @@ const BASE_URL = 'http://127.0.0.1';
|
||||||
|
|
||||||
/** NativeX asynchronous API */
|
/** NativeX asynchronous API */
|
||||||
export const API = {
|
export const API = {
|
||||||
/**
|
|
||||||
* Local days API.
|
|
||||||
* @regex ^/api/days$
|
|
||||||
* @returns {IDay[]} for all locally available days.
|
|
||||||
*/
|
|
||||||
DAYS: () => `${BASE_URL}/api/days`,
|
|
||||||
/**
|
|
||||||
* Local photos API.
|
|
||||||
* @regex ^/api/days/\d+$
|
|
||||||
* @param dayId Day ID to fetch photos for
|
|
||||||
* @returns {IPhoto[]} for all locally available photos for this day.
|
|
||||||
*/
|
|
||||||
DAY: (dayId: number) => `${BASE_URL}/api/days/${dayId}`,
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Local photo metadata API.
|
* Local photo metadata API.
|
||||||
* @regex ^/api/image/info/\d+$
|
* @regex ^/api/image/info/\d+$
|
||||||
|
@ -81,13 +73,6 @@ export const API = {
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
SHARE_LOCAL: (auid: number) => `${BASE_URL}/api/share/local/${auid}`,
|
SHARE_LOCAL: (auid: number) => `${BASE_URL}/api/share/local/${auid}`,
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of local folders configuration.
|
|
||||||
* @regex ^/api/config/local-folders$
|
|
||||||
* @returns {LocalFolderConfig[]} List of local folders configuration
|
|
||||||
*/
|
|
||||||
CONFIG_LOCAL_FOLDERS: () => `${BASE_URL}/api/config/local-folders`,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** NativeX synchronous API. */
|
/** NativeX synchronous API. */
|
||||||
|
@ -142,12 +127,6 @@ export type NativeX = {
|
||||||
*/
|
*/
|
||||||
destroyVideo: (fileid: string) => void;
|
destroyVideo: (fileid: string) => void;
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the local folders configuration to show in the timeline.
|
|
||||||
* @param json JSON-encoded array of LocalFolderConfig
|
|
||||||
*/
|
|
||||||
configSetLocalFolders: (json: string) => void;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the login process
|
* Start the login process
|
||||||
* @param baseUrl Base URL of the Nextcloud instance
|
* @param baseUrl Base URL of the Nextcloud instance
|
||||||
|
@ -164,13 +143,18 @@ export type NativeX = {
|
||||||
* Reload the app.
|
* Reload the app.
|
||||||
*/
|
*/
|
||||||
reload: () => void;
|
reload: () => void;
|
||||||
};
|
|
||||||
|
|
||||||
/** Setting of whether a local folder is enabled */
|
/**
|
||||||
export type LocalFolderConfig = {
|
* Initialize local sync.
|
||||||
id: string;
|
* @param startTime Start time of the sync
|
||||||
name: string;
|
*/
|
||||||
enabled: boolean;
|
localSyncInit: (startTime: number) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next batch of local sync.
|
||||||
|
* @returns {ISystemImage[]} Batch of photos to add
|
||||||
|
*/
|
||||||
|
localSyncNext: () => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The native interface is a global object that is injected by the native app. */
|
/** The native interface is a global object that is injected by the native app. */
|
||||||
|
@ -270,9 +254,7 @@ export async function extendDaysWithLocal(days: IDay[]) {
|
||||||
if (!has()) return;
|
if (!has()) return;
|
||||||
|
|
||||||
// Query native part
|
// Query native part
|
||||||
const res = await fetch(API.DAYS());
|
const local: IDay[] = await sync.getDaysLocal();
|
||||||
if (!res.ok) return;
|
|
||||||
const local: IDay[] = await res.json();
|
|
||||||
const remoteMap = new Map(days.map((d) => [d.dayid, d]));
|
const remoteMap = new Map(days.map((d) => [d.dayid, d]));
|
||||||
|
|
||||||
// Merge local days into remote days
|
// Merge local days into remote days
|
||||||
|
@ -301,12 +283,10 @@ export async function extendDaysWithLocal(days: IDay[]) {
|
||||||
export async function extendDayWithLocal(dayId: number, photos: IPhoto[]) {
|
export async function extendDayWithLocal(dayId: number, photos: IPhoto[]) {
|
||||||
if (!has()) return;
|
if (!has()) return;
|
||||||
|
|
||||||
// Query native part
|
|
||||||
const res = await fetch(API.DAY(dayId));
|
|
||||||
if (!res.ok) return;
|
|
||||||
|
|
||||||
// Merge local photos into remote photos
|
// Merge local photos into remote photos
|
||||||
const localPhotos: IPhoto[] = await res.json();
|
const localPhotos: IPhoto[] = await sync.getDayLocal(dayId);
|
||||||
|
console.log('nativex', dayId, JSON.stringify(localPhotos));
|
||||||
|
|
||||||
const serverAUIDs = new Set(photos.map((p) => p.auid));
|
const serverAUIDs = new Set(photos.map((p) => p.auid));
|
||||||
|
|
||||||
// Filter out files that are only available locally
|
// Filter out files that are only available locally
|
||||||
|
@ -332,21 +312,6 @@ export async function deleteLocalPhotos(photos: IPhoto[], dry: boolean = false):
|
||||||
return res.data.confirms ? res.data.count : 0;
|
return res.data.confirms ? res.data.count : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of local folders configuration.
|
|
||||||
* Should be called only if NativeX is available.
|
|
||||||
*/
|
|
||||||
export async function getLocalFolders() {
|
|
||||||
return (await axios.get<LocalFolderConfig[]>(API.CONFIG_LOCAL_FOLDERS())).data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set list of local folders configuration.
|
|
||||||
*/
|
|
||||||
export async function setLocalFolders(config: LocalFolderConfig[]) {
|
|
||||||
nativex?.configSetLocalFolders(JSON.stringify(config));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log out from Nextcloud and pass ahead.
|
* Log out from Nextcloud and pass ahead.
|
||||||
*/
|
*/
|
||||||
|
@ -357,12 +322,28 @@ export async function logout() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add current origin to URL if doesn't have any protocol or origin.
|
* Iterate the local files on the device.
|
||||||
*/
|
*/
|
||||||
function addOrigin(url: string) {
|
export async function* localSyncIter(startTime: number) {
|
||||||
return url.match(/^(https?:)?\/\//)
|
if (!has()) return;
|
||||||
? url
|
|
||||||
: url.startsWith('/')
|
// Initialize and iterate
|
||||||
? `${location.origin}${url}`
|
nativex.localSyncInit(startTime);
|
||||||
: `${location.origin}/${url}`;
|
|
||||||
|
while (true) {
|
||||||
|
const next = nativex.localSyncNext();
|
||||||
|
if (!next) break;
|
||||||
|
|
||||||
|
let photos: ISystemImage[];
|
||||||
|
try {
|
||||||
|
photos = JSON.parse(next);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse local sync JSON', e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const photo of photos) {
|
||||||
|
yield photo;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,144 @@
|
||||||
|
import Dexie from 'dexie';
|
||||||
|
import { getBuilder } from '@nextcloud/browser-storage';
|
||||||
|
import { localSyncIter } from '.';
|
||||||
|
|
||||||
|
import type { IDay, IPhoto } from '../types';
|
||||||
|
import { ISystemImage, LocalFolderConfig } from './types';
|
||||||
|
|
||||||
|
class MemoriesDatabase extends Dexie {
|
||||||
|
local!: Dexie.Table<ILocalFile, number>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('MemoriesDatabase');
|
||||||
|
this.version(1).stores({
|
||||||
|
local: '++id, fileid, auid, dayid, flag, [bucket_id+dayid]',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Table for a locally stored file. */
|
||||||
|
interface ILocalFile extends ISystemImage {
|
||||||
|
id?: number;
|
||||||
|
dayid: number;
|
||||||
|
flag: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open database */
|
||||||
|
const db = new MemoriesDatabase();
|
||||||
|
|
||||||
|
/** Preferences database */
|
||||||
|
const storage = getBuilder('memories_sync').clearOnLogout(false).persist().build();
|
||||||
|
const STORAGE_LOCAL_FOLDERS = 'local_folders';
|
||||||
|
|
||||||
|
// Cache for preferences
|
||||||
|
let _enabledBucketIds: number[] | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger the synchronization process at the local database.
|
||||||
|
*/
|
||||||
|
export async function go() {
|
||||||
|
// Clear local database
|
||||||
|
// await db.local.clear();
|
||||||
|
|
||||||
|
for await (const sysImg of localSyncIter(0)) {
|
||||||
|
// Check if file already exists with same mtime
|
||||||
|
if (await db.local.where({ fileid: sysImg.fileid, mtime: sysImg.mtime }).first()) continue;
|
||||||
|
|
||||||
|
// Insert new file
|
||||||
|
await db.transaction('rw', db.local, async () => {
|
||||||
|
await db.local.where({ fileid: sysImg.fileid }).delete();
|
||||||
|
await db.local.add({
|
||||||
|
...sysImg,
|
||||||
|
dayid: Math.floor(sysImg.datetaken / 86400),
|
||||||
|
flag: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the local days list.
|
||||||
|
* @param dayid Day ID
|
||||||
|
*/
|
||||||
|
export async function getDaysLocal(): Promise<IDay[]> {
|
||||||
|
if (!_enabledBucketIds?.length) return [];
|
||||||
|
|
||||||
|
const days: IDay[] = [];
|
||||||
|
|
||||||
|
// Get all day IDs
|
||||||
|
const dayIds = await db.local.orderBy('dayid').uniqueKeys();
|
||||||
|
|
||||||
|
// For each unique username, call usePostCountSince():
|
||||||
|
await Promise.all(
|
||||||
|
dayIds.map(async (dayid: number) => {
|
||||||
|
days.push({
|
||||||
|
dayid: dayid,
|
||||||
|
count: await db.local
|
||||||
|
.where(['bucket_id', 'dayid'])
|
||||||
|
.anyOf(getEnabledBucketIds().map((bucket_id) => [bucket_id, dayid]))
|
||||||
|
.count(),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return days.filter((d) => d.count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the local photos for a given day.
|
||||||
|
* @param dayid Day ID
|
||||||
|
*/
|
||||||
|
export async function getDayLocal(dayid: number): Promise<IPhoto[]> {
|
||||||
|
if (!_enabledBucketIds?.length) return [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
await db.local
|
||||||
|
.where(['bucket_id', 'dayid'])
|
||||||
|
.anyOf(getEnabledBucketIds().map((bucket_id) => [bucket_id, dayid]))
|
||||||
|
.toArray()
|
||||||
|
).map((file) => ({
|
||||||
|
...file,
|
||||||
|
bucket_id: undefined,
|
||||||
|
bucket_name: undefined,
|
||||||
|
flag: undefined as unknown as number,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the enabled bucket ids list */
|
||||||
|
export function getEnabledBucketIds(): number[] {
|
||||||
|
if (_enabledBucketIds) return _enabledBucketIds;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_enabledBucketIds = JSON.parse(storage.getItem(STORAGE_LOCAL_FOLDERS) || '[]') as number[];
|
||||||
|
} catch (e) {
|
||||||
|
_enabledBucketIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return _enabledBucketIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the active bucket list. */
|
||||||
|
export async function getLocalFolders(): Promise<LocalFolderConfig[]> {
|
||||||
|
const bucketIds = await db.local.orderBy('bucket_id').uniqueKeys();
|
||||||
|
const enabledIds = getEnabledBucketIds();
|
||||||
|
|
||||||
|
const buckets = await Promise.all(
|
||||||
|
bucketIds.map(async (bucket_id: number) => {
|
||||||
|
const file = await db.local.where({ bucket_id }).first();
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: file.bucket_id,
|
||||||
|
name: file.bucket_name,
|
||||||
|
enabled: enabledIds.includes(file.bucket_id),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return buckets.filter((b) => b !== null) as LocalFolderConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLocalFolders(config: LocalFolderConfig[]) {
|
||||||
|
_enabledBucketIds = config.filter((f) => f.enabled).map((f) => f.id);
|
||||||
|
storage.setItem(STORAGE_LOCAL_FOLDERS, JSON.stringify(_enabledBucketIds));
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
/** Local file type */
|
||||||
|
export type ISystemImage = {
|
||||||
|
fileid: number;
|
||||||
|
basename: string;
|
||||||
|
mimetype: string;
|
||||||
|
h: number;
|
||||||
|
w: number;
|
||||||
|
size: number;
|
||||||
|
etag: string;
|
||||||
|
mtime: number;
|
||||||
|
epoch: number;
|
||||||
|
auid: number;
|
||||||
|
bucket_id: number;
|
||||||
|
bucket_name: string;
|
||||||
|
datetaken: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Setting of whether a local folder is enabled */
|
||||||
|
export type LocalFolderConfig = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
Loading…
Reference in New Issue