From e896fd0137c267fb6dd738d94cf5f3f4f9971997 Mon Sep 17 00:00:00 2001 From: Tom Najdek Date: Thu, 11 Sep 2025 10:54:03 +0200 Subject: [PATCH] Add continuous-renaming functionality for attachment files (#3860) Resolves #1685 --- .../zotero/components/virtualized-table.jsx | 13 +- .../content/zotero/elements/attachmentBox.js | 21 +- chrome/content/zotero/itemTree.jsx | 2 +- .../preferences/preferences_file_renaming.js | 96 +++++- .../preferences_file_renaming.xhtml | 7 +- .../zotero/preferences/preferences_general.js | 22 ++ .../preferences/preferences_general.xhtml | 29 +- chrome/content/zotero/renameFiles.mjs | 318 ++++++++++++++++++ chrome/content/zotero/renameFilesPreview.js | 151 +++++++++ .../content/zotero/renameFilesPreview.xhtml | 60 ++++ .../content/zotero/standalone/standalone.js | 2 +- chrome/content/zotero/xpcom/attachments.js | 74 +++- chrome/content/zotero/xpcom/data/item.js | 47 ++- chrome/content/zotero/xpcom/prefs.js | 20 +- .../content/zotero/xpcom/recognizeDocument.js | 2 +- chrome/content/zotero/xpcom/sync/syncLocal.js | 39 +-- chrome/content/zotero/xpcom/zotero.js | 5 + chrome/content/zotero/zoteroPane.js | 113 ++----- chrome/content/zotero/zoteroPane.xhtml | 10 +- chrome/locale/en-US/zotero/preferences.ftl | 7 +- chrome/locale/en-US/zotero/zotero.ftl | 21 ++ .../16/universal/rename-from-parent.svg | 10 + defaults/preferences/zotero.js | 4 +- scss/_zotero.scss | 1 + scss/abstracts/_mixins.scss | 1 - scss/components/_banners.scss | 13 +- scss/components/_renameFilesPreview.scss | 47 +++ scss/elements/_attachmentBox.scss | 11 + scss/preferences/_file_renaming.scss | 16 +- test/content/support.js | 6 +- test/tests/attachmentsTest.js | 170 +++++++++- test/tests/itemPaneTest.js | 76 +++++ test/tests/itemTest.js | 2 +- test/tests/recognizeDocumentTest.js | 45 ++- test/tests/zoteroPaneTest.js | 144 -------- 35 files changed, 1273 insertions(+), 332 deletions(-) create mode 100644 chrome/content/zotero/renameFiles.mjs create mode 100644 chrome/content/zotero/renameFilesPreview.js create mode 100644 chrome/content/zotero/renameFilesPreview.xhtml create mode 100644 chrome/skin/default/zotero/16/universal/rename-from-parent.svg create mode 100644 scss/components/_renameFilesPreview.scss diff --git a/chrome/content/zotero/components/virtualized-table.jsx b/chrome/content/zotero/components/virtualized-table.jsx index 9b239f300b..bfb55b7938 100644 --- a/chrome/content/zotero/components/virtualized-table.jsx +++ b/chrome/content/zotero/components/virtualized-table.jsx @@ -338,6 +338,9 @@ class VirtualizedTable extends React.Component { this.preventScrollKeys = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", " ", "PageUp", "PageDown"]); this.onSelection = oncePerAnimationFrame(this._onSelection); + + // Create a map of custom row heights (if provided) so `this._renderItem` can apply the correct per-row height + this._customRowHeightMap = Object.fromEntries(props.customRowHeights ?? []); } static defaultProps = { @@ -450,6 +453,8 @@ class VirtualizedTable extends React.Component { onFocus: PropTypes.func, onItemContextMenu: PropTypes.func, + customRowHeights: PropTypes.array, + getRowHeight: PropTypes.func, }; // ------------------------ Selection Methods ------------------------- // @@ -1099,6 +1104,7 @@ class VirtualizedTable extends React.Component { itemHeight: this._rowHeight, renderItem: this._renderItem, targetElement: document.getElementById(this._jsWindowID), + customRowHeights: this.props.customRowHeights ?? [] }; } @@ -1115,7 +1121,7 @@ class VirtualizedTable extends React.Component { node.addEventListener('mouseup', e => this._handleMouseUp(e, index), { passive: true }); node.addEventListener('dblclick', e => this._activateNode(e, [index]), { passive: true }); } - node.style.height = this._rowHeight + 'px'; + node.style.height = (index in this._customRowHeightMap ? this._customRowHeightMap[index] : this._rowHeight) + 'px'; node.id = this.props.id + "-row-" + index; node.classList.toggle('odd', index % 2 == 1); node.classList.toggle('even', index % 2 == 0); @@ -1300,14 +1306,19 @@ class VirtualizedTable extends React.Component { * @param customRowHeights an array of tuples specifying row index and row height: e.g. [[1, 10], [5, 10]] */ updateCustomRowHeights = (customRowHeights=[]) => { + this._customRowHeightMap = Object.fromEntries(customRowHeights); return this._jsWindow.update({customRowHeights}); }; _getRowHeight() { + if (this.props.getRowHeight) { + return this.props.getRowHeight(this); + } let rowHeight = this.props.linesPerRow * this._renderedTextHeight; if (!this.props.disableFontSizeScaling) { rowHeight *= Zotero.Prefs.get('fontSize'); } + rowHeight += Zotero.Prefs.get('uiDensity') === 'comfortable' ? 11 : 5; // @TODO: Check row height across platforms and remove commented code below diff --git a/chrome/content/zotero/elements/attachmentBox.js b/chrome/content/zotero/elements/attachmentBox.js index 517bdbd56e..2c46d8b41b 100644 --- a/chrome/content/zotero/elements/attachmentBox.js +++ b/chrome/content/zotero/elements/attachmentBox.js @@ -28,6 +28,7 @@ { + let { canRenameFileFromParent, renameFileFromParent } = ChromeUtils.importESModule("chrome://zotero/content/renameFiles.mjs"); class AttachmentBox extends ItemPaneSectionElementBase { content = MozXULElement.parseXULToFragment(` @@ -43,7 +44,10 @@ - + @@ -257,6 +261,9 @@ fileName.addEventListener('focus', this._handleFileNameFocus); fileName.addEventListener('blur', this._handleFileNameBlur); + let renameFromParent = this._id("rename-from-parent"); + renameFromParent.addEventListener("command", this._handleRenameFromParent); + let noteButton = this._id('note-button'); noteButton.addEventListener("command", this._handleNoteButtonCommand); @@ -510,6 +517,12 @@ else { selectButton.hidden = true; } + + + const isRenamePossible = this._item.isAttachment() && !this._item.isTopLevelItem(); + + // Hide the rename button for cases where it's not possible to rename from parent, not editable, the file does not exist, or the file name would not be changed + this._id("rename-from-parent").hidden = !isRenamePossible || !this.editable || !fileExists || !(await canRenameFileFromParent(this._item)); } async updatePreview() { @@ -626,7 +639,7 @@ } // Force overwrite, but make sure we check that this doesn't fail - renamed = await item.renameAttachmentFile(newFilename, true); + renamed = await item.renameAttachmentFile(newFilename, { overwrite: true }); } if (renamed == -2) { @@ -787,6 +800,10 @@ } }; + _handleRenameFromParent = async () => { + await renameFileFromParent(this.item); + }; + _handleMetaLabelMousedown = (event) => { event.preventDefault(); }; diff --git a/chrome/content/zotero/itemTree.jsx b/chrome/content/zotero/itemTree.jsx index 06e2ec52c0..27dd5a36fc 100644 --- a/chrome/content/zotero/itemTree.jsx +++ b/chrome/content/zotero/itemTree.jsx @@ -2566,7 +2566,7 @@ var ItemTree = class ItemTree extends LibraryTree { let parentItem; if (parentItemID && data.length == 1 - && Zotero.Attachments.shouldAutoRenameFile(dropEffect == 'link')) { + && Zotero.Attachments.shouldAutoRenameFile(dropEffect == 'link', targetLibraryID)) { parentItem = Zotero.Items.get(parentItemID); if (!parentItem.numNonHTMLFileAttachments()) { renameIfAllowedType = true; diff --git a/chrome/content/zotero/preferences/preferences_file_renaming.js b/chrome/content/zotero/preferences/preferences_file_renaming.js index b55c835c5b..73c8f19e07 100644 --- a/chrome/content/zotero/preferences/preferences_file_renaming.js +++ b/chrome/content/zotero/preferences/preferences_file_renaming.js @@ -1,9 +1,9 @@ /* ***** BEGIN LICENSE BLOCK ***** - Copyright © Corporation for Digital Scholarship - Vienna, Virginia, USA - https://www.zotero.org + Copyright © Corporation for Digital Scholarship + Vienna, Virginia, USA + https://www.zotero.org This file is part of Zotero. @@ -24,24 +24,96 @@ */ /* global Zotero_Preferences: false */ +const { DEFAULT_ATTACHMENT_RENAME_TEMPLATE, openRenameFilesPreview, + promptAutoRenameFiles } = ChromeUtils.importESModule("chrome://zotero/content/renameFiles.mjs"); + Zotero_Preferences.FileRenaming = { mockItem: null, defaultExt: 'pdf', + prompted: false, + init: function () { - this.inputRef = document.getElementById('file-renaming-format-template'); + this.lastFormatString = Zotero.SyncedSettings.get(Zotero.Libraries.userLibraryID, 'attachmentRenameTemplate') ?? DEFAULT_ATTACHMENT_RENAME_TEMPLATE; + this.isTemplateInSync = Zotero.Prefs.get('autoRenameFiles.done'); + this.inputEl = document.getElementById('file-renaming-format-template'); + this.backButtonEl = document.getElementById('prefs-subpane-back-button'); + this.navigationEl = document.getElementById('prefs-navigation'); + this.renameNowBtnEl = document.getElementById('file-renaming-rename-now'); + this.updatePreview(); - this.inputRef.addEventListener('input', this.updatePreview.bind(this)); - this.inputRef.addEventListener('blur', this.handleInputBlur.bind(this)); + this.inputEl.addEventListener('input', this.handleInputChange.bind(this)); + this.inputEl.addEventListener('blur', this.handleInputBlur.bind(this)); + this.renameNowBtnEl.addEventListener('command', this.renameNow.bind(this)); + this.renameNowBtnEl.setAttribute('disabled', Zotero.Prefs.get('autoRenameFiles.done')); + this.inputEl.value = this.lastFormatString; this._itemsView = Zotero.getActiveZoteroPane()?.itemsView; this._updatePreview = this.updatePreview.bind(this); + this._promptReplace = this.promptReplace.bind(this); + this._handleDonePrefChange = this.handleDonePrefChange.bind(this); + + this._renameFilesPrefObserver = Zotero.Prefs.registerObserver('autoRenameFiles.done', this._handleDonePrefChange); + if (this._itemsView) { this._itemsView.onSelect.addListener(this._updatePreview); } + if (this.backButtonEl) { + this.backButtonEl.addEventListener('command', this._promptReplace); + } + if (this.navigationEl) { + this.navigationEl.addEventListener('select', this._promptReplace); + } }, uninit: function () { this._itemsView.onSelect.removeListener(this._updatePreview); + this.backButtonEl.removeEventListener('command', this._promptReplace); + this.navigationEl.removeEventListener('select', this._promptReplace); + Zotero.Prefs.unregisterObserver(this._renameFilesPrefObserver); + this.promptReplace(); + }, + + async handleInputChange() { + const formatString = this.inputEl.value; + // Ignore empty value, which we'll reset in handleInputBlur() if necessary + if (formatString.replace(/\s/g, '') === '') { + return; + } + this.updatePreview(); + await Zotero.SyncedSettings.set(Zotero.Libraries.userLibraryID, 'attachmentRenameTemplate', formatString); + + // reset 'done' to enable the rename button, set it to `false` if the + // template is out of sync (e.g., the user changed it and declined + // renaming) or if the new template has changed + Zotero.Prefs.set('autoRenameFiles.done', this.isTemplateInSync ? formatString === this.lastFormatString : false); + }, + + async handleInputBlur() { + const formatString = this.inputEl.value; + if (formatString.replace(/\s/g, '') === '') { + this.inputEl.value = this.lastFormatString = DEFAULT_ATTACHMENT_RENAME_TEMPLATE; + this.updatePreview(); + await Zotero.SyncedSettings.clear(Zotero.Libraries.userLibraryID, 'attachmentRenameTemplate'); + } + }, + + handleDonePrefChange(newValue) { + this.renameNowBtnEl.setAttribute('disabled', newValue); + + // renaming has finished, store the new value of the template and reset the flags + if (newValue) { + this.lastFormatString = Zotero.SyncedSettings.get(Zotero.Libraries.userLibraryID, 'attachmentRenameTemplate'); + this.isTemplateInSync = true; + this.prompted = false; + } + }, + + promptReplace: function () { + if (!this.prompted && !Zotero.Prefs.get('autoRenameFiles.done')) { + // Set the flag to avoid repeating the prompt while renaming is in progress or user declined renaming + this.prompted = true; + promptAutoRenameFiles(); + } }, getActiveItem() { @@ -62,22 +134,18 @@ Zotero_Preferences.FileRenaming = { updatePreview() { const [item, ext, attachmentTitle] = this.getActiveItem() ?? [this.mockItem ?? this.makeMockItem(), this.defaultExt, '']; - const formatString = this.inputRef.value; + const formatString = this.inputEl.value; const preview = Zotero.Attachments.getFileBaseNameFromItem(item, { formatString, attachmentTitle }); document.getElementById('file-renaming-format-preview').innerText = `${preview}.${ext}`; }, - handleInputBlur() { - const formatString = this.inputRef.value; - const prefKey = this.inputRef.getAttribute('preference'); - if (formatString.replace(/\s/g, '') === '') { - Zotero.Prefs.clear(prefKey, true); - this.updatePreview(); - } + async renameNow() { + openRenameFilesPreview(); }, 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' }, diff --git a/chrome/content/zotero/preferences/preferences_file_renaming.xhtml b/chrome/content/zotero/preferences/preferences_file_renaming.xhtml index 6af7971218..64df0c1afe 100644 --- a/chrome/content/zotero/preferences/preferences_file_renaming.xhtml +++ b/chrome/content/zotero/preferences/preferences_file_renaming.xhtml @@ -56,7 +56,6 @@ @@ -68,5 +67,11 @@ aria-labelledby="file-renaming-format-preview-label" id="file-renaming-format-preview" /> + +