pro and smart conflict
This commit is contained in:
+104
@@ -0,0 +1,104 @@
|
||||
# PolyForm Strict License 1.0.0
|
||||
|
||||
<https://polyformproject.org/licenses/strict/1.0.0>
|
||||
|
||||
## Acceptance
|
||||
|
||||
In order to get any license under these terms, you must agree
|
||||
to them as both strict obligations and conditions to all
|
||||
your licenses.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a copyright license for the software
|
||||
to do everything you might do with the software that would
|
||||
otherwise infringe the licensor's copyright in it for any
|
||||
permitted purpose, other than distributing the software or
|
||||
making changes or new works based on the software.
|
||||
|
||||
## Patent License
|
||||
|
||||
The licensor grants you a patent license for the software that
|
||||
covers patent claims the licensor can license, or becomes able
|
||||
to license, that you would infringe by using the software.
|
||||
|
||||
## Noncommercial Purposes
|
||||
|
||||
Any noncommercial purpose is a permitted purpose.
|
||||
|
||||
## Personal Uses
|
||||
|
||||
Personal use for research, experiment, and testing for
|
||||
the benefit of public knowledge, personal study, private
|
||||
entertainment, hobby projects, amateur pursuits, or religious
|
||||
observance, without any anticipated commercial application,
|
||||
is use for a permitted purpose.
|
||||
|
||||
## Noncommercial Organizations
|
||||
|
||||
Use by any charitable organization, educational institution,
|
||||
public research organization, public safety or health
|
||||
organization, environmental protection organization,
|
||||
or government institution is use for a permitted purpose
|
||||
regardless of the source of funding or obligations resulting
|
||||
from the funding.
|
||||
|
||||
## Fair Use
|
||||
|
||||
You may have "fair use" rights for the software under the
|
||||
law. These terms do not limit them.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not allow you to sublicense or transfer any of
|
||||
your licenses to anyone else, or prevent the licensor from
|
||||
granting licenses to anyone else. These terms do not imply
|
||||
any other licenses.
|
||||
|
||||
## Patent Defense
|
||||
|
||||
If you make any written claim that the software infringes or
|
||||
contributes to infringement of any patent, your patent license
|
||||
for the software granted under these terms ends immediately. If
|
||||
your company makes such a claim, your patent license ends
|
||||
immediately for work on behalf of your company.
|
||||
|
||||
## Violations
|
||||
|
||||
The first time you are notified in writing that you have
|
||||
violated any of these terms, or done anything with the software
|
||||
not covered by your licenses, your licenses can nonetheless
|
||||
continue if you come into full compliance with these terms,
|
||||
and take practical steps to correct past violations, within
|
||||
32 days of receiving notice. Otherwise, all your licenses
|
||||
end immediately.
|
||||
|
||||
## No Liability
|
||||
|
||||
***As far as the law allows, the software comes as is, without
|
||||
any warranty or condition, and the licensor will not be liable
|
||||
to you for any damages arising out of these terms or the use
|
||||
or nature of the software, under any kind of legal claim.***
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the individual or entity offering these
|
||||
terms, and the **software** is the software the licensor makes
|
||||
available under these terms.
|
||||
|
||||
**You** refers to the individual or entity agreeing to these
|
||||
terms.
|
||||
|
||||
**Your company** is any legal entity, sole proprietorship,
|
||||
or other kind of organization that you work for, plus all
|
||||
organizations that have control over, are under the control of,
|
||||
or are under common control with that organization. **Control**
|
||||
means ownership of substantially all the assets of an entity,
|
||||
or the power to direct its management and policies by vote,
|
||||
contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
**Your licenses** are all the licenses granted to you for the
|
||||
software under these terms.
|
||||
|
||||
**Use** means anything you do with the software requiring one
|
||||
of your licenses.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Pro Features
|
||||
|
||||
## What?
|
||||
|
||||
Remotely Save has some "pro features", which users have to pay for using them.
|
||||
|
||||
## Sign Up / Sign In
|
||||
|
||||
Please go to <https://remotelysave.com> to sign up and sign in an account firstly.
|
||||
|
||||
## Smart Conflict
|
||||
|
||||
Basic (free) version can detect conflicts, but users have to choose to keep newer version or larger version of the files.
|
||||
|
||||
PRO (paid) feature "Smart Conflict" gives users one more option: merge small markdown files, or duplicate large markdown files or non-markdown files.
|
||||
|
||||
## License
|
||||
|
||||
The codes or files or subfolders inside the current folder (`pro` in the repo), are released under "source available" license: "PolyForm Strict License 1.0.0".
|
||||
|
||||
Suggestions are welcome.
|
||||
@@ -0,0 +1,302 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { base64url } from "rfc4648";
|
||||
import {
|
||||
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
|
||||
type RemotelySavePluginSettings,
|
||||
} from "../../src/baseTypes";
|
||||
import {
|
||||
COMMAND_CALLBACK_PRO,
|
||||
type FeatureInfo,
|
||||
PRO_CLIENT_ID,
|
||||
type PRO_FEATURE_TYPE,
|
||||
PRO_WEBSITE,
|
||||
type ProConfig,
|
||||
} from "./baseTypesPro";
|
||||
|
||||
const site = PRO_WEBSITE;
|
||||
console.debug(`remotelysave official website: ${site}`);
|
||||
|
||||
export const DEFAULT_PRO_CONFIG: ProConfig = {
|
||||
accessToken: "",
|
||||
accessTokenExpiresInMs: 0,
|
||||
accessTokenExpiresAtTimeMs: 0,
|
||||
refreshToken: "",
|
||||
enabledProFeatures: [],
|
||||
email: "",
|
||||
};
|
||||
|
||||
/**
|
||||
* https://datatracker.ietf.org/doc/html/rfc7636
|
||||
* dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
|
||||
* => E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
|
||||
* @param x
|
||||
* @returns BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
|
||||
*/
|
||||
async function codeVerifier2CodeChallenge(x: string) {
|
||||
if (x === undefined || x === "") {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return base64url.stringify(
|
||||
new Uint8Array(
|
||||
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(x))
|
||||
),
|
||||
{
|
||||
pad: false,
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export const generateAuthUrlAndCodeVerifierChallenge = async (
|
||||
hasCallback: boolean
|
||||
) => {
|
||||
const appKey = PRO_CLIENT_ID ?? "cli-"; // hard-code
|
||||
const codeVerifier = nanoid(128);
|
||||
const codeChallenge = await codeVerifier2CodeChallenge(codeVerifier);
|
||||
let authUrl = `${site}/oauth2/authorize?response_type=code&client_id=${appKey}&token_access_type=offline&code_challenge_method=S256&code_challenge=${codeChallenge}&scope=pro.list.read`;
|
||||
if (hasCallback) {
|
||||
authUrl += `&redirect_uri=obsidian://${COMMAND_CALLBACK_PRO}`;
|
||||
}
|
||||
return {
|
||||
authUrl,
|
||||
codeVerifier,
|
||||
codeChallenge,
|
||||
};
|
||||
};
|
||||
|
||||
export const sendAuthReq = async (
|
||||
verifier: string,
|
||||
authCode: string,
|
||||
errorCallBack: any
|
||||
) => {
|
||||
const appKey = PRO_CLIENT_ID ?? "cli-"; // hard-code
|
||||
try {
|
||||
const k = {
|
||||
code: authCode,
|
||||
grant_type: "authorization_code",
|
||||
code_verifier: verifier,
|
||||
client_id: appKey,
|
||||
// redirect_uri: `obsidian://${COMMAND_CALLBACK_PRO}`,
|
||||
scope: "pro.list.read",
|
||||
};
|
||||
// console.debug(k);
|
||||
const resp1 = await fetch(`${site}/api/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
body: new URLSearchParams(k),
|
||||
});
|
||||
const resp2 = await resp1.json();
|
||||
return resp2;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (errorCallBack !== undefined) {
|
||||
await errorCallBack(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const sendRefreshTokenReq = async (refreshToken: string) => {
|
||||
const appKey = PRO_CLIENT_ID ?? "cli-"; // hard-code
|
||||
try {
|
||||
console.info("start auto getting refreshed Remotely Save access token.");
|
||||
const resp1 = await fetch(`${site}/api/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: appKey,
|
||||
scope: "pro.list.read",
|
||||
}),
|
||||
});
|
||||
const resp2: AuthResError | AuthResSucc = await resp1.json();
|
||||
console.info("finish auto getting refreshed Remotely Save access token.");
|
||||
return resp2;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
interface AuthResError {
|
||||
error: "invalid_request";
|
||||
}
|
||||
|
||||
interface AuthResSucc {
|
||||
error: undefined; // needed for typescript
|
||||
refresh_token?: string;
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export const setConfigBySuccessfullAuthInplace = async (
|
||||
config: ProConfig,
|
||||
authRes: AuthResError | AuthResSucc,
|
||||
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
||||
) => {
|
||||
if (authRes.error !== undefined) {
|
||||
throw Error(`you should not save the setting for ${authRes.error}`);
|
||||
}
|
||||
|
||||
config.accessToken = authRes.access_token;
|
||||
config.accessTokenExpiresAtTimeMs =
|
||||
Date.now() + authRes.expires_in * 1000 - 5 * 60 * 1000;
|
||||
config.accessTokenExpiresInMs = authRes.expires_in * 1000;
|
||||
config.refreshToken = authRes.refresh_token || config.refreshToken;
|
||||
|
||||
// manually set it expired after 80 days;
|
||||
config.credentialsShouldBeDeletedAtTimeMs =
|
||||
Date.now() + OAUTH2_FORCE_EXPIRE_MILLISECONDS;
|
||||
|
||||
await saveUpdatedConfigFunc?.();
|
||||
|
||||
console.info(
|
||||
"finish updating local info of Remotely Save official website token"
|
||||
);
|
||||
};
|
||||
|
||||
export const getAccessToken = async (
|
||||
config: ProConfig,
|
||||
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
||||
) => {
|
||||
const ts = Date.now();
|
||||
if (
|
||||
config.accessToken !== undefined &&
|
||||
config.accessToken !== "" &&
|
||||
config.accessTokenExpiresAtTimeMs > ts &&
|
||||
(config.credentialsShouldBeDeletedAtTimeMs ?? ts + 1000 * 1000) > ts
|
||||
) {
|
||||
return config.accessToken;
|
||||
}
|
||||
|
||||
console.debug(
|
||||
`currently, accessToken=${config.accessToken}, accessTokenExpiresAtTimeMs=${
|
||||
config.accessTokenExpiresAtTimeMs
|
||||
}, credentialsShouldBeDeletedAtTimeMs=${
|
||||
config.credentialsShouldBeDeletedAtTimeMs
|
||||
},comp1=${config.accessTokenExpiresAtTimeMs > ts}, comp2=${
|
||||
(config.credentialsShouldBeDeletedAtTimeMs ?? ts + 1000 * 1000) > ts
|
||||
}`
|
||||
);
|
||||
|
||||
// try to get it again??
|
||||
const res = await sendRefreshTokenReq(config.refreshToken ?? "refresh-");
|
||||
await setConfigBySuccessfullAuthInplace(config, res, saveUpdatedConfigFunc);
|
||||
|
||||
if (res.error !== undefined) {
|
||||
throw Error("cannot update accessToken");
|
||||
}
|
||||
return res.access_token;
|
||||
};
|
||||
|
||||
export const getAndSaveProFeatures = async (
|
||||
config: ProConfig,
|
||||
pluginVersion: string,
|
||||
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
||||
) => {
|
||||
const access = await getAccessToken(config, saveUpdatedConfigFunc);
|
||||
|
||||
const resp1 = await fetch(`${site}/api/v1/pro/list`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${access}`,
|
||||
"REMOTELYSAVE-API-Plugin-Ver": pluginVersion,
|
||||
},
|
||||
});
|
||||
const rsp2: {
|
||||
proFeatures: FeatureInfo[];
|
||||
} = await resp1.json();
|
||||
|
||||
config.enabledProFeatures = rsp2.proFeatures;
|
||||
await saveUpdatedConfigFunc?.();
|
||||
return rsp2;
|
||||
};
|
||||
|
||||
export const getAndSaveProEmail = async (
|
||||
config: ProConfig,
|
||||
pluginVersion: string,
|
||||
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
||||
) => {
|
||||
const access = await getAccessToken(config, saveUpdatedConfigFunc);
|
||||
|
||||
const resp1 = await fetch(`${site}/api/v1/profile/list`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${access}`,
|
||||
"REMOTELYSAVE-API-Plugin-Ver": pluginVersion,
|
||||
},
|
||||
});
|
||||
const rsp2: {
|
||||
email: string;
|
||||
} = await resp1.json();
|
||||
|
||||
config.email = rsp2.email;
|
||||
await saveUpdatedConfigFunc?.();
|
||||
return rsp2;
|
||||
};
|
||||
|
||||
/**
|
||||
* If the check doesn't pass, the function should throw the error
|
||||
* @returns
|
||||
*/
|
||||
export const checkProRunnableAndFixInplace = async (
|
||||
featuresToCheck: PRO_FEATURE_TYPE[],
|
||||
config: RemotelySavePluginSettings,
|
||||
pluginVersion: string,
|
||||
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
||||
): Promise<true> => {
|
||||
// if no pro features are used, we are good to go, no checking
|
||||
if (
|
||||
featuresToCheck.contains("feature-smart_conflict") &&
|
||||
config.conflictAction !== "smart_conflict"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// many checks if status is valid
|
||||
|
||||
// no account
|
||||
if (config.pro === undefined || config.pro.refreshToken === undefined) {
|
||||
throw Error(`you need to "connect" to your account to use PRO features`);
|
||||
}
|
||||
|
||||
// every features should have at most 40 days expiration dates
|
||||
// and if the time has expired, we also check
|
||||
const msIn40Days = 1000 * 60 * 60 * 24 * 40;
|
||||
for (const f of config.pro.enabledProFeatures) {
|
||||
const tooFarInTheFuture = f.expireAtTimeMs >= Date.now() + msIn40Days;
|
||||
const alreadyExpired = f.expireAtTimeMs <= Date.now();
|
||||
if (tooFarInTheFuture || alreadyExpired) {
|
||||
console.info(
|
||||
`the pro feature is too far in the future and has expired, check again.`
|
||||
);
|
||||
await getAndSaveProFeatures(
|
||||
config.pro,
|
||||
pluginVersion,
|
||||
saveUpdatedConfigFunc
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// check for the features
|
||||
if (featuresToCheck.contains("feature-smart_conflict")) {
|
||||
if (config.conflictAction === "smart_conflict") {
|
||||
if (
|
||||
config.pro.enabledProFeatures.filter(
|
||||
(x) => x.featureName === "feature-smart_conflict"
|
||||
).length === 1
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
throw Error(
|
||||
`You're trying to use "smart conflict" PRO feature but you haven't subscribe to it.`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
export const MERGABLE_SIZE = 1000 * 1000; // 1 MB
|
||||
|
||||
export const COMMAND_CALLBACK_PRO = "remotely-save-cb-pro";
|
||||
export const PRO_CLIENT_ID = process.env.DEFAULT_REMOTELYSAVE_CLIENT_ID;
|
||||
export const PRO_WEBSITE = process.env.DEFAULT_REMOTELYSAVE_WEBSITE;
|
||||
|
||||
export type PRO_FEATURE_TYPE =
|
||||
| "feature-smart_conflict"
|
||||
| "feature-google_drive";
|
||||
|
||||
export interface FeatureInfo {
|
||||
featureName: PRO_FEATURE_TYPE;
|
||||
enableAtTimeMs: bigint;
|
||||
expireAtTimeMs: bigint;
|
||||
}
|
||||
|
||||
export interface ProConfig {
|
||||
email?: string;
|
||||
refreshToken?: string;
|
||||
accessToken: string;
|
||||
accessTokenExpiresInMs: number;
|
||||
accessTokenExpiresAtTimeMs: number;
|
||||
enabledProFeatures: FeatureInfo[];
|
||||
credentialsShouldBeDeletedAtTimeMs?: number;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
// import {
|
||||
// makePatches,
|
||||
// applyPatches,
|
||||
// stringifyPatches,
|
||||
// parsePatch,
|
||||
// } from "@sanity/diff-match-patch";
|
||||
import {
|
||||
LCS,
|
||||
diff3Merge,
|
||||
diffComm,
|
||||
diffPatch,
|
||||
mergeDiff3,
|
||||
mergeDigIn,
|
||||
patch,
|
||||
} from "node-diff3";
|
||||
import type { Entity } from "../../src/baseTypes";
|
||||
import { copyFile } from "../../src/copyLogic";
|
||||
import type { FakeFs } from "../../src/fsAll";
|
||||
import { MERGABLE_SIZE } from "./baseTypesPro";
|
||||
|
||||
export function isMergable(a: Entity, b?: Entity) {
|
||||
if (b !== undefined && a.keyRaw !== b.keyRaw) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!a.keyRaw.endsWith("/") &&
|
||||
a.sizeRaw <= MERGABLE_SIZE &&
|
||||
(a.keyRaw.endsWith(".md") || a.keyRaw.endsWith(".markdown"))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* slightly modify to adjust in markdown context
|
||||
* @param a
|
||||
* @param o
|
||||
* @param b
|
||||
*/
|
||||
function mergeDigInModified(a: string, o: string, b: string) {
|
||||
const { conflict, result } = mergeDigIn(a, o, b);
|
||||
for (let index = 0; index < result.length; ++index) {
|
||||
if (["<<<<<<<", "=======", ">>>>>>>"].contains(result[index])) {
|
||||
result[index] = "`" + result[index] + "`";
|
||||
}
|
||||
}
|
||||
return {
|
||||
conflict,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
function getLCSText(a: string, b: string) {
|
||||
const aa = a.split("\n");
|
||||
const bb = b.split("\n");
|
||||
let raw = LCS(aa, bb);
|
||||
|
||||
const k: string[] = [];
|
||||
|
||||
do {
|
||||
k.unshift(aa[raw.buffer1index]);
|
||||
|
||||
raw = raw.chain as any;
|
||||
} while (raw !== null && raw !== undefined && raw.buffer1index !== -1);
|
||||
|
||||
return k.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* It's tricky. We find LCS then pretend it's the original text
|
||||
* @param a
|
||||
* @param b
|
||||
* @returns
|
||||
*/
|
||||
function twoWayMerge(a: string, b: string): string {
|
||||
// const c = getLCSText(a, b);
|
||||
// const patches = makePatches(c, a);
|
||||
// const [d] = applyPatches(patches, b);
|
||||
const c = getLCSText(a, b);
|
||||
const d = mergeDigInModified(a, c, b).result.join("\n");
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Originally three way merge.
|
||||
* @param a
|
||||
* @param b
|
||||
* @param orig
|
||||
* @returns
|
||||
*/
|
||||
function threeWayMerge(a: string, b: string, orig: string) {
|
||||
return mergeDigInModified(a, orig, b).result.join("\n");
|
||||
}
|
||||
|
||||
export async function mergeFile(
|
||||
key: string,
|
||||
left: FakeFs,
|
||||
right: FakeFs,
|
||||
contentOrig: ArrayBuffer | null | undefined
|
||||
) {
|
||||
// console.debug(
|
||||
// `mergeFile: key=${key}, left=${left.kind}, right=${right.kind}`
|
||||
// );
|
||||
if (key.endsWith("/")) {
|
||||
throw Error(`should not call ${key} in mergeFile`);
|
||||
}
|
||||
|
||||
if (!key.endsWith(".md") && !key.endsWith(".markdown")) {
|
||||
throw Error(`currently only support markdown files in mergeFile`);
|
||||
}
|
||||
|
||||
const [contentLeft, contentRight] = await Promise.all([
|
||||
left.readFile(key),
|
||||
right.readFile(key),
|
||||
]);
|
||||
|
||||
let newArrayBuffer: ArrayBuffer | undefined = undefined;
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
if (isEqual(contentLeft, contentRight)) {
|
||||
// we are lucky enough
|
||||
newArrayBuffer = contentLeft;
|
||||
// TODO: save the write
|
||||
} else {
|
||||
if (contentOrig === null || contentOrig === undefined) {
|
||||
const newText = twoWayMerge(
|
||||
decoder.decode(contentLeft),
|
||||
decoder.decode(contentRight)
|
||||
);
|
||||
// no need to worry about the offset here because the array is new and not sliced
|
||||
newArrayBuffer = new TextEncoder().encode(newText).buffer;
|
||||
} else {
|
||||
const newText = threeWayMerge(
|
||||
decoder.decode(contentLeft),
|
||||
decoder.decode(contentRight),
|
||||
decoder.decode(contentOrig)
|
||||
);
|
||||
newArrayBuffer = new TextEncoder().encode(newText).buffer;
|
||||
}
|
||||
}
|
||||
|
||||
const mtime = Date.now();
|
||||
|
||||
// left (local) must wait for the right
|
||||
// because the mtime might be different after upload
|
||||
// upload firstly
|
||||
const rightEntity = await right.writeFile(key, newArrayBuffer, mtime, mtime);
|
||||
// write local secondly
|
||||
const leftEntity = await left.writeFile(
|
||||
key,
|
||||
newArrayBuffer,
|
||||
rightEntity.mtimeCli ?? mtime,
|
||||
rightEntity.mtimeCli ?? mtime
|
||||
);
|
||||
|
||||
return {
|
||||
entity: rightEntity,
|
||||
content: newArrayBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
export function getFileRename(key: string) {
|
||||
if (
|
||||
key === "" ||
|
||||
key === "." ||
|
||||
key === ".." ||
|
||||
key === "/" ||
|
||||
key.endsWith("/")
|
||||
) {
|
||||
throw Error(`we cannot rename key=${key}`);
|
||||
}
|
||||
|
||||
const segsPath = key.split("/");
|
||||
const name = segsPath[segsPath.length - 1];
|
||||
const segsName = name.split(".");
|
||||
|
||||
if (segsName.length === 0) {
|
||||
throw Error(`we cannot rename key=${key}`);
|
||||
} else if (segsName.length === 1) {
|
||||
// name = "kkk" without any dot
|
||||
segsPath[segsPath.length - 1] = `${name}.dup`;
|
||||
} else if (segsName.length === 2) {
|
||||
if (segsName[0] === "") {
|
||||
// name = ".kkkk" with leading dot
|
||||
segsPath[segsPath.length - 1] = `${name}.dup`;
|
||||
} else if (segsName[1] === "") {
|
||||
// name = "kkkk." with tailing dot
|
||||
segsPath[segsPath.length - 1] = `${segsName[0]}.dup`;
|
||||
} else {
|
||||
// name = "aaa.bbb" normally
|
||||
segsPath[segsPath.length - 1] = `${segsName[0]}.dup.${segsName[1]}`;
|
||||
}
|
||||
} else {
|
||||
// name = "[...].bbb.ccc"
|
||||
const firstPart = segsName.slice(0, segsName.length - 1).join(".");
|
||||
const thirdPart = segsName[segsName.length - 1];
|
||||
segsPath[segsPath.length - 1] = `${firstPart}.dup.${thirdPart}`;
|
||||
}
|
||||
const res = segsPath.join("/");
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* local: x.md -> x.dup.md -> upload to remote
|
||||
* remote: x.md -> download to local -> using original name x.md
|
||||
*/
|
||||
export async function duplicateFile(
|
||||
key: string,
|
||||
left: FakeFs,
|
||||
right: FakeFs,
|
||||
uploadCallback: (entity: Entity) => Promise<any>,
|
||||
downloadCallback: (entity: Entity) => Promise<any>
|
||||
) {
|
||||
let key2 = getFileRename(key);
|
||||
let usable = false;
|
||||
do {
|
||||
try {
|
||||
const s = await left.stat(key2);
|
||||
if (s === null || s === undefined) {
|
||||
throw Error(`not exist $${key2}`);
|
||||
}
|
||||
console.debug(`key2=${key2} exists, cannot use for new file`);
|
||||
key2 = getFileRename(key2);
|
||||
console.debug(`key2=${key2} is prepared for next try`);
|
||||
} catch (e) {
|
||||
// not exists, exactly what we want
|
||||
console.debug(`key2=${key2} doesn't exist, usable for new file`);
|
||||
usable = true;
|
||||
}
|
||||
} while (!usable);
|
||||
await left.rename(key, key2);
|
||||
|
||||
/**
|
||||
* x.dup.md -> upload to remote
|
||||
*/
|
||||
async function f1() {
|
||||
const k = await copyFile(key2, left, right);
|
||||
await uploadCallback(k.entity);
|
||||
return k.entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* x.md -> download to local
|
||||
*/
|
||||
async function f2() {
|
||||
const k = await copyFile(key, right, left);
|
||||
await downloadCallback(k.entity);
|
||||
return k.entity;
|
||||
}
|
||||
|
||||
const [resUpload, resDownload] = await Promise.all([f1(), f2()]);
|
||||
|
||||
return {
|
||||
upload: resUpload,
|
||||
download: resDownload,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"settings_conflictaction_smart_conflict": "Smart Conflict (PRO) (beta)",
|
||||
"settings_conflictaction_smart_conflict_desc": "<p><strong>!!It's a PRO feature! You need an online account for this feature!!</strong>(<a href=\"#settings-pro\">scroll down</a> for more info about PRO account.)</p><p><ul><li>For small markdown files, the plugin tries to merge them with diff3 algorithm.</li><li>For large files or not-markdown files, the plugin saves both files by renaming them.</li></ul></p><p><strong>Please manually backup your vaule before using this feature!</strong></p>",
|
||||
|
||||
"protocol_pro_connecting": "Connectting",
|
||||
"protocol_pro_connect_manualinput_succ": "You've connected",
|
||||
"protocol_pro_connect_fail": "Something went wrong from response from Remotely Save official website. Maybe the network connection is not good. Maybe you rejected the auth?",
|
||||
"protocol_pro_connect_succ_revoke": "You've connected as user {{email}}. If you want to disconnect, click this button.",
|
||||
|
||||
"modal_prorevokeauth": "Revoke auth by clicking here and follow the steps.",
|
||||
"modal_prorevokeauth_clean": "Clean",
|
||||
"modal_prorevokeauth_clean_desc": "Clean local auth record",
|
||||
"modal_prorevokeauth_clean_button": "Clean",
|
||||
"modal_prorevokeauth_clean_notice": "Local auth record is cleaned",
|
||||
"modal_prorevokeauth_clean_fail": "Fail to clean local auth record.",
|
||||
"modal_proauth_copybutton": "Click to copy the auth url",
|
||||
"modal_proauth_copynotice": "The auth url is copied to the clipboard!",
|
||||
"modal_proauth_maualinput": "The Code from the website",
|
||||
"modal_proauth_maualinput_desc": "Please input the code here from the end of auth flow, and press confirm.",
|
||||
"modal_proauth_maualinput_notice": "Trying to connect, wait...",
|
||||
"modal_proauth_maualinput_conn_fail": "Failed to connect",
|
||||
|
||||
"settings_pro": "Account (for PRO features)",
|
||||
"settings_pro_tutorial": "<p>Using <stong>basic</strong> features of Remotely Save is <strong>FREE</strong> and do <strong>NOT</strong> need an account.</p><p>However, you will <strong>need</strong> an online account and <strong>PAY</strong> for the <strong>PRO</strong> features such as smart conflict.</p><p>Firstly please click the button to sign up and sign in to the website: <a href=\"https://remotelysave.com\">https://remotelysave.com</a>. Notice: It's different from, and NOT affiliated with Obsidian account.</p><p>Secondly please \"connect\" your local device to your online account.",
|
||||
"settings_pro_features": "Features",
|
||||
"settings_pro_features_desc": "Here are features you've enabled:<br/>{{{features}}}",
|
||||
"settings_pro_features_refresh_button": "Check again",
|
||||
"settings_pro_features_refresh_fetch": "Fetching...",
|
||||
"settings_pro_features_refresh_succ": "Refreshed!",
|
||||
"settings_pro_revoke": "Disconnect",
|
||||
"settings_pro_revoke_desc": "You've connected as user {{email}}. If you want to disconnect, click this button.",
|
||||
"settings_pro_revoke_button": "Disconnect",
|
||||
"settings_pro_intro": "Remotely Save Online Account",
|
||||
"settings_pro_intro_desc": "Click the button to jump to the website to sign up or sign in.",
|
||||
"settings_pro_intro_button": "Sign Up / Sign In",
|
||||
"settings_pro_auth": "Connect",
|
||||
"settings_pro_auth_desc": "After you sign up and sign in the account on the website, you need to connect your plugin here to the online account. Please click the button to connect.",
|
||||
"settings_pro_auth_button": "Connect"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import en from "./en.json";
|
||||
import zh_cn from "./zh_cn.json";
|
||||
import zh_tw from "./zh_tw.json";
|
||||
|
||||
export const LANGS = {
|
||||
en: en,
|
||||
zh_cn: zh_cn,
|
||||
zh_tw: zh_tw,
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"settings_conflictaction_smart_conflict": "智能处理冲突 (PRO) (beta)",
|
||||
"settings_conflictaction_smart_conflict_desc": "<p><strong>!!这是 PRO(付费)功能! 您需要在线账号来使用此功能!!</strong>(<a href=\"#settings-pro\">向下滑</a>可以看到 PRO 账号的更多信息。)</p><p><ul><li>小 markdown 文件,本插件尝试使用 diff3 算法合并它;</li><li>对于大文件或非 markdown 文件,本插件尝试改名字并均进行保存。</li></ul></p><p><strong>请注意先手动备份 vault 文件再用此功能!</strong></p>",
|
||||
|
||||
"protocol_pro_connecting": "正在连接",
|
||||
"protocol_pro_connect_manualinput_succ": "连接成功",
|
||||
"protocol_pro_connect_fail": "Remotely Save 官网返回错误。可能是网络连接不稳定。也可能是您拒绝了授权?",
|
||||
"protocol_pro_connect_succ_revoke": "您已连接上账号 {{email}}。如果要取消连接,请点击此按钮。",
|
||||
|
||||
"modal_prorevokeauth": "点击这里和按照步骤取消授权。",
|
||||
"modal_prorevokeauth_clean": "清理",
|
||||
"modal_prorevokeauth_clean_desc": "清理本地授权记录",
|
||||
"modal_prorevokeauth_clean_button": "清理",
|
||||
"modal_prorevokeauth_clean_notice": "清理本地授权记录完毕",
|
||||
"modal_prorevokeauth_clean_fail": "清理本地授权记录粗错。",
|
||||
"modal_proauth_copybutton": "点击从而复制授权网址",
|
||||
"modal_proauth_copynotice": "授权网址已复制!",
|
||||
"modal_proauth_maualinput": "网站的授权码",
|
||||
"modal_proauth_maualinput_desc": "请输入授权流程最后一步的授权码,然后点击确认。",
|
||||
"modal_proauth_maualinput_notice": "正在连接,请稍候......",
|
||||
"modal_proauth_maualinput_conn_fail": "连接失败",
|
||||
|
||||
"settings_pro": "账号(PRO 付费功能)",
|
||||
"settings_pro_tutorial": "<p>使用 Remotely Save 的<stong>基本</strong>功能是<strong>免费的</strong>,而且<strong>不</strong>需要注册对应账号。</p><p>但是,您<strong>需要</strong>注册账号和对<strong>PRO</strong>功能<strong>付费</strong>使用,如智能处理冲突功能。</p><p>第一步:点击按钮从而注册和登录网站:<a href=\"https://remotelysave.com\">https://remotelysave.com</a>。注意:这和 Obsidian 官方账号无关,是不同的账号。</p><p>第二部:点击“连接”按钮,从而连接本设备和在线账号。",
|
||||
"settings_pro_features": "功能",
|
||||
"settings_pro_features_desc": "您开通了以下功能:<br/>{{{features}}}",
|
||||
"settings_pro_features_refresh_button": "再次检查",
|
||||
"settings_pro_features_refresh_fetch": "正在获取数据......",
|
||||
"settings_pro_features_refresh_succ": "已刷新!",
|
||||
"settings_pro_revoke": "断开连接",
|
||||
"settings_pro_revoke_desc": "您已连接上账号 {{email}}。如果要取消连接,请点击此按钮。",
|
||||
"settings_pro_revoke_button": "断开连接",
|
||||
"settings_pro_intro": "Remotely Save 账号",
|
||||
"settings_pro_intro_desc": "点击此按钮,从而到网站上注册和登录。",
|
||||
"settings_pro_intro_button": "注册或登录",
|
||||
"settings_pro_auth": "连接",
|
||||
"settings_pro_auth_desc": "在网站上注册和登录后,您需要“连接”本设备和在线账号。请点击按钮开始连接。",
|
||||
"settings_pro_auth_button": "连接"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"settings_conflictaction_smart_conflict": "智慧處理衝突 (PRO) (beta)",
|
||||
"settings_conflictaction_smart_conflict_desc": "<p><strong>!!這是 PRO(付費)功能! 您需要線上賬號來使用此功能!!</strong>(<a href=\"#settings-pro\">向下滑</a>可以看到 PRO 賬號的更多資訊。)</p><p><ul><li>小 markdown 檔案,本外掛嘗試使用 diff3 演算法合併它;</li><li>對於大檔案或非 markdown 檔案,本外掛嘗試改名字並均進行儲存。</li></ul></p><p><strong>請注意先手動備份 vault 檔案再用此功能!</strong></p>",
|
||||
|
||||
"protocol_pro_connecting": "正在連線",
|
||||
"protocol_pro_connect_manualinput_succ": "連線成功",
|
||||
"protocol_pro_connect_fail": "Remotely Save 官網返回錯誤。可能是網路連線不穩定。也可能是您拒絕了授權?",
|
||||
"protocol_pro_connect_succ_revoke": "您已連線上賬號 {{email}}。如果要取消連線,請點選此按鈕。",
|
||||
|
||||
"modal_prorevokeauth": "點選這裡和按照步驟取消授權。",
|
||||
"modal_prorevokeauth_clean": "清理",
|
||||
"modal_prorevokeauth_clean_desc": "清理本地授權記錄",
|
||||
"modal_prorevokeauth_clean_button": "清理",
|
||||
"modal_prorevokeauth_clean_notice": "清理本地授權記錄完畢",
|
||||
"modal_prorevokeauth_clean_fail": "清理本地授權記錄粗錯。",
|
||||
"modal_proauth_copybutton": "點選從而複製授權網址",
|
||||
"modal_proauth_copynotice": "授權網址已複製!",
|
||||
"modal_proauth_maualinput": "網站的授權碼",
|
||||
"modal_proauth_maualinput_desc": "請輸入授權流程最後一步的授權碼,然後點選確認。",
|
||||
"modal_proauth_maualinput_notice": "正在連線,請稍候......",
|
||||
"modal_proauth_maualinput_conn_fail": "連線失敗",
|
||||
|
||||
"settings_pro": "賬號(PRO 付費功能)",
|
||||
"settings_pro_tutorial": "<p>使用 Remotely Save 的<stong>基本</strong>功能是<strong>免費的</strong>,而且<strong>不</strong>需要註冊對應賬號。</p><p>但是,您<strong>需要</strong>註冊賬號和對<strong>PRO</strong>功能<strong>付費</strong>使用,如智慧處理衝突功能。</p><p>第一步:點選按鈕從而註冊和登入網站:<a href=\"https://remotelysave.com\">https://remotelysave.com</a>。注意:這和 Obsidian 官方賬號無關,是不同的賬號。</p><p>第二部:點選“連線”按鈕,從而連線本裝置和線上賬號。",
|
||||
"settings_pro_features": "功能",
|
||||
"settings_pro_features_desc": "您開通了以下功能:<br/>{{{features}}}",
|
||||
"settings_pro_features_refresh_button": "再次檢查",
|
||||
"settings_pro_features_refresh_fetch": "正在獲取資料......",
|
||||
"settings_pro_features_refresh_succ": "已重新整理!",
|
||||
"settings_pro_revoke": "斷開連線",
|
||||
"settings_pro_revoke_desc": "您已連線上賬號 {{email}}。如果要取消連線,請點選此按鈕。",
|
||||
"settings_pro_revoke_button": "斷開連線",
|
||||
"settings_pro_intro": "Remotely Save 賬號",
|
||||
"settings_pro_intro_desc": "點選此按鈕,從而到網站上註冊和登入。",
|
||||
"settings_pro_intro_button": "註冊或登入",
|
||||
"settings_pro_auth": "連線",
|
||||
"settings_pro_auth_desc": "在網站上註冊和登入後,您需要“連線”本裝置和線上賬號。請點選按鈕開始連線。",
|
||||
"settings_pro_auth_button": "連線"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Entity } from "../../src/baseTypes";
|
||||
import type { InternalDBs } from "../../src/localdb";
|
||||
|
||||
export const upsertFileContentHistoryByVaultAndProfile = async (
|
||||
db: InternalDBs,
|
||||
vaultRandomID: string,
|
||||
profileID: string,
|
||||
prevSync: Entity,
|
||||
prevContent: ArrayBuffer
|
||||
) => {
|
||||
await db.fileContentHistoryTbl.setItem(
|
||||
`${vaultRandomID}\t${profileID}\t${prevSync.key}`,
|
||||
prevContent
|
||||
);
|
||||
};
|
||||
|
||||
export const getFileContentHistoryByVaultAndProfile = async (
|
||||
db: InternalDBs,
|
||||
vaultRandomID: string,
|
||||
profileID: string,
|
||||
prevSync: Entity
|
||||
) => {
|
||||
return (await db.fileContentHistoryTbl.getItem(
|
||||
`${vaultRandomID}\t${profileID}\t${prevSync.key}`
|
||||
)) as ArrayBuffer | null | undefined;
|
||||
};
|
||||
|
||||
export const clearFileContentHistoryByVaultAndProfile = async (
|
||||
db: InternalDBs,
|
||||
vaultRandomID: string,
|
||||
profileID: string,
|
||||
key: string
|
||||
) => {
|
||||
await db.fileContentHistoryTbl.removeItem(
|
||||
`${vaultRandomID}\t${profileID}\t${key}`
|
||||
);
|
||||
};
|
||||
|
||||
export const clearAllFileContentHistoryByVault = async (
|
||||
db: InternalDBs,
|
||||
vaultRandomID: string
|
||||
) => {
|
||||
const keys = (await db.fileContentHistoryTbl.keys()).filter((x) =>
|
||||
x.startsWith(`${vaultRandomID}\t`)
|
||||
);
|
||||
await db.fileContentHistoryTbl.removeItems(keys);
|
||||
};
|
||||
@@ -0,0 +1,359 @@
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import { type App, Modal, Notice, Setting } from "obsidian";
|
||||
import { features } from "process";
|
||||
import type { TransItemType } from "../../src/i18n";
|
||||
import type RemotelySavePlugin from "../../src/main";
|
||||
import { stringToFragment } from "../../src/misc";
|
||||
import {
|
||||
DEFAULT_PRO_CONFIG,
|
||||
generateAuthUrlAndCodeVerifierChallenge,
|
||||
getAndSaveProEmail,
|
||||
getAndSaveProFeatures,
|
||||
sendAuthReq,
|
||||
setConfigBySuccessfullAuthInplace,
|
||||
} from "./account";
|
||||
import {
|
||||
type FeatureInfo,
|
||||
PRO_CLIENT_ID,
|
||||
type ProConfig,
|
||||
} from "./baseTypesPro";
|
||||
|
||||
export class ProAuthModal extends Modal {
|
||||
readonly plugin: RemotelySavePlugin;
|
||||
readonly authDiv: HTMLDivElement;
|
||||
readonly revokeAuthDiv: HTMLDivElement;
|
||||
readonly revokeAuthSetting: Setting;
|
||||
readonly proFeaturesListSetting: Setting;
|
||||
readonly t: (x: TransItemType, vars?: any) => string;
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: RemotelySavePlugin,
|
||||
authDiv: HTMLDivElement,
|
||||
revokeAuthDiv: HTMLDivElement,
|
||||
revokeAuthSetting: Setting,
|
||||
proFeaturesListSetting: Setting,
|
||||
t: (x: TransItemType, vars?: any) => string
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.authDiv = authDiv;
|
||||
this.revokeAuthDiv = revokeAuthDiv;
|
||||
this.revokeAuthSetting = revokeAuthSetting;
|
||||
this.proFeaturesListSetting = proFeaturesListSetting;
|
||||
this.t = t;
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
const { contentEl } = this;
|
||||
|
||||
const { authUrl, codeVerifier, codeChallenge } =
|
||||
await generateAuthUrlAndCodeVerifierChallenge(false);
|
||||
this.plugin.oauth2Info.verifier = codeVerifier;
|
||||
|
||||
const t = this.t;
|
||||
|
||||
const div2 = contentEl.createDiv();
|
||||
div2.createEl(
|
||||
"button",
|
||||
{
|
||||
text: t("modal_proauth_copybutton"),
|
||||
},
|
||||
(el) => {
|
||||
el.onclick = async () => {
|
||||
await navigator.clipboard.writeText(authUrl);
|
||||
new Notice(t("modal_proauth_copynotice"));
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
contentEl.createEl("p").createEl("a", {
|
||||
href: authUrl,
|
||||
text: authUrl,
|
||||
});
|
||||
|
||||
// manual paste
|
||||
let authCode = "";
|
||||
new Setting(contentEl)
|
||||
.setName(t("modal_proauth_maualinput"))
|
||||
.setDesc(t("modal_proauth_maualinput_desc"))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("")
|
||||
.setValue("")
|
||||
.onChange((val) => {
|
||||
authCode = val.trim();
|
||||
})
|
||||
)
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText(t("submit"));
|
||||
button.onClick(async () => {
|
||||
new Notice(t("modal_proauth_maualinput_notice"));
|
||||
try {
|
||||
const authRes = await sendAuthReq(
|
||||
codeVerifier ?? "verifier",
|
||||
authCode,
|
||||
async (e: any) => {
|
||||
new Notice(t("protocol_pro_connect_fail"));
|
||||
new Notice(`${e}`);
|
||||
throw e;
|
||||
}
|
||||
);
|
||||
console.debug(authRes);
|
||||
const self = this;
|
||||
setConfigBySuccessfullAuthInplace(
|
||||
this.plugin.settings.pro!,
|
||||
authRes!,
|
||||
() => self.plugin.saveSettings()
|
||||
);
|
||||
await getAndSaveProFeatures(
|
||||
this.plugin.settings.pro!,
|
||||
this.plugin.manifest.version,
|
||||
() => self.plugin.saveSettings()
|
||||
);
|
||||
this.proFeaturesListSetting.setDesc(
|
||||
stringToFragment(
|
||||
t("settings_pro_features_desc", {
|
||||
features: featureListToText(
|
||||
this.plugin.settings.pro!.enabledProFeatures
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
await getAndSaveProEmail(
|
||||
this.plugin.settings.pro!,
|
||||
this.plugin.manifest.version,
|
||||
() => self.plugin.saveSettings()
|
||||
);
|
||||
|
||||
new Notice(
|
||||
t("protocol_pro_connect_manualinput_succ", {
|
||||
email: this.plugin.settings.pro!.email ?? "(no email)",
|
||||
})
|
||||
);
|
||||
|
||||
this.plugin.oauth2Info.verifier = ""; // reset it
|
||||
this.plugin.oauth2Info.authDiv?.toggleClass(
|
||||
"pro-auth-button-hide",
|
||||
this.plugin.settings.pro?.refreshToken !== ""
|
||||
);
|
||||
this.plugin.oauth2Info.authDiv = undefined;
|
||||
|
||||
this.plugin.oauth2Info.revokeAuthSetting?.setDesc(
|
||||
t("protocol_pro_connect_succ_revoke", {
|
||||
email: this.plugin.settings.pro?.email,
|
||||
})
|
||||
);
|
||||
this.plugin.oauth2Info.revokeAuthSetting = undefined;
|
||||
this.plugin.oauth2Info.revokeDiv?.toggleClass(
|
||||
"pro-revoke-auth-button-hide",
|
||||
this.plugin.settings.pro?.email === ""
|
||||
);
|
||||
this.plugin.oauth2Info.revokeDiv = undefined;
|
||||
|
||||
this.close();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
new Notice(t("modal_proauth_maualinput_conn_fail"));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
||||
export class ProRevokeAuthModal extends Modal {
|
||||
readonly plugin: RemotelySavePlugin;
|
||||
readonly authDiv: HTMLDivElement;
|
||||
readonly revokeAuthDiv: HTMLDivElement;
|
||||
readonly t: (x: TransItemType, vars?: any) => string;
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: RemotelySavePlugin,
|
||||
authDiv: HTMLDivElement,
|
||||
revokeAuthDiv: HTMLDivElement,
|
||||
t: (x: TransItemType, vars?: any) => string
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.authDiv = authDiv;
|
||||
this.revokeAuthDiv = revokeAuthDiv;
|
||||
this.t = t;
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
const { contentEl } = this;
|
||||
const t = this.t;
|
||||
|
||||
contentEl.createEl("p", {
|
||||
text: t("modal_prorevokeauth"),
|
||||
});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName(t("modal_prorevokeauth_clean"))
|
||||
.setDesc(t("modal_prorevokeauth_clean_desc"))
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText(t("modal_prorevokeauth_clean_button"));
|
||||
button.onClick(async () => {
|
||||
try {
|
||||
this.plugin.settings.pro = cloneDeep(DEFAULT_PRO_CONFIG);
|
||||
await this.plugin.saveSettings();
|
||||
this.authDiv.toggleClass(
|
||||
"pro-auth-button-hide",
|
||||
this.plugin.settings.pro?.refreshToken !== ""
|
||||
);
|
||||
this.revokeAuthDiv.toggleClass(
|
||||
"pro-revoke-auth-button-hide",
|
||||
this.plugin.settings.pro?.refreshToken === ""
|
||||
);
|
||||
new Notice(t("modal_prorevokeauth_clean_notice"));
|
||||
this.close();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
new Notice(t("modal_prorevokeauth_clean_fail"));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
||||
const featureListToText = (features: FeatureInfo[]) => {
|
||||
// TODO: i18n
|
||||
if (features === undefined || features.length === 0) {
|
||||
return "No features enabled.";
|
||||
}
|
||||
return features
|
||||
.map((x) => {
|
||||
return `${x.featureName} (expire: ${new Date(
|
||||
Number(x.expireAtTimeMs)
|
||||
).toISOString()})`;
|
||||
})
|
||||
.join("<br/>");
|
||||
};
|
||||
|
||||
export const generateProSettingsPart = (
|
||||
proDiv: HTMLDivElement,
|
||||
t: (x: TransItemType, vars?: any) => string,
|
||||
app: App,
|
||||
plugin: RemotelySavePlugin,
|
||||
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
||||
) => {
|
||||
proDiv
|
||||
.createEl("h2", { text: t("settings_pro") })
|
||||
.setAttribute("id", "settings-pro");
|
||||
|
||||
proDiv.createEl("div", {
|
||||
text: stringToFragment(t("settings_pro_tutorial")),
|
||||
});
|
||||
|
||||
const proSelectAuthDiv = proDiv.createDiv();
|
||||
const proAuthDiv = proSelectAuthDiv.createDiv({
|
||||
cls: "pro-auth-button-hide settings-auth-related",
|
||||
});
|
||||
|
||||
const proRevokeAuthDiv = proSelectAuthDiv.createDiv({
|
||||
cls: "pro-revoke-auth-button-hide settings-auth-related",
|
||||
});
|
||||
|
||||
const proFeaturesListSetting = new Setting(proRevokeAuthDiv)
|
||||
.setName(t("settings_pro_features"))
|
||||
.setDesc(
|
||||
stringToFragment(
|
||||
t("settings_pro_features_desc", {
|
||||
features: featureListToText(plugin.settings.pro!.enabledProFeatures),
|
||||
})
|
||||
)
|
||||
);
|
||||
proFeaturesListSetting.addButton(async (button) => {
|
||||
button.setButtonText(t("settings_pro_features_refresh_button"));
|
||||
button.onClick(async () => {
|
||||
new Notice(t("settings_pro_features_refresh_fetch"));
|
||||
await getAndSaveProFeatures(
|
||||
plugin.settings.pro!,
|
||||
plugin.manifest.version,
|
||||
saveUpdatedConfigFunc
|
||||
);
|
||||
proFeaturesListSetting.setDesc(
|
||||
stringToFragment(
|
||||
t("settings_pro_features_desc", {
|
||||
features: featureListToText(
|
||||
plugin.settings.pro!.enabledProFeatures
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
new Notice(t("settings_pro_features_refresh_succ"));
|
||||
});
|
||||
});
|
||||
|
||||
const proRevokeAuthSetting = new Setting(proRevokeAuthDiv)
|
||||
.setName(t("settings_pro_revoke"))
|
||||
.setDesc(
|
||||
t("settings_pro_revoke_desc", {
|
||||
email: plugin.settings.pro?.email,
|
||||
})
|
||||
)
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText(t("settings_pro_revoke_button"));
|
||||
button.onClick(async () => {
|
||||
new ProRevokeAuthModal(
|
||||
app,
|
||||
plugin,
|
||||
proAuthDiv,
|
||||
proRevokeAuthDiv,
|
||||
t
|
||||
).open();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(proAuthDiv)
|
||||
.setName(t("settings_pro_intro"))
|
||||
.setDesc(stringToFragment(t("settings_pro_intro_desc")))
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText(t("settings_pro_intro_button"));
|
||||
button.onClick(async () => {
|
||||
window.open("https://remotelysave.com/user/signupin", "_self");
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(proAuthDiv)
|
||||
.setName(t("settings_pro_auth"))
|
||||
.setDesc(t("settings_pro_auth_desc"))
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText(t("settings_pro_auth_button"));
|
||||
button.onClick(async () => {
|
||||
const modal = new ProAuthModal(
|
||||
app,
|
||||
plugin,
|
||||
proAuthDiv,
|
||||
proRevokeAuthDiv,
|
||||
proRevokeAuthSetting,
|
||||
proFeaturesListSetting,
|
||||
t
|
||||
);
|
||||
plugin.oauth2Info.helperModal = modal;
|
||||
plugin.oauth2Info.authDiv = proAuthDiv;
|
||||
plugin.oauth2Info.revokeDiv = proRevokeAuthDiv;
|
||||
plugin.oauth2Info.revokeAuthSetting = proRevokeAuthSetting;
|
||||
|
||||
modal.open();
|
||||
});
|
||||
});
|
||||
|
||||
proAuthDiv.toggleClass(
|
||||
"pro-auth-button-hide",
|
||||
plugin.settings.pro?.refreshToken !== ""
|
||||
);
|
||||
proRevokeAuthDiv.toggleClass(
|
||||
"pro-revoke-auth-button-hide",
|
||||
plugin.settings.pro?.refreshToken === ""
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { deepStrictEqual, rejects, throws } from "assert";
|
||||
import { getFileRename } from "../src/conflictLogic";
|
||||
|
||||
describe("New name is generated", () => {
|
||||
it("should throw for empty file", async () => {
|
||||
for (const key of ["", "/", ".", ".."]) {
|
||||
throws(() => getFileRename(key));
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw for folder", async () => {
|
||||
for (const key of ["sss/", "ssss/yyy/"]) {
|
||||
throws(() => getFileRename(key));
|
||||
}
|
||||
});
|
||||
|
||||
it("should correctly get no ext files renamed", async () => {
|
||||
deepStrictEqual(getFileRename("abc"), "abc.dup");
|
||||
|
||||
deepStrictEqual(getFileRename("xxxx/yyyy/abc"), "xxxx/yyyy/abc.dup");
|
||||
});
|
||||
|
||||
it("should correctly get dot files renamed", async () => {
|
||||
deepStrictEqual(getFileRename(".abc"), ".abc.dup");
|
||||
|
||||
deepStrictEqual(getFileRename("xxxx/yyyy/.efg"), "xxxx/yyyy/.efg.dup");
|
||||
|
||||
deepStrictEqual(getFileRename("xxxx/yyyy/hij."), "xxxx/yyyy/hij.dup");
|
||||
});
|
||||
|
||||
it("should correctly get normal files renamed", async () => {
|
||||
deepStrictEqual(getFileRename("abc.efg"), "abc.dup.efg");
|
||||
|
||||
deepStrictEqual(
|
||||
getFileRename("xxxx/yyyy/abc.efg"),
|
||||
"xxxx/yyyy/abc.dup.efg"
|
||||
);
|
||||
|
||||
deepStrictEqual(
|
||||
getFileRename("xxxx/yyyy/abc.tar.gz"),
|
||||
"xxxx/yyyy/abc.tar.dup.gz"
|
||||
);
|
||||
|
||||
deepStrictEqual(
|
||||
getFileRename("xxxx/yyyy/.abc.efg"),
|
||||
"xxxx/yyyy/.abc.dup.efg"
|
||||
);
|
||||
});
|
||||
|
||||
it("should correctly get duplicated files renamed again", async () => {
|
||||
deepStrictEqual(getFileRename("abc.dup"), "abc.dup.dup");
|
||||
|
||||
deepStrictEqual(
|
||||
getFileRename("xxxx/yyyy/.abc.dup"),
|
||||
"xxxx/yyyy/.abc.dup.dup"
|
||||
);
|
||||
|
||||
deepStrictEqual(
|
||||
getFileRename("xxxx/yyyy/abc.dup.md"),
|
||||
"xxxx/yyyy/abc.dup.dup.md"
|
||||
);
|
||||
|
||||
deepStrictEqual(
|
||||
getFileRename("xxxx/yyyy/.abc.dup.md"),
|
||||
"xxxx/yyyy/.abc.dup.dup.md"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user