-
-
Notifications
You must be signed in to change notification settings - Fork 713
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: option to export watch history database
- Loading branch information
Showing
3 changed files
with
261 additions
and
24 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,142 @@ | ||
<template> | ||
<ModalComponent> | ||
<div class="min-w-max flex flex-col"> | ||
<h2 class="mb-4 text-center text-xl font-bold">Export History</h2> | ||
<form> | ||
<div> | ||
<label class="mr-2" for="export-format">Export as:</label> | ||
<select id="export-format" v-model="exportAs" class="select"> | ||
<option | ||
v-for="option in exportOptions" | ||
:key="option" | ||
:value="option" | ||
v-text="formatField(option)" | ||
/> | ||
</select> | ||
</div> | ||
<div v-if="exportAs === 'history'"> | ||
<label v-for="field in fields" :key="field" class="flex items-center gap-2"> | ||
<input | ||
v-model="selectedFields" | ||
class="checkbox" | ||
type="checkbox" | ||
:value="field" | ||
:disabled="field === 'videoId'" | ||
/> | ||
<span v-text="formatField(field)" /> | ||
</label> | ||
</div> | ||
</form> | ||
<button class="btn mt-4" @click="handleExport">Export</button> | ||
</div> | ||
</ModalComponent> | ||
</template> | ||
|
||
<script> | ||
import ModalComponent from "./ModalComponent.vue"; | ||
export default { | ||
components: { | ||
ModalComponent, | ||
}, | ||
data() { | ||
return { | ||
exportOptions: ["playlist", "history"], | ||
exportAs: "playlist", | ||
fields: [ | ||
"videoId", | ||
"title", | ||
"uploaderName", | ||
"uploaderUrl", | ||
"duration", | ||
"thumbnail", | ||
"watchedAt", | ||
"currentTime", | ||
], | ||
selectedFields: [ | ||
"videoId", | ||
"title", | ||
"uploaderName", | ||
"uploaderUrl", | ||
"duration", | ||
"thumbnail", | ||
"watchedAt", | ||
"currentTime", | ||
], | ||
}; | ||
}, | ||
methods: { | ||
async fetchAllVideos() { | ||
if (window.db) { | ||
var tx = window.db.transaction("watch_history", "readonly"); | ||
var store = tx.objectStore("watch_history"); | ||
const request = store.getAll(); | ||
return new Promise((resolve, reject) => { | ||
(request.onsuccess = e => { | ||
const videos = e.target.result; | ||
this.exportVideos = videos; | ||
resolve(); | ||
}), | ||
(request.onerror = e => { | ||
reject(e); | ||
}); | ||
}); | ||
} | ||
}, | ||
handleExport() { | ||
if (this.exportAs === "playlist") { | ||
this.fetchAllVideos() | ||
.then(() => { | ||
this.exportAsPlaylist(); | ||
}) | ||
.catch(e => { | ||
console.error(e); | ||
}); | ||
} else if (this.exportAs === "history") { | ||
this.fetchAllVideos() | ||
.then(() => { | ||
this.exportAsHistory(); | ||
}) | ||
.catch(e => { | ||
console.error(e); | ||
}); | ||
} | ||
}, | ||
exportAsPlaylist() { | ||
const dateStr = new Date().toISOString().split(".")[0]; | ||
let json = { | ||
format: "Piped", | ||
version: 1, | ||
playlists: [ | ||
{ | ||
name: `Piped History ${dateStr}`, | ||
type: "history", | ||
visibility: "private", | ||
videos: this.exportVideos.map(video => "https://youtube.com" + video.url), | ||
}, | ||
], | ||
}; | ||
this.download(JSON.stringify(json), `piped_history_${dateStr}.json`, "application/json"); | ||
}, | ||
exportAsHistory() { | ||
const dateStr = new Date().toISOString().split(".")[0]; | ||
let json = { | ||
format: "Piped", | ||
version: 1, | ||
watchHistory: this.exportVideos.map(video => { | ||
let obj = {}; | ||
this.selectedFields.forEach(field => { | ||
obj[field] = video[field]; | ||
}); | ||
return obj; | ||
}), | ||
}; | ||
this.download(JSON.stringify(json), `piped_history_${dateStr}.json`, "application/json"); | ||
}, | ||
formatField(field) { | ||
// camelCase to Title Case | ||
return field.replace(/([A-Z])/g, " $1").replace(/^./, str => str.toUpperCase()); | ||
}, | ||
}, | ||
}; | ||
</script> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
<template> | ||
<ModalComponent> | ||
<div class="text-center"> | ||
<h2 class="mb-4 text-center text-xl font-bold">Import History</h2> | ||
<form> | ||
<br /> | ||
<div> | ||
<input ref="fileSelector" class="btn mb-2 ml-2" type="file" @change="fileChange" /> | ||
</div> | ||
<div> | ||
<strong v-text="`Found ${itemsLength} items`" /> | ||
</div> | ||
<div> | ||
<strong class="flex items-center justify-center gap-2"> | ||
Override: <input v-model="override" class="checkbox" type="checkbox" /> | ||
</strong> | ||
</div> | ||
<br /> | ||
<div> | ||
<progress :value="index" :max="itemsLength" /> | ||
<div v-text="`Success: ${success} Error: ${error} Skipped: ${skipped}`" /> | ||
</div> | ||
<br /> | ||
<div> | ||
<a class="btn w-auto" @click="handleImport">Import</a> | ||
</div> | ||
</form> | ||
</div> | ||
</ModalComponent> | ||
</template> | ||
<script> | ||
import ModalComponent from "./ModalComponent.vue"; | ||
export default { | ||
components: { ModalComponent }, | ||
data() { | ||
return { | ||
items: [], | ||
override: false, | ||
index: 0, | ||
success: 0, | ||
error: 0, | ||
skipped: 0, | ||
}; | ||
}, | ||
computed: { | ||
itemsLength() { | ||
return this.items.length; | ||
}, | ||
}, | ||
methods: { | ||
fileChange() { | ||
const file = this.$refs.fileSelector.files[0]; | ||
file.text().then(text => { | ||
this.items = []; | ||
const json = JSON.parse(text); | ||
const items = json.watchHistory.map(video => { | ||
return { | ||
...video, | ||
watchedAt: video.watchedAt ?? 0, | ||
currentTime: video.currentTime ?? 0, | ||
}; | ||
}); | ||
this.items = items.sort((a, b) => b.watchedAt - a.watchedAt); | ||
}); | ||
}, | ||
handleImport() { | ||
if (window.db) { | ||
var tx = window.db.transaction("watch_history", "readwrite"); | ||
var store = tx.objectStore("watch_history"); | ||
this.items.forEach(item => { | ||
const dbItem = store.get(item.videoId); | ||
dbItem.onsuccess = () => { | ||
if (dbItem.result && dbItem.result.videoId === item.videoId) { | ||
if (!this.override) { | ||
this.index++; | ||
this.skipped++; | ||
return; | ||
} | ||
} | ||
try { | ||
const request = store.put(JSON.parse(JSON.stringify(item))); // prevent "Symbol could not be cloned." error | ||
request.onsuccess = () => { | ||
this.index++; | ||
this.success++; | ||
}; | ||
request.onerror = () => { | ||
this.index++; | ||
this.error++; | ||
}; | ||
} catch (error) { | ||
console.error(error); | ||
this.index++; | ||
this.error++; | ||
} | ||
}; | ||
}); | ||
} | ||
}, | ||
}, | ||
}; | ||
</script> |