镜像自地址
https://github.com/lhly/cloud-drive-renamer.git
已同步 2026-10-20 13:47:47 +08:00
feat(aliyun-adapter): 添加重命名后自动同步页面文件列表功能
实现重命名后自动更新页面文件列表的功能,解决用户需要手动刷新才能看到更新后文件名的问题 - 通过DOM操作直接更新可见文件名(最佳尝试) - 观察并短时间修补虚拟化列表中的新节点 - 尝试触发原生列表重新渲染(排序切换/路由微调)
这个提交包含在:
父节点
61ee4ea620
当前提交
a796eca950
@ -1,5 +1,11 @@
|
||||
import { BasePlatformAdapter } from '../base/adapter.interface';
|
||||
import { PlatformName, FileItem, RenameResult, PlatformConfig } from '../../types/platform';
|
||||
import {
|
||||
PlatformName,
|
||||
FileItem,
|
||||
RenameResult,
|
||||
PlatformConfig,
|
||||
PageSyncResult,
|
||||
} from '../../types/platform';
|
||||
import { parseFileName } from '../../utils/helpers';
|
||||
import { AliyunAPIError, getErrorMessage, isRetryableError } from './errors';
|
||||
import { getPageScriptInjector } from './page-script-injector';
|
||||
@ -384,6 +390,341 @@ export class AliyunAdapter extends BasePlatformAdapter {
|
||||
}, `重命名文件 ${fileId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync page file list after rename so users can see updated names without manual refresh.
|
||||
*
|
||||
* Aliyun Drive has no obvious "refresh" button in some UI variants, but the list can be
|
||||
* reloaded via actions such as sorting (observed calling /adrive/v3/file/list).
|
||||
*
|
||||
* Strategy:
|
||||
* - Always patch visible DOM (best-effort) for immediate feedback
|
||||
* - Observe and patch for a short window (virtualized list)
|
||||
* - Best-effort trigger native list rerender (sort toggle / route nudge)
|
||||
*/
|
||||
async syncAfterRename(
|
||||
renames: Array<{ fileId: string; oldName?: string; newName: string }>
|
||||
): Promise<PageSyncResult> {
|
||||
const renameByOldName = new Map<string, string>();
|
||||
|
||||
for (const item of renames) {
|
||||
if (!item?.oldName || !item?.newName) continue;
|
||||
renameByOldName.set(item.oldName, item.newName);
|
||||
}
|
||||
|
||||
if (renameByOldName.size === 0) {
|
||||
return { success: true, method: 'none' };
|
||||
}
|
||||
|
||||
const patched = this.applyRenameMappingToDomByOldName(renameByOldName);
|
||||
this.observeAndPatchRenamedNodes(renameByOldName, 15000);
|
||||
|
||||
const rerendered = await this.tryTriggerNativeFileListRerender();
|
||||
if (rerendered.triggered) {
|
||||
return { success: true, method: 'ui-refresh', message: rerendered.message };
|
||||
}
|
||||
|
||||
if (patched > 0) {
|
||||
return { success: true, method: 'dom-patch', message: `patched ${patched} nodes` };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
method: 'none',
|
||||
message: 'failed to locate filename nodes on page',
|
||||
};
|
||||
}
|
||||
|
||||
private async tryTriggerNativeFileListRerender(): Promise<{ triggered: boolean; message: string }> {
|
||||
try {
|
||||
const sortToggled = await this.tryTriggerSortToggleRefresh();
|
||||
if (sortToggled) {
|
||||
return { triggered: true, message: 'triggered sort toggle' };
|
||||
}
|
||||
|
||||
const routeNudged = this.tryTriggerRouteNudgeRefresh();
|
||||
if (routeNudged) {
|
||||
return { triggered: true, message: 'triggered route nudge' };
|
||||
}
|
||||
|
||||
return { triggered: false, message: 'no native rerender trigger matched' };
|
||||
} catch (error) {
|
||||
logger.debug('[AliyunAdapter] Failed to trigger native rerender:', error instanceof Error ? error : new Error(String(error)));
|
||||
return { triggered: false, message: 'native rerender trigger failed' };
|
||||
}
|
||||
}
|
||||
|
||||
private getFileListSearchRoot(): ParentNode {
|
||||
const row =
|
||||
(document.querySelector('[data-is-selected]') as HTMLElement | null) ||
|
||||
(document.querySelector('[data-file-id],[data-fileid],[data-id]') as HTMLElement | null);
|
||||
|
||||
const table = row?.closest('table,[role="table"],[class*="table"],[class*="Table"]') || null;
|
||||
if (table) return table;
|
||||
|
||||
const list = row?.closest('[role="list"],[class*="list"],[class*="List"]') || null;
|
||||
if (list) return list;
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private dispatchSyntheticClick(el: HTMLElement): void {
|
||||
try {
|
||||
el.dispatchEvent(
|
||||
new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
el.click();
|
||||
}
|
||||
}
|
||||
|
||||
private async tryTriggerSortToggleRefresh(): Promise<boolean> {
|
||||
try {
|
||||
const root = this.getFileListSearchRoot();
|
||||
const headers = Array.from(
|
||||
root.querySelectorAll('[role="columnheader"], thead th')
|
||||
) as HTMLElement[];
|
||||
if (headers.length === 0) return false;
|
||||
|
||||
const pickHeader = (re: RegExp) =>
|
||||
headers.find((h) => re.test((h.textContent || '').trim())) || null;
|
||||
|
||||
// Prefer the "Name" column (least disruptive), fallback to any header.
|
||||
const nameHeader =
|
||||
pickHeader(/名称|文件名|name/i) ||
|
||||
pickHeader(/修改时间|更新时间|时间|time|modified|updated/i) ||
|
||||
headers[0] ||
|
||||
null;
|
||||
if (!nameHeader) return false;
|
||||
|
||||
const initial = this.getSortState(nameHeader);
|
||||
this.dispatchSyntheticClick(nameHeader);
|
||||
await this.delayMs(250);
|
||||
|
||||
// Attempt to restore sort state (best-effort, bounded)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (this.getSortState(nameHeader) === initial) break;
|
||||
this.dispatchSyntheticClick(nameHeader);
|
||||
await this.delayMs(250);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.debug('[AliyunAdapter] Failed to toggle sort for refresh:', error instanceof Error ? error : new Error(String(error)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private getSortState(el: Element): string {
|
||||
const aria = el.getAttribute('aria-sort');
|
||||
if (aria) return aria;
|
||||
return 'none';
|
||||
}
|
||||
|
||||
private tryTriggerRouteNudgeRefresh(): boolean {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const key = '__cdr_sync';
|
||||
const previous = url.searchParams.get(key);
|
||||
url.searchParams.set(key, String(Date.now()));
|
||||
|
||||
history.replaceState(history.state, '', url.toString());
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: history.state }));
|
||||
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
const next = new URL(window.location.href);
|
||||
if (previous === null) {
|
||||
next.searchParams.delete(key);
|
||||
} else {
|
||||
next.searchParams.set(key, previous);
|
||||
}
|
||||
history.replaceState(history.state, '', next.toString());
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: history.state }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.debug('[AliyunAdapter] Failed to nudge route for refresh:', error instanceof Error ? error : new Error(String(error)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private applyRenameMappingToDomByOldName(renameByOldName: Map<string, string>): number {
|
||||
if (renameByOldName.size === 0) return 0;
|
||||
|
||||
let patched = 0;
|
||||
|
||||
// Prefer patching within elements that explicitly reference the old name (keeps native DOM structure)
|
||||
for (const [oldName, newName] of renameByOldName.entries()) {
|
||||
const escaped = this.escapeForAttributeSelector(oldName);
|
||||
const candidates = Array.from(
|
||||
document.querySelectorAll(`[title="${escaped}"],[aria-label="${escaped}"]`)
|
||||
);
|
||||
|
||||
for (const el of candidates) {
|
||||
patched += this.patchStructuredNameWithinElement(el, oldName, newName);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: patch plain text nodes (works if the whole filename is a single text node)
|
||||
patched += this.patchExactTextInElement(document.body, renameByOldName);
|
||||
|
||||
// Keep tooltip attributes in sync (safe, no DOM structure changes)
|
||||
this.patchAttributesInElement(document.body, renameByOldName, 'title');
|
||||
this.patchAttributesInElement(document.body, renameByOldName, 'aria-label');
|
||||
|
||||
return patched;
|
||||
}
|
||||
|
||||
private observeAndPatchRenamedNodes(renameByOldName: Map<string, string>, timeoutMs: number): void {
|
||||
try {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of Array.from(mutation.addedNodes)) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
this.patchElementTree(node, renameByOldName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
this.patchElementTree(document.body, renameByOldName);
|
||||
|
||||
window.setTimeout(() => observer.disconnect(), timeoutMs);
|
||||
} catch (error) {
|
||||
logger.debug('[AliyunAdapter] Failed to observe DOM for patching:', error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
private patchElementTree(root: Element, renameByOldName: Map<string, string>): void {
|
||||
if (renameByOldName.size === 0) return;
|
||||
|
||||
for (const [oldName, newName] of renameByOldName.entries()) {
|
||||
const escaped = this.escapeForAttributeSelector(oldName);
|
||||
|
||||
// Include root itself if it matches
|
||||
if (root.getAttribute('title') === oldName || root.getAttribute('aria-label') === oldName) {
|
||||
this.patchStructuredNameWithinElement(root, oldName, newName);
|
||||
}
|
||||
|
||||
const candidates = Array.from(
|
||||
root.querySelectorAll(`[title="${escaped}"],[aria-label="${escaped}"]`)
|
||||
);
|
||||
for (const el of candidates) {
|
||||
this.patchStructuredNameWithinElement(el, oldName, newName);
|
||||
}
|
||||
}
|
||||
|
||||
this.patchExactTextInElement(root, renameByOldName);
|
||||
this.patchAttributesInElement(root, renameByOldName, 'title');
|
||||
this.patchAttributesInElement(root, renameByOldName, 'aria-label');
|
||||
}
|
||||
|
||||
private patchStructuredNameWithinElement(root: Element, oldName: string, newName: string): number {
|
||||
const fullMap = new Map([[oldName, newName]]);
|
||||
let patched = this.patchExactTextInElement(root, fullMap);
|
||||
|
||||
// If the UI renders base name / extension in separate nodes, patch them separately.
|
||||
if (patched === 0) {
|
||||
const oldParts = parseFileName(oldName);
|
||||
const newParts = parseFileName(newName);
|
||||
|
||||
const partsMap = new Map<string, string>();
|
||||
if (oldParts.name && oldParts.name !== newParts.name) {
|
||||
partsMap.set(oldParts.name, newParts.name);
|
||||
}
|
||||
if (oldParts.ext && oldParts.ext !== newParts.ext) {
|
||||
partsMap.set(oldParts.ext, newParts.ext);
|
||||
}
|
||||
|
||||
if (partsMap.size > 0) {
|
||||
patched += this.patchExactTextInElement(root, partsMap);
|
||||
}
|
||||
}
|
||||
|
||||
this.patchAttributesInElement(root, fullMap, 'title');
|
||||
this.patchAttributesInElement(root, fullMap, 'aria-label');
|
||||
|
||||
return patched;
|
||||
}
|
||||
|
||||
private patchExactTextInElement(root: Node, renameByOldName: Map<string, string>): number {
|
||||
if (renameByOldName.size === 0) return 0;
|
||||
|
||||
let patched = 0;
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
const text = node.nodeValue?.trim();
|
||||
if (!text) return NodeFilter.FILTER_SKIP;
|
||||
return renameByOldName.has(text) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
|
||||
},
|
||||
});
|
||||
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
const oldText = node.nodeValue?.trim();
|
||||
if (!oldText) continue;
|
||||
const next = renameByOldName.get(oldText);
|
||||
if (!next) continue;
|
||||
if (node.nodeValue !== next) {
|
||||
node.nodeValue = next;
|
||||
patched++;
|
||||
}
|
||||
|
||||
const parent = node.parentElement;
|
||||
if (parent) {
|
||||
if (parent.getAttribute('title') === oldText) parent.setAttribute('title', next);
|
||||
if (parent.getAttribute('aria-label') === oldText) parent.setAttribute('aria-label', next);
|
||||
}
|
||||
}
|
||||
|
||||
return patched;
|
||||
}
|
||||
|
||||
private patchAttributesInElement(
|
||||
root: Element,
|
||||
renameByOldName: Map<string, string>,
|
||||
attributeName: 'title' | 'aria-label'
|
||||
): number {
|
||||
if (renameByOldName.size === 0) return 0;
|
||||
|
||||
let patched = 0;
|
||||
const nodes = Array.from(root.querySelectorAll(`[${attributeName}]`));
|
||||
for (const node of nodes) {
|
||||
const value = node.getAttribute(attributeName);
|
||||
if (!value) continue;
|
||||
const next = renameByOldName.get(value);
|
||||
if (!next) continue;
|
||||
|
||||
if (value !== next) {
|
||||
node.setAttribute(attributeName, next);
|
||||
patched++;
|
||||
}
|
||||
}
|
||||
return patched;
|
||||
}
|
||||
|
||||
private escapeForAttributeSelector(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\f/g, '\\f');
|
||||
}
|
||||
|
||||
private delayMs(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if filename conflicts with existing files
|
||||
*
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
* 1. Edit version in package.json
|
||||
* 2. Run: npm run sync:version (or automatically via prebuild/predev)
|
||||
*
|
||||
* Generated at: 2025-12-29T09:07:09.736Z
|
||||
* Generated at: 2025-12-29T09:42:53.807Z
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户