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
+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 === ""
);
};