Add continuous-renaming functionality for attachment files (#3860)

Resolves #1685
This commit is contained in:
Tom Najdek 2025-09-11 10:54:03 +02:00 committed by GitHub
parent 17bcb1e84c
commit e896fd0137
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1273 additions and 332 deletions

View file

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

View file

@ -28,6 +28,7 @@
{
let { canRenameFileFromParent, renameFileFromParent } = ChromeUtils.importESModule("chrome://zotero/content/renameFiles.mjs");
class AttachmentBox extends ItemPaneSectionElementBase {
content = MozXULElement.parseXULToFragment(`
<collapsible-section data-l10n-id="section-attachment-info" data-pane="attachment-info">
@ -43,7 +44,10 @@
</html:div>
<html:div id="fileNameRow" class="meta-row">
<html:div class="meta-label"><html:label id="fileName-label" class="key" data-l10n-id="attachment-info-filename"/></html:div>
<html:div class="meta-data"><editable-text id="fileName" aria-labelledby="fileName-label" tight="true"/></html:div>
<html:div class="meta-data">
<editable-text id="fileName" aria-labelledby="fileName-label" tight="true"/>
<toolbarbutton id="rename-from-parent" data-l10n-id="attachment-rename-from-parent" tabindex="0" oncommand=""/>
</html:div>
</html:div>
<html:div id="accessedRow" class="meta-row">
<html:div class="meta-label"><html:label id="accessed-label" class="key" data-l10n-id="attachment-info-accessed"/></html:div>
@ -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();
};

View file

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

View file

@ -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' },

View file

@ -56,7 +56,6 @@
<html:textarea
aria-labelledby="file-renaming-format-template-label"
id="file-renaming-format-template"
preference="extensions.zotero.attachmentRenameTemplate"
rows="8"
/>
<html:label id="file-renaming-format-preview-label">
@ -68,5 +67,11 @@
aria-labelledby="file-renaming-format-preview-label"
id="file-renaming-format-preview"
/>
<hbox id="file-renaming-format-preview-buttons">
<button
id="file-renaming-rename-now"
data-l10n-id="preferences-file-renaming-rename-now"
/>
</hbox>
</groupbox>
</vbox>

View file

@ -26,6 +26,7 @@
"use strict";
var { FilePicker } = ChromeUtils.importESModule('chrome://zotero/content/modules/filePicker.mjs');
let { openRenameFilesPreview, promptAutoRenameFiles } = ChromeUtils.importESModule("chrome://zotero/content/renameFiles.mjs");
Zotero_Preferences.General = {
_openURLResolvers: null,
@ -53,6 +54,7 @@ Zotero_Preferences.General = {
}
document.getElementById('openurl-primary-popup').firstChild.setAttribute('label', resolverName);
this._renameFilesPrefObserver = Zotero.Prefs.registerObserver('autoRenameFiles.done', this._handleRenameFilesDonePrefChange.bind(this));
this.refreshLocale();
this._initItemPaneHeaderUI();
this.updateAutoRenameFilesUI();
@ -61,6 +63,10 @@ Zotero_Preferences.General = {
this._initAutoDisableToolCheckbox();
},
uninit: function () {
Zotero.Prefs.unregisterObserver(this._renameFilesPrefObserver);
},
_getAutomaticLocaleMenuLabel: function () {
return Zotero.getString(
'zotero.preferences.locale.automaticWithLocale',
@ -233,6 +239,7 @@ Zotero_Preferences.General = {
}
}
Zotero.Prefs.set('autoRenameFiles.fileTypes', [...enabledTypes].join(','));
Zotero.Prefs.set('autoRenameFiles.done', false);
},
updateAutoRenameFilesUI: function () {
@ -245,8 +252,23 @@ Zotero_Preferences.General = {
checkbox.disabled = disabled;
}
document.getElementById('rename-linked-files').disabled = disabled;
document.getElementById('file-renaming-general-rename-now').setAttribute('hidden', Zotero.Prefs.get('autoRenameFiles.done'));
},
handleAutoRenameChange: function () {
if (Zotero.Prefs.get('autoRenameFiles')) {
promptAutoRenameFiles();
}
},
_handleRenameFilesDonePrefChange: function (newValue) {
document.getElementById('file-renaming-general-rename-now').setAttribute('hidden', newValue);
},
openRenameFilesPreview: function () {
openRenameFilesPreview();
},
//
// File handlers
//

View file

@ -22,7 +22,10 @@
***** END LICENSE BLOCK *****
-->
<vbox id="zotero-prefpane-general" onload="Zotero_Preferences.General.init()">
<vbox id="zotero-prefpane-general"
onload="Zotero_Preferences.General.init()"
onunload="Zotero_Preferences.General.uninit()"
>
<vbox class="main-section">
<groupbox aria-labelledby="preferences-appearance-title">
<label><html:h2 id="preferences-appearance-title" data-l10n-id="preferences-appearance-title"/></label>
@ -108,9 +111,10 @@
<vbox align="start">
<label id="preferences-file-renaming-intro" data-l10n-id="preferences-file-renaming-intro"/>
<separator class="thin"/>
<checkbox data-l10n-id="preferences-file-renaming-auto-rename-files"
<checkbox id="auto-rename-files"
data-l10n-id="preferences-file-renaming-auto-rename-files"
preference="extensions.zotero.autoRenameFiles"
oncommand="setTimeout(() => Zotero_Preferences.General.updateAutoRenameFilesUI())" native="true"
oncommand="setTimeout(() => { Zotero_Preferences.General.handleAutoRenameChange(); Zotero_Preferences.General.updateAutoRenameFilesUI(); })" native="true"
/>
<vbox class="indented-pref" aria-labelledby="preferences-file-renaming-file-types" role="group">
<label id="preferences-file-renaming-file-types" data-l10n-id="preferences-file-renaming-file-types"/>
@ -151,12 +155,19 @@
preference="extensions.zotero.autoRenameFiles.linked"
oncommand="Zotero_Preferences.General.updateAutoRenameFilesUI()" native="true"
/>
<button id="file-renaming-button"
data-l10n-id="preferences-file-renaming-customize-button"
data-search-strings="preferences-file-renaming-format-title, preferences-file-renaming-format-template"
oncommand="Zotero_Preferences.navigateToPane('zotero-subpane-file-renaming')"
/>
<hbox id="file-renaming-buttons">
<button id="file-renaming-customize-button"
data-l10n-id="preferences-file-renaming-customize-button"
data-search-strings="preferences-file-renaming-format-title, preferences-file-renaming-format-template"
oncommand="Zotero_Preferences.navigateToPane('zotero-subpane-file-renaming')"
/>
<button
hidden="true"
id="file-renaming-general-rename-now"
data-l10n-id="preferences-file-renaming-rename-now"
oncommand="Zotero_Preferences.General.openRenameFilesPreview()"
/>
</hbox>
</vbox>
</groupbox>

View file

@ -0,0 +1,318 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2025 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 <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
var { Zotero } = ChromeUtils.importESModule("chrome://zotero/content/zotero.mjs");
const clamp = (val, min = 0, max = 1.0) => Math.min(Math.max(val, min), max);
export const DEFAULT_ATTACHMENT_RENAME_TEMPLATE = "{{ firstCreator suffix=\" - \" }}{{ year suffix=\" - \" }}{{ title truncate=\"100\" }}";
export const DEFAULT_AUTO_RENAME_FILE_TYPES = "application/pdf,application/epub+zip";
/**
* Rename eligible attachment files based on their parent items' metadata.
* @async
* @param {Object} [options]
* @param {boolean} [options.userLibrary=true] - Process "My Library".
* @param {boolean} [options.groupLibrary=false] - Process group libraries.
* @param {boolean} [options.pretend=false] - If true, perform a dry run (compile a list of files to rename).
* @param {(progress:number)=>void} [options.reportProgress] - Callback for progress updates (0..1).
* @returns {Promise<Array<{attachmentId:number,parentItemId:number,oldName:string,newName:string,isFilePresent:boolean}>>}
* Summary of (performed or proposed) rename operations.
*/
export async function renameFilesFromParent({ userLibrary = true, groupLibrary = false, pretend = false, reportProgress = () => {} } = {}) {
const t1 = Date.now();
let summary = [];
let progress = 0;
let adjustProgressBy = (additionalProgress) => {
progress = clamp(progress + additionalProgress);
reportProgress(progress);
};
let libraries = Zotero.Libraries.getAll();
adjustProgressBy(0.01); // move progress bar slightly while we load required data
let items = [];
let librariesWithAttachmentsToRename = [];
for (let library of libraries) {
let shouldRename = userLibrary && library.libraryType === 'user';
if (!shouldRename) {
// for group libraries, check `autoRenameFiles` synced setting
shouldRename = groupLibrary && Zotero.SyncedSettings.get(library.libraryID, 'autoRenameFiles');
}
if (shouldRename) {
items.push(...await Zotero.Items.getAll(library.libraryID, false, true));
librariesWithAttachmentsToRename.push(library);
}
}
adjustProgressBy(0.01);
await Zotero.Items.loadDataTypes(items, ['itemData']);
adjustProgressBy(0.01);
// use remaining 97% of progress bar for renaming attachments
let perItemProgress = 0.97 / items.length;
let count = 0;
let noFilePresentCount = 0;
for (let parentItem of items) {
adjustProgressBy(perItemProgress);
if (!parentItem.isTopLevelItem() || !parentItem.isRegularItem()) {
continue;
}
let attachmentItem = await parentItem.getBestAttachment();
if (!attachmentItem) {
continue;
}
if (!Zotero.Attachments.shouldAutoRenameAttachment(attachmentItem)) {
continue;
}
let path = await attachmentItem.getFilePathAsync();
const ext = path ? Zotero.File.getExtension(path) : attachmentItem.attachmentFilename.split('.').pop();
let newName = Zotero.Attachments.getFileBaseNameFromItem(parentItem, { attachmentTitle: attachmentItem.getField('title') });
let newNameWithExtension = ext.length ? `${newName}.${ext}` : newName;
Zotero.debug(`Renaming attachment ${attachmentItem.id} on parent item ${parentItem.id} to ${newName}`);
if (newNameWithExtension !== attachmentItem.attachmentFilename) {
summary.push({
attachmentId: attachmentItem.id,
parentItemId: parentItem.id,
oldName: attachmentItem.attachmentFilename,
newName: newNameWithExtension,
isFilePresent: !!path
});
}
if (!pretend) {
if (path) {
let out = {};
const renamed = await attachmentItem.renameAttachmentFile(newNameWithExtension, { updateTitle: true, out });
if (out.noChange) {
continue;
}
if (renamed === true) {
count++;
}
else {
Zotero.debug(`Failed to rename attachment ${attachmentItem.id} on parent item ${parentItem.id}`);
}
}
else if (attachmentItem.attachmentFilename !== newName && attachmentItem.isStoredFileAttachment()) {
const oldFileName = attachmentItem.attachmentFilename;
const oldBaseName = attachmentItem.attachmentFilename.replace(/\.[^.]+$/, '');
// file is not present locally but we can still update filename in the database
attachmentItem.attachmentFilename = newName;
// update title if it matches the old filename
if (attachmentItem.getField('title') === oldBaseName || attachmentItem.getField('title') === oldFileName) {
attachmentItem.setAutoAttachmentTitle({ ignoreAutoRenamePrefs: true });
}
await attachmentItem.saveTx();
noFilePresentCount++;
}
}
}
const t2 = Date.now();
if (!pretend) {
Zotero.debug(`Renaming ${count + noFilePresentCount} attachments (${noFilePresentCount} with no file present) in ${librariesWithAttachmentsToRename.length} `
+ `libraries took ${((t2 - t1) / 1000).toFixed(2)} seconds `
+ `(user library: ${userLibrary}, group libraries: ${groupLibrary})`);
Zotero.Prefs.set('autoRenameFiles.done', true);
}
return summary;
};
/**
* Renames an invidual attachment file based on its parent item's metadata.
*
* @async
* @param {Zotero.Item} attachmentItem - The attachment item to be renamed.
* @throws {Error} If the item is not a valid attachment for renaming.
* @returns {Promise}
*/
export async function renameFileFromParent(attachmentItem) {
if (!attachmentItem.isAttachment() || attachmentItem.isTopLevelItem() || attachmentItem.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
throw new Error('Item ' + attachmentItem.itemID + ' cannot be renamed based on its parent item');
}
const parentItemID = attachmentItem.parentItemID;
let parentItem = await Zotero.Items.getAsync(parentItemID);
const oldBaseName = attachmentItem.attachmentFilename.replace(/\.[^.]+$/, '');
const fileBaseName = Zotero.Attachments.getFileBaseNameFromItem(parentItem, { attachmentTitle: attachmentItem.getField('title') });
const ext = Zotero.Attachments.getCorrectFileExtension(attachmentItem);
const newName = fileBaseName + (ext ? '.' + ext : '');
const renamed = await attachmentItem.renameAttachmentFile(newName, { updateTitle: false, unique: true });
let requiresSave = false;
if (!renamed && attachmentItem.isStoredFileAttachment()) {
// file is not present locally but we can still update filename in the database
attachmentItem.attachmentFilename = newName;
requiresSave = true;
}
if (attachmentItem.getField('title') === oldBaseName) {
attachmentItem.setAutoAttachmentTitle({ ignoreAutoRenamePrefs: true });
requiresSave = true;
}
if (requiresSave) {
await attachmentItem.saveTx();
}
};
export async function canRenameFileFromParent(attachmentItem) {
if (!attachmentItem.isAttachment() || attachmentItem.isTopLevelItem() || attachmentItem.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
return false;
}
let path = await attachmentItem.getFilePathAsync();
if (!path) {
return false;
}
const parentItemID = attachmentItem.parentItemID;
let parentItem = await Zotero.Items.getAsync(parentItemID);
const origFilename = PathUtils.filename(path);
const ext = Zotero.File.getExtension(path);
let newName = Zotero.Attachments.getFileBaseNameFromItem(parentItem, { attachmentTitle: attachmentItem.getField('title') });
newName = ext.length ? `${newName}.${ext}` : newName;
return newName !== origFilename;
};
export function registerAutoRenameFileFromParent() {
Zotero.Notifier.registerObserver({
notify: async (event, _type, ids, extraData) => {
if (!Zotero.Prefs.get('autoRenameFiles.onMetadataChange')) {
return;
}
if (event !== 'modify') {
return;
}
for (let id of ids) {
if (extraData[id]?.skipRenameFile) {
continue;
}
const parentItem = await Zotero.Items.getAsync(id);
if (!parentItem.isTopLevelItem() || !parentItem.isRegularItem()) {
continue;
}
let attachmentItem = await parentItem.getBestAttachment();
if (!attachmentItem) {
continue;
}
if (!Zotero.Attachments.shouldAutoRenameAttachment(attachmentItem)) {
continue;
}
if (!extraData?.[id]?.changed) {
continue;
}
let changes = Object.entries(extraData[id].changed).filter(([key, _value]) => {
if (['tags', 'collections'].includes(key)) {
return false; // Don't care about tags or collections
}
return true;
});
if (changes.length === 0) {
continue; // No relevant changes
}
let parentItemBefore = parentItem.clone(null, { skipTags: true, includeCollections: false });
let validFields = Zotero.ItemFields.getItemTypeFields(parentItem.itemTypeID).map(fieldID => Zotero.ItemFields.getName(fieldID));
for (let [key, value] of changes) {
if (key === 'itemType') {
parentItemBefore.setType(value);
}
else if (key === 'creators') {
parentItemBefore.setCreators(value);
}
else if (validFields.includes(key)) {
parentItemBefore.setField(key, value);
}
}
let previousMetadataBaseName = Zotero.Attachments.getFileBaseNameFromItem(
parentItemBefore, { attachmentTitle: attachmentItem.getField('title') }
);
let currentBaseName = attachmentItem.attachmentFilename?.replace(/\.[^.]+$/, '') ?? '';
if (previousMetadataBaseName === currentBaseName) {
// Filename appears to be derived from the metadata, so update it to match the latest metadata.
renameFileFromParent(attachmentItem);
}
else {
// Filename has most likely been manually changed, so
// dont rename it. Reset `autoRenameFiles.done` so that
// "Rename Files Now" appears in Preferences.
Zotero.Prefs.set('autoRenameFiles.done', false);
}
}
}
}, ['item'], 'autoRenameFileFromParent', 150); // lower priority than the other item observers
}
export async function openRenameFilesPreview() {
Services.ww.openWindow(null, "chrome://zotero/content/renameFilesPreview.xhtml",
"renameFilesPreview", "chrome,dialog=yes,centerscreen,modal", null);
}
export async function promptAutoRenameFiles() {
let [title, description, yes, no] = await Zotero.getMainWindow().document.l10n.formatValues([
'file-renaming-auto-rename-prompt-title',
'file-renaming-auto-rename-prompt-body',
'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) {
openRenameFilesPreview();
}
else {
Zotero.Prefs.set('autoRenameFiles.done', false);
}
}

View file

@ -0,0 +1,151 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2025 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 <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
import VirtualizedTable from 'components/virtualized-table';
import React from 'react'; // eslint-disable-line no-unused-vars
import ReactDOM from "react-dom";
const { renameFilesFromParent } = ChromeUtils.importESModule("chrome://zotero/content/renameFiles.mjs");
var RenameFilesPreview = { // eslint-disable-line no-unused-vars
rows: [],
columns: [],
introEl: null,
filesListEl: null,
treeRef: null,
loadingEl: null,
progressEl: null,
acceptBtnEl: null,
cancelBtnEl: null,
init: function () {
this.introEl = document.getElementById('intro');
this.filesListEl = document.getElementById('renamed-files-list');
this.loadingEl = document.getElementById('loading');
this.progressEl = document.getElementById('progress');
this.progressEl.classList.add('hidden');
this.acceptBtnEl = document.querySelector('dialog').getButton('accept');
this.cancelBtnEl = document.querySelector('dialog').getButton('cancel');
this.acceptBtnEl.disabled = false;
this.rowRenderer = VirtualizedTable.makeRowRenderer(this.getRowData.bind(this));
document.addEventListener('dialogaccept', this.handleAcceptClick.bind(this));
setTimeout(this.pretendRenameItems.bind(this), 0);
},
handleAcceptClick: async function (ev) {
ev.preventDefault();
this.filesListEl.remove();
this.progressEl.classList.remove('hidden');
this.introEl.dataset.l10nId = 'rename-files-preview-renaming';
this.acceptBtnEl.disabled = true;
this.cancelBtnEl.disabled = true;
await renameFilesFromParent({ reportProgress: this.updateProgress.bind(this) });
setTimeout(() => {
// remain open for a moment longer so that user can see 100% complete
window.close();
}, 500);
},
prepareColumns: async function () {
return [
{ dataKey: 'name', label: '', primary: true, flex: 1 },
];
},
pretendRenameItems: async function () {
this.columns = await this.prepareColumns();
let rows = await renameFilesFromParent({ pretend: true });
this.loadingEl.remove();
if (rows.length === 0) {
this.introEl.dataset.l10nId = 'rename-files-preview-no-files';
this.cancelBtnEl.label = await document.l10n.formatValue('general-done');
this.acceptBtnEl.remove();
// There is nothing that would be renamed; ensure the “Rename Files” button is hidden in the preferences pane.
Zotero.Prefs.set('autoRenameFiles.done', true);
}
else {
this.rows = rows.flatMap(obj => [{ name: obj.oldName }, { name: obj.newName }, { type: 'separator' }]);
this.acceptBtnEl.disabled = false;
this.render();
}
},
updateProgress: async function (progress) {
this.progressEl.value = progress;
},
render: function () {
let customRowHeights = [];
this.rows.forEach((row, index) => {
if (row.type === 'separator') {
customRowHeights.push([index, 8]);
}
});
ReactDOM.createRoot(this.filesListEl).render((
<VirtualizedTable
columns={this.columns}
containerWidth={this.filesListEl.clientWidth}
customRowHeights={customRowHeights}
disableFontSizeScaling={true}
getRowCount={() => this.rows.length}
getRowHeight={this.getRowHeight.bind(this)}
id="rename-files-confirm-table"
isSelectable={this.getIsSelectable.bind(this)}
ref={ref => this.treeRef = ref}
renderItem={this.renderItem.bind(this)}
showHeader={false}
/>
));
},
renderItem: function (index, selection, oldDiv, ...args) {
if (this.rows[index].type === 'separator') {
let div = oldDiv || document.createElement('div');
div.innerHTML = '';
div.className = 'row separator';
return div;
}
else {
let div = this.rowRenderer(index, selection, oldDiv, ...args);
div.classList.toggle('old', index % 3 === 0);
div.classList.toggle('new', index % 3 === 1);
return div;
}
},
getIsSelectable: function (index) {
return this.rows[index].type !== 'separator';
},
getRowData: function (index) {
return this.rows[index];
},
getRowHeight: function ({ _renderedTextHeight }) {
return _renderedTextHeight;
}
};

View file

@ -0,0 +1,60 @@
<?xml version="1.0"?>
<!--
***** BEGIN LICENSE BLOCK *****
Copyright © 2025 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 <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
-->
<?xml-stylesheet href="chrome://global/skin/"?>
<?xml-stylesheet href="chrome://zotero/skin/zotero.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero/skin/overlay.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero-platform/content/overlay.css"?>
<?xml-stylesheet href="chrome://zotero-platform/content/zotero.css"?>
<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" xmlns:html="http://www.w3.org/1999/xhtml"
id="rename-files-preview" class="zotero-dialog" drawintitlebar-platforms="mac" onload="RenameFilesPreview.init()">
<dialog buttons="cancel,accept" data-l10n-id="rename-files-preview" data-l10n-attrs="buttonlabelaccept">
<script>
Services.scriptloader.loadSubScript('chrome://zotero/content/include.js', this);
Services.scriptloader.loadSubScript('chrome://zotero/content/titlebar.js', this);
Services.scriptloader.loadSubScript('chrome://zotero/content/customElements.js', this);
Services.scriptloader.loadSubScript('chrome://zotero/content/renameFilesPreview.js', this);
</script>
<linkset>
<html:link rel="localization" href="branding/brand.ftl" />
<html:link rel="localization" href="zotero.ftl" />
<html:link rel="localization" href="preferences.ftl" />
</linkset>
<vbox>
<html:p id="intro" data-l10n-id="rename-files-preview-intro" />
</vbox>
<vbox class="virtualized-table-container">
<html:div id="renamed-files-list" class="virtualized-table-wrapper">
<html:div class="virtualized-table-loading" id="loading">
<html:span data-l10n-id="rename-files-confirmation-loading" />
</html:div>
</html:div>
<html:progress id="progress" max="1" value="0" />
</vbox>
</dialog>
</window>

View file

@ -810,7 +810,7 @@ const ZoteroStandalone = new function () {
ZoteroPane.loadURI(ZOTERO_CONFIG.SUPPORT_URL);
}
}
/**
* Checks for updates
*/

View file

@ -581,7 +581,7 @@ Zotero.Attachments = new function () {
// Save using remote web browser persist
var externalHandlerImport = async function (contentType) {
// Rename attachment
if (renameIfAllowedType && !fileBaseName && this.isRenameAllowedForType(contentType)) {
if (renameIfAllowedType && !fileBaseName && this.isRenameAllowedForType(contentType, libraryID)) {
let parentItem = Zotero.Items.get(parentItemID);
fileBaseName = this.getFileBaseNameFromItem(parentItem, { attachmentTitle: title });
}
@ -2329,7 +2329,7 @@ Zotero.Attachments = new function () {
* based on the metadata of the specified item and a format string
*
* (Optional) |formatString| specifies the format string -- otherwise
* the 'attachmentRenameTemplate' pref is used
* the 'attachmentRenameTemplate' synced setting for the user's library is used
*
* @param {Zotero.Item} item
* @param {String} formatString
@ -2338,6 +2338,9 @@ Zotero.Attachments = new function () {
if (!(item instanceof Zotero.Item)) {
throw new Error("'item' must be a Zotero.Item");
}
if (!item.libraryID) {
throw new Error("Item must have a libraryID");
}
if (typeof options === 'string') {
Zotero.warn("Zotero.Attachments.getFileBaseNameFromItem(item, formatString) is deprecated -- use Zotero.Attachments.getFileBaseNameFromItem(item, options)");
options = { formatString: options };
@ -2346,7 +2349,8 @@ Zotero.Attachments = new function () {
let { formatString = null, attachmentTitle = '' } = options;
if (!formatString) {
formatString = Zotero.Prefs.get('attachmentRenameTemplate');
const { DEFAULT_ATTACHMENT_RENAME_TEMPLATE } = ChromeUtils.importESModule("chrome://zotero/content/renameFiles.mjs");
formatString = Zotero.SyncedSettings.get(item.libraryID, 'attachmentRenameTemplate') ?? DEFAULT_ATTACHMENT_RENAME_TEMPLATE;
}
let chunks = [];
@ -2615,28 +2619,59 @@ Zotero.Attachments = new function () {
return ext;
};
this.shouldAutoRenameFile = function (isLink) {
if (!Zotero.Prefs.get('autoRenameFiles')) {
this.shouldAutoRenameFile = function (isLink, libraryID = null) {
if (libraryID === null) {
Zotero.debug('Calling Zotero.Attachments.shouldAutoRenameFile without a libraryID is deprecated. Assuming user library.');
libraryID = Zotero.Libraries.userLibraryID;
}
if (libraryID === Zotero.Libraries.userLibraryID && !Zotero.Prefs.get('autoRenameFiles')) {
return false;
}
if (libraryID !== Zotero.Libraries.userLibraryID && !Zotero.SyncedSettings.get(libraryID, 'autoRenameFiles')) {
return false;
}
if (isLink) {
return Zotero.Prefs.get('autoRenameFiles.linked');
// Linked files may only be renamed in the user library, where it's based on the preference (in group libraries, it's always false)
return libraryID === Zotero.Libraries.userLibraryID ? Zotero.Prefs.get('autoRenameFiles.linked') : false;
}
return true;
}
this.isRenameAllowedForType = function (contentType) {
this.isRenameAllowedForType = function (contentType, libraryID = null) {
let typePrefixes;
try {
typePrefixes = Zotero.Prefs.get('autoRenameFiles.fileTypes')
.split(',')
.filter(Boolean);
}
catch (e) {
typePrefixes = [];
if (libraryID === null) {
Zotero.debug('Calling Zotero.Attachments.isRenameAllowedForType without a libraryID is deprecated. Assuming user library.');
libraryID = Zotero.Libraries.userLibraryID;
}
if (libraryID === Zotero.Libraries.userLibraryID) {
try {
typePrefixes = Zotero.Prefs.get('autoRenameFiles.fileTypes')
.split(',')
.filter(Boolean);
}
catch (e) { // eslint-disable-line no-unused-vars
typePrefixes = [];
}
}
else {
try {
typePrefixes = Zotero.SyncedSettings.get(libraryID, 'autoRenameFilesFileTypes')
.split(',')
.filter(Boolean);
}
catch (e) { // eslint-disable-line no-unused-vars
const { DEFAULT_AUTO_RENAME_FILE_TYPES } = ChromeUtils.importESModule("chrome://zotero/content/renameFiles.mjs");
typePrefixes = DEFAULT_AUTO_RENAME_FILE_TYPES
.split(',')
.filter(Boolean);
}
}
return typePrefixes.some(prefix => contentType.startsWith(prefix));
};
@ -2654,17 +2689,22 @@ Zotero.Attachments = new function () {
this.shouldAutoRenameAttachment = function (attachment) {
return Zotero.Attachments.shouldAutoRenameFile(attachment.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE)
&& Zotero.Attachments.isRenameAllowedForType(attachment.attachmentContentType);
return Zotero.Attachments.shouldAutoRenameFile(attachment.isLinkedFileAttachment(), attachment.libraryID)
&& Zotero.Attachments.isRenameAllowedForType(attachment.attachmentContentType, attachment.libraryID)
&& !attachment.isSnapshotAttachment();
};
// NOTE: This should only be used during attachment item creation, where the
// attachment item does not exist yet, because when generating a new
// filename, the current file name is used in place of the
// `attachmentTitle`.
this.getRenamedFileBaseNameIfAllowedType = async function (parentItem, file) {
var contentType = file.endsWith('.pdf')
// Don't bother reading file if there's a .pdf extension
? 'application/pdf'
: await Zotero.MIME.getMIMETypeFromFile(file);
if (!this.isRenameAllowedForType(contentType)) {
if (!this.isRenameAllowedForType(contentType, parentItem.libraryID)) {
return false;
}
return this.getFileBaseNameFromItem(parentItem, { attachmentTitle: PathUtils.filename(file) });

View file

@ -2889,15 +2889,25 @@ Zotero.Item.prototype.fileExistsCached = function () {
/**
* Rename file associated with an attachment
*
* @param {String} newName
* @param {Boolean} [overwrite=false] - Overwrite file if one exists
* @param {Boolean} [unique=false] - Add suffix to create unique filename if necessary
* @return {Number|false} -- true - Rename successful
* -1 - Destination file exists; use _force_ to overwrite
* -2 - Error renaming
* false - Attachment file not found
* @param {String} newName - The new name for the file
* @param {Object} [options={}] - Options for renaming the file
* @param {Boolean} [options.overwrite=false] - Overwrite file if one exists
* @param {Boolean} [options.unique=false] - Add suffix to create unique filename if necessary
* @param {Boolean} [options.updateTitle=false] - Also update the attachment item title if currently matches filename
* @param {Object} [options.out={}] - Output object for additional information about the operation
* @return {Number|Boolean} - Returns:
* - true: Rename successful
* - -1: Destination file exists; use _force_ to overwrite
* - -2: Error renaming
* - false: Attachment file not found
*/
Zotero.Item.prototype.renameAttachmentFile = async function (newName, overwrite = false, unique = false) {
Zotero.Item.prototype.renameAttachmentFile = async function (newName, options = { overwrite: false, unique: false, updateTitle: false, out: {} }, ...rest) {
if (typeof options === 'boolean') {
Zotero.debug("Zotero.Item.renameAttachmentFile() now takes an options object as a second argument -- update your code", 2);
options = { overwrite: options, unique: rest[0], updateTitle: false, out: {} };
}
let { overwrite, unique, updateTitle, out = {} } = options;
var origPath = await this.getFilePathAsync();
if (!origPath) {
Zotero.debug("Attachment file not found in renameAttachmentFile()", 2);
@ -2905,11 +2915,12 @@ Zotero.Item.prototype.renameAttachmentFile = async function (newName, overwrite
}
try {
let origName = PathUtils.filename(origPath);
let origFilename = PathUtils.filename(origPath);
// No change
if (origName === newName) {
if (origFilename === newName) {
Zotero.debug("Filename has not changed");
out.noChange = true;
return true;
}
@ -2928,6 +2939,22 @@ Zotero.Item.prototype.renameAttachmentFile = async function (newName, overwrite
await this.relinkAttachmentFile(destPath);
if (updateTitle) {
// Update title if it matches the old filename
const ext = Zotero.File.getExtension(origPath);
let origFilenameNoExt = origFilename;
if (ext.length && origFilename.endsWith(ext)) {
origFilenameNoExt = origFilename.substring(0, origFilename.length - ext.length - 1);
}
let origTitle = this.getField('title');
if (origTitle === origFilename || origTitle === origFilenameNoExt) {
this.setField('title', newName);
out.titleUpdated = true;
await this.saveTx();
}
}
return true;
}
catch (e) {

View file

@ -46,7 +46,7 @@ Zotero.Prefs = new function () {
// Process pref version updates
var fromVersion = this.get('prefVersion');
var toVersion = 16;
var toVersion = 18;
if (!fromVersion) {
this.set('prefVersion', toVersion);
}
@ -191,11 +191,27 @@ Zotero.Prefs = new function () {
case 16:
this.clear('reopenPanesOnRestart');
break;
case 17: {
if (this.get('autoRenameFiles')) {
// If the user has `autoRenameFiles` enabled, show a banner informing that file names are now kept in sync
this.set('autoRenameFiles.bannerShown', false);
}
let attachmentRenameTemplate = this.get('attachmentRenameTemplate');
if (this.prefHasUserValue('attachmentRenameTemplate')) {
// If the user has a custom template, reset `autoRenameFiles.done` so that the "Rename Files Now" button appears in preferences
Zotero.initializationPromise.then(() => {
Zotero.SyncedSettings.set(Zotero.Libraries.userLibraryID, 'attachmentRenameTemplate', attachmentRenameTemplate);
this.set('autoRenameFiles.done', false);
});
}
break;
}
}
}
this.set('prefVersion', toVersion);
}
}
};
/**

View file

@ -301,7 +301,7 @@ Zotero.RecognizeDocument = new function () {
let fileBaseName = Zotero.Attachments.getFileBaseNameFromItem(parentItem, { attachmentTitle: originalTitle });
let ext = Zotero.Attachments.getCorrectFileExtension(attachment);
let newName = fileBaseName + (ext ? '.' + ext : '');
let result = await attachment.renameAttachmentFile(newName, false, true);
let result = await attachment.renameAttachmentFile(newName, { overwrite: false, unique: true });
if (result !== true) {
throw new Error("Error renaming " + path);
}

View file

@ -1494,6 +1494,22 @@ Zotero.Sync.Data.Local = {
_saveObjectFromJSON: async function (obj, json, options) {
var results = {};
var saveOptions = {
skipEditCheck: true,
skipDateModifiedUpdate: true,
skipSelect: true,
skipCache: options.skipCache || false,
notifierQueue: options.notifierQueue,
// Errors are logged elsewhere, so skip in DataObject.save()
errorHandler: function (e) {
return;
}
};
if (obj.objectType == 'item') {
saveOptions.notifierData = {
skipRenameFile: true
};
}
try {
results.key = json.key;
json = this._checkCacheJSON(json);
@ -1523,17 +1539,7 @@ Zotero.Sync.Data.Local = {
if (!options.saveAsUnsynced) {
obj.synced = true;
}
await obj.save({
skipEditCheck: true,
skipDateModifiedUpdate: true,
skipSelect: true,
skipCache: options.skipCache || false,
notifierQueue: options.notifierQueue,
// Errors are logged elsewhere, so skip in DataObject.save()
errorHandler: function (e) {
return;
}
});
await obj.save(saveOptions);
let cacheJSON = options.cacheObject ? options.cacheObject : json.data;
await this.saveCacheObject(obj.objectType, obj.libraryID, cacheJSON);
// Delete older versions of the object in the cache
@ -1575,16 +1581,7 @@ Zotero.Sync.Data.Local = {
for (let c of options.newParentItemCollections) {
parentItem.addToCollection(c);
}
await parentItem.save({
skipEditCheck: true,
skipDateModifiedUpdate: true,
skipSelect: true,
notifierQueue: options.notifierQueue,
// Errors are logged elsewhere, so skip in DataObject.save()
errorHandler: function (e) {
return;
}
});
await parentItem.save(saveOptions);
}
}
catch (e) {

View file

@ -700,6 +700,11 @@ const { CommandLineOptions } = ChromeUtils.importESModule("chrome://zotero/conte
Zotero.Notifier.registerObserver(Zotero.Tags, 'setting', 'tags');
const { registerAutoRenameFileFromParent } = ChromeUtils.importESModule(
"chrome://zotero/content/renameFiles.mjs"
);
registerAutoRenameFileFromParent();
await Zotero.Sync.Data.Local.init();
await Zotero.Sync.Data.Utilities.init();
Zotero.Sync.Storage.Local.init();

View file

@ -638,6 +638,7 @@ var ZoteroPane = new function () {
ZoteroPane.showPostUpgradeBanner();
ZoteroPane.showRetractionBanner();
ZoteroPane.showArchitectureWarning();
ZoteroPane.showFileRenamingBanner();
ZoteroPane.initSyncReminders(true);
});
@ -2942,7 +2943,6 @@ var ZoteroPane = new function () {
'syncReminder');
};
this.showSetUpSyncReminder = function () {
const sevenDays = 60 * 60 * 24 * 7;
@ -3698,7 +3698,6 @@ var ZoteroPane = new function () {
'recognizePDF',
'unrecognize',
'createParent',
'renameAttachments',
'reindexItem',
];
@ -3755,8 +3754,7 @@ var ZoteroPane = new function () {
showRelate = true, canRelate = true,
canIndex = true,
canRecognize = true,
canUnrecognize = true,
canRename = true;
canUnrecognize = true;
var canMarkRead = collectionTreeRow.isFeedsOrFeed();
var markUnread = true;
@ -3786,12 +3784,7 @@ var ZoteroPane = new function () {
canUnrecognize = false;
}
// Show rename option only if all items are child attachments
if (canRename && (!item.isAttachment() || item.isTopLevelItem() || item.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL)) {
canRename = false;
}
if(canMarkRead && markUnread && !item.isRead) {
if (canMarkRead && markUnread && !item.isRead) {
markUnread = false;
}
}
@ -3872,13 +3865,10 @@ var ZoteroPane = new function () {
if (canCreateParent) {
show.add(m.createParent);
}
if (canRename) {
show.add(m.renameAttachments);
}
// Add in attachment separator
if (canCreateParent || canRecognize || canUnrecognize || canRename || canIndex) {
if (canCreateParent || canRecognize || canUnrecognize || canIndex) {
show.add(m.sep5);
}
@ -3889,7 +3879,6 @@ var ZoteroPane = new function () {
if (item.isFileAttachment()) {
disable.add(m.moveToTrash);
disable.add(m.createParent);
disable.add(m.renameAttachments);
break;
}
}
@ -3971,12 +3960,6 @@ var ZoteroPane = new function () {
showSep5 = true;
}
// Attachment rename option
if (!item.isTopLevelItem() && item.attachmentLinkMode != Zotero.Attachments.LINK_MODE_LINKED_URL) {
show.add(m.renameAttachments);
showSep5 = true;
}
// If not linked URL, show reindex line
if (await Zotero.Fulltext.canReindex(item)) {
show.add(m.reindexItem);
@ -4026,7 +4009,7 @@ var ZoteroPane = new function () {
// Block certain actions on files if no access
if (item.isFileAttachment() && !collectionTreeRow.filesEditable) {
[m.moveToTrash, m.createParent, m.renameAttachments]
[m.moveToTrash, m.createParent]
.forEach(function (x) {
disable.add(x);
});
@ -4167,7 +4150,6 @@ var ZoteroPane = new function () {
menu.childNodes[m.loadReport].setAttribute('label', Zotero.getString('pane.items.menu.generateReport' + multiple));
menu.childNodes[m.createParent].setAttribute('label', Zotero.getString('pane.items.menu.createParent' + multiple));
menu.childNodes[m.recognizePDF].setAttribute('label', Zotero.getString('pane.items.menu.recognizeDocument'));
menu.childNodes[m.renameAttachments].setAttribute('label', Zotero.getString('pane.items.menu.renameAttachments' + multiple));
menu.childNodes[m.reindexItem].setAttribute('label', Zotero.getString('pane.items.menu.reindexItem' + multiple));
// Hide and enable all actions by default (so if they're shown they're enabled)
@ -4749,7 +4731,7 @@ var ZoteroPane = new function () {
// If only one item is being added, automatic renaming is enabled, and the parent item
// doesn't have any other non-HTML file attachments, rename the file.
// This should be kept in sync with itemTreeView::drop().
if (files.length == 1 && Zotero.Attachments.shouldAutoRenameFile(link)) {
if (files.length == 1 && Zotero.Attachments.shouldAutoRenameFile(link, libraryID)) {
let parentItem = Zotero.Items.get(parentItemID);
if (!parentItem.numNonHTMLFileAttachments()) {
fileBaseName = await Zotero.Attachments.getRenamedFileBaseNameIfAllowedType(
@ -5874,7 +5856,7 @@ var ZoteroPane = new function () {
let fileBaseName = Zotero.Attachments.getFileBaseNameFromItem(item.parentItem, { attachmentTitle: item.getField('title') });
let ext = Zotero.Attachments.getCorrectFileExtension(item);
let newName = fileBaseName + (ext ? '.' + ext : '');
let result = await item.renameAttachmentFile(newName, false, true);
let result = await item.renameAttachmentFile(newName, { overwrite: false, unique: true });
if (result !== true) {
throw new Error('Error renaming ' + path);
}
@ -6185,59 +6167,6 @@ var ZoteroPane = new function () {
}
};
this.renameSelectedAttachmentsFromParents = async function () {
// TEMP: fix
if (!this.canEdit()) {
this.displayCannotEditLibraryMessage();
return;
}
var items = this.getSelectedItems();
if (!items.length) return;
var progressWin = new Zotero.ProgressWindow();
for (var i=0; i<items.length; i++) {
var item = items[i];
if (!item.isAttachment() || item.isTopLevelItem() || item.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
throw('Item ' + itemID + ' is not a child file attachment in ZoteroPane_Local.renameAttachmentFromParent()');
}
var file = await item.getFilePathAsync();
if (!file) {
continue;
}
let parentItemID = item.parentItemID;
let parentItem = await Zotero.Items.getAsync(parentItemID);
var oldBaseName = item.attachmentFilename.replace(/\.[^.]+$/, '');
var fileBaseName = Zotero.Attachments.getFileBaseNameFromItem(parentItem, { attachmentTitle: item.getField('title') });
let ext = Zotero.Attachments.getCorrectFileExtension(item);
let newName = fileBaseName + (ext ? '.' + ext : '');
var renamed = await item.renameAttachmentFile(newName, false, true);
if (renamed !== true) {
Zotero.debug("Could not rename file (" + renamed + ")");
continue;
}
if (item.getField('title') === oldBaseName) {
item.setAutoAttachmentTitle({ ignoreAutoRenamePrefs: true });
await item.saveTx();
}
let str = await document.l10n.formatValue('file-renaming-file-renamed-to', { filename: newName });
progressWin.addLines(str, item.getItemTypeIconName());
progressWin.show();
}
progressWin.startCloseTimer(4000);
};
this.convertLinkedFilesToStoredFiles = async function () {
if (!this.canEdit() || !this.canEditFiles()) {
this.displayCannotEditLibraryMessage();
@ -6777,6 +6706,32 @@ var ZoteroPane = new function () {
}
};
this.showFileRenamingBanner = function () {
if (Zotero.Prefs.get('autoRenameFiles.bannerShown')) {
return;
}
document.getElementById('file-renaming-documentation-link').onclick = () => {
Zotero.launchURL("https://www.zotero.org/support/file_renaming");
};
document.getElementById('file-renaming-settings-link').onclick = () => {
Zotero.Utilities.Internal.openPreferences('zotero-prefpane-general', {
scrollTo: '#zotero-prefpane-file-renaming-groupbox'
});
};
this.document.getElementById('file-renaming-banner-close').onclick = () => {
this.hideFileRenamingBanner();
};
this.document.getElementById('file-renaming-banner-container').removeAttribute('collapsed');
};
this.hideFileRenamingBanner = function () {
document.getElementById('file-renaming-banner-container').setAttribute('collapsed', true);
Zotero.Prefs.set('autoRenameFiles.bannerShown', true);
};
/**
* Sets the layout to either a three-vertical-pane layout and a layout where itemsPane is above itemPane

View file

@ -991,7 +991,6 @@
<menuitem class="menuitem-iconic zotero-menuitem-retrieve-metadata" oncommand="ZoteroPane.recognizeSelected();"/>
<menuitem class="menuitem-iconic zotero-menuitem-unrecognize" label="&zotero.items.menu.unrecognize;" oncommand="ZoteroPane.unrecognizeSelected()"/>
<menuitem class="menuitem-iconic zotero-menuitem-create-parent" oncommand="ZoteroPane_Local.createParentItemsFromSelected();"/>
<menuitem class="menuitem-iconic zotero-menuitem-rename-from-parent" oncommand="ZoteroPane_Local.renameSelectedAttachmentsFromParents()"/>
<menuitem class="menuitem-iconic zotero-menuitem-reindex" oncommand="ZoteroPane_Local.reindexItem();"/>
</menupopup>
@ -1158,6 +1157,15 @@
</html:div>
</vbox>
<vbox id="file-renaming-banner-container" class="banner-container" collapsed="true" role="status">
<html:div id="file-renaming-banner" class="banner">
<html:div id="file-renaming-message" class="message" data-l10n-id="file-renaming-banner-message" />
<html:a id="file-renaming-documentation-link" data-l10n-id="file-renaming-banner-documentation-link" class="link" />
<html:a id="file-renaming-settings-link" data-l10n-id="file-renaming-banner-settings-link" class="link" />
<html:div class="spacer" />
<label is="text-link" id="file-renaming-banner-close" class="close-link">×</label>
</html:div>
</vbox>
<hbox id="zotero-trees" flex="1">
<vbox id="zotero-collections-pane" zotero-persist="width">

View file

@ -7,9 +7,10 @@ preferences-auto-recognize-files =
.label = Automatically retrieve metadata for PDFs and ebooks
preferences-file-renaming-title = File Renaming
preferences-file-renaming-intro = { -app-name } automatically renames downloaded files based on the details of the parent item (title, author, etc.). You can choose to rename files added from your computer as well.
preferences-file-renaming-intro =
{ -app-name } can automatically rename files based on the details of the parent item (title, author, etc.) and keep the filenames in sync as you make changes. Downloaded files are always initially named based on the parent item.
preferences-file-renaming-auto-rename-files =
.label = Automatically rename locally added files
.label = Automatically rename files
preferences-file-renaming-file-types = Rename files of these types:
preferences-file-renaming-file-type-pdf =
.label = { file-type-pdf }
@ -23,6 +24,8 @@ preferences-file-renaming-file-type-video =
.label = { file-type-video }
preferences-file-renaming-customize-button =
.label = Customize Filename Format…
preferences-file-renaming-rename-now =
.label = Rename Files…
preferences-file-renaming-format-title = Filename Format
preferences-file-renaming-format-instructions = You can customize the filename pattern { -app-name } uses to rename attachment files from parent metadata.

View file

@ -31,6 +31,7 @@ general-remind-me-later = Remind Me Later
general-dont-ask-again = Dont Ask Again
general-choose-file = Choose File…
general-open-settings = Open Settings
general-settings = Settings…
general-help = Help
general-tag = Tag
general-done = Done
@ -56,6 +57,7 @@ general-and = and
general-et-al = et al.
general-previous = Previous
general-next = Next
general-learn-more = Learn More
general-red = Red
general-orange = Orange
@ -637,6 +639,21 @@ attachment-info-convert-note =
.tooltiptext = Adding notes to attachments is no longer supported, but you can edit this note by migrating it to a separate note.
attachment-preview-placeholder = No attachment to preview
attachment-rename-from-parent =
.tooltiptext = Rename File to Match Parent Item
file-renaming-auto-rename-prompt-title = Renaming Settings Changed
file-renaming-auto-rename-prompt-body = Would you like to rename existing files in your library to match the new settings?
file-renaming-auto-rename-prompt-yes = Preview Changes…
file-renaming-auto-rename-prompt-no = Keep Existing Filenames
rename-files-preview =
.buttonlabelaccept = Rename Files
rename-files-preview-loading = Loading…
rename-files-preview-intro = { -app-name } will rename the following files in your library to match their parent items:
rename-files-preview-renaming = Renaming…
rename-files-preview-no-files = All filenames already match parent items. No changes are required.
toggle-preview =
.label = {
@ -776,6 +793,10 @@ mac-word-plugin-install-remind-later-button =
mac-word-plugin-install-dont-ask-again-button =
.label = { general-dont-ask-again }
file-renaming-banner-message = { -app-name } now automatically keeps attachment filenames in sync as you make changes to items.
file-renaming-banner-documentation-link = { general-learn-more }
file-renaming-banner-settings-link = { general-settings }
connector-version-warning = The { -app-name } Connector must be updated to work with this version of { -app-name }.
userjs-pref-warning = Some { -app-name } settings have been overridden using an unsupported method. { -app-name } will revert them and restart.

View file

@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1346_124)">
<path d="M15.9719 12.0001C15.7231 14.2499 13.8164 16 11.5002 16.0001C10.0851 16.0001 8.82487 15.3449 8.00024 14.3234V16.0001H7.00024V13.0001H10.0002V14.0001H9.05493C9.68563 14.6178 10.5476 15.0001 11.5002 15.0001C13.2633 15 14.7214 13.6961 14.9641 12.0001H15.9719ZM16.0002 10.0001H13.0002V9.00012H13.9456C13.3148 8.38239 12.4529 8.00018 11.5002 8.00012C9.73704 8.00012 8.27903 9.30402 8.03638 11.0001H7.02856C7.27733 8.75019 9.184 7.00012 11.5002 7.00012C12.9155 7.00019 14.1756 7.65524 15.0002 8.67688V7.00012H16.0002V10.0001ZM14.0002 6.60071C13.6835 6.43875 13.3489 6.30676 13.0002 6.20813V4.00012H1.00024V8.00012H7.25806C7.00454 8.30705 6.78467 8.64257 6.60181 9.00012H0.000244141V3.00012H14.0002V6.60071ZM3.00024 7.00012H2.00024V5.00012H3.00024V7.00012Z" fill="context-fill"/>
</g>
<defs>
<clipPath id="clip0_1346_124">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -35,7 +35,9 @@ pref("extensions.zotero.autoRecognizeFiles", true);
pref("extensions.zotero.autoRenameFiles", true);
pref("extensions.zotero.autoRenameFiles.linked", false);
pref("extensions.zotero.autoRenameFiles.fileTypes", "application/pdf,application/epub+zip");
pref("extensions.zotero.attachmentRenameTemplate", "{{ firstCreator suffix=\" - \" }}{{ year suffix=\" - \" }}{{ title truncate=\"100\" }}");
pref("extensions.zotero.autoRenameFiles.onMetadataChange", true);
pref("extensions.zotero.autoRenameFiles.done", true);
pref("extensions.zotero.autoRenameFiles.bannerShown", true);
pref("extensions.zotero.capitalizeTitles", false);
pref("extensions.zotero.launchNonNativeFiles", false);
pref("extensions.zotero.naturalSorting", true);

View file

@ -51,6 +51,7 @@
@import "components/notesList";
@import "components/progressMeter";
@import "components/publications-dialog";
@import "components/renameFilesPreview";
@import "components/richlistbox";
@import "components/rtfScan";
@import "components/runJS";

View file

@ -219,7 +219,6 @@
// needed to have the outline appear on all platforms
appearance: none;
-moz-appearance: none;
align-self: center;
// Make all buttons tigher to not stretch the rows
height: auto;
width: auto;

View file

@ -1,4 +1,12 @@
.banner-container > .banner {
@media (prefers-color-scheme: light) {
--banner-link-active: #4b4b4b;
}
@media (prefers-color-scheme: dark) {
--banner-link-active: #b4b4b4;
}
@include macOS-normalize-controls;
display: flex;
@ -42,7 +50,7 @@
.link {
&:active {
color: #4b4b4b;
color: var(--banner-link-active);
}
}
}
@ -122,7 +130,8 @@
}
}
#mac-word-plugin-install-banner {
#mac-word-plugin-install-banner,
#file-renaming-banner-container {
background: var(--accent-blue30);
color: var(--fill-primary);
font-weight: normal;

View file

@ -0,0 +1,47 @@
#rename-files-preview {
min-width: 800px;
min-height: 500px;
dialog {
background-color: var(--color-sidepane);
padding: 14px 24px;
}
.virtualized-table-container {
flex: 1 1 auto;
display: flex;
overflow: hidden;
border: var(--material-panedivider);
.virtualized-table-wrapper {
width: 100%;
}
.virtualized-table-loading {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.virtualized-table {
overflow: hidden;
.row {
&.new:not(.selected) {
color: var(--fill-primary);
}
&.old:not(.selected) {
color: var(--fill-secondary);
}
}
}
}
progress {
&.hidden {
display: none;
}
}
}

View file

@ -41,6 +41,17 @@ attachment-box {
color: var(--fill-secondary);
}
}
#rename-from-parent {
height: 22px;
width: 22px;
padding: 4px;
margin-left: 4px;
@include svgicon-menu("rename-from-parent", "universal", "16");
&:not([disabled='true']) {
color: var(--fill-secondary);
}
}
}
#url

View file

@ -1,5 +1,9 @@
#file-renaming-customize-button {
#file-renaming-buttons {
margin-top: .6em;
> button + button {
margin-left: 0.5em;
}
}
#zotero-prefpane-file-renaming-format label:not([is=zotero-text-link]) {
@ -17,3 +21,13 @@
#file-renaming-format-preview {
padding: 0.4em;
}
#file-renaming-format-preview-buttons {
justify-content: flex-end;
margin-top: 0.5em;
margin-inline: 6px 5px;
}
#file-renaming-rename-now {
margin-top: 1em;
}

View file

@ -35,7 +35,7 @@ async function waitForDOMAttributes(target, attributes, callback) {
for (let mutation of mutations) {
if (mutation.type === 'attributes') {
let oldValue = mutation.oldValue;
let newValue = mutation.target.value;
let newValue = mutation.target.getAttribute(mutation.attributeName);
if (callback(newValue, oldValue)) {
observer.disconnect();
deferred.resolve();
@ -360,7 +360,7 @@ function waitForItemEvent(event) {
/**
* Wait for a single notifier event and return a promise for the data
*
* Tests run after all other handlers (priority 101, since handlers are 100 by default)
* Tests run after all other handlers (priority 200, since handlers are 100 by default, file renaming is 150)
*/
function waitForNotifierEvent(event, type) {
if (!event) throw new Error("event not provided");
@ -374,7 +374,7 @@ function waitForNotifierEvent(event, type) {
extraData: extraData
});
}
}}, [type], 'test', 101);
}}, [type], 'test', 200);
return deferred.promise;
}

View file

@ -1431,7 +1431,7 @@ describe("Zotero.Attachments", function () {
itemNoRepeatedHyphens, itemNoRepeatedUnderscores, itemLowerCase, itemMixedCase, itemUnicode;
before(() => {
item = createUnsavedDataObject('item', { title: 'Lorem Ipsum', itemType: 'journalArticle' });
item = createUnsavedDataObject('item', { title: 'Lorem Ipsum', itemType: 'journalArticle', libraryID: 1 });
item.setCreators([
{ firstName: 'Foocius', lastName: 'Barius', creatorType: 'author' },
{ firstName: 'Bazius', lastName: 'Pixelus', creatorType: 'author' }
@ -1442,10 +1442,10 @@ describe("Zotero.Attachments", function () {
item.setField('issue', '42');
item.setField('pages', '321');
itemBookSection = createUnsavedDataObject('item', { title: 'Book Section', itemType: 'bookSection' });
itemBookSection = createUnsavedDataObject('item', { title: 'Book Section', itemType: 'bookSection', libraryID: 1 });
itemBookSection.setField('bookTitle', 'Book Title');
itemManyAuthors = createUnsavedDataObject('item', { title: 'Has Many Authors', itemType: 'book' });
itemManyAuthors = createUnsavedDataObject('item', { title: 'Has Many Authors', itemType: 'book', libraryID: 1 });
itemManyAuthors.setCreators([
{ firstName: 'First', lastName: 'Author', creatorType: 'author' },
{ firstName: 'Second', lastName: 'Creator', creatorType: 'author' },
@ -1459,7 +1459,7 @@ describe("Zotero.Attachments", function () {
itemManyAuthors.setField('publisher', 'Awesome House');
itemManyAuthors.setField('volume', '3');
itemPatent = createUnsavedDataObject('item', { title: 'Retroencabulator', itemType: 'patent' });
itemPatent = createUnsavedDataObject('item', { title: 'Retroencabulator', itemType: 'patent', libraryID: 1 });
itemPatent.setCreators([
{ name: 'AcmeCorp', creatorType: 'inventor' },
{ firstName: 'Wile', lastName: 'E', creatorType: 'contributor' },
@ -1468,26 +1468,27 @@ describe("Zotero.Attachments", function () {
itemPatent.setField('date', '1952-05-10');
itemPatent.setField('number', 'HBK-8539b');
itemPatent.setField('assignee', 'Fast FooBar');
itemIncomplete = createUnsavedDataObject('item', { title: 'Incomplete', itemType: 'preprint' });
itemIncomplete = createUnsavedDataObject('item', { title: 'Incomplete', itemType: 'preprint', libraryID: 1 });
itemSpaces = createUnsavedDataObject('item', { title: ' Spaces! ', itemType: 'book' });
itemSuffixes = createUnsavedDataObject('item', { title: '-Suffixes-', itemType: 'book' });
itemSpaces = createUnsavedDataObject('item', { title: ' Spaces! ', itemType: 'book', libraryID: 1 });
itemSuffixes = createUnsavedDataObject('item', { title: '-Suffixes-', itemType: 'book', libraryID: 1 });
itemSuffixes.setField('date', "1999-07-15");
itemKeepHyphens = createUnsavedDataObject('item', { title: 'keep--hyphens', itemType: 'journalArticle' });
itemKeepHyphens = createUnsavedDataObject('item', { title: 'keep--hyphens', itemType: 'journalArticle', libraryID: 1 });
itemKeepHyphens.setField('publicationTitle', "keep");
itemKeepHyphens.setField('issue', 'hyphens');
itemKeepHyphens.setField('date', "1999-07-15");
itemNoRepeatedHyphens = createUnsavedDataObject('item', { title: 'no - repeated - hyphens', itemType: 'journalArticle' });
itemNoRepeatedHyphens = createUnsavedDataObject('item', { title: 'no - repeated - hyphens', itemType: 'journalArticle', libraryID: 1 });
itemNoRepeatedHyphens.setField('publicationTitle', "no- repeated- hyphens");
itemNoRepeatedUnderscores = createUnsavedDataObject('item', { title: 'no _ repeated _ underscores', itemType: 'journalArticle' });
itemNoRepeatedUnderscores = createUnsavedDataObject('item', { title: 'no _ repeated _ underscores', itemType: 'journalArticle', libraryID: 1 });
itemNoRepeatedUnderscores.setField('publicationTitle', "no_ repeated_ underscores");
itemLowerCase = createUnsavedDataObject('item', { title: 'lower case title', itemType: 'journalArticle' });
itemMixedCase = createUnsavedDataObject('item', { title: 'Old MacDonald Had a Farm', itemType: 'journalArticle' });
itemUnicode = createUnsavedDataObject('item', { title: '金毛猎犬 - Golden Retriever', itemType: 'journalArticle' });
itemLowerCase = createUnsavedDataObject('item', { title: 'lower case title', itemType: 'journalArticle', libraryID: 1 });
itemMixedCase = createUnsavedDataObject('item', { title: 'Old MacDonald Had a Farm', itemType: 'journalArticle', libraryID: 1 });
itemUnicode = createUnsavedDataObject('item', { title: '金毛猎犬 - Golden Retriever', itemType: 'journalArticle', libraryID: 1 });
});
it('should strip HTML tags from title', function () {
var htmlItem = createUnsavedDataObject('item', { title: 'Foo <i>Bar</i> Foo<br><br/><br />Bar' });
htmlItem.libraryID = 1;
var str = Zotero.Attachments.getFileBaseNameFromItem(htmlItem, { formatString: '{{ title }}' });
assert.equal(str, 'Foo Bar Foo Bar');
});
@ -1978,7 +1979,7 @@ describe("Zotero.Attachments", function () {
it("should strip bidi isolates from firstCreator", async function () {
var item = createUnsavedDataObject('item',
{ creators: [{ name: 'Foo', creatorType: 'author' }, { name: 'Bar', creatorType: 'author' }] });
{ creators: [{ name: 'Foo', creatorType: 'author' }, { name: 'Bar', creatorType: 'author' }], libraryID: 1 });
var str = Zotero.Attachments.getFileBaseNameFromItem(item);
assert.equal(str, Zotero.getString('general.andJoiner', ['Foo', 'Bar']) + ' - ');
});
@ -2178,4 +2179,145 @@ describe("Zotero.Attachments", function () {
assert.isTrue(await newAttachment.fileExists());
});
});
describe("#renameFile()", function () {
let { renameFileFromParent } = ChromeUtils.importESModule("chrome://zotero/content/renameFiles.mjs");
it("should rename a linked file", async function () {
var oldFilename = 'old.png';
var newFilename = 'Test.png';
var file = getTestDataDirectory();
file.append('test.png');
var tmpDir = await getTempDirectory();
var oldFile = OS.Path.join(tmpDir, oldFilename);
await OS.File.copy(file.path, oldFile);
var item = createUnsavedDataObject('item');
item.setField('title', 'Test');
await item.saveTx();
var attachment = await Zotero.Attachments.linkFromFile({
file: oldFile,
parentItemID: item.id
});
await renameFileFromParent(attachment);
assert.equal(attachment.attachmentFilename, newFilename);
var path = await attachment.getFilePathAsync();
assert.equal(OS.Path.basename(path), newFilename);
await OS.File.exists(path);
});
it("should use unique name for linked file if target name is taken", async function () {
var oldFilename = 'old.png';
var newFilename = 'Test.png';
var uniqueFilename = 'Test 2.png';
var file = getTestDataDirectory();
file.append('test.png');
var tmpDir = await getTempDirectory();
var oldFile = OS.Path.join(tmpDir, oldFilename);
await OS.File.copy(file.path, oldFile);
// Create file with target filename
await Zotero.File.putContentsAsync(OS.Path.join(tmpDir, newFilename), '');
var item = createUnsavedDataObject('item');
item.setField('title', 'Test');
await item.saveTx();
var attachment = await Zotero.Attachments.linkFromFile({
file: oldFile,
parentItemID: item.id
});
await renameFileFromParent(attachment);
assert.equal(attachment.attachmentFilename, uniqueFilename);
var path = await attachment.getFilePathAsync();
assert.equal(OS.Path.basename(path), uniqueFilename)
await OS.File.exists(path);
});
it("should use unique name for linked file without extension if target name is taken", async function () {
var oldFilename = 'old';
var newFilename = 'Test.png';
var uniqueFilename = 'Test 2.png';
var file = getTestDataDirectory();
file.append('test.png');
var tmpDir = await getTempDirectory();
var oldFile = OS.Path.join(tmpDir, oldFilename);
await OS.File.copy(file.path, oldFile);
// Create file with target filename
await Zotero.File.putContentsAsync(OS.Path.join(tmpDir, newFilename), '');
var item = createUnsavedDataObject('item');
item.setField('title', 'Test');
await item.saveTx();
var attachment = await Zotero.Attachments.linkFromFile({
file: oldFile,
parentItemID: item.id
});
await renameFileFromParent(attachment);
assert.equal(attachment.attachmentFilename, uniqueFilename);
var path = await attachment.getFilePathAsync();
assert.equal(OS.Path.basename(path), uniqueFilename);
await OS.File.exists(path);
});
it("shouldn't change attachment title if different from filename", async function () {
var item = createUnsavedDataObject('item');
item.setField('title', 'Title');
await item.saveTx();
var attachment = await importFileAttachment('test.png', { parentItemID: item.id });
attachment.setField('title', 'Title');
await attachment.saveTx();
await renameFileFromParent(attachment);
assert.equal(attachment.attachmentFilename, 'Title.png');
assert.equal(attachment.getField('title'), 'Title');
});
it("should change attachment title if previously set to the file basename by setAutoAttachmentTitle()", async function () {
var item = createUnsavedDataObject('item');
item.setField('title', 'Title');
await item.saveTx();
var attachment = await importFileAttachment('test.png', {
parentItemID: item.id,
// Use default setAutoAttachmentTitle() behavior -- the file isn't going to be
// renamed because autoRenameFiles.fileTypes doesn't match image/, so the title
// becomes the filename minus extension, i.e., "test"
title: null
});
assert.equal(attachment.getField('title'), 'test');
await renameFileFromParent(attachment);
assert.equal(attachment.attachmentFilename, 'Title.png');
// After a manual rename, the title becomes the default for this type
assert.equal(attachment.getField('title'), Zotero.getString('file-type-image'));
});
it("should restore an extension when renaming a misnamed file", async function () {
let pdfFile = getTestDataDirectory();
pdfFile.append('test.pdf');
let tmpDir = await getTempDirectory();
let tmpFileToImport = OS.Path.join(tmpDir, 'bad name . not an extension');
await OS.File.copy(pdfFile.path, tmpFileToImport);
var item = createUnsavedDataObject('item');
item.setField('title', 'Title');
await item.saveTx();
let attachment = await Zotero.Attachments.importFromFile({
file: tmpFileToImport,
parentItemID: item.id
});
await renameFileFromParent(attachment);
assert.equal(attachment.attachmentFilename, 'Title.pdf');
});
});
})

View file

@ -1714,6 +1714,82 @@ describe("Item pane", function () {
attachmentBox._discardPreviewTimeout = currentDiscardTimeout;
});
it("should hide the rename from parent button if already renamed", async function () {
let item = await createDataObject('item', { title: 'Lorem Ipsum' });
let file = getTestDataDirectory();
file.append('test.pdf');
let attachment = await Zotero.Attachments.importFromFile({
file: file,
fileBaseName: "Lorem Ipsum", // Simulate auto-renaming, normally code would call getRenamedFileBaseNameIfAllowedType to generate fileBaseName
parentItemID: item.id
});
let zp = win.ZoteroPane;
await zp.selectItems([attachment.id]);
let itemBox = doc.getElementById('zotero-attachment-box');
let itemDetails = ZoteroPane.itemPane._itemDetails;
await zp.selectItems([attachment.id]);
await itemDetails._renderPromise;
let label = itemBox._id('fileName');
let button = itemBox._id('rename-from-parent');
// File is auto-renamed during import, button should be hidden
assert.isTrue(button.hidden);
assert.equal(label.value, "Lorem Ipsum.pdf");
await attachment.eraseTx();
await item.eraseTx();
});
it("should hide the rename from parent button, after file was renamed", async function () {
let item = await createDataObject('item', { title: 'Lorem Ipsum' });
let file = getTestDataDirectory();
file.append('test.txt');
let attachment = await Zotero.Attachments.importFromFile({
file: file,
parentItemID: item.id
});
let zp = win.ZoteroPane;
let itemBox = doc.getElementById('zotero-attachment-box');
let label = itemBox._id('fileName');
let button = itemBox._id('rename-from-parent');
let itemDetails = ZoteroPane.itemPane._itemDetails;
await zp.selectItems([attachment.id]);
await itemDetails._renderPromise;
assert.isFalse(button.hidden);
button.click();
assert.equal(await waitForItemEvent('modify'), attachment.id);
assert.equal(await waitForItemEvent('modify'), attachment.id);
assert.isTrue(button.hidden);
assert.equal(label.value, "Lorem Ipsum.txt");
await attachment.eraseTx();
await item.eraseTx();
});
it("should hide the rename from parent button for top-level items", async function () {
let file = getTestDataDirectory();
file.append('test.pdf');
let topLevelAttachment = await Zotero.Attachments.importFromFile({
file: file,
});
let zp = win.ZoteroPane;
let itemBox = doc.getElementById('zotero-attachment-box');
let button = itemBox._id('rename-from-parent');
let itemDetails = ZoteroPane.itemPane._itemDetails;
await zp.selectItems([topLevelAttachment.id]);
await itemDetails._renderPromise;
assert.isTrue(button.hidden);
await topLevelAttachment.eraseTx();
});
it("should not transfer focused title while switching between items", async function () {
let item = new Zotero.Item('book');
let attachmentOne = await importFileAttachment('test.pdf', { title: 'PDF_one', parentItemID: item.id });

View file

@ -1332,7 +1332,7 @@ describe("Zotero.Item", function () {
await parentItem.getBestAttachment();
assert.deepEqual(
parentItem.getBestAttachmentStateCached(),
{ key: childItem.key }
{ exists: true, key: childItem.key }
);
await childItem._updateAttachmentStates(false);
assert.deepEqual(

View file

@ -15,6 +15,7 @@ describe("Document Recognition", function () {
Zotero.Prefs.set('fulltext.textMaxLength', 0);
this.timeout(60000);
Zotero.Prefs.set('autoRenameFiles.onMetadataChange', false); // Prevent auto-rename triggering during recognition
// Load Zotero pane and install PDF tools
yield Promise.all([
loadZoteroPane().then(w => win = w)
@ -47,6 +48,7 @@ describe("Document Recognition", function () {
if (win) {
win.close();
}
Zotero.Prefs.clear('autoRenameFiles.onMetadataChange');
});
describe("PDFs", function () {
@ -67,6 +69,12 @@ describe("Document Recognition", function () {
assert.equal(item.getField("title"), "Shaping the Research Agenda");
assert.equal(item.getField("libraryCatalog"), "DOI.org (Crossref)");
assert.lengthOf(modifiedIDs, 2);
// after item has been recognized, attachment item will be modified
// two more times (by `attachment.renameAttachmentFile` and then by
// `attachment.setAutoAttachmentTitle` and in recognizeDocument.js):
assert.equal(await waitForItemEvent('modify'), attachment.id);
assert.equal(await waitForItemEvent('modify'), attachment.id);
await waitForProgressWindow();
@ -228,13 +236,13 @@ describe("Document Recognition", function () {
});
// Link to the PDF
var tempDir = await getTempDirectory();
var tempFile = OS.Path.join(tempDir, 'test.pdf');
let tempDir = await getTempDirectory();
let tempFile = OS.Path.join(tempDir, 'test.pdf');
await OS.File.copy(OS.Path.join(getTestDataDirectory().path, 'test.pdf'), tempFile);
var attachment = await Zotero.Attachments.linkFromFile({
file: tempFile
});
win.ZoteroPane.recognizeSelected();
var addedIDs = await waitForItemEvent("add");
@ -243,6 +251,12 @@ describe("Document Recognition", function () {
var item = Zotero.Items.get(addedIDs[0]);
assert.equal(item.getField("title"), itemTitle);
assert.lengthOf(modifiedIDs, 2);
// after item has been recognized, attachment item will be modified
// two more times (by `attachment.renameAttachmentFile` and then by
// `attachment.setAutoAttachmentTitle` and in recognizeDocument.js):
assert.equal(await waitForItemEvent('modify'), attachment.id);
assert.equal(await waitForItemEvent('modify'), attachment.id);
await waitForProgressWindow();
@ -286,6 +300,7 @@ describe("Document Recognition", function () {
// The title should not have changed
assert.equal(attachment.getField('title'), 'test');
Zotero.Prefs.clear('autoRenameFiles.fileTypes');
});
it("shouldn't rename a linked file attachment using parent metadata if pref disabled", async function () {
@ -344,6 +359,12 @@ describe("Document Recognition", function () {
var item = Zotero.Items.get(addedIDs[0]);
assert.lengthOf(modifiedIDs, 2);
// after item has been recognized, attachment item will be modified
// two more times (by `attachment.renameAttachmentFile` and then by
// `attachment.setAutoAttachmentTitle` and in recognizeDocument.js):
assert.equal(await waitForItemEvent('modify'), attachment.id);
assert.equal(await waitForItemEvent('modify'), attachment.id);
await waitForProgressWindow();
// The file should have been renamed
@ -386,6 +407,12 @@ describe("Document Recognition", function () {
assert.equal(Zotero.Utilities.cleanISBN(item.getField('ISBN')), isbn);
assert.lengthOf(modifiedIDs, 2);
// after item has been recognized, attachment item will be modified
// two more times (by `attachment.renameAttachmentFile` and then by
// `attachment.setAutoAttachmentTitle` and in recognizeDocument.js):
assert.equal(await waitForItemEvent('modify'), attachment.id);
assert.equal(await waitForItemEvent('modify'), attachment.id);
await waitForProgressWindow();
// The file should have been renamed
@ -452,6 +479,12 @@ describe("Document Recognition", function () {
assert.equal(Zotero.Utilities.cleanDOI(item.getField('DOI')), doi);
assert.lengthOf(modifiedIDs, 2);
// after item has been recognized, attachment item will be modified
// two more times (by `attachment.renameAttachmentFile` and then by
// `attachment.setAutoAttachmentTitle` and in recognizeDocument.js):
assert.equal(await waitForItemEvent('modify'), attachment.id);
assert.equal(await waitForItemEvent('modify'), attachment.id);
await waitForProgressWindow();
// The file should have been renamed
@ -485,6 +518,12 @@ describe("Document Recognition", function () {
assert.equal(item.getField('ISBN'), '');
assert.lengthOf(modifiedIDs, 2);
// after item has been recognized, attachment item will be modified
// two more times (by `attachment.renameAttachmentFile` and then by
// `attachment.setAutoAttachmentTitle` and in recognizeDocument.js):
assert.equal(await waitForItemEvent('modify'), attachment.id);
assert.equal(await waitForItemEvent('modify'), attachment.id);
await waitForProgressWindow();
// The file should have been renamed

View file

@ -590,150 +590,6 @@ describe("ZoteroPane", function () {
});
describe("#renameSelectedAttachmentsFromParents()", function () {
it("should rename a linked file", async function () {
var oldFilename = 'old.png';
var newFilename = 'Test.png';
var file = getTestDataDirectory();
file.append('test.png');
var tmpDir = await getTempDirectory();
var oldFile = OS.Path.join(tmpDir, oldFilename);
await OS.File.copy(file.path, oldFile);
var item = createUnsavedDataObject('item');
item.setField('title', 'Test');
await item.saveTx();
var attachment = await Zotero.Attachments.linkFromFile({
file: oldFile,
parentItemID: item.id
});
await zp.selectItem(attachment.id);
await zp.renameSelectedAttachmentsFromParents();
assert.equal(attachment.attachmentFilename, newFilename);
var path = await attachment.getFilePathAsync();
assert.equal(OS.Path.basename(path), newFilename)
await OS.File.exists(path);
});
it("should use unique name for linked file if target name is taken", async function () {
var oldFilename = 'old.png';
var newFilename = 'Test.png';
var uniqueFilename = 'Test 2.png';
var file = getTestDataDirectory();
file.append('test.png');
var tmpDir = await getTempDirectory();
var oldFile = OS.Path.join(tmpDir, oldFilename);
await OS.File.copy(file.path, oldFile);
// Create file with target filename
await Zotero.File.putContentsAsync(OS.Path.join(tmpDir, newFilename), '');
var item = createUnsavedDataObject('item');
item.setField('title', 'Test');
await item.saveTx();
var attachment = await Zotero.Attachments.linkFromFile({
file: oldFile,
parentItemID: item.id
});
await zp.selectItem(attachment.id);
await zp.renameSelectedAttachmentsFromParents();
assert.equal(attachment.attachmentFilename, uniqueFilename);
var path = await attachment.getFilePathAsync();
assert.equal(OS.Path.basename(path), uniqueFilename)
await OS.File.exists(path);
});
it("should use unique name for linked file without extension if target name is taken", async function () {
var oldFilename = 'old';
var newFilename = 'Test.png';
var uniqueFilename = 'Test 2.png';
var file = getTestDataDirectory();
file.append('test.png');
var tmpDir = await getTempDirectory();
var oldFile = OS.Path.join(tmpDir, oldFilename);
await OS.File.copy(file.path, oldFile);
// Create file with target filename
await Zotero.File.putContentsAsync(OS.Path.join(tmpDir, newFilename), '');
var item = createUnsavedDataObject('item');
item.setField('title', 'Test');
await item.saveTx();
var attachment = await Zotero.Attachments.linkFromFile({
file: oldFile,
parentItemID: item.id
});
await zp.selectItem(attachment.id);
await zp.renameSelectedAttachmentsFromParents();
assert.equal(attachment.attachmentFilename, uniqueFilename);
var path = await attachment.getFilePathAsync();
assert.equal(OS.Path.basename(path), uniqueFilename);
await OS.File.exists(path);
});
it("shouldn't change attachment title if different from filename", async function () {
var item = createUnsavedDataObject('item');
item.setField('title', 'Title');
await item.saveTx();
var attachment = await importFileAttachment('test.png', { parentItemID: item.id });
attachment.setField('title', 'Title');
await attachment.saveTx();
await zp.selectItem(attachment.id);
await zp.renameSelectedAttachmentsFromParents();
assert.equal(attachment.attachmentFilename, 'Title.png');
assert.equal(attachment.getField('title'), 'Title');
});
it("should change attachment title if previously set to the file basename by setAutoAttachmentTitle()", async function () {
var item = createUnsavedDataObject('item');
item.setField('title', 'Title');
await item.saveTx();
var attachment = await importFileAttachment('test.png', {
parentItemID: item.id,
// Use default setAutoAttachmentTitle() behavior -- the file isn't going to be
// renamed because autoRenameFiles.fileTypes doesn't match image/, so the title
// becomes the filename minus extension, i.e., "test"
title: null
});
assert.equal(attachment.getField('title'), 'test');
await zp.selectItem(attachment.id);
await zp.renameSelectedAttachmentsFromParents();
assert.equal(attachment.attachmentFilename, 'Title.png');
// After a manual rename, the title becomes the default for this type
assert.equal(attachment.getField('title'), Zotero.getString('file-type-image'));
});
it("should restore an extension when renaming a misnamed file", async function () {
let pdfFile = getTestDataDirectory();
pdfFile.append('test.pdf');
let tmpDir = await getTempDirectory();
let tmpFileToImport = OS.Path.join(tmpDir, 'bad name . not an extension');
await OS.File.copy(pdfFile.path, tmpFileToImport);
var item = createUnsavedDataObject('item');
item.setField('title', 'Title');
await item.saveTx();
let attachment = await Zotero.Attachments.importFromFile({
file: tmpFileToImport,
parentItemID: item.id
});
await zp.selectItem(attachment.id);
await zp.renameSelectedAttachmentsFromParents();
assert.equal(attachment.attachmentFilename, 'Title.pdf');
});
});
describe("#duplicateSelectedItem()", function () {
it("should add reverse relations", async function () {
await selectLibrary(win);