Skip to content

Commit 99e6a99

Browse files
committed
feat(export): add support for data exporting
1 parent f0a45ae commit 99e6a99

16 files changed

+530
-46
lines changed

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"analyse": "vite-bundle-visualizer",
4343
"test:unit": "vitest run --coverage --mode testing",
4444
"test:watch": "vitest --mode testing",
45-
"typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
45+
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
4646
"lint": "eslint .",
4747
"lint:fix": "pnpm run lint --fix",
4848
"style": "stylelint **/*.{vue,css,scss,less,html} --go '{\"gitignore\":true}'",
@@ -74,6 +74,7 @@
7474
"@types/jsdom": "^21.1.2",
7575
"@types/node": "^20.5.3",
7676
"@types/webextension-polyfill": "^0.10.2",
77+
"@types/wicg-file-system-access": "^2023.10.5",
7778
"@typescript-eslint/eslint-plugin": "^7.0.0",
7879
"@typescript-eslint/parser": "^7.0.0",
7980
"@vitejs/plugin-vue": "^5.0.1",

pnpm-lock.yaml

+8
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/icons/IconDownload.vue

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<template>
2+
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
3+
<g
4+
stroke="currentColor"
5+
stroke-linecap="round"
6+
stroke-linejoin="round"
7+
stroke-width="1.5"
8+
>
9+
<path fill="none" stroke-dasharray="14" stroke-dashoffset="14" d="M6 19h12">
10+
<animate
11+
fill="freeze"
12+
attributeName="stroke-dashoffset"
13+
dur="0.4s"
14+
values="14;0"
15+
/>
16+
</path>
17+
<path fill="currentColor" d="M12 4 h2 v6 h2.5 L12 14.5M12 4 h-2 v6 h-2.5 L12 14.5">
18+
<animate
19+
attributeName="d"
20+
calcMode="linear"
21+
dur="1.5s"
22+
keyTimes="0;0.7;1"
23+
repeatCount="indefinite"
24+
values="M12 4 h2 v6 h2.5 L12 14.5M12 4 h-2 v6 h-2.5 L12 14.5;M12 4 h2 v3 h2.5 L12 11.5M12 4 h-2 v3 h-2.5 L12 11.5;M12 4 h2 v6 h2.5 L12 14.5M12 4 h-2 v6 h-2.5 L12 14.5"
25+
/>
26+
</path>
27+
</g>
28+
</svg>
29+
</template>

src/components/views/panel/MoviePanelButtons.vue

+9-8
Original file line numberDiff line numberDiff line change
@@ -110,17 +110,18 @@ const listLoading = computed(() => {
110110
return listsLoading.value || activeLoading.value;
111111
});
112112
113-
const listOptions = computed(() => {
114-
return lists.value
115-
?.filter(list => [ListType.List, ListType.Watchlist].map(String).includes(list.type))
116-
.map(list => {
117-
return {
113+
const listOptions = computed(
114+
() =>
115+
lists.value
116+
?.filter(list =>
117+
[ListType.List, ListType.Watchlist].map(String).includes(list.type),
118+
)
119+
.map(list => ({
118120
label: list.type === ListType.Watchlist ? i18n(list.name) : list.name,
119121
value: list.id,
120122
icon: getIcon(list),
121-
};
122-
});
123-
});
123+
})),
124+
);
124125
125126
onMounted(() => {
126127
fetchLists();

src/components/views/panel/ShowPanelButtons.vue

+5-6
Original file line numberDiff line numberDiff line change
@@ -186,15 +186,14 @@ const listLoading = computed(() => {
186186
return listsLoading.value || activeLoading.value;
187187
});
188188
189-
const listOptions = computed(() => {
190-
return myLists.value?.map(list => {
191-
return {
189+
const listOptions = computed(
190+
() =>
191+
myLists.value?.map(list => ({
192192
label: list.type === ListType.Watchlist ? i18n(list.name) : list.name,
193193
value: list.id,
194194
icon: getIcon(list),
195-
};
196-
});
197-
});
195+
})),
196+
);
198197
199198
onMounted(() => {
200199
fetchLists();

src/components/views/settings/SettingsComponent.vue

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type Component, onDeactivated, type Ref, ref } from 'vue';
55
66
import SettingsAccount from '~/components/views/settings/SettingsAccount.vue';
77
import SettingsCache from '~/components/views/settings/SettingsCache.vue';
8+
import SettingsExport from '~/components/views/settings/SettingsExport.vue';
89
import SettingsLinks from '~/components/views/settings/SettingsLinks.vue';
910
import SettingsLogs from '~/components/views/settings/SettingsLogs.vue';
1011
import SettingsMenus from '~/components/views/settings/SettingsMenus.vue';
@@ -26,6 +27,7 @@ const sections: Section[] = [
2627
{ title: 'menu__menus', reference: ref(), component: SettingsMenus },
2728
{ title: 'menu__cache', reference: ref(), component: SettingsCache },
2829
{ title: 'menu__logs', reference: ref(), component: SettingsLogs },
30+
{ title: 'menu__export', reference: ref(), component: SettingsExport },
2931
];
3032
3133
const focus = ref();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
<script lang="ts" setup>
2+
import {
3+
TraktApiExtended,
4+
type TraktClientPagination,
5+
} from '@dvcol/trakt-http-client/models';
6+
import { NButton, NIcon, NProgress } from 'naive-ui';
7+
8+
import { reactive, ref } from 'vue';
9+
10+
import type { CancellableWritePromise } from '~/utils/trakt-service.utils';
11+
12+
import IconDownload from '~/components/icons/IconDownload.vue';
13+
import IconLoadingDots from '~/components/icons/IconLoadingDots.vue';
14+
import SettingsFormItem from '~/components/views/settings/SettingsFormItem.vue';
15+
16+
import { NotificationService } from '~/services/notification.service';
17+
import { TraktService } from '~/services/trakt.service';
18+
import {
19+
isDefaultList,
20+
type ListEntity,
21+
ListType,
22+
type ListTypes,
23+
useListsStoreRefs,
24+
} from '~/stores/data/list.store';
25+
import { logger } from '~/stores/settings/log.store';
26+
import { useUserSettingsStoreRefs } from '~/stores/settings/user.store';
27+
import { useI18n } from '~/utils/i18n.utils';
28+
29+
const i18n = useI18n('settings', 'export');
30+
const { user, userSetting } = useUserSettingsStoreRefs();
31+
32+
const { lists } = useListsStoreRefs();
33+
34+
const loading = reactive<Record<string | number, boolean>>({});
35+
const cancelled = reactive<Record<string | number, boolean>>({});
36+
const fetching = reactive<
37+
Record<string | number, CancellableWritePromise<unknown> | undefined>
38+
>({});
39+
const pagination = reactive<
40+
Record<string | number, Partial<TraktClientPagination> | undefined>
41+
>({});
42+
43+
type ExportScope<T extends Promise<unknown> = CancellableWritePromise<unknown>> = {
44+
label: string;
45+
name: string;
46+
click: () => T;
47+
};
48+
const getOnClick = (scope: ExportScope, index: number) => async () => {
49+
loading[index] = true;
50+
51+
try {
52+
if (fetching[index]) {
53+
fetching[index]?.cancel();
54+
cancelled[index] = true;
55+
await fetching[index];
56+
NotificationService.message.warning(`${scope.name} ${i18n('export_aborted')}`);
57+
} else {
58+
fetching[index] = scope.click();
59+
pagination[index] = fetching[index]?.pagination;
60+
await fetching[index];
61+
if (cancelled[index]) {
62+
NotificationService.message.success(`${scope.name} ${i18n('export_success')}`);
63+
}
64+
}
65+
} catch (error) {
66+
logger.error(`Failed to export '${scope.name}'.`, error);
67+
NotificationService.error(`${scope.name} ${i18n('export_failure')}`, error);
68+
} finally {
69+
cancelled[index] = false;
70+
delete fetching[index];
71+
delete pagination[index];
72+
loading[index] = false;
73+
}
74+
};
75+
76+
const fetchData = (type: ListTypes, name: string, entity?: ListEntity) => {
77+
const writer = {
78+
picker: {
79+
suggestedName: [
80+
isDefaultList(type) || type === ListType.History
81+
? i18n(`export_${name}_name`)
82+
: `${i18n(`export_${type}_name`)}_${name}`,
83+
user.value,
84+
new Date().toISOString(),
85+
]
86+
.join('_')
87+
.replace(' ', '_')
88+
.toLowerCase(),
89+
},
90+
separator: ',',
91+
};
92+
93+
switch (type) {
94+
case 'history':
95+
return TraktService.export.history({ writer });
96+
case 'watchlist':
97+
return TraktService.export.watchlist({ writer });
98+
case 'collection':
99+
if (!entity?.scope) throw new Error('Entity scope is required for export.');
100+
return TraktService.export.collection({
101+
payload: {
102+
type: entity.scope,
103+
extended: TraktApiExtended.Full,
104+
},
105+
writer,
106+
});
107+
case 'favorites':
108+
return TraktService.export.favorites({ writer });
109+
case 'list':
110+
if (!entity) throw new Error('List entity is required for export.');
111+
return TraktService.export.list({
112+
payload: {
113+
id: user.value,
114+
list_id: entity.id.toString(),
115+
extended: TraktApiExtended.Full,
116+
pagination: { limit: 1000 },
117+
},
118+
writer,
119+
});
120+
default:
121+
throw new Error(`Unsupported list type: ${type}`);
122+
}
123+
};
124+
125+
const scopes: ExportScope[] = [
126+
{
127+
label: i18n('export_history'),
128+
name: i18n('export_history_name'),
129+
click: () => fetchData(ListType.History, ListType.History),
130+
},
131+
...(lists.value?.map(list => {
132+
const name = isDefaultList(list) ? i18n(list.name, 'list') : list.name;
133+
return {
134+
label: `${i18n('export_list')} ${name}`,
135+
name,
136+
click: () => fetchData(list.type, list.name, list),
137+
};
138+
}) ?? []),
139+
];
140+
141+
const exportScope: ExportScope<Promise<unknown>>[] = scopes.map((item, index) => ({
142+
...item,
143+
click: getOnClick(item, index),
144+
}));
145+
146+
const getProgress = (index: number) => {
147+
if (!pagination[index]?.pageCount) return;
148+
return [
149+
i18n('export_progress_1'),
150+
pagination[index]?.page,
151+
i18n('export_progress_2'),
152+
pagination[index]?.pageCount,
153+
].join(' ');
154+
};
155+
156+
const getProgressPercentage = (index: number) => {
157+
if (!pagination[index]?.pageCount || !pagination[index]?.page) return;
158+
return (pagination[index]!.page! / pagination[index]!.pageCount!) * 100;
159+
};
160+
161+
const container = ref();
162+
</script>
163+
164+
<template>
165+
<div ref="container" class="cache-container">
166+
<!-- Scope export -->
167+
<SettingsFormItem
168+
v-for="({ label, click }, index) in exportScope"
169+
:key="index"
170+
:label="label"
171+
:warning="getProgress(index)"
172+
>
173+
<NButton
174+
class="export-button"
175+
:type="fetching[index] ? 'error' : 'info'"
176+
secondary
177+
:disabled="cancelled[index]"
178+
@click="click"
179+
>
180+
<span>
181+
{{ i18n(fetching[index] ? 'export_cancel' : 'export', 'common', 'button') }}
182+
</span>
183+
<template #icon>
184+
<NIcon :component="loading[index] ? IconLoadingDots : IconDownload" />
185+
</template>
186+
</NButton>
187+
<NProgress
188+
v-if="pagination[index]?.pageCount"
189+
class="export-line"
190+
type="line"
191+
status="warning"
192+
:percentage="getProgressPercentage(index)"
193+
:show-indicator="false"
194+
:theme-overrides="{
195+
railHeight: 'var(--rail-height)',
196+
}"
197+
/>
198+
</SettingsFormItem>
199+
</div>
200+
</template>
201+
202+
<style lang="scss" scoped>
203+
.cache-container {
204+
display: flex;
205+
flex-direction: column;
206+
gap: 1.5rem;
207+
}
208+
209+
.export-button {
210+
i {
211+
margin-left: calc(0% - var(--n-icon-margin));
212+
}
213+
}
214+
215+
.export-line {
216+
--rail-height: 2px;
217+
218+
position: absolute;
219+
bottom: calc(var(--rail-height) * -1);
220+
border-radius: 0;
221+
}
222+
223+
.form-select {
224+
min-width: 10rem;
225+
}
226+
</style>

src/i18n/en/common/button.json

+8
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,13 @@
4646
"common__button__logout": {
4747
"message": "Logout",
4848
"description": "Button to logout."
49+
},
50+
"common__button__export": {
51+
"message": "Export my data",
52+
"description": "Button to export data."
53+
},
54+
"common__button__export_cancel": {
55+
"message": "Cancel export",
56+
"description": "Button to cancel export data."
4957
}
5058
}

0 commit comments

Comments
 (0)