-
-
Notifications
You must be signed in to change notification settings - Fork 274
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(TorrentDetail): add bulk renaming (#1624)
- Loading branch information
Showing
6 changed files
with
436 additions
and
8 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
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,390 @@ | ||
<script setup lang="ts"> | ||
import { useDialog } from '@/composables' | ||
import HistoryField from '@/components/Core/HistoryField.vue' | ||
import { getFileIcon } from '@/constants/vuetorrent' | ||
import { useContentStore } from '@/stores' | ||
import { TreeFolder, TreeNode } from '@/types/vuetorrent' | ||
import { HistoryKey } from '@/constants/vuetorrent' | ||
import { reactive, ref, onMounted, watch, computed } from 'vue' | ||
import { useI18n } from 'vue-i18n' | ||
import { toast } from 'vue3-toastify' | ||
import { VForm } from 'vuetify/components' | ||
import { useDisplay } from 'vuetify/lib/framework.mjs' | ||
const props = defineProps<{ | ||
guid: string | ||
node: TreeFolder | ||
hash: string | ||
}>() | ||
const { isOpened } = useDialog(props.guid) | ||
const { t } = useI18n() | ||
const contentStore = useContentStore() | ||
const inMobile = useDisplay().mobile | ||
const form = ref<VForm>() | ||
const isFormValid = ref(false) | ||
const hasDuplicated = ref(false) | ||
const regexpInput = ref('') | ||
const regexpEl = ref<typeof HistoryField>() | ||
const regexpFlagsInput = ref([]) | ||
const targetInput = ref('') | ||
const targetEl = ref<typeof HistoryField>() | ||
const running = ref(false) | ||
const rules = [(v: string) => !!v] | ||
const headers = computed(() => { | ||
const headers = [ | ||
{ fixed: true, sortable: false, key: 'selected', width: '50px' }, | ||
{ sortable: false, key: 'name' } | ||
] | ||
if (!inMobile.value) { | ||
headers.push({ sortable: false, key: 'targetName' }) | ||
} | ||
return headers | ||
}) | ||
type ItemRow<T extends TreeNode = TreeNode> = Pick<T, 'name' | 'fullName'> & { | ||
indent: number | ||
selected: boolean | ||
show: boolean | ||
folded?: boolean // folder only | ||
indeterminate?: boolean // folder only | ||
parentItem?: ItemRow | ||
duplicated?: boolean | ||
notChanged?: boolean | ||
targetName?: string | ||
targetFullName?: string | ||
node: T | ||
} & (T extends TreeFolder ? { type: 'folder' } : { type: 'file' }) | ||
const items = reactive<ItemRow[]>([]) | ||
const candidateItems = computed(() => items.filter(item => item.type === 'file' && item.selected && item.targetName && item.name !== item.targetName)) | ||
/** parse TreeNode to plain list */ | ||
const parseNode = (node: TreeNode, parentItem: ItemRow | undefined = undefined, indent: number = 0) => { | ||
const item: ItemRow = { | ||
indent, | ||
name: node.name, | ||
fullName: node.fullName, | ||
parentItem, | ||
show: true, | ||
folded: false, | ||
selected: false, | ||
type: node.type, | ||
/** keep the corresponding node, for expand/collapse folder */ | ||
node | ||
} | ||
items.push(item) | ||
if (node.type === 'folder') { | ||
for (const child of node.children) { | ||
parseNode(child, item, indent + 1) | ||
} | ||
} | ||
} | ||
const toggleFolderFolded = (item: ItemRow, folded: boolean) => { | ||
item.folded = folded | ||
;(item as ItemRow<TreeFolder>).node.children.forEach(node => { | ||
const correspondence = items.find(item => item.node.id === node.id)! | ||
correspondence.show = !folded | ||
if (correspondence.type === 'folder') { | ||
if (folded) { | ||
toggleFolderFolded(correspondence, folded) | ||
} | ||
} | ||
}) | ||
} | ||
/** | ||
* @return | ||
* * -1: not selected | ||
* * 0: indeterminate(folder only) | ||
* * 1: selected | ||
*/ | ||
const detectIndeterminate = (node: TreeNode): -1 | 0 | 1 => { | ||
const correspondence = items.find(item => item.node.id === node.id)! | ||
if (node.type === 'folder') { | ||
let selectedLength = 0 | ||
let indeterminateLength = 0 | ||
node.children.forEach(item => { | ||
switch (detectIndeterminate(item)) { | ||
case 1: | ||
selectedLength++ | ||
break | ||
case 0: | ||
indeterminateLength++ | ||
break | ||
} | ||
}) | ||
if (selectedLength === 0 && indeterminateLength === 0) { | ||
correspondence.selected = false | ||
correspondence.indeterminate = false | ||
return -1 | ||
} else if (selectedLength === node.children.length) { | ||
correspondence.selected = true | ||
correspondence.indeterminate = false | ||
return 1 | ||
} else { | ||
correspondence.indeterminate = true | ||
return 0 | ||
} | ||
} else { | ||
correspondence.indeterminate = false | ||
return correspondence.selected ? 1 : -1 | ||
} | ||
} | ||
const folderCheckChange = (item: ItemRow) => { | ||
const fn = (item: ItemRow) => { | ||
;(item as ItemRow<TreeFolder>).node.children.forEach(child => { | ||
const foundRow = items.find(row => row.node.id === child.id) | ||
if (foundRow) { | ||
foundRow.selected = item.selected | ||
if (foundRow.selected) { | ||
// unfold all children when selected | ||
foundRow.show = true | ||
foundRow.folded = false | ||
} | ||
if (foundRow.type === 'folder') { | ||
fn(foundRow as ItemRow<TreeFolder>) | ||
} | ||
} | ||
}) | ||
// unfold when selected | ||
if (item.selected) { | ||
item.show = true | ||
item.folded = false | ||
} | ||
} | ||
fn(item) | ||
detectIndeterminate(props.node) | ||
dryRunRename() | ||
} | ||
const fileCheckChange = (item: ItemRow) => { | ||
detectIndeterminate(props.node) | ||
dryRunRename([item]) | ||
} | ||
const dryRunRename = async (partialItems?: ItemRow[]) => { | ||
await form.value?.validate() | ||
if (!isFormValid.value) { | ||
return | ||
} | ||
let regexp: RegExp | ||
try { | ||
regexp = new RegExp(regexpInput.value, regexpFlagsInput.value.join('')) | ||
} catch { | ||
return | ||
} | ||
;(partialItems ? partialItems : items).forEach(item => { | ||
if (item.type === 'file') { | ||
if (item.selected && regexp.test(item.name)) { | ||
item.targetName = item.name.replace(regexp, targetInput.value) | ||
item.targetFullName = (item.parentItem!.fullName === '' ? '' : item.parentItem!.fullName + '/') + item.targetName | ||
} else { | ||
item.targetName = undefined | ||
item.targetFullName = undefined | ||
} | ||
item.notChanged = item.name === item.targetName | ||
} | ||
}) | ||
hasDuplicated.value = false | ||
const allTarget = new Map() | ||
items | ||
.filter(item => !!item.targetFullName) | ||
.forEach(item => { | ||
allTarget.set(item.targetFullName, (allTarget.get(item.targetFullName) || 0) + 1) | ||
}) | ||
items.forEach(item => { | ||
item.duplicated = allTarget.get(item.targetFullName) > 1 | ||
if (item.duplicated) { | ||
hasDuplicated.value = true | ||
} | ||
}) | ||
} | ||
const run = async () => { | ||
if (!candidateItems.value.length) { | ||
return toast.warn(t('dialogs.bulkRenameFiles.nothing_to_do')) | ||
} | ||
const reqList = [] | ||
for (const item of candidateItems.value) { | ||
reqList.push(contentStore.renameTorrentFile(props.hash, item.fullName, item.targetFullName!)) | ||
} | ||
running.value = true | ||
Promise.all(reqList) | ||
.then(() => { | ||
toast.success(t('dialogs.bulkRenameFiles.success')) | ||
regexpEl.value?.saveValueToHistory() | ||
targetEl.value?.saveValueToHistory() | ||
}) | ||
.catch(e => { | ||
toast.error(e.toString()) | ||
}) | ||
.finally(() => { | ||
running.value = false | ||
contentStore.updateFileTree() | ||
// close dialog, because haven't a good way to refresh torrent files after rename | ||
// because bulkRenameFilesDialog can be create with subfolder | ||
close() | ||
}) | ||
} | ||
const close = () => { | ||
isOpened.value = false | ||
} | ||
watch([regexpInput, regexpFlagsInput, targetInput], () => { | ||
dryRunRename() | ||
}) | ||
onMounted(() => { | ||
parseNode(props.node) | ||
}) | ||
</script> | ||
|
||
<template> | ||
<v-dialog v-model="isOpened" persistent> | ||
<v-card density="compact"> | ||
<v-card-title> | ||
<v-toolbar density="compact" color="transparent"> | ||
<v-toolbar-title>{{ $t('dialogs.bulkRenameFiles.title') }}</v-toolbar-title> | ||
<v-btn icon="mdi-close" @click="close()" /> | ||
</v-toolbar> | ||
</v-card-title> | ||
<v-card-text class="d-flex flex-column"> | ||
<v-form v-model="isFormValid" ref="form"> | ||
<v-row no-gutters align="center" justify="center"> | ||
<v-col :cols="inMobile ? 9 : undefined"> | ||
<HistoryField | ||
:historyKey="HistoryKey.BULK_RENAME_REGEXP" | ||
ref="regexpEl" | ||
hide-details | ||
density="compact" | ||
v-model="regexpInput" | ||
:rules="rules" | ||
:label="$t('dialogs.bulkRenameFiles.regexp')" /> | ||
</v-col> | ||
<v-col :cols="inMobile ? 3 : 'auto'"> | ||
<v-select | ||
class="ml-2" | ||
v-model="regexpFlagsInput" | ||
:items="['d', 'g', 'i', 'm', 's', 'u', 'v', 'y']" | ||
:placeholder="t('dialogs.bulkRenameFiles.select_regex_flags')" | ||
label="Flags" | ||
density="compact" | ||
multiple | ||
hide-details /> | ||
</v-col> | ||
<v-col cols="auto"> | ||
<v-icon class="mx-2" :icon="`mdi-arrow-${inMobile ? 'down' : 'right'}`" /> | ||
</v-col> | ||
<v-col :cols="inMobile ? 12 : undefined"> | ||
<HistoryField | ||
:historyKey="HistoryKey.BULK_RENAME_TARGET" | ||
ref="targetEl" | ||
hide-details | ||
density="compact" | ||
v-model="targetInput" | ||
:rules="rules" | ||
:label="$t('dialogs.bulkRenameFiles.target')" /> | ||
</v-col> | ||
<v-col cols="auto"> | ||
<v-badge :class="inMobile ? 'mt-2' : 'ml-5'" color="success" location="top left" :content="candidateItems.length"> | ||
<v-btn :loading="running" :disabled="!isFormValid || hasDuplicated" color="primary" @click="run()">{{ $t('dialogs.bulkRenameFiles.run') }}</v-btn> | ||
</v-badge> | ||
</v-col> | ||
</v-row> | ||
</v-form> | ||
<v-data-table-virtual :headers="headers" :items="items" density="compact" fixed-header> | ||
<template v-slot:header.name> | ||
{{ $t('dialogs.bulkRenameFiles.col_origin_name') }} | ||
<template v-if="inMobile"> | ||
<br /> | ||
{{ $t('dialogs.bulkRenameFiles.col_result_name') }} | ||
</template> | ||
</template> | ||
<template v-slot:header.targetName> | ||
<template v-if="!inMobile"> | ||
{{ $t('dialogs.bulkRenameFiles.col_result_name') }} | ||
</template> | ||
</template> | ||
<template v-slot:item="{ index, item }"> | ||
<v-data-table-row v-if="item.show" :index="index" :item="item as any"> | ||
<template v-slot:item.selected> | ||
<v-checkbox-btn v-if="item.type === 'file'" v-model="item.selected" :color="item.targetName && 'indigo'" @change="fileCheckChange(item)" /> | ||
<v-checkbox-btn v-else v-model="item.selected" :indeterminate="item.indeterminate" @change="folderCheckChange(item)" /> | ||
</template> | ||
<template v-slot:item.name> | ||
<span | ||
class="fold-toggle" | ||
:class="{ clickable: item.type === 'folder' }" | ||
:style="{ 'padding-left': `${item.indent * 20}px` }" | ||
@click="item.type === 'folder' && toggleFolderFolded(item, !item.folded)"> | ||
<v-tooltip v-if="item.type === 'folder'" location="top" activator="parent"> | ||
{{ t(`dialogs.bulkRenameFiles.${item.folded ? 'unfold' : 'fold'}`) }} | ||
</v-tooltip> | ||
<v-icon v-if="item.type === 'folder'">{{ item.folded ? 'mdi-chevron-down' : 'mdi-chevron-up' }}</v-icon> | ||
<v-icon v-if="item.fullName === ''" icon="mdi-file-tree" /> | ||
<v-icon v-else-if="item.type === 'file'" :icon="getFileIcon(item.name)" /> | ||
<v-icon v-else-if="!item.folded" icon="mdi-folder-open" color="#ffe476" /> | ||
<v-icon v-else icon="mdi-folder" color="#ffe476" /> | ||
<div class="d-inline-flex flex-column"> | ||
<span> | ||
{{ item.name }} | ||
</span> | ||
<span v-if="inMobile" class="target-name" :class="{ duplicated: item.duplicated, 'not-changed': item.notChanged }"> | ||
{{ item.targetName }} | ||
</span> | ||
</div> | ||
</span> | ||
</template> | ||
<template v-slot:item.targetName> | ||
<span v-if="item.type === 'file'" class="target-name" :class="{ duplicated: item.duplicated, 'not-changed': item.notChanged }"> | ||
{{ item.targetName }} | ||
<v-tooltip v-if="item.duplicated || item.notChanged" activator="parent"> | ||
{{ t(`dialogs.bulkRenameFiles.${item.duplicated ? 'duplicated' : 'not_changed'}`) }} | ||
</v-tooltip> | ||
</span> | ||
<span v-else> | ||
<v-icon icon="mdi-cancel" color="grey-lighten-1" /> | ||
<v-tooltip activator="parent"> | ||
{{ t('dialogs.bulkRenameFiles.notForFolder') }} | ||
</v-tooltip> | ||
</span> | ||
</template> | ||
</v-data-table-row> | ||
</template> | ||
<template v-slot:bottom></template> | ||
</v-data-table-virtual> | ||
</v-card-text> | ||
</v-card> | ||
</v-dialog> | ||
</template> | ||
|
||
<style lang="scss" scoped> | ||
.v-card-text { | ||
height: calc(100vh - 115px); | ||
} | ||
.v-table { | ||
overflow: auto; | ||
} | ||
.target-name { | ||
&.duplicated { | ||
color: red; | ||
} | ||
&.not-changed { | ||
color: rgb(255, 149, 149); | ||
} | ||
} | ||
.fold-toggle.clickable { | ||
cursor: pointer; | ||
} | ||
.fold-toggle, | ||
.target-name { | ||
word-break: keep-all; | ||
white-space: pre; | ||
} | ||
</style> |
Oops, something went wrong.