diff --git a/chrome/content/zotero/customElements.js b/chrome/content/zotero/customElements.js index b1c55de54b..736d3eebb8 100644 --- a/chrome/content/zotero/customElements.js +++ b/chrome/content/zotero/customElements.js @@ -78,6 +78,7 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe ['libraries-collections-box', 'chrome://zotero/content/elements/librariesCollectionsBox.js'], ['autocomplete-textarea', 'chrome://zotero/content/elements/autocompleteTextArea.js'], ['bubble-input', 'chrome://zotero/content/elements/bubbleInput.js'], + ['file-renaming-settings', 'chrome://zotero/content/elements/fileRenamingSettings.js'] ]) { customElements.setElementCreationCallback(tag, () => { Services.scriptloader.loadSubScript(script, window); diff --git a/chrome/content/zotero/elements/fileRenamingSettings.js b/chrome/content/zotero/elements/fileRenamingSettings.js new file mode 100644 index 0000000000..649aa09923 --- /dev/null +++ b/chrome/content/zotero/elements/fileRenamingSettings.js @@ -0,0 +1,338 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2026 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://www.zotero.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +{ + const { DEFAULT_ATTACHMENT_RENAME_TEMPLATE } = ChromeUtils.importESModule("chrome://zotero/content/renameFiles.mjs"); + const DEFAULT_EXT = 'pdf'; + class FileRenameSettings extends XULElementBase { + content = MozXULElement.parseXULToFragment(` + + + + + + + + + + + + + + + + + + + + + + + `); + + static get observedAttributes() { + return [ + 'auto-rename-enabled', + 'file-types', + 'format-template', + 'rename-linked-enabled', + 'rename-linked-hidden', + 'readonly', + ]; + } + + get autoRenameEnabled() { + return this.autoRenameToggleCheckbox.checked; + } + + set autoRenameEnabled(val) { + this.autoRenameToggleCheckbox.checked = val; + this.updateDisabled(); + } + + get renameLinkedEnabled() { + return this.renameLinkedCheckbox.checked; + } + + set renameLinkedEnabled(val) { + this.renameLinkedCheckbox.checked = val; + } + + get enabledFileTypes() { + let enabledTypes = new Set( + (this._enabledFileTypes).split(',').filter(Boolean) + ); + for (let checkbox of this.fileTypesCheckboxes.querySelectorAll('checkbox')) { + if (checkbox.checked) { + enabledTypes.add(checkbox.dataset.contentType); + } + else { + enabledTypes.delete(checkbox.dataset.contentType); + } + } + return [...enabledTypes].join(','); + } + + set enabledFileTypes(types) { + this._enabledFileTypes = types; + let enabledTypes = new Set( + (this._enabledFileTypes).split(',').filter(Boolean) + ); + for (let checkbox of this.fileTypesCheckboxes.querySelectorAll('checkbox')) { + checkbox.checked = enabledTypes.has(checkbox.dataset.contentType); + } + } + + get formatTemplate() { + return this.formatTemplateTextarea.value; + } + + set formatTemplate(val) { + this.formatTemplateTextarea.value = val; + } + + handleChange = () => { + let autoRenameEnabled = this.autoRenameEnabled; + let enabledFileTypes = this.enabledFileTypes; + let renameLinkedEnabled = this.renameLinkedEnabled; + let formatTemplate = this.formatTemplate; + this.dispatchEvent(new CustomEvent("change", { + detail: { + autoRenameEnabled, + enabledFileTypes, + renameLinkedEnabled, + formatTemplate + }, + bubbles: true, + cancelable: true + })); + }; + + handleTemplateInput = () => { + let formatString = this.formatTemplateTextarea.value; + // Ignore the empty value, which we'll reset in handleInputBlur() if necessary + if (formatString.replace(/\s/g, '') === '') { + return; + } + this.updatePreview(); + this.handleChange(); + }; + + handleTemplateBlur = () => { + let formatString = this.formatTemplateTextarea.value; + if (formatString.replace(/\s/g, '') === '') { + this.formatTemplateTextarea.value = DEFAULT_ATTACHMENT_RENAME_TEMPLATE; + this.updatePreview(); + this.handleChange(); + } + }; + + handleRenameToggle = () => { + this.autoRenameEnabled = this.autoRenameToggleCheckbox.checked; + this.handleChange(); + }; + + updateDisabled = () => { + let readonly = this.getAttribute('readonly') === 'true'; + for (let checkbox of this.fileTypesCheckboxes.querySelectorAll('checkbox')) { + checkbox.disabled = readonly || !this.autoRenameEnabled; + } + this.autoRenameToggleCheckbox.disabled = readonly; + this.renameLinkedCheckbox.disabled = readonly || !this.autoRenameEnabled; + this.formatTemplateTextarea.readOnly = readonly; + }; + + updatePreview = () => { + let [item, ext, attachmentTitle] = this.getActiveItem() ?? [this.mockItem ?? this.makeMockItem(), DEFAULT_EXT, '']; + let formatString = this.formatTemplate; + let preview = Zotero.Attachments.getFileBaseNameFromItem(item, { formatString, attachmentTitle }); + this.querySelector('#file-renaming-format-preview').innerText = `${preview}.${ext}`; + }; + + async init() { + this.sectionMain = this.querySelector('#file-rename-settings-section-main'); + this.sectionInstructions = this.querySelector('#file-rename-settings-section-instructions'); + this.sectionTemplate = this.querySelector('#file-rename-settings-section-template'); + this.autoRenameToggleCheckbox = this.querySelector('#auto-rename-files'); + this.fileTypesCheckboxes = this.querySelector('#file-renaming-file-types-box'); + this.renameLinkedCheckbox = this.querySelector('#rename-linked-files'); + this.formatTemplateTextarea = this.querySelector('#file-renaming-format-template'); + this.enabledFileTypes = this.getAttribute('file-types') ?? ''; + this.autoRenameEnabled = this.getAttribute('auto-rename-enabled') === 'true'; + this.renameLinkedCheckbox.checked = this.getAttribute('rename-linked-enabled') === 'true'; + this.formatTemplate = this.getAttribute('format-template') ?? ''; + this.renameLinkedCheckbox.hidden = this.getAttribute('rename-linked-hidden') === 'true'; + + this.autoRenameToggleCheckbox.addEventListener("command", this.handleRenameToggle); + this.fileTypesCheckboxes.addEventListener("command", this.handleChange); + this.renameLinkedCheckbox.addEventListener("command", this.handleChange); + this.formatTemplateTextarea.addEventListener("input", this.handleTemplateInput); + this.formatTemplateTextarea.addEventListener("blur", this.handleTemplateBlur); + + this._itemsView = Zotero.getActiveZoteroPane()?.itemsView; + if (this._itemsView) { + this._itemsView.onSelect.addListener(this.updatePreview); + } + + this.updatePreview(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._itemsView?.onSelect.removeListener(this.updatePreview); + } + + attributeChangedCallback(name, oldValue, newValue) { + if (!this.sectionMain) return; + + switch (name) { + case 'auto-rename-enabled': + this.autoRenameEnabled = newValue === 'true'; + break; + case 'file-types': + this.enabledFileTypes = newValue ?? ''; + break; + case 'format-template': + this.formatTemplate = newValue ?? ''; + this.updatePreview(); + break; + case 'rename-linked-enabled': + this.renameLinkedCheckbox.checked = newValue === 'true'; + break; + case 'rename-linked-hidden': + this.renameLinkedCheckbox.hidden = newValue === 'true'; + break; + case 'readonly': + this.updateDisabled(); + break; + } + } + + getActiveItem() { + let selectedItem = Zotero.getActiveZoteroPane()?.getSelectedItems()?.[0]; + if (selectedItem) { + if (selectedItem.isRegularItem() && !selectedItem.parentKey) { + return [selectedItem, DEFAULT_EXT, '']; + } + if (selectedItem.isFileAttachment() && selectedItem.parentKey) { + let ext = Zotero.Attachments.getCorrectFileExtension(selectedItem); + let parentItem = Zotero.Items.getByLibraryAndKey(selectedItem.libraryID, selectedItem.parentKey); + return [parentItem, ext ?? DEFAULT_EXT, selectedItem.getField('title')]; + } + } + + return null; + } + + makeMockItem() { + this.mockItem = new Zotero.Item('journalArticle'); + this.mockItem.libraryID = Zotero.Libraries.userLibraryID; + this.mockItem.setField('title', 'Example Title: Example Subtitle'); + this.mockItem.setCreators([ + { firstName: 'Jane', lastName: 'Doe', creatorType: 'author' }, + { firstName: 'John', lastName: 'Smith', creatorType: 'author' } + ]); + this.mockItem.setField('shortTitle', 'Example Title'); + this.mockItem.setField('publicationTitle', 'Advances in Example Engineering'); + this.mockItem.setField('volume', '9'); + this.mockItem.setField('issue', '1'); + this.mockItem.setField('pages', '34-55'); + this.mockItem.setField('date', '2018'); + this.mockItem.setField('DOI', '10.1016/1234-example'); + this.mockItem.setField('ISSN', '1234-5678'); + this.mockItem.setField('abstractNote', 'This is an example abstract.'); + this.mockItem.setField('extra', 'This is an example Extra field.'); + this.mockItem.setField('accessDate', '2020-01-01'); + this.mockItem.setField('url', 'https://example.com'); + this.mockItem.setField('libraryCatalog', 'Example Library Catalog'); + return this.mockItem; + } + } + + customElements.define('file-renaming-settings', FileRenameSettings); +} diff --git a/chrome/content/zotero/fileRenamingDialog.js b/chrome/content/zotero/fileRenamingDialog.js new file mode 100644 index 0000000000..ec5dac071c --- /dev/null +++ b/chrome/content/zotero/fileRenamingDialog.js @@ -0,0 +1,313 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2026 Corporation for Digital Scholarship + Vienna, Virginia, USA + http://zotero.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +var FileRenamingDialog = { // eslint-disable-line no-unused-vars + _settingsChanged: false, + _currentLibraryID: null, + _forceClose: false, + + init: function () { + const { DEFAULT_ATTACHMENT_RENAME_TEMPLATE, DEFAULT_AUTO_RENAME_FILE_TYPES } = ChromeUtils.importESModule("chrome://zotero/content/renameFiles.mjs"); + this.DEFAULT_ATTACHMENT_RENAME_TEMPLATE = DEFAULT_ATTACHMENT_RENAME_TEMPLATE; + this.DEFAULT_AUTO_RENAME_FILE_TYPES = DEFAULT_AUTO_RENAME_FILE_TYPES; + + this.settingsEl = document.getElementById('file-renaming-settings'); + this.libraryPicker = document.getElementById('library-picker'); + this.renameFilesBtn = document.getElementById('file-renaming-rename-files-btn'); + this.doneBtn = document.getElementById('file-renaming-done-btn'); + + // Populate library picker + let libraries = Zotero.Libraries.getAll().filter(lib => !(lib instanceof Zotero.Feed)); + let menupopup = this.libraryPicker.querySelector('menupopup'); + for (let lib of libraries) { + let menuitem = document.createXULElement('menuitem'); + menuitem.setAttribute('label', lib.name); + menuitem.setAttribute('value', lib.libraryID); + menupopup.appendChild(menuitem); + } + + // Default to user library, or use passed-in libraryID + let initialLibraryID = window.arguments?.[0]?.wrappedJSObject?.libraryID ?? Zotero.Libraries.userLibraryID; + this.libraryPicker.value = String(initialLibraryID); + + this.libraryPicker.addEventListener('command', this.handleLibraryChange.bind(this)); + this.settingsEl.addEventListener('change', this.handleSettingsChange.bind(this)); + this.renameFilesBtn.addEventListener('command', this._handleRenameFilesClick.bind(this)); + this.doneBtn.addEventListener('command', this._handleDoneClick.bind(this)); + + this._handleDonePrefChange = this._handleDonePrefChange.bind(this); + this._renameFilesPrefObserver = Zotero.Prefs.registerObserver('autoRenameFiles.done', this._handleDonePrefChange); + + this.loadSettingsForLibrary(initialLibraryID); + this._currentLibraryID = initialLibraryID; + this._updateButtons(); + + window.addEventListener('unload', () => { + Zotero.Prefs.unregisterObserver(this._renameFilesPrefObserver); + }); + }, + + get libraryID() { + return parseInt(this.libraryPicker.value); + }, + + get isUserLibrary() { + return this.libraryID === Zotero.Libraries.userLibraryID; + }, + + _updateButtons: function () { + let autoRenameEnabled = this.settingsEl.autoRenameEnabled; + let library = Zotero.Libraries.get(this._currentLibraryID); + let isAdmin = this.isUserLibrary || library.isAdmin; + this.renameFilesBtn.hidden = !isAdmin; + this.renameFilesBtn.disabled = !autoRenameEnabled || this._isRenameFilesBtnDisabled(); + }, + + _isRenameFilesBtnDisabled: function () { + if (this.isUserLibrary) { + return Zotero.Prefs.get('autoRenameFiles.done'); + } + return false; + }, + + _openRenameFilesPreview: function (libraryID) { + let args = { libraryID }; + Services.ww.openWindow(null, "chrome://zotero/content/renameFilesPreview.xhtml", + "renameFilesPreview", "chrome,dialog=yes,centerscreen,modal", args); + if (!args.cancelled) { + this._resetBaseline(libraryID); + } + return !args.cancelled; + }, + + _handleDoneClick: async function () { + // "Done" button -- prompt if dirty, then close + if (this._shouldPromptRename(this._currentLibraryID)) { + let wantsRename = await this._promptRename(this._currentLibraryID); + if (wantsRename) { + this._openRenameFilesPreview(this._currentLibraryID); + } + } + window.close(); // bypasses the onclose handler + }, + + _handleRenameFilesClick: function () { + // "Rename Files..." button + this._openRenameFilesPreview(this._currentLibraryID); + this._updateButtons(); + }, + + loadSettingsForLibrary: function (libraryID) { + this._settingsChanged = false; + let isUserLib = libraryID === Zotero.Libraries.userLibraryID; + + let autoRenameEnabled; + let fileTypes; + let formatTemplate; + let renameLinked; + + if (isUserLib) { + autoRenameEnabled = Zotero.Prefs.get('autoRenameFiles'); + fileTypes = Zotero.Prefs.get('autoRenameFiles.fileTypes'); + renameLinked = Zotero.Prefs.get('autoRenameFiles.linked'); + formatTemplate = Zotero.SyncedSettings.get(libraryID, 'attachmentRenameTemplate') + ?? this.DEFAULT_ATTACHMENT_RENAME_TEMPLATE; + + let library = Zotero.Libraries.get(libraryID); + this.settingsEl.setAttribute('readonly', String(!library.editable)); + this.settingsEl.setAttribute('rename-linked-hidden', 'false'); + this.settingsEl.setAttribute('rename-linked-enabled', String(renameLinked)); + + this._baselineDone = Zotero.Prefs.get('autoRenameFiles.done'); + } + else { + autoRenameEnabled = Zotero.Attachments.isAutoRenameFilesEnabledForLibrary(libraryID); + fileTypes = Zotero.SyncedSettings.get(libraryID, 'autoRenameFilesFileTypes') + ?? this.DEFAULT_AUTO_RENAME_FILE_TYPES; + formatTemplate = Zotero.SyncedSettings.get(libraryID, 'attachmentRenameTemplate') + ?? this.DEFAULT_ATTACHMENT_RENAME_TEMPLATE; + + let isAdmin = Zotero.Libraries.get(libraryID).isAdmin; + this.settingsEl.setAttribute('readonly', String(!isAdmin)); + this.settingsEl.setAttribute('rename-linked-hidden', 'true'); + } + + // Store baseline for comparing against future changes + this._baselineSettings = isUserLib + ? { autoRenameEnabled, fileTypes, formatTemplate, renameLinked } + : { autoRenameEnabled, fileTypes, formatTemplate }; + + this.settingsEl.setAttribute('auto-rename-enabled', String(autoRenameEnabled)); + this.settingsEl.setAttribute('file-types', fileTypes); + this.settingsEl.setAttribute('format-template', formatTemplate); + }, + + handleLibraryChange: async function () { + let previousLibraryID = this._currentLibraryID; + let newLibraryID = this.libraryID; + if (newLibraryID === previousLibraryID) { + return; + } + if (this._shouldPromptRename(previousLibraryID)) { + // Revert picker so the previous library is visible behind the prompt + this.libraryPicker.value = String(previousLibraryID); + let wantsRename = await this._promptRename(previousLibraryID); + if (wantsRename && !this._openRenameFilesPreview(previousLibraryID)) { + // User cancelled the preview -- stay on the current library + return; + } + this.libraryPicker.value = String(newLibraryID); + } + this._currentLibraryID = newLibraryID; + this.loadSettingsForLibrary(newLibraryID); + this._updateButtons(); + }, + + handleSettingsChange: function (event) { + if (!event.detail) { + return; + } + + let { autoRenameEnabled, enabledFileTypes, renameLinkedEnabled, formatTemplate } = event.detail; + let base = this._baselineSettings; + + if (this.isUserLibrary) { + Zotero.Prefs.set('autoRenameFiles', autoRenameEnabled); + Zotero.Prefs.set('autoRenameFiles.fileTypes', enabledFileTypes); + Zotero.Prefs.set('autoRenameFiles.linked', renameLinkedEnabled); + + // Handle template changes + if (formatTemplate.replace(/\s/g, '') === '') { + Zotero.SyncedSettings.clear(Zotero.Libraries.userLibraryID, 'attachmentRenameTemplate'); + } + else { + Zotero.SyncedSettings.set(Zotero.Libraries.userLibraryID, 'attachmentRenameTemplate', formatTemplate); + } + + let settingsMatch = autoRenameEnabled === base.autoRenameEnabled + && enabledFileTypes === base.fileTypes + && formatTemplate === base.formatTemplate + && renameLinkedEnabled === base.renameLinked; + Zotero.Prefs.set('autoRenameFiles.done', settingsMatch && this._baselineDone); + this._settingsChanged = !settingsMatch; + } + else { + Zotero.SyncedSettings.set(this.libraryID, 'autoRenameFiles', autoRenameEnabled); + Zotero.SyncedSettings.set(this.libraryID, 'autoRenameFilesFileTypes', enabledFileTypes); + Zotero.SyncedSettings.set(this.libraryID, 'attachmentRenameTemplate', formatTemplate); + + this._settingsChanged = autoRenameEnabled !== base.autoRenameEnabled + || enabledFileTypes !== base.fileTypes + || formatTemplate !== base.formatTemplate; + } + + this._updateButtons(); + }, + + handleWindowClose: async function (event) { + if (this._forceClose) { + return true; + } + if (this._shouldPromptRename(this._currentLibraryID)) { + event.preventDefault(); + let wantsRename = await this._promptRename(this._currentLibraryID); + if (wantsRename) { + this._openRenameFilesPreview(this._currentLibraryID); + } + this._forceClose = true; + window.close(); + return false; + } + return true; + }, + + _handleDonePrefChange: function () { + if (this._currentLibraryID !== Zotero.Libraries.userLibraryID) { + return; + } + + this._updateButtons(); + }, + + _shouldPromptRename: function (libraryID) { + if (!this._settingsChanged) { + return false; + } + if (libraryID === Zotero.Libraries.userLibraryID) { + return Zotero.Prefs.get('autoRenameFiles') && !Zotero.Prefs.get('autoRenameFiles.done'); + } + return Zotero.Attachments.isAutoRenameFilesEnabledForLibrary(libraryID); + }, + + _promptRename: async function (libraryID) { + let isUserLib = libraryID === Zotero.Libraries.userLibraryID; + let bodyID = isUserLib + ? { id: 'file-renaming-auto-rename-prompt-body' } + : { id: 'file-renaming-auto-rename-prompt-body-library', args: { library: Zotero.Libraries.get(libraryID).name } }; + let [title, description, yes, no] = await document.l10n.formatValues([ + 'file-renaming-auto-rename-prompt-title', + bodyID, + 'file-renaming-auto-rename-prompt-yes', + 'file-renaming-auto-rename-prompt-no' + ]); + let index = Zotero.Prompt.confirm({ + title, + text: description, + button0: yes, + button1: no + }); + if (index === 0) { + return true; + } + if (isUserLib) { + Zotero.Prefs.set('autoRenameFiles.done', false); + } + return false; + }, + + _resetBaseline: function (libraryID) { + this._settingsChanged = false; + if (libraryID === Zotero.Libraries.userLibraryID) { + this._baselineDone = true; + this._baselineSettings = { + autoRenameEnabled: Zotero.Prefs.get('autoRenameFiles'), + fileTypes: Zotero.Prefs.get('autoRenameFiles.fileTypes'), + formatTemplate: Zotero.SyncedSettings.get( + Zotero.Libraries.userLibraryID, 'attachmentRenameTemplate' + ) ?? this.DEFAULT_ATTACHMENT_RENAME_TEMPLATE, + renameLinked: Zotero.Prefs.get('autoRenameFiles.linked'), + }; + } + else { + this._baselineSettings = { + autoRenameEnabled: Zotero.Attachments.isAutoRenameFilesEnabledForLibrary(libraryID), + fileTypes: Zotero.SyncedSettings.get(libraryID, 'autoRenameFilesFileTypes') + ?? this.DEFAULT_AUTO_RENAME_FILE_TYPES, + formatTemplate: Zotero.SyncedSettings.get(libraryID, 'attachmentRenameTemplate') + ?? this.DEFAULT_ATTACHMENT_RENAME_TEMPLATE, + }; + } + } +}; diff --git a/chrome/content/zotero/fileRenamingDialog.xhtml b/chrome/content/zotero/fileRenamingDialog.xhtml new file mode 100644 index 0000000000..9d5b3103a8 --- /dev/null +++ b/chrome/content/zotero/fileRenamingDialog.xhtml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + +