mirror of
https://github.com/VueTorrent/VueTorrent.git
synced 2024-10-23 03:06:43 +03:00
feat(TorrentDetail): add bulk renaming (#1624)
This commit is contained in:
parent
ad305b4f50
commit
a7ebcb59d9
6 changed files with 436 additions and 8 deletions
|
@ -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>
|
||||
|
||||
|
|
390
src/components/Dialogs/BulkRenameFilesDialog.vue
Normal file
390
src/components/Dialogs/BulkRenameFilesDialog.vue
Normal 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>
|
|
@ -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'
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "名称",
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in a new issue