diff --git a/src/components/modals/ManageShotsModal.vue b/src/components/modals/ManageShotsModal.vue index ef9356f780..d6f0940940 100644 --- a/src/components/modals/ManageShotsModal.vue +++ b/src/components/modals/ManageShotsModal.vue @@ -18,6 +18,7 @@ :options="shotPaddingOptions" class="shot-padding flexrow-item" v-model="shotPadding" + v-show="shotMode === 'single'" /> @@ -104,39 +105,127 @@
-

{{ $t('shots.title') }}

-
-
+
-
-
- -
-
+ {{ $t('shots.single_tab') }} +
+ +

{{ $t('shots.title') }}

+ +
+ + +
+ + + +
@@ -170,7 +259,13 @@ const props = defineProps({ active: { type: Boolean, default: true } }) -const emit = defineEmits(['add-episode', 'add-sequence', 'add-shot', 'cancel']) +const emit = defineEmits([ + 'add-episode', + 'add-sequence', + 'add-shot', + 'add-shots-bulk', + 'cancel' +]) useModal(toRef(props, 'active'), emit) @@ -188,13 +283,16 @@ const names = reactive({ episode: '', sequence: '', shot: '' }) const loading = reactive({ addEpisode: false, addSequence: false, - addShot: false + addShot: false, + bulkGenerate: false }) const sequences = ref([]) const displayedShots = ref([]) const selectedEpisodeId = ref(null) const selectedSequenceId = ref(null) const shotPadding = ref('1') +const shotMode = ref('single') +const bulk = reactive({ start: '', count: 20, step: 10 }) const currentProduction = computed(() => store.getters.currentProduction) const displayedEpisodes = computed(() => store.getters.displayedEpisodes) @@ -218,6 +316,26 @@ const isAddSequenceAllowed = computed(() => { return !exists && (selectedEpisodeId.value || !isTVShow.value) }) +const bulkStartError = computed( + () => bulk.start.length > 0 && !/\d+$/.test(bulk.start) +) + +const bulkPreviewNames = computed(() => { + if (!bulk.start || bulkStartError.value || !bulk.count || !bulk.step) { + return [] + } + return stringHelpers.generateBulkShotNames(bulk.start, bulk.count, bulk.step) +}) + +const isBulkGenerateAllowed = computed( + () => + !bulkStartError.value && + bulkPreviewNames.value.length > 0 && + !!selectedSequenceId.value && + !bulkPreviewNames.value.some(name => isBulkNameCollision(name)) && + !loading.bulkGenerate +) + const isAddShotAllowed = computed(() => { if (!names.shot) return false const exists = displayedShots.value.find(shot => names.shot === shot.name) @@ -254,6 +372,33 @@ const selectEpisode = episodeId => { } } +const isBulkNameCollision = name => + !!displayedShots.value.find(shot => shot.name === name) + +const generateShots = () => { + if (!isBulkGenerateAllowed.value) return + loading.bulkGenerate = true + const sequence = displayedSequences.value.find( + s => s.id === selectedSequenceId.value + ) + const episode = isTVShow.value + ? displayedEpisodes.value.find(e => e.id === selectedEpisodeId.value) + : null + emit( + 'add-shots-bulk', + { + shotNames: bulkPreviewNames.value, + sequenceName: sequence?.name, + episodeName: episode?.name ?? null + }, + () => { + loading.bulkGenerate = false + selectSequence(selectedSequenceId.value) + bulk.start = '' + } + ) +} + const addEpisode = () => { if (!isAddEpisodeAllowed.value) return loading.addEpisode = true @@ -413,4 +558,47 @@ input::placeholder { .shot-padding { margin-right: 1em; } + +.shot-tabs { + display: flex; + margin-bottom: 4px; +} + +.tab-button { + flex: 1; + border: 1px solid $light-grey; + background: transparent; + cursor: pointer; + padding: 4px 0; + + &.active { + background: var(--background-selected); + } + + &:first-child { + border-radius: 4px 0 0 4px; + } + + &:last-child { + border-radius: 0 4px 4px 0; + border-left: 0; + } +} + +.bulk-fields { + display: flex; + gap: 4px; + margin-right: 10px; + margin-bottom: 0; +} + +.bulk-field { + flex: 1; + min-width: 0; +} + +.entity-line.collision { + color: $red; + text-decoration: line-through; +} diff --git a/src/components/pages/Shots.vue b/src/components/pages/Shots.vue index ec1570e6a3..4955b6ad1a 100644 --- a/src/components/pages/Shots.vue +++ b/src/components/pages/Shots.vue @@ -143,6 +143,7 @@ @add-episode="addEpisode" @add-sequence="addSequence" @add-shot="addShot" + @add-shots-bulk="addShotsBulk" @cancel="hideManageShots" /> @@ -578,6 +579,7 @@ export default { methods: { ...mapActions([ 'addMetadataDescriptor', + 'bulkCreateShots', 'createTasks', 'changeShotSort', 'clearSelectedShots', @@ -651,6 +653,18 @@ export default { this.newShot(shot).then(callback).catch(console.error) }, + addShotsBulk(payload, callback) { + this.bulkCreateShots(payload) + .then(() => this.loadShots()) + .then(() => { + if (callback) callback() + }) + .catch(err => { + console.error(err) + if (callback) callback() + }) + }, + onDeleteClicked(shot) { this.shotToDelete = shot this.modals.isDeleteDisplayed = true diff --git a/src/lib/string.js b/src/lib/string.js index 0c2120e115..afebde5d9b 100644 --- a/src/lib/string.js +++ b/src/lib/string.js @@ -18,6 +18,22 @@ export default { } }, + generateBulkShotNames(startName, count, step) { + const matches = startName.match(/\d+$/) + if (!matches) return [] + const numStr = matches[0] + const prefix = startName.slice(0, startName.length - numStr.length) + const padLen = numStr.length + const startNum = parseInt(numStr, 10) + const cappedCount = Math.min(count, 500) + const names = [] + for (let i = 0; i < cappedCount; i++) { + const n = startNum + i * step + names.push(prefix + String(n).padStart(padLen, '0')) + } + return names + }, + shortenText(text, maxLength) { let result = text || '' if (text?.length > maxLength) { diff --git a/src/locales/en.js b/src/locales/en.js index 9775c17bad..2c11f539c4 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -1905,6 +1905,10 @@ export default { manage: 'Create shots', new_success: 'Shot {name} successfully created.', padding: 'Shot Padding', + single_tab: 'Single', + bulk_tab: 'Bulk', + bulk_generate: 'Generate', + bulk_invalid_start: 'Start name must end with a number.', restore_text: 'Are you sure you want to restore {name} from your archive?', restore_error: 'An error occurred while restoring this shot.', sequences: 'Sequences', @@ -1924,6 +1928,9 @@ export default { name: 'Name', person: 'Modifier', placeholder: 'SH01', + start: 'Start', + count: 'Count', + step: 'Step', production: 'Prod', resolution: 'Resolution', sequence: 'Sequence', diff --git a/src/store/modules/shots.js b/src/store/modules/shots.js index 54601305b3..8061967c08 100644 --- a/src/store/modules/shots.js +++ b/src/store/modules/shots.js @@ -566,6 +566,27 @@ const actions = { }) }, + bulkCreateShots( + { commit, dispatch, rootGetters }, + { shotNames, sequenceName, episodeName } + ) { + const isTVShow = rootGetters.isTVShow + const q = v => `"${String(v ?? '').replace(/"/g, '""')}"` + const header = isTVShow ? 'Episode,Sequence,Name' : 'Sequence,Name' + const rows = shotNames.map(name => + isTVShow + ? `${q(episodeName)},${q(sequenceName)},${q(name)}` + : `${q(sequenceName)},${q(name)}` + ) + const csv = [header, ...rows].join('\n') + const blob = new Blob([csv], { type: 'text/csv' }) + const file = new File([blob], 'shots.csv', { type: 'text/csv' }) + const formData = new FormData() + formData.append('file', file) + commit(SHOT_CSV_FILE_SELECTED, formData) + return dispatch('uploadShotFile', false) + }, + uploadEdlFile({ rootGetters }, { edl_file, namingConvention, matchCase }) { const production = rootGetters.currentProduction const episode = rootGetters.isTVShow ? rootGetters.currentEpisode : null diff --git a/tests/unit/lib/string.spec.js b/tests/unit/lib/string.spec.js index 2ecfe9652b..c595160f8f 100644 --- a/tests/unit/lib/string.spec.js +++ b/tests/unit/lib/string.spec.js @@ -19,6 +19,15 @@ describe('lib/string', () => { expect(stringHelpers.generateNextName('SH001', 2)).toEqual('SH003') }) + it('generateBulkShotNames', () => { + expect(stringHelpers.generateBulkShotNames('SH010', 3, 10)).toEqual(['SH010', 'SH020', 'SH030']) + expect(stringHelpers.generateBulkShotNames('SH001', 3, 1)).toEqual(['SH001', 'SH002', 'SH003']) + expect(stringHelpers.generateBulkShotNames('SC0010', 3, 10)).toEqual(['SC0010', 'SC0020', 'SC0030']) + expect(stringHelpers.generateBulkShotNames('SH090', 2, 10)).toEqual(['SH090', 'SH100']) + expect(stringHelpers.generateBulkShotNames('MAIN', 3, 10)).toEqual([]) + expect(stringHelpers.generateBulkShotNames('SH010', 501, 1)).toHaveLength(500) + }) + it('shortenText', () => { expect(stringHelpers.shortenText('long text', 0)).toEqual('...') expect(stringHelpers.shortenText('short text', 10)).toEqual('short text')