diff --git a/.vscode/settings.json b/.vscode/settings.json index 8f6c51b9..b7f213d8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,8 +21,10 @@ "filemanager", "Hengyang", "Kanban", + "MKCOL", "Openeds", "Popconfirm", + "PROPFIND", "Qihu", "sheepzh", "vueuse", diff --git a/package.json b/package.json index 3e9d65be..8bfdfa3f 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "countup.js": "^2.8.0", "echarts": "^5.5.1", "element-plus": "2.8.1", + "js-base64": "^3.7.7", "punycode": "^2.3.1", "stream-browserify": "^3.0.0", "vue": "^3.4.38", @@ -76,4 +77,4 @@ "engines": { "node": ">=20" } -} \ No newline at end of file +} diff --git a/src/api/web-dav.ts b/src/api/web-dav.ts new file mode 100644 index 00000000..0062d603 --- /dev/null +++ b/src/api/web-dav.ts @@ -0,0 +1,113 @@ +/** + * WebDAV client api + * + * Testing with server implemented by https://github.com/svtslv/webdav-cli + */ +import { encode } from 'js-base64' +import { fetchDelete, fetchGet } from './http' + +// Only support password for now +export type WebDAVAuth = { + type: 'password' + username: string + password: string +} + +export type WebDAVContext = { + auth: WebDAVAuth + endpoint: string +} + +const authHeaders = (auth: WebDAVAuth): Headers => { + const type = auth?.type + let headerVal = null + if (type === 'password') { + headerVal = `Basic ${encode(`${auth?.username}:${auth?.password}`)}` + } + const headers = new Headers() + headers.set('Authorization', headerVal) + return headers +} + +export async function judgeDirExist(context: WebDAVContext, dirPath: string): Promise { + const { auth, endpoint } = context || {} + const headers = authHeaders(auth) + const url = `${endpoint}/${dirPath}` + const method = 'PROPFIND' + headers.append('Accept', 'text/plain,application/xml') + headers.append('Depth', '1') + const response = await fetch(url, { method, headers }) + const status = response?.status + if (status == 207) { + return true + } else if (status === 300) { + throw new Error("Your server does't support PROPFIND method!") + } else if (status == 404) { + return false + } else if (status == 401) { + throw new Error("Authorization is invalid!") + } else { + throw new Error("Unknown directory status") + } +} + +export async function makeDir(context: WebDAVContext, dirPath: string) { + const { auth, endpoint } = context || {} + const url = `${endpoint}/${dirPath}` + const headers = authHeaders(auth) + const response = await fetch(url, { method: 'MKCOL', headers }) + handleWriteResponse(response) +} + +export async function deleteDir(context: WebDAVContext, dirPath: string) { + const { auth, endpoint } = context || {} + const url = `${endpoint}/${dirPath}` + const headers = authHeaders(auth) + const response = await fetchDelete(url, { headers }) + const status = response.status + if (status === 403) { + throw new Error("Unauthorized to delete directory") + } + if (status !== 201 && status !== 200) { + throw new Error("Failed to delete directory: " + status) + } +} + +export async function writeFile(context: WebDAVContext, filePath: string, content: string): Promise { + const { auth, endpoint } = context || {} + const headers = authHeaders(auth) + headers.set("Content-Type", "application/octet-stream") + const url = `${endpoint}/${filePath}` + const response = await fetch(url, { headers, method: 'put', body: content }) + handleWriteResponse(response) +} + +function handleWriteResponse(response: Response) { + const status = response.status + if (status === 403) { + throw new Error("Unauthorized to write file or create directory") + } + if (status !== 201 && status !== 200) { + throw new Error("Failed to write file or create directory: " + status) + } +} + +export async function readFile(context: WebDAVContext, filePath: string): Promise { + const { auth, endpoint } = context || {} + const headers = authHeaders(auth) + const url = `${endpoint}/${filePath}` + try { + const response = await fetchGet(url, { headers }) + const status = response?.status + if (status === 200) { + return response.text() + } + if (status !== 404) { + console.warn("Unexpected status: " + status) + } + return null + } catch (e) { + console.error("Failed to read WebDAV content", e) + return null + } +} \ No newline at end of file diff --git a/src/app/components/Option/components/BackupOption/AutoInput.tsx b/src/app/components/Option/components/BackupOption/AutoInput.tsx index 5325416d..ed94f632 100644 --- a/src/app/components/Option/components/BackupOption/AutoInput.tsx +++ b/src/app/components/Option/components/BackupOption/AutoInput.tsx @@ -5,13 +5,13 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import { ElInputNumber, ElSwitch } from "element-plus" -import { defineComponent, watch } from "vue" -import localeMessages from "@i18n/message/common/locale" import I18nNode from "@app/components/common/I18nNode" +import { t } from "@app/locale" import { useShadow } from "@hooks" import { locale } from "@i18n" +import localeMessages from "@i18n/message/common/locale" +import { ElInputNumber, ElSwitch } from "element-plus" +import { defineComponent, watch } from "vue" const _default = defineComponent({ props: { @@ -28,7 +28,7 @@ const _default = defineComponent({ return () => <> {' ' + t(msg => msg.option.backup.auto.label)} -
+ {!!autoBackUp.value && <> {localeMessages[locale].comma || ' '} msg.option.backup.auto.interval} @@ -41,8 +41,7 @@ const _default = defineComponent({ /> }} /> -
- {localeMessages[locale].comma || ' '} + } }, }) diff --git a/src/app/components/Option/components/BackupOption/Clear/index.tsx b/src/app/components/Option/components/BackupOption/Clear/index.tsx index e78434d9..2a600947 100644 --- a/src/app/components/Option/components/BackupOption/Clear/index.tsx +++ b/src/app/components/Option/components/BackupOption/Clear/index.tsx @@ -21,7 +21,6 @@ const _default = defineComponent({ } - style={{ marginRight: "12px" }} onClick={() => dialogVisible.value = true} > {t(msg => msg.option.backup.clear.btn)} diff --git a/src/app/components/Option/components/BackupOption/ClientTable.tsx b/src/app/components/Option/components/BackupOption/ClientTable.tsx index 665ea97f..4327083e 100644 --- a/src/app/components/Option/components/BackupOption/ClientTable.tsx +++ b/src/app/components/Option/components/BackupOption/ClientTable.tsx @@ -7,11 +7,13 @@ import { t } from "@app/locale" import { cvt2LocaleTime } from "@app/util/time" +import { Loading, RefreshRight } from "@element-plus/icons-vue" +import { useRequest } from "@hooks" import metaService from "@service/meta-service" import processor from "@src/common/backup/processor" import { ElTableRowScope } from "@src/element-ui/table" -import { ElMessage, ElRadio, ElTable, ElTableColumn, ElTag } from "element-plus" -import { defineComponent, ref, Ref, onMounted } from "vue" +import { ElLink, ElMessage, ElRadio, ElTable, ElTableColumn, ElTag } from "element-plus" +import { defineComponent, ref } from "vue" const formatTime = (value: timer.backup.Client): string => { const { minDate, maxDate } = value || {} @@ -25,33 +27,28 @@ const _default = defineComponent({ select: (_: timer.backup.Client) => true, }, setup(_, ctx) { - const list: Ref = ref([]) - const loading: Ref = ref(false) - const selectedCid: Ref = ref() - const localCid: Ref = ref() - - onMounted(() => { - loading.value = true - processor.listClients() - .then(res => { - if (res.success) { - list.value = res.data - } else { - throw res.errorMsg - } - }) - .catch(e => ElMessage.error(typeof e === 'string' ? e : (e as Error).message || 'Unknown error...')) - .finally(() => loading.value = false) - metaService.getCid().then(cid => localCid.value = cid) + const { data: list, loading, refresh } = useRequest(async () => { + const { success, data, errorMsg } = await processor.listClients() || {} + if (!success) { + throw new Error(errorMsg) + } + return data + }, { + defaultValue: [], + onError: e => ElMessage.error(typeof e === 'string' ? e : (e as Error).message || 'Unknown error...') }) + const { data: localCid } = useRequest(() => metaService.getCid()) + + const selectedCid = ref() const handleRowSelect = (row: timer.backup.Client) => { selectedCid.value = row.id ctx.emit("select", row) } return () => ( - handleRowSelect(row)} emptyText={loading.value ? 'Loading data ...' : 'Empty data'} > - - { - ({ row }: ElTableRowScope) => ( + ( + : } + onClick={refresh} + type="primary" + underline={false} + /> + ), + default: ({ row }: ElTableRowScope) => ( handleRowSelect(row)} - v-slots={() => ''} /> - ) - } - + ), + }} + /> - { - ({ row: client }: ElTableRowScope) => <> - {client.name || '-'} - - {t(msg => msg.option.backup.clientTable.current)} - - - } + {({ row: client }: ElTableRowScope) => <> + {client.name || '-'} + + {t(msg => msg.option.backup.clientTable.current)} + + } msg.option.backup.clientTable.dataRange)} diff --git a/src/app/components/Option/components/BackupOption/Download/Step2.tsx b/src/app/components/Option/components/BackupOption/Download/Step2.tsx index 693eac44..08a80539 100644 --- a/src/app/components/Option/components/BackupOption/Download/Step2.tsx +++ b/src/app/components/Option/components/BackupOption/Download/Step2.tsx @@ -12,6 +12,7 @@ import { Back, Check } from "@element-plus/icons-vue" import { processImportedData } from "@service/components/import-processor" import { renderResolutionFormItem } from "@app/components/common/imported/conflict" import CompareTable from "@app/components/common/imported/CompareTable" +import { useRequest } from "@hooks/useRequest" const _default = defineComponent({ props: { @@ -27,23 +28,21 @@ const _default = defineComponent({ }, setup(props, ctx) { const resolution: Ref = ref() - const downloading: Ref = ref(false) - const handleDownload = () => { + const { refresh: handleDownload, loading: downloading } = useRequest(async () => { const resolutionVal = resolution.value if (!resolutionVal) { ElMessage.warning(t(msg => msg.dataManage.importOther.conflictNotSelected)) return } - downloading.value = true - processImportedData(props.data, resolutionVal) - .then(() => { - ElMessage.success(t(msg => msg.operation.successMsg)) - ctx.emit('download') - }) - .catch() - .finally(() => downloading.value = false) - } + await processImportedData(props.data, resolutionVal) + }, { + manual: true, + onSuccess() { + ElMessage.success(t(msg => msg.operation.successMsg)) + ctx.emit('download') + } + }) return () => <> { diff --git a/src/app/components/Option/components/BackupOption/Download/index.tsx b/src/app/components/Option/components/BackupOption/Download/index.tsx index 75deecf4..972b28cc 100644 --- a/src/app/components/Option/components/BackupOption/Download/index.tsx +++ b/src/app/components/Option/components/BackupOption/Download/index.tsx @@ -21,7 +21,6 @@ const _default = defineComponent({ } - style={{ marginRight: "12px" }} onClick={() => dialogVisible.value = true} > {t(msg => msg.option.backup.download.btn)} diff --git a/src/app/components/Option/components/BackupOption/Footer.tsx b/src/app/components/Option/components/BackupOption/Footer.tsx index dba0b135..71892b37 100644 --- a/src/app/components/Option/components/BackupOption/Footer.tsx +++ b/src/app/components/Option/components/BackupOption/Footer.tsx @@ -8,14 +8,14 @@ import type { PropType, Ref } from "vue" import { t } from "@app/locale" -import { UploadFilled } from "@element-plus/icons-vue" -import { ElButton, ElDivider, ElLoading, ElMessage, ElText } from "element-plus" -import { defineComponent, ref, watch } from "vue" +import { Operation, UploadFilled } from "@element-plus/icons-vue" import metaService from "@service/meta-service" import processor from "@src/common/backup/processor" import { formatTime } from "@util/time" -import Download from "./Download" +import { ElButton, ElDivider, ElLoading, ElMessage, ElText } from "element-plus" +import { defineComponent, ref, watch } from "vue" import Clear from "./Clear" +import Download from "./Download" async function handleBackup(lastTime: Ref) { const loading = ElLoading.service({ @@ -31,6 +31,20 @@ async function handleBackup(lastTime: Ref) { } } +async function handleTest() { + const loading = ElLoading.service({ text: "Please wait...." }) + try { + const { errorMsg } = await processor.checkAuth() + if (!errorMsg) { + ElMessage.success("Valid!") + } else { + ElMessage.error(errorMsg) + } + } finally { + loading.close() + } +} + const TIME_FORMAT = t(msg => msg.calendar.timeFormat) const _default = defineComponent({ @@ -54,6 +68,9 @@ const _default = defineComponent({ return () =>
diff --git a/src/app/components/Option/components/BackupOption/index.tsx b/src/app/components/Option/components/BackupOption/index.tsx index 7f9aa11b..3d73c59c 100644 --- a/src/app/components/Option/components/BackupOption/index.tsx +++ b/src/app/components/Option/components/BackupOption/index.tsx @@ -5,121 +5,50 @@ * https://opensource.org/licenses/MIT */ import { - DEFAULT_ENDPOINT as DEFAULT_OBSIDIAN_ENDPOINT, DEFAULT_VAULT as DEFAULT_OBSIDIAN_BUCKET, + DEFAULT_ENDPOINT as DEFAULT_OBSIDIAN_ENDPOINT, } from "@api/obsidian" import { t } from "@app/locale" -import optionService from "@service/option-service" -import processor from "@src/common/backup/processor" import { AUTHOR_EMAIL } from "@src/package" -import { defaultBackup } from "@util/constant/option" -import { ElAlert, ElButton, ElInput, ElLoading, ElMessage, ElOption, ElSelect } from "element-plus" -import { computed, defineComponent, ref, watch } from "vue" +import { ElAlert, ElInput, ElOption, ElSelect } from "element-plus" +import { computed, defineComponent } from "vue" import { OptionInstance } from "../../common" import OptionItem from "../OptionItem" import OptionTooltip from "../OptionTooltip" import AutoInput from "./AutoInput" import Footer from "./Footer" +import { useOptionState } from "./state" import "./style.sass" const ALL_TYPES: timer.backup.Type[] = [ 'none', 'gist', + 'web_dav', 'obsidian_local_rest_api', ] -const AUTH_LABELS: { [t in timer.backup.Type]: string } = { - 'none': '', - 'gist': 'Personal Access Token {info} {input}', - 'obsidian_local_rest_api': 'Authorization {input}' -} - const TYPE_NAMES: { [t in timer.backup.Type]: string } = { none: t(msg => msg.option.backup.meta.none.label), gist: 'GitHub Gist', - obsidian_local_rest_api: "Obsidian - Local REST API", + obsidian_local_rest_api: 'Obsidian - Local REST API', + web_dav: 'WebDAV' } -const DEFAULT = defaultBackup() - const _default = defineComponent((_props, ctx) => { - const backupType = ref(DEFAULT.backupType) - const autoBackUp = ref(DEFAULT.autoBackUp) - const autoBackUpInterval = ref(DEFAULT.autoBackUpInterval) - const backupExts = ref(DEFAULT.backupExts) - const backupAuths = ref(DEFAULT.backupAuths) - const clientName = ref(DEFAULT.clientName) - watch([ - backupType, autoBackUp, autoBackUpInterval, backupExts, backupAuths, clientName - ], () => optionService.setBackupOption({ - backupType: backupType.value, - autoBackUp: autoBackUp.value, - autoBackUpInterval: autoBackUpInterval.value, - backupExts: backupExts.value, - backupAuths: backupAuths.value, - clientName: clientName.value, - })) + const { + backupType, clientName, reset, + autoBackUp, autoBackUpInterval, + auth, account, password, + ext, setExtField, + } = useOptionState() - optionService.getAllOption().then(val => { - backupType.value = val?.backupType - autoBackUp.value = val?.autoBackUp - autoBackUpInterval.value = val?.autoBackUpInterval - backupExts.value = val?.backupExts - backupAuths.value = val?.backupAuths - clientName.value = val?.clientName - }) + const isNotNone = computed(() => backupType.value && backupType.value !== 'none') - const isObsidian = computed(() => backupType.value === "obsidian_local_rest_api") - const isNotNone = computed(() => backupType.value !== "none") - const auth = computed({ - get: () => backupAuths.value?.[backupType?.value], - set: val => { - const typeVal = backupType.value - if (!typeVal) return - const newAuths = { - ...backupAuths.value || {}, - [typeVal]: val, - } - backupAuths.value = newAuths - } - }) - const ext = computed({ - get: () => backupExts.value?.[backupType.value], - set: val => { - const typeVal = backupType.value - if (!typeVal) return - const newExts = { - ...backupExts.value || {}, - [typeVal]: val, - } - backupExts.value = newExts - }, - }) + ctx.expose({ reset } satisfies OptionInstance) - async function handleTest() { - const loading = ElLoading.service({ text: "Please wait...." }) - try { - const { errorMsg } = await processor.checkAuth() - if (!errorMsg) { - ElMessage.success("Valid!") - } else { - ElMessage.error(errorMsg) - } - } finally { - loading.close() - } - } - - ctx.expose({ - reset: () => { - // Only reset type and auto flag - backupType.value = DEFAULT.backupType - autoBackUp.value = DEFAULT.autoBackUp - } - } satisfies OptionInstance) return () => <> msg.option.backup.alert, { email: AUTHOR_EMAIL })} /> - msg.option.backup.type} defaultValue={TYPE_NAMES[DEFAULT.backupType]}> + msg.option.backup.type} defaultValue={TYPE_NAMES['none']}> { }} /> - msg.option.backup.meta.obsidian_local_rest_api.endpointLabel} - v-slots={{ - info: () => {t(msg => msg.option.backup.meta.obsidian_local_rest_api.endpointInfo)} - }} - > - ext.value = { ...(ext.value || {}), endpoint: val?.trim?.() || '' }} - /> - - "Vault Name {input}"} - > - ext.value = { ...(ext.value || {}), bucket: val?.trim?.() || '' }} - /> - - msg.option.backup.meta.obsidian_local_rest_api.pathLabel}> - ext.value = { ...(ext.value || {}), dirPath: val?.trim?.() || '' }} - /> - - AUTH_LABELS[backupType.value]} - v-slots={{ - info: () => {t(msg => msg.option.backup.meta[backupType.value]?.authInfo)} - }} - > - auth.value = val?.trim?.() || ''} + {backupType.value === 'gist' && ( + 'Personal Access Token {info} {input}'} v-slots={{ - append: () => {t(msg => msg.button.test)} + info: () => {t(msg => msg.option.backup.meta.gist.authInfo)} }} - /> - + > + auth.value = val?.trim?.() || ''} + /> + + )} + {backupType.value === 'obsidian_local_rest_api' && <> + msg.option.backup.label.endpoint} + v-slots={{ + info: () => {t(msg => msg.option.backup.meta.obsidian_local_rest_api.endpointInfo)} + }} + > + setExtField('endpoint', val)} + /> + + "Vault Name {input}"}> + setExtField('bucket', val)} + /> + + msg.option.backup.label.path} required> + setExtField('dirPath', val)} + /> + + "Authorization {input}"}> + auth.value = val?.trim?.() || ''} + /> + + } + {backupType.value === 'web_dav' && <> + msg.option.backup.label.endpoint} + v-slots={{ info: () => '' }} + required + > + setExtField('endpoint', val)} + /> + + msg.option.backup.label.path} required> + setExtField('dirPath', val)} + /> + + msg.option.backup.label.account} required> + account.value = val?.trim?.()} + /> + + msg.option.backup.label.password} required> + password.value = val?.trim?.()} + /> + + } msg.option.backup.client}> clientName.value = val?.trim?.() || ''} /> diff --git a/src/app/components/Option/components/BackupOption/state.ts b/src/app/components/Option/components/BackupOption/state.ts new file mode 100644 index 00000000..e245b40c --- /dev/null +++ b/src/app/components/Option/components/BackupOption/state.ts @@ -0,0 +1,116 @@ +import optionService from "@service/option-service" +import { defaultBackup } from "@util/constant/option" +import { computed, Ref, ref, watch } from "vue" + +type Result = { + reset: () => void + backupType: Ref + clientName: Ref + autoBackUp: Ref + autoBackUpInterval: Ref + auth: Ref + account: Ref + password: Ref + ext: Ref + setExtField: (field: keyof timer.backup.TypeExt, val: string) => void +} + +export const useOptionState = (): Result => { + const defaultOption = defaultBackup() + const backupType = ref(defaultOption.backupType) + const autoBackUp = ref(defaultOption.autoBackUp) + const autoBackUpInterval = ref(defaultOption.autoBackUpInterval) + const backupExts = ref(defaultOption.backupExts) + const backupAuths = ref(defaultOption.backupAuths) + const clientName = ref(defaultOption.clientName) + const login = ref(defaultOption.backupLogin) + + watch([ + backupType, + autoBackUp, autoBackUpInterval, + backupExts, backupAuths, login, + clientName, + ], () => optionService.setBackupOption({ + backupType: backupType.value, + autoBackUp: autoBackUp.value, + autoBackUpInterval: autoBackUpInterval.value, + backupExts: backupExts.value, + backupAuths: backupAuths.value, + clientName: clientName.value, + backupLogin: login.value, + })) + + optionService.getAllOption().then(val => { + backupType.value = val?.backupType + autoBackUp.value = val?.autoBackUp + autoBackUpInterval.value = val?.autoBackUpInterval + backupExts.value = val?.backupExts + backupAuths.value = val?.backupAuths + clientName.value = val?.clientName + login.value = val?.backupLogin + }) + + const reset = () => { + // Only reset type and auto flag + backupType.value = defaultOption.backupType + autoBackUp.value = defaultOption.autoBackUp + } + + const auth = computed({ + get: () => backupAuths.value?.[backupType?.value], + set: val => { + const typeVal = backupType.value + if (!typeVal) return + const newAuths = { + ...backupAuths.value || {}, + [typeVal]: val, + } + backupAuths.value = newAuths + } + }) + + const ext = computed({ + get: () => backupExts.value?.[backupType.value], + set: val => { + const typeVal = backupType.value + if (!typeVal) return + const newExts = { + ...backupExts.value || {}, + [typeVal]: val, + } + backupExts.value = newExts + }, + }) + + const setExtField = (field: keyof timer.backup.TypeExt, val: string) => { + const newVal = { ...(ext.value || {}), [field]: val?.trim?.() } + ext.value = newVal + } + + const setLoginField = (field: keyof timer.backup.LoginInfo, val: string) => { + const typeVal = backupType.value + if (!typeVal) return + const newLogin = { + ...login.value || {}, + [typeVal]: { ...(login.value?.[typeVal] || {}), [field]: val } + } + login.value = newLogin + } + + const account = computed({ + get: () => login.value?.[backupType?.value]?.acc, + set: (val: string) => setLoginField('acc', val) + }) + + const password = computed({ + get: () => login.value?.[backupType?.value]?.psw, + set: (val: string) => setLoginField('psw', val) + }) + + return { + backupType, clientName, reset, + autoBackUp, autoBackUpInterval, + auth, account, password, + ext, setExtField, + } +} diff --git a/src/app/components/Option/components/BackupOption/style.sass b/src/app/components/Option/components/BackupOption/style.sass index ded130ca..7789f040 100644 --- a/src/app/components/Option/components/BackupOption/style.sass +++ b/src/app/components/Option/components/BackupOption/style.sass @@ -10,5 +10,7 @@ margin: 40px 20px 0 20px .backup-footer - .el-button+.el-button + >.el-overlay,>.el-button + margin-right: 12px + >.el-button+.el-button margin-left: 0 diff --git a/src/app/components/Option/components/OptionItem.tsx b/src/app/components/Option/components/OptionItem.tsx index 7dd322a5..0d78aba8 100644 --- a/src/app/components/Option/components/OptionItem.tsx +++ b/src/app/components/Option/components/OptionItem.tsx @@ -14,6 +14,7 @@ const _default = defineComponent({ type: Boolean, default: false, }, + required: Boolean }, setup: (props, ctx) => { return () => { @@ -24,6 +25,7 @@ const _default = defineComponent({
+ {!!props.required && *} {props.defaultValue && ( diff --git a/src/app/components/Option/style.sass b/src/app/components/Option/style.sass index 3704257d..c8660739 100644 --- a/src/app/components/Option/style.sass +++ b/src/app/components/Option/style.sass @@ -36,6 +36,9 @@ color: var(--el-text-color-primary) float: left line-height: 32px + .option-item-required + color: var(--el-color-danger) + margin-right: 4px i margin: 0 2px font-size: 13px !important diff --git a/src/app/components/common/imported/CompareTable.tsx b/src/app/components/common/imported/CompareTable.tsx index 807fa06f..72ec6ab6 100644 --- a/src/app/components/common/imported/CompareTable.tsx +++ b/src/app/components/common/imported/CompareTable.tsx @@ -5,13 +5,13 @@ * https://opensource.org/licenses/MIT */ +import HostAlert from "@app/components/common/HostAlert" +import { t } from "@app/locale" import { cvt2LocaleTime, periodFormatter } from "@app/util/time" +import { useShadow, useState } from "@hooks" import { isRemainHost } from "@util/constant/remain-host" import { ElTable, ElTableColumn, type Sort } from "element-plus" -import HostAlert from "@app/components/common/HostAlert" import { computed, defineComponent, type PropType, type VNode } from "vue" -import { t } from "@app/locale" -import { useShadow, useState } from "@hooks" type SortInfo = Sort & { prop: keyof timer.imported.Row @@ -119,7 +119,11 @@ const _default = defineComponent({ sortable minWidth={300} align="center" - formatter={({ host }: timer.imported.Row) => } + formatter={({ host }: timer.imported.Row) => ( +

+ +

+ )} /> {renderFocus(data.value, props.comparedColName)} {renderTime(data.value, props.comparedColName)} diff --git a/src/common/backup/common.ts b/src/common/backup/common.ts new file mode 100644 index 00000000..f54b7dbc --- /dev/null +++ b/src/common/backup/common.ts @@ -0,0 +1,13 @@ +export function processDir(dirPath: string) { + dirPath = dirPath?.trim?.() + if (!dirPath) { + return null + } + while (dirPath.startsWith("/")) { + dirPath = dirPath.substring(1) + } + if (!dirPath.endsWith("/")) { + dirPath = dirPath + '/' + } + return dirPath +} \ No newline at end of file diff --git a/src/common/backup/gist/compressor.ts b/src/common/backup/gist/compressor.ts index 650e781b..78b5ec0a 100644 --- a/src/common/backup/gist/compressor.ts +++ b/src/common/backup/gist/compressor.ts @@ -8,6 +8,26 @@ import { groupBy } from "@util/array" import { formatTimeYMD, getBirthday, parseTime } from "@util/time" +/** + * Data format in each json file in gist + */ +export type GistData = { + /** + * Index = month_of_part * 32 + date_of_month + */ + [index: string]: GistRow +} + +/** + * Row stored in the gist + */ +export type GistRow = { + [host: string]: [ + number, // Visit count + number, // Browsing time + ] +} + function calcGroupKey(row: timer.stat.RowBase): string { const date = row.date if (!date) { diff --git a/src/common/backup/gist/coordinator.ts b/src/common/backup/gist/coordinator.ts index bca2261b..12a26c0c 100644 --- a/src/common/backup/gist/coordinator.ts +++ b/src/common/backup/gist/coordinator.ts @@ -9,7 +9,7 @@ import type { Gist, GistForm, File, FileForm } from "@api/gist" import { getJsonFileContent, findTarget, getGist, createGist, updateGist, testToken } from "@api/gist" import { SOURCE_CODE_PAGE } from "@util/constant/url" -import { calcAllBuckets, divide2Buckets, gistData2Rows } from "./compressor" +import { calcAllBuckets, divide2Buckets, GistData, gistData2Rows } from "./compressor" import MonthIterator from "@util/month-iterator" import { formatTimeYMD } from "@util/time" @@ -70,7 +70,7 @@ export default class GistCoordinator implements timer.backup.Coordinator filename: CLIENT_FILE_NAME, content: JSON.stringify(clients) } - await updateGist(context.auth, gist.id, { description: gist.description, public: false, files }) + await updateGist(context.auth?.token, gist.id, { description: gist.description, public: false, files }) } async listAllClients(context: timer.backup.CoordinatorContext): Promise { @@ -117,7 +117,7 @@ export default class GistCoordinator implements timer.backup.Coordinator files: files2Update, description: TIMER_DATA_GIST_DESC } - updateGist(context.auth, gist.id, gist2update) + updateGist(context.auth?.token, gist.id, gist2update) } private isTargetMetaGist(gist: Gist): boolean { @@ -130,16 +130,16 @@ export default class GistCoordinator implements timer.backup.Coordinator private async getMetaGist(context: timer.backup.CoordinatorContext): Promise { const gistId = context.cache.metaGistId - const auth = context.auth + const token = context.auth?.token // 1. Find by id if (gistId) { - const gist = await getGist(auth, gistId) + const gist = await getGist(token, gistId) if (gist && this.isTargetMetaGist(gist)) { return gist } } // 2. Find another - const anotherGist = await findTarget(auth, gist => this.isTargetMetaGist(gist)) + const anotherGist = await findTarget(token, gist => this.isTargetMetaGist(gist)) if (anotherGist) { context.cache.metaGistId = anotherGist.id context.handleCacheChanged() @@ -150,7 +150,7 @@ export default class GistCoordinator implements timer.backup.Coordinator files[INIT_README_MD.filename] = INIT_README_MD files[INIT_CLIENT_JSON.filename] = INIT_CLIENT_JSON const gist2Create: GistForm = { description: TIMER_META_GIST_DESC, files, public: false } - const created = await createGist(auth, gist2Create) + const created = await createGist(token, gist2Create) const newId = created?.id newId && (context.cache.metaGistId = newId) && context.handleCacheChanged() return created @@ -158,16 +158,16 @@ export default class GistCoordinator implements timer.backup.Coordinator private async getStatGist(context: timer.backup.CoordinatorContext): Promise { const gistId = context.cache.statGistId - const auth = context.auth + const token = context.auth?.token // 1. Find by id if (gistId) { - const gist = await getGist(auth, gistId) + const gist = await getGist(token, gistId) if (gist && this.isTargetStatGist(gist)) { return gist } } // 2. Find another - const anotherGist = await findTarget(auth, gist => this.isTargetStatGist(gist)) + const anotherGist = await findTarget(token, gist => this.isTargetStatGist(gist)) if (anotherGist) { context.cache.statGistId = anotherGist.id context.handleCacheChanged() @@ -177,14 +177,14 @@ export default class GistCoordinator implements timer.backup.Coordinator const files = {} files[README_FILE_NAME] = INIT_README_MD const gist2Create: GistForm = { description: TIMER_DATA_GIST_DESC, files, public: false } - const created = await createGist(auth, gist2Create) + const created = await createGist(token, gist2Create) const newId = created?.id newId && (context.cache.statGistId = newId) && context.handleCacheChanged() return created } - testAuth(auth: string): Promise { - return testToken(auth) + testAuth(auth: timer.backup.Auth): Promise { + return testToken(auth?.token) } async clear(context: timer.backup.CoordinatorContext, client: timer.backup.Client): Promise { @@ -202,6 +202,6 @@ export default class GistCoordinator implements timer.backup.Coordinator files: files2Delete, description: TIMER_DATA_GIST_DESC } - await updateGist(context.auth, gist.id, gist2update) + await updateGist(context.auth?.token, gist.id, gist2update) } } diff --git a/src/common/backup/gist/gist.d.ts b/src/common/backup/gist/gist.d.ts deleted file mode 100644 index d3ec491f..00000000 --- a/src/common/backup/gist/gist.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Data format in each json file in gist - */ -declare type GistData = { - /** - * Index = month_of_part * 32 + date_of_month - */ - [index: string]: GistRow -} - -/** - * Row stored in the gist - */ -declare type GistRow = { - [host: string]: [ - number, // Visit count - number, // Browsing time - ] -} \ No newline at end of file diff --git a/src/common/backup/obsidian/compressor.ts b/src/common/backup/markdown.ts similarity index 98% rename from src/common/backup/obsidian/compressor.ts rename to src/common/backup/markdown.ts index d7e33dc6..96144d21 100644 --- a/src/common/backup/obsidian/compressor.ts +++ b/src/common/backup/markdown.ts @@ -8,6 +8,8 @@ import { groupBy } from "@util/array" import { formatPeriodCommon } from "@util/time" +export const CLIENT_FILE_NAME = "clients_no_modify.md" + const CLIENT_FIELDS: MarkdownTableField[] = [ { name: "Client Id", diff --git a/src/common/backup/obsidian/coordinator.ts b/src/common/backup/obsidian/coordinator.ts index a5756804..882fd438 100644 --- a/src/common/backup/obsidian/coordinator.ts +++ b/src/common/backup/obsidian/coordinator.ts @@ -1,34 +1,22 @@ import { - ObsidianRequestContext, - getFileContent, listAllFiles, updateFile, deleteFile, - NOT_FOUND_CODE, + DEFAULT_VAULT, + deleteFile, + getFileContent, INVALID_AUTH_CODE, - DEFAULT_VAULT + listAllFiles, + NOT_FOUND_CODE, + ObsidianRequestContext, + updateFile } from "@api/obsidian" -import { convertClients2Markdown, divideByDate, parseData } from "./compressor" import DateIterator from "@util/date-iterator" - -const CLIENT_FILE_NAME = "clients_no_modify.md" - -function processDir(dirPath: string) { - dirPath = dirPath?.trim?.() - if (!dirPath) { - return null - } - while (dirPath.startsWith("/")) { - dirPath = dirPath.substring(1) - } - if (!dirPath.endsWith("/")) { - dirPath = dirPath + '/' - } - return dirPath -} +import { processDir } from "../common" +import { CLIENT_FILE_NAME, convertClients2Markdown, divideByDate, parseData } from "../markdown" function prepareContext(context: timer.backup.CoordinatorContext) { const { auth, ext, cid } = context let { endpoint, dirPath, bucket } = ext || {} dirPath = processDir(dirPath) - const ctx: ObsidianRequestContext = { auth, endpoint, vault: bucket } + const ctx: ObsidianRequestContext = { auth: auth?.token, endpoint, vault: bucket } return { ctx, dirPath, cid } } @@ -79,12 +67,16 @@ export default class ObsidianCoordinator implements timer.backup.Coordinator { + async testAuth(authInfo: timer.backup.Auth, ext: timer.backup.TypeExt): Promise { let { endpoint, dirPath, bucket } = ext || {} + let { token: auth } = authInfo || {} dirPath = processDir(dirPath) if (!dirPath) { return "Path of directory is blank" } + if (!auth) { + return "Authorization is blank" + } try { const result = await listAllFiles({ endpoint, auth, vault: bucket }, dirPath) const { errorCode, message } = result || {} diff --git a/src/common/backup/processor.ts b/src/common/backup/processor.ts index a6e06000..704ccbb1 100644 --- a/src/common/backup/processor.ts +++ b/src/common/backup/processor.ts @@ -13,13 +13,14 @@ import { judgeVirtualFast } from "@util/pattern" import { formatTimeYMD, getBirthday } from "@util/time" import GistCoordinator from "./gist/coordinator" import ObsidianCoordinator from "./obsidian/coordinator" +import WebDAVCoordinator from "./web-dav/coordinator" const storage = chrome.storage.local const syncDb = new BackupDatabase(storage) export type AuthCheckResult = { option: timer.option.BackupOption - auth: string + auth: timer.backup.Auth ext: timer.backup.TypeExt type: timer.backup.Type coordinator: timer.backup.Coordinator @@ -27,13 +28,13 @@ export type AuthCheckResult = { } class CoordinatorContextWrapper implements timer.backup.CoordinatorContext { - auth: string + auth: timer.backup.Auth ext?: timer.backup.TypeExt cache: Cache type: timer.backup.Type cid: string - constructor(cid: string, auth: string, ext: timer.backup.TypeExt, type: timer.backup.Type) { + constructor(cid: string, auth: timer.backup.Auth, ext: timer.backup.TypeExt, type: timer.backup.Type) { this.cid = cid this.auth = auth this.ext = ext @@ -116,11 +117,7 @@ async function syncFull( client.maxDate = allDates[allDates.length - 1] client.minDate = allDates[0] // 2. upload - try { - await coordinator.upload(context, rows) - } catch (error) { - console.log(error) - } + await coordinator.upload(context, rows) return { ts: end.getTime(), date: formatTimeYMD(end), @@ -136,6 +133,13 @@ function filterClient(c: timer.backup.Client, excludeLocal: boolean, localClient return true } +function prepareAuth(option: timer.option.BackupOption): timer.backup.Auth { + const type = option?.backupType || 'none' + const token = option?.backupAuths?.[type] + const login = option.backupLogin?.[type] + return { token, login } +} + export type RemoteQueryParam = { start: Date end: Date @@ -153,6 +157,7 @@ class Processor { none: undefined, gist: new GistCoordinator(), obsidian_local_rest_api: new ObsidianCoordinator(), + web_dav: new WebDAVCoordinator(), } } @@ -168,15 +173,21 @@ class Processor { minDate: undefined, maxDate: undefined } - let snapshot: timer.backup.Snapshot = await syncFull(context, coordinator, client) - await syncDb.updateSnapshot(type, snapshot) - const clients: timer.backup.Client[] = (await coordinator.listAllClients(context)).filter(a => a.id !== cid) || [] - clients.push(client) - await coordinator.updateClients(context, clients) - // Update time - const now = Date.now() - metaService.updateBackUpTime(type, now) - return success(now) + try { + let snapshot: timer.backup.Snapshot = await syncFull(context, coordinator, client) + await syncDb.updateSnapshot(type, snapshot) + const clients: timer.backup.Client[] = (await coordinator.listAllClients(context)).filter(a => a.id !== cid) || [] + clients.push(client) + await coordinator.updateClients(context, clients) + // Update time + const now = Date.now() + metaService.updateBackUpTime(type, now) + return success(now) + } catch (e) { + console.error("Error to sync data", e) + const msg = (e as Error)?.message || e + return error(msg) + } } async listClients(): Promise> { @@ -191,8 +202,8 @@ class Processor { async checkAuth(): Promise { const option = (await optionService.getAllOption()) as timer.option.BackupOption const type = option?.backupType || 'none' - const auth = option?.backupAuths?.[type] const ext = option?.backupExts?.[type] + const auth = prepareAuth(option) const coordinator: timer.backup.Coordinator = type && this.coordinators[type] if (!coordinator) { diff --git a/src/common/backup/web-dav/coordinator.ts b/src/common/backup/web-dav/coordinator.ts new file mode 100644 index 00000000..f85ad316 --- /dev/null +++ b/src/common/backup/web-dav/coordinator.ts @@ -0,0 +1,118 @@ +import { deleteDir, judgeDirExist, makeDir, readFile, WebDAVAuth, WebDAVContext, writeFile } from "@api/web-dav" +import DateIterator from "@util/date-iterator" +import { processDir } from "../common" +import { CLIENT_FILE_NAME, convertClients2Markdown, divideByDate, parseData } from "../markdown" + +function prepareContext(context: timer.backup.CoordinatorContext): WebDAVContext { + const { auth, ext } = context + let { endpoint } = ext || {} + const webDavAuth: WebDAVAuth = { + type: "password", + username: auth?.login?.acc, + password: auth?.login?.psw, + } + return { auth: webDavAuth, endpoint } +} + +export default class WebDAVCoordinator implements timer.backup.Coordinator { + async updateClients(context: timer.backup.CoordinatorContext, clients: timer.backup.Client[]): Promise { + const dirPath = processDir(context?.ext?.dirPath) + const clientFilePath = `${dirPath}${CLIENT_FILE_NAME}` + const content = convertClients2Markdown(clients) + const davContext = prepareContext(context) + await writeFile(davContext, clientFilePath, content) + } + + async listAllClients(context: timer.backup.CoordinatorContext): Promise { + const dirPath = processDir(context?.ext?.dirPath) + const clientFilePath = `${dirPath}${CLIENT_FILE_NAME}` + const davContext = prepareContext(context) + try { + const content = await readFile(davContext, clientFilePath) + return parseData(content) || [] + } catch (e) { + console.warn("Failed to read WebDav file content", e) + return [] + } + } + + async download(context: timer.backup.CoordinatorContext, dateStart: Date, dateEnd: Date, targetCid?: string): Promise { + const dirPath = processDir(context?.ext?.dirPath) + const davContext = prepareContext(context) + targetCid = targetCid || context?.cid + + const dateIterator = new DateIterator(dateStart, dateEnd) + const result: timer.stat.RowBase[] = [] + await Promise.all(dateIterator.toArray().map(async date => { + const filePath = `${dirPath}${targetCid}/${date}.md` + const fileContent = await readFile(davContext, filePath) + const rows: timer.stat.RowBase[] = parseData(fileContent) + rows?.forEach?.(row => result.push(row)) + })) + return result + } + + async upload(context: timer.backup.CoordinatorContext, rows: timer.stat.RowBase[]): Promise { + const dateAndContents = divideByDate(rows) + const dirPath = processDir(context?.ext?.dirPath) + const cid = context?.cid + const davContext = prepareContext(context) + + const clientPath = await this.checkClientDirExist(davContext, dirPath, cid) + + await Promise.all( + Object.entries(dateAndContents).map(async ([date, content]) => { + const filePath = `${clientPath}/${date}.md` + await writeFile(davContext, filePath, content) + }) + ) + } + + private async checkClientDirExist(davContext: WebDAVContext, dirPath: string, cid: string) { + const clientDirPath = `${dirPath}${cid}` + const clientExist = await judgeDirExist(davContext, clientDirPath) + if (!clientExist) { + await makeDir(davContext, clientDirPath) + } + return clientDirPath + } + + async testAuth(auth: timer.backup.Auth, ext: timer.backup.TypeExt): Promise { + const { endpoint, dirPath } = ext || {} + if (!endpoint) { + return "The endpoint is blank" + } + if (!dirPath) { + return "The path of directory is blank" + } + const { acc, psw } = auth?.login || {} + if (!acc) { + return 'Account is blank' + } + if (!psw) { + return 'Password is blank' + } + const davAuth: WebDAVAuth = { type: 'password', username: acc, password: psw } + const davContext: WebDAVContext = { endpoint, auth: davAuth } + const webDavPath = processDir(dirPath) + try { + const exist = await judgeDirExist(davContext, webDavPath) + if (!exist) { + return "Directory not found" + } + } catch (e) { + return (e as Error)?.message || e + } + } + + async clear(context: timer.backup.CoordinatorContext, client: timer.backup.Client): Promise { + const cid = client.id + const dirPath = processDir(context.ext?.dirPath) + const davContext = prepareContext(context) + const clientDirPath = `${dirPath}${cid}/` + const exist = await judgeDirExist(davContext, clientDirPath) + + if (!exist) return + await deleteDir(davContext, clientDirPath) + } +} \ No newline at end of file diff --git a/src/database/option-database.ts b/src/database/option-database.ts index 39f08c44..790473e5 100644 --- a/src/database/option-database.ts +++ b/src/database/option-database.ts @@ -44,7 +44,7 @@ class OptionDatabase extends BaseDatabase { _areaName: "sync" | "local" | "managed" ) => { const optionInfo = changes[DB_KEY] - optionInfo && listener(optionInfo.newValue as timer.option.AllOption) + optionInfo && listener(optionInfo.newValue || {} as timer.option.AllOption) } chrome.storage.onChanged.addListener(storageListener) } diff --git a/src/hooks/useRequest.ts b/src/hooks/useRequest.ts index a01a4be5..0762e9a1 100644 --- a/src/hooks/useRequest.ts +++ b/src/hooks/useRequest.ts @@ -8,6 +8,8 @@ type Option = { loadingText?: string defaultParam?: P deps?: WatchSource | WatchSource[] + onSuccess?: (result: T) => void, + onError?: (e: unknown) => void } type Result = { @@ -18,7 +20,13 @@ type Result = { } export const useRequest = (getter: (p?: P) => Promise | T, option?: Option): Result => { - const { manual = false, defaultValue, defaultParam, loadingTarget, loadingText, deps } = option || {} + const { + manual = false, + defaultValue, defaultParam, + loadingTarget, loadingText, + deps, + onSuccess, onError, + } = option || {} const data: Ref = ref(defaultValue) as Ref const loading = ref(false) @@ -29,9 +37,12 @@ export const useRequest = (getter: (p?: P) => Promise | T, option?: Opt try { const value = await getter?.(p) data.value = value - loadingInstance?.close?.() + onSuccess?.(value) + } catch (e) { + onError?.(e) } finally { loading.value = false + loadingInstance?.close?.() } } const refresh = (p?: P) => { refreshAsync(p) } diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index b1325428..23f3bbc5 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -86,10 +86,15 @@ "authInfo": "需要创建一个至少包含 gist 权限的 token" }, "obsidian_local_rest_api": { - "endpointLabel": "服务地址 {info} {input}", - "endpointInfo": "因为无法为浏览器插件配置跨域,所以只能使用 HTTP 协议", - "pathLabel": "文件夹路径 {input}" - } + "endpointInfo": "因为无法为浏览器插件配置跨域,所以只能使用 HTTP 协议" + }, + "web_dav": {} + }, + "label": { + "endpoint": "服务地址 {info} {input}", + "path": "文件夹路径 {input}", + "account": "账号 {input}", + "password": "密码 {input}" }, "alert": "这是一项实验性功能,如果有任何问题请联系作者~ ({email})", "lastTimeTip": "上次备份时间: {lastTime}", @@ -206,11 +211,13 @@ "authInfo": "需要創建一個至少包含 gist 權限的 token" }, "obsidian_local_rest_api": { - "endpointLabel": "服務地址 {info} {input}", - "endpointInfo": "僅 HTTP 可用,因為無法為擴展頁面配置 CORS", - "pathLabel": "目錄的路徑 {input}" + "endpointInfo": "僅 HTTP 可用,因為無法為擴展頁面配置 CORS" } }, + "label": { + "endpoint": "服務地址 {info} {input}", + "path": "目錄的路徑 {input}" + }, "alert": "這是一項實驗性功能,如果有任何問題請聯繫作者 ({email}) ~", "operation": "備份數據", "lastTimeTip": "上次備份時間: {lastTime}", @@ -326,10 +333,15 @@ "authInfo": "One token with at least gist permission is required" }, "obsidian_local_rest_api": { - "endpointLabel": "Endpoint address {info} {input}", - "endpointInfo": "Only HTTP is available because it is not possible to configure CORS for extensions pages", - "pathLabel": "The path of directory {input}" - } + "endpointInfo": "Only HTTP is available because it is not possible to configure CORS for extensions pages" + }, + "web_dav": {} + }, + "label": { + "endpoint": "Endpoint address {info} {input}", + "path": "The path of directory {input}", + "account": "Username {input}", + "password": "Password {input}" }, "alert": "This is an experimental feature, if you have any questions please contact the author via {email}~", "operation": "Backup", @@ -446,11 +458,13 @@ "authInfo": "少なくとも gist 権限を持つトークンが 1 つ必要です" }, "obsidian_local_rest_api": { - "endpointLabel": "エンドポイントアドレス {info} {input}", - "endpointInfo": "拡張機能のページに CORS を設定できないため、HTTP のみが利用できます。", - "pathLabel": "ディレクトリのパス {input}" + "endpointInfo": "拡張機能のページに CORS を設定できないため、HTTP のみが利用できます。" } }, + "label": { + "endpoint": "エンドポイントアドレス {info} {input}", + "path": "ディレクトリのパス {input}" + }, "alert": "これは実験的な機能です。質問がある場合は、作成者に連絡してください ({email})", "operation": "バックアップ", "lastTimeTip": "前回のバックアップ時間: {lastTime}", @@ -569,11 +583,13 @@ "authInfo": "É necessário um token com pelo menos gist permissão" }, "obsidian_local_rest_api": { - "endpointLabel": "Endereço Intendente {info} {input}", - "endpointInfo": "Somente HTTP está disponível porque não é possível configurar CORS para páginas de extensões", - "pathLabel": "O caminho do diretório {input}" + "endpointInfo": "Somente HTTP está disponível porque não é possível configurar CORS para páginas de extensões" } }, + "label": { + "endpoint": "Endereço Intendente {info} {input}", + "path": "O caminho do diretório {input}" + }, "operation": "Backup", "auto": { "label": "Se deseja ativar o backup automático", @@ -668,11 +684,13 @@ "authInfo": "Потрібно вказати токен для доступу gist" }, "obsidian_local_rest_api": { - "endpointLabel": "Адреса кінцевої точки {info} {input}", - "endpointInfo": "Підтримується лише HTTP-доступ, оскільки для сторінок розширень неможливо налаштувати CORS", - "pathLabel": "Шлях каталогу {input}" + "endpointInfo": "Підтримується лише HTTP-доступ, оскільки для сторінок розширень неможливо налаштувати CORS" } }, + "label": { + "endpoint": "Адреса кінцевої точки {info} {input}", + "path": "Шлях каталогу {input}" + }, "operation": "Зробити резервну копію", "auto": { "label": "Увімкнути автоматичне резервне копіювання", @@ -790,11 +808,13 @@ "authInfo": "Se requiere un token con al menos los permisos esenciales" }, "obsidian_local_rest_api": { - "endpointLabel": "Dirección de punto final {info} {input}", - "endpointInfo": "Solo HTTP está disponible porque no es posible configurar CORS para las páginas de extensiones", - "pathLabel": "La ruta del directorio {input}" + "endpointInfo": "Solo HTTP está disponible porque no es posible configurar CORS para las páginas de extensiones" } }, + "label": { + "endpoint": "Dirección de punto final {info} {input}", + "path": "La ruta del directorio {input}" + }, "operation": "Copia de seguridad", "auto": { "label": "Activar la copia de seguridad automática", @@ -910,11 +930,13 @@ "authInfo": "Ein Token mit mindestens gist Berechtigung ist erforderlich" }, "obsidian_local_rest_api": { - "endpointLabel": "Endpunkt Adresse {info} {input}", - "endpointInfo": "Es ist nur HTTP verfügbar, da CORS nicht für Erweiterungsseiten konfiguriert werden kann", - "pathLabel": "Der Pfad des Verzeichnisses {input}" + "endpointInfo": "Es ist nur HTTP verfügbar, da CORS nicht für Erweiterungsseiten konfiguriert werden kann" } }, + "label": { + "endpoint": "Endpunkt Adresse {info} {input}", + "path": "Der Pfad des Verzeichnisses {input}" + }, "operation": "Backup", "auto": { "label": "Automatische Sicherung aktivieren", @@ -1030,11 +1052,13 @@ "authInfo": "Un jeton avec au moins une permission de gist est requis" }, "obsidian_local_rest_api": { - "endpointLabel": "Adresse de fin {info} {input}", - "endpointInfo": "Seul HTTP est disponible car il n'est pas possible de configurer CORS pour les pages d'extensions", - "pathLabel": "Le chemin du répertoire {input}" + "endpointInfo": "Seul HTTP est disponible car il n'est pas possible de configurer CORS pour les pages d'extensions" } }, + "label": { + "endpoint": "Adresse de fin {info} {input}", + "path": "Le chemin du répertoire {input}" + }, "operation": "Sauvegarder", "auto": { "label": "Activer la sauvegarde automatique", diff --git a/src/i18n/message/app/option.ts b/src/i18n/message/app/option.ts index 7ddf82d3..6f5e7308 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -81,12 +81,16 @@ export type OptionMessage = { authInfo?: string } } & { - [type in Extract]: { - endpointLabel: string + [type in Extract]: { endpointInfo: string - pathLabel: string } } + label: { + endpoint: string + path: string + account: string + password: string + }, alert: string operation: string clientTable: { diff --git a/src/util/constant/option.ts b/src/util/constant/option.ts index 354d5fa8..dcca4ec3 100644 --- a/src/util/constant/option.ts +++ b/src/util/constant/option.ts @@ -57,6 +57,7 @@ export function defaultBackup(): timer.option.BackupOption { backupType: 'none', clientName: 'unknown', backupAuths: {}, + backupLogin: {}, backupExts: {}, autoBackUp: false, autoBackUpInterval: 30, diff --git a/test/background/backup/gist/compressor.test.ts b/test/background/backup/gist/compressor.test.ts index 5c566c20..891a93c1 100644 --- a/test/background/backup/gist/compressor.test.ts +++ b/test/background/backup/gist/compressor.test.ts @@ -1,4 +1,4 @@ -import { divide2Buckets, gistData2Rows } from "@src/common/backup/gist/compressor" +import { divide2Buckets, GistData, gistData2Rows } from "@src/common/backup/gist/compressor" test('divide 1', () => { const rows: timer.stat.Row[] = [{ diff --git a/types/timer/backup.d.ts b/types/timer/backup.d.ts index 8c430268..66e3b4e8 100644 --- a/types/timer/backup.d.ts +++ b/types/timer/backup.d.ts @@ -10,9 +10,20 @@ declare namespace timer.backup { maxDate?: string } + type LoginInfo = { + acc?: string + psw?: string + } + + type Auth = { + token?: string + login?: LoginInfo + } + interface CoordinatorContext { cid: string - auth: string + auth?: Auth + login?: LoginInfo ext?: TypeExt cache: Cache handleCacheChanged: () => Promise @@ -46,7 +57,7 @@ declare namespace timer.backup { * * @returns errorMsg or null/undefined */ - testAuth(auth: string, ext: timer.backup.TypeExt): Promise + testAuth(auth: Auth, ext: timer.backup.TypeExt): Promise /** * Clear data */ @@ -59,6 +70,12 @@ declare namespace timer.backup { // Sync into Obsidian via its plugin Local REST API // @since 1.9.4 | 'obsidian_local_rest_api' + // @since 2.4.5 + | 'web_dav' + + type AuthType = + | 'token' + | 'password' type TypeExt = { /** diff --git a/types/timer/option.d.ts b/types/timer/option.d.ts index 2400902d..1ecd4bca 100644 --- a/types/timer/option.d.ts +++ b/types/timer/option.d.ts @@ -154,6 +154,10 @@ declare namespace timer.option { * The auth of types, maybe ak/sk or static token */ backupAuths: { [type in backup.Type]?: string } + /** + * Login info of types + */ + backupLogin: { [type in backup.Type]?: backup.LoginInfo } /** * The extended information of types, including url, file path, and so on */