pro and smart conflict

This commit is contained in:
fyears
2024-05-27 00:33:49 +08:00
parent 0802767726
commit 06dad54d4c
42 changed files with 2087 additions and 348 deletions
+104
View File
@@ -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.
+21
View File
@@ -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.
+302
View File
@@ -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;
};
+25
View File
@@ -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;
}
+257
View File
@@ -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,
};
}
+39
View File
@@ -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"
}
+9
View File
@@ -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,
};
+39
View File
@@ -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": "连接"
}
+39
View File
@@ -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": "連線"
}
+47
View File
@@ -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);
};
+359
View File
@@ -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 === ""
);
};
+68
View File
@@ -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"
);
});
});