Skip to content

Commit 7898bd2

Browse files
committed
Support WebDAV to backup data (sheepzh#257, sheepzh#314)
1 parent d328f8d commit 7898bd2

File tree

31 files changed

+754
-325
lines changed

31 files changed

+754
-325
lines changed

.vscode/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121
"filemanager",
2222
"Hengyang",
2323
"Kanban",
24+
"MKCOL",
2425
"Openeds",
2526
"Popconfirm",
27+
"PROPFIND",
2628
"Qihu",
2729
"sheepzh",
2830
"vueuse",

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"countup.js": "^2.8.0",
6969
"echarts": "^5.5.1",
7070
"element-plus": "2.8.1",
71+
"js-base64": "^3.7.7",
7172
"punycode": "^2.3.1",
7273
"stream-browserify": "^3.0.0",
7374
"vue": "^3.4.38",
@@ -76,4 +77,4 @@
7677
"engines": {
7778
"node": ">=20"
7879
}
79-
}
80+
}

src/api/web-dav.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* WebDAV client api
3+
*
4+
* Testing with server implemented by https://github.com/svtslv/webdav-cli
5+
*/
6+
import { encode } from 'js-base64'
7+
import { fetchDelete, fetchGet } from './http'
8+
9+
// Only support password for now
10+
export type WebDAVAuth = {
11+
type: 'password'
12+
username: string
13+
password: string
14+
}
15+
16+
export type WebDAVContext = {
17+
auth: WebDAVAuth
18+
endpoint: string
19+
}
20+
21+
const authHeaders = (auth: WebDAVAuth): Headers => {
22+
const type = auth?.type
23+
let headerVal = null
24+
if (type === 'password') {
25+
headerVal = `Basic ${encode(`${auth?.username}:${auth?.password}`)}`
26+
}
27+
const headers = new Headers()
28+
headers.set('Authorization', headerVal)
29+
return headers
30+
}
31+
32+
export async function judgeDirExist(context: WebDAVContext, dirPath: string): Promise<boolean> {
33+
const { auth, endpoint } = context || {}
34+
const headers = authHeaders(auth)
35+
const url = `${endpoint}/${dirPath}`
36+
const method = 'PROPFIND'
37+
headers.append('Accept', 'text/plain,application/xml')
38+
headers.append('Depth', '1')
39+
const response = await fetch(url, { method, headers })
40+
const status = response?.status
41+
if (status == 207) {
42+
return true
43+
} else if (status === 300) {
44+
throw new Error("Your server does't support PROPFIND method!")
45+
} else if (status == 404) {
46+
return false
47+
} else if (status == 401) {
48+
throw new Error("Authorization is invalid!")
49+
} else {
50+
throw new Error("Unknown directory status")
51+
}
52+
}
53+
54+
export async function makeDir(context: WebDAVContext, dirPath: string) {
55+
const { auth, endpoint } = context || {}
56+
const url = `${endpoint}/${dirPath}`
57+
const headers = authHeaders(auth)
58+
const response = await fetch(url, { method: 'MKCOL', headers })
59+
handleWriteResponse(response)
60+
}
61+
62+
export async function deleteDir(context: WebDAVContext, dirPath: string) {
63+
const { auth, endpoint } = context || {}
64+
const url = `${endpoint}/${dirPath}`
65+
const headers = authHeaders(auth)
66+
const response = await fetchDelete(url, { headers })
67+
const status = response.status
68+
if (status === 403) {
69+
throw new Error("Unauthorized to delete directory")
70+
}
71+
if (status !== 201 && status !== 200) {
72+
throw new Error("Failed to delete directory: " + status)
73+
}
74+
}
75+
76+
export async function writeFile(context: WebDAVContext, filePath: string, content: string): Promise<void> {
77+
const { auth, endpoint } = context || {}
78+
const headers = authHeaders(auth)
79+
headers.set("Content-Type", "application/octet-stream")
80+
const url = `${endpoint}/${filePath}`
81+
const response = await fetch(url, { headers, method: 'put', body: content })
82+
handleWriteResponse(response)
83+
}
84+
85+
function handleWriteResponse(response: Response) {
86+
const status = response.status
87+
if (status === 403) {
88+
throw new Error("Unauthorized to write file or create directory")
89+
}
90+
if (status !== 201 && status !== 200) {
91+
throw new Error("Failed to write file or create directory: " + status)
92+
}
93+
}
94+
95+
export async function readFile(context: WebDAVContext, filePath: string): Promise<string> {
96+
const { auth, endpoint } = context || {}
97+
const headers = authHeaders(auth)
98+
const url = `${endpoint}/${filePath}`
99+
try {
100+
const response = await fetchGet(url, { headers })
101+
const status = response?.status
102+
if (status === 200) {
103+
return response.text()
104+
}
105+
if (status !== 404) {
106+
console.warn("Unexpected status: " + status)
107+
}
108+
return null
109+
} catch (e) {
110+
console.error("Failed to read WebDAV content", e)
111+
return null
112+
}
113+
}

src/app/components/Option/components/BackupOption/AutoInput.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
* https://opensource.org/licenses/MIT
66
*/
77

8-
import { t } from "@app/locale"
9-
import { ElInputNumber, ElSwitch } from "element-plus"
10-
import { defineComponent, watch } from "vue"
11-
import localeMessages from "@i18n/message/common/locale"
128
import I18nNode from "@app/components/common/I18nNode"
9+
import { t } from "@app/locale"
1310
import { useShadow } from "@hooks"
1411
import { locale } from "@i18n"
12+
import localeMessages from "@i18n/message/common/locale"
13+
import { ElInputNumber, ElSwitch } from "element-plus"
14+
import { defineComponent, watch } from "vue"
1515

1616
const _default = defineComponent({
1717
props: {
@@ -28,7 +28,7 @@ const _default = defineComponent({
2828
return () => <>
2929
<ElSwitch modelValue={autoBackUp.value} onChange={setAutoBackUp} />
3030
{' ' + t(msg => msg.option.backup.auto.label)}
31-
<div v-show={autoBackUp.value}>
31+
{!!autoBackUp.value && <>
3232
{localeMessages[locale].comma || ' '}
3333
<I18nNode
3434
path={msg => msg.option.backup.auto.interval}
@@ -41,8 +41,7 @@ const _default = defineComponent({
4141
/>
4242
}}
4343
/>
44-
</div>
45-
<span v-show={autoBackUp.value}>{localeMessages[locale].comma || ' '}</span>
44+
</>}
4645
</>
4746
},
4847
})

src/app/components/Option/components/BackupOption/Clear/index.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ const _default = defineComponent({
2121
<ElButton
2222
type="danger"
2323
icon={<Delete />}
24-
style={{ marginRight: "12px" }}
2524
onClick={() => dialogVisible.value = true}
2625
>
2726
{t(msg => msg.option.backup.clear.btn)}

src/app/components/Option/components/BackupOption/ClientTable.tsx

Lines changed: 41 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77

88
import { t } from "@app/locale"
99
import { cvt2LocaleTime } from "@app/util/time"
10+
import { Loading, RefreshRight } from "@element-plus/icons-vue"
11+
import { useRequest } from "@hooks"
1012
import metaService from "@service/meta-service"
1113
import processor from "@src/common/backup/processor"
1214
import { ElTableRowScope } from "@src/element-ui/table"
13-
import { ElMessage, ElRadio, ElTable, ElTableColumn, ElTag } from "element-plus"
14-
import { defineComponent, ref, Ref, onMounted } from "vue"
15+
import { ElLink, ElMessage, ElRadio, ElTable, ElTableColumn, ElTag } from "element-plus"
16+
import { defineComponent, ref } from "vue"
1517

1618
const formatTime = (value: timer.backup.Client): string => {
1719
const { minDate, maxDate } = value || {}
@@ -25,52 +27,56 @@ const _default = defineComponent({
2527
select: (_: timer.backup.Client) => true,
2628
},
2729
setup(_, ctx) {
28-
const list: Ref<timer.backup.Client[]> = ref([])
29-
const loading: Ref<boolean> = ref(false)
30-
const selectedCid: Ref<string> = ref()
31-
const localCid: Ref<string> = ref()
32-
33-
onMounted(() => {
34-
loading.value = true
35-
processor.listClients()
36-
.then(res => {
37-
if (res.success) {
38-
list.value = res.data
39-
} else {
40-
throw res.errorMsg
41-
}
42-
})
43-
.catch(e => ElMessage.error(typeof e === 'string' ? e : (e as Error).message || 'Unknown error...'))
44-
.finally(() => loading.value = false)
45-
metaService.getCid().then(cid => localCid.value = cid)
30+
const { data: list, loading, refresh } = useRequest(async () => {
31+
const { success, data, errorMsg } = await processor.listClients() || {}
32+
if (!success) {
33+
throw new Error(errorMsg)
34+
}
35+
return data
36+
}, {
37+
defaultValue: [],
38+
onError: e => ElMessage.error(typeof e === 'string' ? e : (e as Error).message || 'Unknown error...')
4639
})
4740

41+
const { data: localCid } = useRequest(() => metaService.getCid())
42+
43+
const selectedCid = ref<string>()
4844
const handleRowSelect = (row: timer.backup.Client) => {
4945
selectedCid.value = row.id
5046
ctx.emit("select", row)
5147
}
5248

5349
return () => (
54-
<ElTable data={list.value}
50+
<ElTable
51+
data={list.value}
5552
border
5653
maxHeight="40vh"
5754
class="backup-client-table"
5855
highlightCurrentRow
5956
onCurrent-change={(row: timer.backup.Client) => handleRowSelect(row)}
6057
emptyText={loading.value ? 'Loading data ...' : 'Empty data'}
6158
>
62-
<ElTableColumn align="center" width={50}>
63-
{
64-
({ row }: ElTableRowScope<timer.backup.Client>) => (
59+
<ElTableColumn
60+
align="center"
61+
width={50}
62+
v-slots={{
63+
header: () => (
64+
<ElLink
65+
icon={loading.value ? <Loading /> : <RefreshRight />}
66+
onClick={refresh}
67+
type="primary"
68+
underline={false}
69+
/>
70+
),
71+
default: ({ row }: ElTableRowScope<timer.backup.Client>) => (
6572
<ElRadio
66-
label={row.id}
73+
value={row.id}
6774
modelValue={selectedCid.value}
6875
onChange={() => handleRowSelect(row)}
69-
v-slots={() => ''}
7076
/>
71-
)
72-
}
73-
</ElTableColumn>
77+
),
78+
}}
79+
/>
7480
<ElTableColumn
7581
label="CID"
7682
align="center"
@@ -83,14 +89,12 @@ const _default = defineComponent({
8389
align="center"
8490
headerAlign="center"
8591
>
86-
{
87-
({ row: client }: ElTableRowScope<timer.backup.Client>) => <>
88-
{client.name || '-'}
89-
<ElTag v-show={localCid.value === client?.id} size="small" type="danger">
90-
{t(msg => msg.option.backup.clientTable.current)}
91-
</ElTag>
92-
</>
93-
}
92+
{({ row: client }: ElTableRowScope<timer.backup.Client>) => <>
93+
{client.name || '-'}
94+
<ElTag v-show={localCid.value === client?.id} size="small" type="danger">
95+
{t(msg => msg.option.backup.clientTable.current)}
96+
</ElTag>
97+
</>}
9498
</ElTableColumn>
9599
<ElTableColumn
96100
label={t(msg => msg.option.backup.clientTable.dataRange)}

src/app/components/Option/components/BackupOption/Download/Step2.tsx

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Back, Check } from "@element-plus/icons-vue"
1212
import { processImportedData } from "@service/components/import-processor"
1313
import { renderResolutionFormItem } from "@app/components/common/imported/conflict"
1414
import CompareTable from "@app/components/common/imported/CompareTable"
15+
import { useRequest } from "@hooks/useRequest"
1516

1617
const _default = defineComponent({
1718
props: {
@@ -27,23 +28,21 @@ const _default = defineComponent({
2728
},
2829
setup(props, ctx) {
2930
const resolution: Ref<timer.imported.ConflictResolution> = ref()
30-
const downloading: Ref<boolean> = ref(false)
3131

32-
const handleDownload = () => {
32+
const { refresh: handleDownload, loading: downloading } = useRequest(async () => {
3333
const resolutionVal = resolution.value
3434
if (!resolutionVal) {
3535
ElMessage.warning(t(msg => msg.dataManage.importOther.conflictNotSelected))
3636
return
3737
}
38-
downloading.value = true
39-
processImportedData(props.data, resolutionVal)
40-
.then(() => {
41-
ElMessage.success(t(msg => msg.operation.successMsg))
42-
ctx.emit('download')
43-
})
44-
.catch()
45-
.finally(() => downloading.value = false)
46-
}
38+
await processImportedData(props.data, resolutionVal)
39+
}, {
40+
manual: true,
41+
onSuccess() {
42+
ElMessage.success(t(msg => msg.operation.successMsg))
43+
ctx.emit('download')
44+
}
45+
})
4746
return () => <>
4847
<ElAlert type="success" closable={false}>
4948
{

src/app/components/Option/components/BackupOption/Download/index.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ const _default = defineComponent({
2121
<ElButton
2222
type="primary"
2323
icon={<Files />}
24-
style={{ marginRight: "12px" }}
2524
onClick={() => dialogVisible.value = true}
2625
>
2726
{t(msg => msg.option.backup.download.btn)}

0 commit comments

Comments
 (0)