commit 692bb794aea5609b9e9abf5228620f4479e50983 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Sun Feb 27 17:52:43 2022 +0800 bump to 0.3.0 commit 77335412ad2da2b5bd1ed5075061a5af006e3c36 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Sun Feb 27 17:50:57 2022 +0800 change titles for minimal intrusive design commit 2812adebb84344d384749a62acb63fd0c6fd509d Author: fyears <1142836+fyears@users.noreply.github.com> Date: Sun Feb 27 17:30:53 2022 +0800 remove syncv1 commit 22fc24a76c9851740bbc7c0177d1cb2526e15d8b Author: fyears <1142836+fyears@users.noreply.github.com> Date: Sun Feb 27 17:30:27 2022 +0800 full notice to any one commit d56ea24a78f6dc1fbea2740011ee1cea9c403d4c Author: fyears <1142836+fyears@users.noreply.github.com> Date: Sat Feb 26 23:11:14 2022 +0800 fix test commit 759f82593bbfb9b49079cfd80dbadbbafc0287e5 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Sat Feb 26 21:59:25 2022 +0800 obfuscate metadata on remote commit 9b6bf05288af0e52d0f02468e5ac8757f4f896df Author: fyears <1142836+fyears@users.noreply.github.com> Date: Sat Feb 26 21:33:52 2022 +0800 avoid re-uploading if meta not changed commit 03be1453764e48e99207f44467ee4d5801072ed8 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Sat Feb 26 00:35:52 2022 +0800 add password condition commit 7f899f7c2572df3e2a35e16cbdaab94db113f366 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Sat Feb 26 00:22:58 2022 +0800 add decision branch for easier debugging commit cf4071bf3156356ae6ec3a9cb187c2cb541d1b17 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Fri Feb 25 23:40:52 2022 +0800 change folder error commit 964493dd998699a1d53018a201637bda192c4baa Author: fyears <1142836+fyears@users.noreply.github.com> Date: Fri Feb 25 23:09:44 2022 +0800 finnaly remote remove should be working commit 2888e65452f9c0e1dde6005f012c3ee0a6313c3f Author: fyears <1142836+fyears@users.noreply.github.com> Date: Fri Feb 25 01:18:15 2022 +0800 optimize comparation commit 024936951d6180b1296c2a5d56c5bf5d468e9ae7 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Fri Feb 25 01:14:44 2022 +0800 allow uploading extra meta commit 007006701d536da2b4b46389941980579bbc4e67 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Thu Feb 24 01:08:58 2022 +0800 add logic to fetch extra meta commit c9d3a05ca1bf45c06f22233124670e5e45b5f5f1 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Thu Feb 24 00:29:16 2022 +0800 another way to deal with trash commit 82d455f8bf60f7bac8eb4e299a2ca44c331a6d7f Author: fyears <1142836+fyears@users.noreply.github.com> Date: Thu Feb 24 00:28:51 2022 +0800 add return buffer for downloading commit b8e6b79bc078def2854bc73578b7f520cc39ab34 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Wed Feb 23 00:16:06 2022 +0800 half way to actual sync commit 90cceb1411b46af9741f2caa3ab8beafaf69c1b2 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Tue Feb 22 23:36:09 2022 +0800 cleaner way to remember the folder being kept commit c1afb763cc4e0f7905c83e0a8eb20f8ed969a279 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Tue Feb 22 00:03:21 2022 +0800 simplified way to get plans for sync algo v2 commit 5c5ecce39eb3854900f1f5b79c7beb189e78a6a7 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Mon Feb 21 23:13:58 2022 +0800 archive the old sync algo v1 doc commit 75cdfa1ee9834600f83ded6672b102de2c5f9616 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Mon Feb 21 23:13:14 2022 +0800 simplify sync algo v2 commit dc9275835d961de07efcbaa81657e4745242e72a Author: fyears <1142836+fyears@users.noreply.github.com> Date: Mon Feb 21 22:58:57 2022 +0800 add way to skip recording removing commit f9712ef96021dfed4ae33e6c649f78e940b7e530 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Mon Feb 21 09:38:09 2022 +0800 fix sync commit 9007dcf22ef317dde36ac4f1dd589d05cc8d5cf6 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Mon Feb 21 00:54:21 2022 +0800 fix assignment commit 77abee6ad443b62353ed3913e0945ea7d1314f87 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Mon Feb 21 00:28:43 2022 +0800 draft of sync v2 commit a0c26238bf60692e095cfd8527abf937255b56d4 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Mon Feb 21 00:23:19 2022 +0800 how to deal with folders commit c10f92a7e8d3c4a4f4c585e39e0abad1a5376c02 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Sun Feb 20 23:39:16 2022 +0800 helper func to get parents commit f903c98b3b7b9d1e785d04b9fc0cfcafdc6e5661 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Sun Feb 20 23:31:21 2022 +0800 add optional ending slash to getFolderLevels commit 2d67c9b2b941e676588fa43ab289fab79f567e5e Author: fyears <1142836+fyears@users.noreply.github.com> Date: Sun Feb 20 14:44:44 2022 +0800 update sync algo v2 commit 491ed1bb85759df2411706fd02d740acb5598ce6 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Sun Feb 20 14:34:51 2022 +0800 dry run mode commit dfd588dcef512ba7dfe760008bcf97138b97e339 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Sat Feb 19 23:14:32 2022 +0800 prettier commit eae580f882a045ae9df799b816e68c3500704131 Author: fyears <1142836+fyears@users.noreply.github.com> Date: Sat Feb 19 23:12:29 2022 +0800 draft design for sync algo v2
369 lines
10 KiB
TypeScript
369 lines
10 KiB
TypeScript
import { Buffer } from "buffer";
|
|
import { Vault } from "obsidian";
|
|
import type { FileStat, WebDAVClient } from "webdav/web";
|
|
import { AuthType, BufferLike, createClient } from "webdav/web";
|
|
import { Queue } from "@fyears/tsqueue";
|
|
import chunk from "lodash/chunk";
|
|
import flatten from "lodash/flatten";
|
|
import type { RemoteItem, WebdavConfig } from "./baseTypes";
|
|
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
|
import { bufferToArrayBuffer, getPathFolder, mkdirpInVault } from "./misc";
|
|
export type { WebDAVClient } from "webdav/web";
|
|
|
|
import * as origLog from "loglevel";
|
|
const log = origLog.getLogger("rs-default");
|
|
|
|
export const DEFAULT_WEBDAV_CONFIG = {
|
|
address: "",
|
|
username: "",
|
|
password: "",
|
|
authType: "basic",
|
|
manualRecursive: false,
|
|
} as WebdavConfig;
|
|
|
|
const getWebdavPath = (fileOrFolderPath: string, vaultName: string) => {
|
|
let key = fileOrFolderPath;
|
|
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
|
// special
|
|
key = `/${vaultName}/`;
|
|
}
|
|
if (!fileOrFolderPath.startsWith("/")) {
|
|
key = `/${vaultName}/${fileOrFolderPath}`;
|
|
}
|
|
return key;
|
|
};
|
|
|
|
const getNormPath = (fileOrFolderPath: string, vaultName: string) => {
|
|
if (
|
|
!(
|
|
fileOrFolderPath === `/${vaultName}` ||
|
|
fileOrFolderPath.startsWith(`/${vaultName}/`)
|
|
)
|
|
) {
|
|
throw Error(`"${fileOrFolderPath}" doesn't starts with "/${vaultName}/"`);
|
|
}
|
|
// if (fileOrFolderPath.startsWith("/")) {
|
|
// return fileOrFolderPath.slice(1);
|
|
// }
|
|
return fileOrFolderPath.slice(`/${vaultName}/`.length);
|
|
};
|
|
|
|
const fromWebdavItemToRemoteItem = (x: FileStat, vaultName: string) => {
|
|
let key = getNormPath(x.filename, vaultName);
|
|
if (x.type === "directory" && !key.endsWith("/")) {
|
|
key = `${key}/`;
|
|
}
|
|
return {
|
|
key: key,
|
|
lastModified: Date.parse(x.lastmod).valueOf(),
|
|
size: x.size,
|
|
remoteType: "webdav",
|
|
etag: x.etag || undefined,
|
|
} as RemoteItem;
|
|
};
|
|
|
|
export class WrappedWebdavClient {
|
|
webdavConfig: WebdavConfig;
|
|
vaultName: string;
|
|
client: WebDAVClient;
|
|
vaultFolderExists: boolean;
|
|
constructor(webdavConfig: WebdavConfig, vaultName: string) {
|
|
this.webdavConfig = webdavConfig;
|
|
this.vaultName = vaultName;
|
|
this.vaultFolderExists = false;
|
|
}
|
|
|
|
init = async () => {
|
|
// init client if not inited
|
|
if (this.client === undefined) {
|
|
if (
|
|
this.webdavConfig.username !== "" &&
|
|
this.webdavConfig.password !== ""
|
|
) {
|
|
this.client = createClient(this.webdavConfig.address, {
|
|
username: this.webdavConfig.username,
|
|
password: this.webdavConfig.password,
|
|
authType:
|
|
this.webdavConfig.authType === "digest"
|
|
? AuthType.Digest
|
|
: AuthType.Password,
|
|
});
|
|
} else {
|
|
log.info("no password");
|
|
this.client = createClient(this.webdavConfig.address);
|
|
}
|
|
}
|
|
|
|
// check vault folder
|
|
if (this.vaultFolderExists) {
|
|
// pass
|
|
} else {
|
|
const res = await this.client.exists(`/${this.vaultName}`);
|
|
if (res) {
|
|
// log.info("remote vault folder exits!");
|
|
this.vaultFolderExists = true;
|
|
} else {
|
|
log.info("remote vault folder not exists, creating");
|
|
await this.client.createDirectory(`/${this.vaultName}`);
|
|
log.info("remote vault folder created!");
|
|
this.vaultFolderExists = true;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
export const getWebdavClient = (
|
|
webdavConfig: WebdavConfig,
|
|
vaultName: string
|
|
) => {
|
|
return new WrappedWebdavClient(webdavConfig, vaultName);
|
|
};
|
|
|
|
export const getRemoteMeta = async (
|
|
client: WrappedWebdavClient,
|
|
fileOrFolderPath: string
|
|
) => {
|
|
await client.init();
|
|
const remotePath = getWebdavPath(fileOrFolderPath, client.vaultName);
|
|
// log.info(`remotePath = ${remotePath}`);
|
|
const res = (await client.client.stat(remotePath, {
|
|
details: false,
|
|
})) as FileStat;
|
|
return fromWebdavItemToRemoteItem(res, client.vaultName);
|
|
};
|
|
|
|
export const uploadToRemote = async (
|
|
client: WrappedWebdavClient,
|
|
fileOrFolderPath: string,
|
|
vault: Vault,
|
|
isRecursively: boolean = false,
|
|
password: string = "",
|
|
remoteEncryptedKey: string = "",
|
|
uploadRaw: boolean = false,
|
|
rawContent: string | ArrayBuffer = ""
|
|
) => {
|
|
await client.init();
|
|
let uploadFile = fileOrFolderPath;
|
|
if (password !== "") {
|
|
uploadFile = remoteEncryptedKey;
|
|
}
|
|
uploadFile = getWebdavPath(uploadFile, client.vaultName);
|
|
|
|
const isFolder = fileOrFolderPath.endsWith("/");
|
|
|
|
if (isFolder && isRecursively) {
|
|
throw Error("upload function doesn't implement recursive function yet!");
|
|
} else if (isFolder && !isRecursively) {
|
|
if (uploadRaw) {
|
|
throw Error(`you specify uploadRaw, but you also provide a folder key!`);
|
|
}
|
|
// folder
|
|
if (password === "") {
|
|
// if not encrypted, mkdir a remote folder
|
|
await client.client.createDirectory(uploadFile, {
|
|
recursive: true,
|
|
});
|
|
const res = await getRemoteMeta(client, uploadFile);
|
|
return res;
|
|
} else {
|
|
// if encrypted, upload a fake file with the encrypted file name
|
|
await client.client.putFileContents(uploadFile, "", {
|
|
overwrite: true,
|
|
onUploadProgress: (progress) => {
|
|
// log.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
|
},
|
|
});
|
|
|
|
return await getRemoteMeta(client, uploadFile);
|
|
}
|
|
} else {
|
|
// file
|
|
// we ignore isRecursively parameter here
|
|
let localContent = undefined;
|
|
if (uploadRaw) {
|
|
if (typeof rawContent === "string") {
|
|
localContent = new TextEncoder().encode(rawContent);
|
|
} else {
|
|
localContent = rawContent;
|
|
}
|
|
} else {
|
|
localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
|
}
|
|
let remoteContent = localContent;
|
|
if (password !== "") {
|
|
remoteContent = await encryptArrayBuffer(localContent, password);
|
|
}
|
|
// we need to create folders before uploading
|
|
const dir = getPathFolder(uploadFile);
|
|
if (dir !== "/" && dir !== "") {
|
|
await client.client.createDirectory(dir, { recursive: true });
|
|
}
|
|
await client.client.putFileContents(uploadFile, remoteContent, {
|
|
overwrite: true,
|
|
onUploadProgress: (progress) => {
|
|
log.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
|
},
|
|
});
|
|
|
|
return await getRemoteMeta(client, uploadFile);
|
|
}
|
|
};
|
|
|
|
export const listFromRemote = async (
|
|
client: WrappedWebdavClient,
|
|
prefix?: string
|
|
) => {
|
|
if (prefix !== undefined) {
|
|
throw Error("prefix not supported");
|
|
}
|
|
await client.init();
|
|
|
|
let contents = [] as FileStat[];
|
|
if (client.webdavConfig.manualRecursive) {
|
|
// the remote doesn't support infinity propfind,
|
|
// we need to do a bfs here
|
|
const q = new Queue([`/${client.vaultName}`]);
|
|
const CHUNK_SIZE = 10;
|
|
while (q.length > 0) {
|
|
const itemsToFetch = [];
|
|
while (q.length > 0) {
|
|
itemsToFetch.push(q.pop());
|
|
}
|
|
const itemsToFetchChunks = chunk(itemsToFetch, CHUNK_SIZE);
|
|
// log.debug(itemsToFetchChunks);
|
|
const subContents = [] as FileStat[];
|
|
for (const singleChunk of itemsToFetchChunks) {
|
|
const r = singleChunk.map((x) => {
|
|
return client.client.getDirectoryContents(x, {
|
|
deep: false,
|
|
details: false /* no need for verbose details here */,
|
|
glob: "/**" /* avoid dot files by using glob */,
|
|
}) as Promise<FileStat[]>;
|
|
});
|
|
const r2 = flatten(await Promise.all(r));
|
|
subContents.push(...r2);
|
|
}
|
|
for (let i = 0; i < subContents.length; ++i) {
|
|
const f = subContents[i];
|
|
contents.push(f);
|
|
if (f.type === "directory") {
|
|
q.push(f.filename);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// the remote supports infinity propfind
|
|
contents = (await client.client.getDirectoryContents(
|
|
`/${client.vaultName}`,
|
|
{
|
|
deep: true,
|
|
details: false /* no need for verbose details here */,
|
|
glob: "/**" /* avoid dot files by using glob */,
|
|
}
|
|
)) as FileStat[];
|
|
}
|
|
return {
|
|
Contents: contents.map((x) =>
|
|
fromWebdavItemToRemoteItem(x, client.vaultName)
|
|
),
|
|
};
|
|
};
|
|
|
|
const downloadFromRemoteRaw = async (
|
|
client: WrappedWebdavClient,
|
|
fileOrFolderPath: string
|
|
) => {
|
|
await client.init();
|
|
const buff = (await client.client.getFileContents(
|
|
getWebdavPath(fileOrFolderPath, client.vaultName)
|
|
)) as BufferLike;
|
|
if (buff instanceof ArrayBuffer) {
|
|
return buff;
|
|
} else if (buff instanceof Buffer) {
|
|
return bufferToArrayBuffer(buff);
|
|
}
|
|
throw Error(`unexpected file content result with type ${typeof buff}`);
|
|
};
|
|
|
|
export const downloadFromRemote = async (
|
|
client: WrappedWebdavClient,
|
|
fileOrFolderPath: string,
|
|
vault: Vault,
|
|
mtime: number,
|
|
password: string = "",
|
|
remoteEncryptedKey: string = "",
|
|
skipSaving: boolean = false
|
|
) => {
|
|
await client.init();
|
|
|
|
const isFolder = fileOrFolderPath.endsWith("/");
|
|
|
|
if (!skipSaving) {
|
|
await mkdirpInVault(fileOrFolderPath, vault);
|
|
}
|
|
|
|
// the file is always local file
|
|
// we need to encrypt it
|
|
|
|
if (isFolder) {
|
|
// mkdirp locally is enough
|
|
// do nothing here
|
|
return new ArrayBuffer(0);
|
|
} else {
|
|
let downloadFile = fileOrFolderPath;
|
|
if (password !== "") {
|
|
downloadFile = remoteEncryptedKey;
|
|
}
|
|
downloadFile = getWebdavPath(downloadFile, client.vaultName);
|
|
const remoteContent = await downloadFromRemoteRaw(client, downloadFile);
|
|
let localContent = remoteContent;
|
|
if (password !== "") {
|
|
localContent = await decryptArrayBuffer(remoteContent, password);
|
|
}
|
|
if (!skipSaving) {
|
|
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
|
mtime: mtime,
|
|
});
|
|
}
|
|
return localContent;
|
|
}
|
|
};
|
|
|
|
export const deleteFromRemote = async (
|
|
client: WrappedWebdavClient,
|
|
fileOrFolderPath: string,
|
|
password: string = "",
|
|
remoteEncryptedKey: string = ""
|
|
) => {
|
|
if (fileOrFolderPath === "/") {
|
|
return;
|
|
}
|
|
let remoteFileName = fileOrFolderPath;
|
|
if (password !== "") {
|
|
remoteFileName = remoteEncryptedKey;
|
|
}
|
|
remoteFileName = getWebdavPath(remoteFileName, client.vaultName);
|
|
|
|
await client.init();
|
|
try {
|
|
await client.client.deleteFile(remoteFileName);
|
|
// log.info(`delete ${remoteFileName} succeeded`);
|
|
} catch (err) {
|
|
console.error("some error while deleting");
|
|
log.info(err);
|
|
}
|
|
};
|
|
|
|
export const checkConnectivity = async (client: WrappedWebdavClient) => {
|
|
try {
|
|
await client.init();
|
|
const results = await getRemoteMeta(client, "/");
|
|
if (results === undefined) {
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
};
|