feat(TorrentDetail): add bulk renaming (#1624)

This commit is contained in:
云与原 2024-04-10 13:11:57 +08:00 committed by GitHub
parent ad305b4f50
commit a7ebcb59d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 436 additions and 8 deletions

View file

@ -27,7 +27,7 @@ defineExpose({
<template>
<v-combobox v-model="_value" ref="field" :items="historyValue">
<template v-slot:prepend><slot name="prepend" /></template>
<template v-slot:prepend v-if="$slots.prepend"><slot name="prepend" /></template>
</v-combobox>
</template>

View file

@ -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>

View file

@ -1,5 +1,7 @@
export enum HistoryKey {
COOKIE = 'cookie',
SEARCH_ENGINE_QUERY = 'searchEngineQuery',
TORRENT_PATH = 'torrentPath'
TORRENT_PATH = 'torrentPath',
BULK_RENAME_REGEXP = 'bulkRenameRegexp',
BULK_RENAME_TARGET = 'bulkRenameTarget'
}

View file

@ -360,6 +360,22 @@
"sameName": "New name must be different from old name",
"title": "Rename Torrent"
},
"bulkRenameFiles": {
"title": "Bulk Rename",
"regexp": "Regular Expression",
"target": "Replacement Input",
"col_origin_name": "Original",
"col_result_name": "Result",
"run": "Run",
"success": "Rename Successful",
"duplicated": "Duplicate Filename",
"not_changed": "Filename Not Changed",
"fold": "Collapse",
"unfold": "Expand",
"notForFolder": "Folder Renaming Not Supported",
"select_regex_flags": "Select Regular Expression Flags",
"nothing_to_do": "No tasks to do"
},
"rss": {
"feed": {
"name": "Name",

View file

@ -354,6 +354,22 @@
"sameName": "新名称必须与旧名称不同",
"title": "重命名种子"
},
"bulkRenameFiles": {
"title": "批量重命名",
"regexp": "正则表达式",
"target": "替换输入",
"col_origin_name": "原始",
"col_result_name": "结果",
"run": "执行",
"success": "重命名成功",
"duplicated": "文件名重复",
"not_changed": "文件名未修改",
"fold": "收起",
"unfold": "展开",
"notForFolder": "不支持修改文件夹名",
"select_regex_flags": "选择正则表达式Flags",
"nothing_to_do": "无任务可做"
},
"rss": {
"feed": {
"name": "名称",

View file

@ -5,10 +5,10 @@ import { useDialogStore } from '@/stores/dialog'
import { useMaindataStore } from '@/stores/maindata'
import { useVueTorrentStore } from '@/stores/vuetorrent'
import { TorrentFile } from '@/types/qbit/models'
import { RightClickMenuEntryType, RightClickProperties, TreeNode } from '@/types/vuetorrent'
import { RightClickMenuEntryType, RightClickProperties, TreeFolder, TreeNode } from '@/types/vuetorrent'
import { useIntervalFn } from '@vueuse/core'
import { defineStore, storeToRefs } from 'pinia'
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { computed, nextTick, reactive, ref, toRaw, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
@ -67,8 +67,8 @@ export const useContentStore = defineStore('content', () => {
{
text: t(`torrentDetail.content.rename.bulk`),
icon: 'mdi-rename',
hidden: true, // internalSelection.value.size <= 1
action: bulkRename
hidden: internalSelection.value.size !== 1 || (selectedNode.value?.type || 'file') === 'file',
action: () => bulkRename(toRaw(selectedNode.value!) as TreeFolder)
},
{
text: t(`torrentDetail.content.rename.${selectedNode.value?.type || 'file'}`),
@ -120,8 +120,12 @@ export const useContentStore = defineStore('content', () => {
renameDialog.value = dialogStore.createDialog(MoveTorrentFileDialog, renamePayload)
}
async function bulkRename() {
//TODO
async function bulkRename(node: TreeFolder) {
const { default: BulkRenameFilesDialog } = await import('@/components/Dialogs/BulkRenameFilesDialog.vue')
renameDialog.value = dialogStore.createDialog(BulkRenameFilesDialog, {
hash: hash.value,
node
})
}
async function renameTorrentFile(hash: string, oldPath: string, newPath: string) {