From f7088df677557a1da77d5ffebc92ee2a0ad82d41 Mon Sep 17 00:00:00 2001 From: MasonDye Date: Fri, 29 Aug 2025 12:03:02 +0800 Subject: [PATCH] update reset thumbnail feature --- public/locales/de-DE/application.json | 3 + public/locales/en-US/application.json | 3 + public/locales/es-ES/application.json | 3 + public/locales/fr-FR/application.json | 4 +- public/locales/it-IT/application.json | 3 + public/locales/ja-JP/application.json | 3 + public/locales/ko-KR/application.json | 3 + public/locales/pt-BR/application.json | 3 + public/locales/ru-RU/application.json | 3 + public/locales/zh-CN/application.json | 3 + public/locales/zh-TW/application.json | 3 + src/api/api.ts | 131 ++++++++++++++++++ .../FileManager/ContextMenu/ContextMenu.tsx | 10 +- .../FileManager/ContextMenu/MoreMenuItems.tsx | 10 ++ .../ContextMenu/useActionDisplayOpt.ts | 16 ++- src/component/FileManager/FileManager.tsx | 3 + src/redux/thunks/file.ts | 64 ++++++++- 17 files changed, 264 insertions(+), 4 deletions(-) diff --git a/public/locales/de-DE/application.json b/public/locales/de-DE/application.json index 0822837..e9a13be 100644 --- a/public/locales/de-DE/application.json +++ b/public/locales/de-DE/application.json @@ -315,6 +315,9 @@ "moreActions": "Weitere Aktionen", "refresh": "Aktualisieren", "createArchive": "Archiv erstellen", + "resetThumbnail": "Miniaturansicht zurücksetzen", + "resetThumbnailRequested": "Zurücksetzen der Miniaturansicht angefordert.", + "noFileCanResetThumbnail": "Keine Dateien zum Zurücksetzen der Miniaturansicht verfügbar.", "newFolder": "Neuen Ordner erstellen", "newFile": "Neue Datei erstellen", "showFullPath": "Pfad anzeigen", diff --git a/public/locales/en-US/application.json b/public/locales/en-US/application.json index b425af7..1278c74 100644 --- a/public/locales/en-US/application.json +++ b/public/locales/en-US/application.json @@ -281,6 +281,9 @@ "moreActions": "More actions", "refresh": "Refresh", "createArchive": "Create archive file", + "resetThumbnail": "Reset thumbnail", + "resetThumbnailRequested": "Thumbnail reset requested.", + "noFileCanResetThumbnail": "No files available for thumbnail reset.", "newFolder": "New folder", "newFile": "New file", "showFullPath": "Show full path", diff --git a/public/locales/es-ES/application.json b/public/locales/es-ES/application.json index e62fa4f..305f1b3 100644 --- a/public/locales/es-ES/application.json +++ b/public/locales/es-ES/application.json @@ -315,6 +315,9 @@ "moreActions": "Más acciones", "refresh": "Actualizar", "createArchive": "Crear archivo comprimido", + "resetThumbnail": "Restablecer miniatura", + "resetThumbnailRequested": "Se solicitó restablecer la miniatura.", + "noFileCanResetThumbnail": "No hay archivos disponibles para restablecer la miniatura.", "newFolder": "Nueva carpeta", "newFile": "Nuevo archivo", "showFullPath": "Mostrar ruta completa", diff --git a/public/locales/fr-FR/application.json b/public/locales/fr-FR/application.json index f51f7b6..7c0c7af 100644 --- a/public/locales/fr-FR/application.json +++ b/public/locales/fr-FR/application.json @@ -18,7 +18,9 @@ "email": "E-mail", "password": "Mot de passe", "captcha": "CAPTCHA", - "captchaError": "Impossible de charger le CAPTCHA : {{message}}", + "resetThumbnail": "Réinitialiser la miniature", + "resetThumbnailRequested": "Réinitialisation de la miniature demandée.", + "noFileCanResetThumbnail": "Aucun fichier pouvant réinitialiser la miniature.", "signIn": "Se connecter", "signUp": "S'inscrire", "signUpAccount": "S'inscrire", diff --git a/public/locales/it-IT/application.json b/public/locales/it-IT/application.json index 2e18a47..e68a674 100644 --- a/public/locales/it-IT/application.json +++ b/public/locales/it-IT/application.json @@ -53,6 +53,9 @@ "loggedOut": "Sei stato disconnesso", "clickToRefresh": "Clicca per aggiornare il CAPTCHA" }, + "resetThumbnail": "Reimposta miniatura", + "resetThumbnailRequested": "Reimpostazione della miniatura richiesta.", + "noFileCanResetThumbnail": "Nessun file può reimpostare la miniatura.", "navbar": { "notBefore": "Non prima di", "notAfter": "Non dopo", diff --git a/public/locales/ja-JP/application.json b/public/locales/ja-JP/application.json index 272cd44..e3a2da5 100644 --- a/public/locales/ja-JP/application.json +++ b/public/locales/ja-JP/application.json @@ -53,6 +53,9 @@ "loggedOut": "ログアウトしました", "clickToRefresh": "CAPTCHAを再読み込み" }, + "resetThumbnail": "サムネイルをリセット", + "resetThumbnailRequested": "サムネイルのリセットをリクエストしました。", + "noFileCanResetThumbnail": "サムネイルをリセットできるファイルがありません。", "navbar": { "notBefore": "~より前", "notAfter": "~より後", diff --git a/public/locales/ko-KR/application.json b/public/locales/ko-KR/application.json index d9e2678..01886d4 100644 --- a/public/locales/ko-KR/application.json +++ b/public/locales/ko-KR/application.json @@ -87,6 +87,9 @@ "photos": "사진", "music": "음악", "documents": "문서", + "resetThumbnail": "썸네일 재설정", + "resetThumbnailRequested": "썸네일 재설정이 요청되었습니다.", + "noFileCanResetThumbnail": "썸네일을 재설정할 수 있는 파일이 없습니다.", "addATag": "태그 추가...", "addTagDialog": { "selectFolder": "폴더 선택", diff --git a/public/locales/pt-BR/application.json b/public/locales/pt-BR/application.json index 767b0b0..311dc34 100644 --- a/public/locales/pt-BR/application.json +++ b/public/locales/pt-BR/application.json @@ -74,6 +74,9 @@ "recentlyViewed": "Visualizados recentemente", "searchFiles": "Buscar arquivos...", "showMore": "Mais", + "resetThumbnail": "Redefinir miniatura", + "resetThumbnailRequested": "Redefinição de miniatura solicitada.", + "noFileCanResetThumbnail": "Nenhum arquivo pode redefinir a miniatura.", "myFiles": "Meus arquivos", "hisFiles": "Arquivos dele/dela", "trash": "Lixeira", diff --git a/public/locales/ru-RU/application.json b/public/locales/ru-RU/application.json index 04355c2..9ece769 100644 --- a/public/locales/ru-RU/application.json +++ b/public/locales/ru-RU/application.json @@ -67,6 +67,9 @@ "notNameOpOr": "Должны содержаться все ключевые слова", "caseFolding": "Игнорировать регистр", "keywords": "Ключевые слова", + "resetThumbnail": "Сбросить миниатюру", + "resetThumbnailRequested": "Запрошен сброс миниатюры.", + "noFileCanResetThumbnail": "Нет файлов для сброса миниатюры.", "fileNameKeywordsHelp": "Нажмите Enter для добавления ключевого слова", "advancedSearch": "Расширенный поиск", "searchFilesTitle": "Поиск файлов", diff --git a/public/locales/zh-CN/application.json b/public/locales/zh-CN/application.json index 709b7b0..42c9451 100644 --- a/public/locales/zh-CN/application.json +++ b/public/locales/zh-CN/application.json @@ -281,6 +281,9 @@ "moreActions": "更多操作", "refresh": "刷新", "createArchive": "创建压缩文件", + "resetThumbnail": "重置缩略图", + "resetThumbnailRequested": "已请求重置缩略图。", + "noFileCanResetThumbnail": "没有可重置缩略图的文件。", "newFolder": "创建文件夹", "newFile": "创建文件", "showFullPath": "显示路径", diff --git a/public/locales/zh-TW/application.json b/public/locales/zh-TW/application.json index e43d811..328fe35 100644 --- a/public/locales/zh-TW/application.json +++ b/public/locales/zh-TW/application.json @@ -57,6 +57,9 @@ "notBefore": "不早於", "notAfter": "不晚於", "minimum": "最小", + "resetThumbnail": "重設縮圖", + "resetThumbnailRequested": "已請求重設縮圖。", + "noFileCanResetThumbnail": "沒有可重設縮圖的檔案。", "maximum": "最大", "fileSize": "檔案大小", "searchBase": "搜尋路徑", diff --git a/src/api/api.ts b/src/api/api.ts index cd4bbc4..1b2391f 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -292,6 +292,137 @@ export function getFileThumb(path: string, contextHint?: string): ThunkResponse< }; } +// Reset thumbnails for given file URIs by clearing thumb:disabled metadata +export interface ResetThumbRequest { + uris: string[]; +} + +export function sendResetFileThumbs(req: ResetThumbRequest): ThunkResponse { + return async (dispatch, _getState) => { + return await dispatch( + send( + "/file/thumb/reset", + { + data: req, + method: "POST", + }, + { + ...defaultOpts, + }, + ), + ); + }; +} + +// Get supported thumbnail file extensions by reading enabled generators' settings +export function getSupportedThumbExts(): ThunkResponse { + return async (dispatch, _getState) => { + // Try backend endpoint first if available + try { + const remote = await dispatch( + send<{ exts?: string[] }>( + "/file/thumb/exts", + { + method: "GET", + }, + { + ...defaultOpts, + }, + ), + ); + if (remote && Array.isArray(remote.exts)) { + return remote.exts; + } + } catch (_e) { + // Fallback to compute from settings below + } + + const keys = { + keys: [ + "thumb_builtin_enabled", + "thumb_vips_enabled", + "thumb_vips_exts", + "thumb_ffmpeg_enabled", + "thumb_ffmpeg_exts", + "thumb_libreoffice_enabled", + "thumb_libreoffice_exts", + "thumb_music_cover_enabled", + "thumb_music_cover_exts", + "thumb_libraw_enabled", + "thumb_libraw_exts", + ], + }; + + const settings = await dispatch(getSettings(keys)); + + const enabled = (k: string) => { + const v = (settings?.[k] ?? "").toString().toLowerCase(); + return v === "1" || v === "true"; + }; + const parseExts = (v?: string) => + (v || "") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter((s) => !!s); + + const exts = new Set(); + + if (enabled("thumb_vips_enabled")) { + parseExts(settings?.["thumb_vips_exts"]).forEach((e) => exts.add(e)); + } + if (enabled("thumb_ffmpeg_enabled")) { + parseExts(settings?.["thumb_ffmpeg_exts"]).forEach((e) => exts.add(e)); + } + if (enabled("thumb_libreoffice_enabled")) { + parseExts(settings?.["thumb_libreoffice_exts"]).forEach((e) => exts.add(e)); + } + if (enabled("thumb_music_cover_enabled")) { + parseExts(settings?.["thumb_music_cover_exts"]).forEach((e) => exts.add(e)); + } + if (enabled("thumb_libraw_enabled")) { + parseExts(settings?.["thumb_libraw_exts"]).forEach((e) => exts.add(e)); + } + + // Note: builtin generator does not expose explicit extensions list in settings + // so we do not add extra exts for it to keep behavior consistent with backend. + + return Array.from(exts); + }; +} + +// --- Cached supported thumbnail extensions helpers --- +let __thumbExtsCache: Set | null | undefined = undefined; // undefined: not fetched, null: unknown/fallback + +// Prime cache once per page. Safe to call multiple times. +export function primeThumbExtsCache(): ThunkResponse { + return async (dispatch, _getState) => { + if (__thumbExtsCache !== undefined) return; + try { + const exts = await dispatch(getSupportedThumbExts()); + __thumbExtsCache = new Set(exts.map((e) => e.toLowerCase())); + } catch (_e) { + // Mark as unknown to fall back to legacy behavior + __thumbExtsCache = null; + } + }; +} + +export function getCachedThumbExts(): Set | null | undefined { + return __thumbExtsCache; +} + +// Check if a file name is likely supported based on cached exts +// Returns undefined if cache is not ready (treat as supported by caller). +export function isThumbExtSupportedSync(fileName: string): boolean | undefined { + const cache = __thumbExtsCache; + if (cache === undefined) return undefined; + if (cache === null) return true; // unknown => allow + const idx = fileName.lastIndexOf("."); + const ext = idx >= 0 ? fileName.substring(idx + 1).toLowerCase() : ""; + if (!ext) return false; + return cache.has(ext); +} + export function getUserInfo(uid: string): ThunkResponse { return async (dispatch, _getState) => { return await dispatch( diff --git a/src/component/FileManager/ContextMenu/ContextMenu.tsx b/src/component/FileManager/ContextMenu/ContextMenu.tsx index d7e74c9..6896e76 100644 --- a/src/component/FileManager/ContextMenu/ContextMenu.tsx +++ b/src/component/FileManager/ContextMenu/ContextMenu.tsx @@ -1,5 +1,5 @@ import { Box, Divider, ListItemIcon, ListItemText, Menu, MenuItem, styled, Typography, useTheme } from "@mui/material"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { closeContextMenu } from "../../../redux/fileManagerSlice.ts"; import { CreateNewDialogType } from "../../../redux/globalStateSlice.ts"; @@ -22,6 +22,7 @@ import { } from "../../../redux/thunks/file.ts"; import { refreshFileList, uploadClicked, uploadFromClipboard } from "../../../redux/thunks/filemanager.ts"; import { openViewers } from "../../../redux/thunks/viewer.ts"; +import { primeThumbExtsCache } from "../../../api/api.ts"; import AppFolder from "../../Icons/AppFolder.tsx"; import ArchiveArrow from "../../Icons/ArchiveArrow.tsx"; import ArrowSync from "../../Icons/ArrowSync.tsx"; @@ -107,6 +108,13 @@ const ContextMenu = ({ fmIndex = 0 }: ContextMenuProps) => { dispatch(closeContextMenu({ index: fmIndex, value: undefined })); }, [dispatch]); + // Ensure supported thumbnail extensions are primed when menu opens + useEffect(() => { + if (contextMenuOpen) { + dispatch(primeThumbExtsCache()); + } + }, [contextMenuOpen, dispatch]); + const showOpenWithCascading = displayOpt.showOpenWithCascading && displayOpt.showOpenWithCascading(); const showOpenWith = displayOpt.showOpenWith && displayOpt.showOpenWith(); let part1 = diff --git a/src/component/FileManager/ContextMenu/MoreMenuItems.tsx b/src/component/FileManager/ContextMenu/MoreMenuItems.tsx index 448ed8d..fbaf6f5 100644 --- a/src/component/FileManager/ContextMenu/MoreMenuItems.tsx +++ b/src/component/FileManager/ContextMenu/MoreMenuItems.tsx @@ -10,11 +10,13 @@ import { } from "../../../redux/globalStateSlice.ts"; import { useAppDispatch } from "../../../redux/hooks.ts"; import Archive from "../../Icons/Archive.tsx"; +import RectangleLandscapeSync from "../../Icons/RectangleLandscapeSync.tsx"; import BranchForkLink from "../../Icons/BranchForkLink.tsx"; import HistoryOutlined from "../../Icons/HistoryOutlined.tsx"; import LinkSetting from "../../Icons/LinkSetting.tsx"; import { CascadingContext, CascadingMenuItem } from "./CascadingMenu.tsx"; import { SubMenuItemsProps } from "./OrganizeMenuItems.tsx"; +import { resetThumbnails } from "../../../redux/thunks/file.ts"; const MoreMenuItems = ({ displayOpt, targets }: SubMenuItemsProps) => { const { rootPopupState } = useContext(CascadingContext); @@ -105,6 +107,14 @@ const MoreMenuItems = ({ displayOpt, targets }: SubMenuItemsProps) => { {t("application:fileManager.createArchive")} )} + {displayOpt.showResetThumb && ( + dispatch(resetThumbnails(targets)))}> + + + + {t("application:fileManager.resetThumbnail")} + + )} ); }; diff --git a/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts b/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts index 9e29a02..0fe30d1 100644 --- a/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts +++ b/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { FileResponse, FileType, Metadata, NavigatorCapability } from "../../../api/explorer.ts"; +import { getCachedThumbExts } from "../../../api/api.ts"; import { GroupPermission } from "../../../api/user.ts"; import { defaultPath } from "../../../hooks/useNavigation.tsx"; import { ContextMenuTypes } from "../../../redux/fileManagerSlice.ts"; @@ -77,6 +78,7 @@ export interface DisplayOption { showDirectLinkManagement?: boolean; showManageShares?: boolean; showCreateArchive?: boolean; + showResetThumb?: boolean; andCapability?: Boolset; orCapability?: Boolset; @@ -291,11 +293,23 @@ export const getActionOpt = ( display.orCapability && display.orCapability.enabled(NavigatorCapability.download_file); + // Reset thumbnail is available when at least one file is selected and + // current capability allows generating thumbnails + // Show only when at least one selected file has a supported extension, + // based on cached supported thumbnail extensions. + const cache = getCachedThumbExts(); + const anySupported = + cache instanceof Set + ? targets.some((f) => f.type == FileType.file && cache.has((fileExtension(f.name) || "").toLowerCase())) + : false; + display.showResetThumb = display.hasFile && anySupported; + display.showMore = display.showVersionControl || display.showManageShares || display.showCreateArchive || - display.showDirectLinkManagement; + display.showDirectLinkManagement || + display.showResetThumb; return display; }; diff --git a/src/component/FileManager/FileManager.tsx b/src/component/FileManager/FileManager.tsx index f0d91f9..417b1d0 100644 --- a/src/component/FileManager/FileManager.tsx +++ b/src/component/FileManager/FileManager.tsx @@ -6,6 +6,7 @@ import { clearSelected } from "../../redux/fileManagerSlice.ts"; import { resetDialogs } from "../../redux/globalStateSlice.ts"; import { useAppDispatch } from "../../redux/hooks.ts"; import { resetFm, selectAll, shortCutDelete } from "../../redux/thunks/filemanager.ts"; +import { primeThumbExtsCache } from "../../api/api.ts"; import ImageViewer from "../Viewers/ImageViewer/ImageViewer.tsx"; import Explorer from "./Explorer/Explorer.tsx"; import { FmIndexContext } from "./FmIndexContext.tsx"; @@ -37,6 +38,8 @@ export const FileManager = ({ index = 0, initialPath, skipRender }: FileManagerP useEffect(() => { if (index == FileManagerIndex.main) { dispatch(resetDialogs()); + // Prime supported thumbnail extension cache once per page + dispatch(primeThumbExtsCache()); return () => { dispatch(resetFm(index)); }; diff --git a/src/redux/thunks/file.ts b/src/redux/thunks/file.ts index 7e204a7..fe4def9 100644 --- a/src/redux/thunks/file.ts +++ b/src/redux/thunks/file.ts @@ -6,6 +6,8 @@ import { getFileEntityUrl, getFileList, getFileThumb, + getCachedThumbExts, + sendResetFileThumbs, sendCreateFile, sendDeleteFiles, sendMetadataPatch, @@ -39,7 +41,7 @@ import { loadingDebounceMs } from "../../constants"; import { defaultPath } from "../../hooks/useNavigation.tsx"; import SessionManager, { UserSettings } from "../../session"; import { addRecentUsedColor, addUsedTags } from "../../session/utils.ts"; -import { getFileLinkedUri } from "../../util"; +import { fileExtension, getFileLinkedUri } from "../../util"; import Boolset from "../../util/boolset.ts"; import { canCopyMoveTo } from "../../util/permission.ts"; import CrUri, { Filesystem } from "../../util/uri.ts"; @@ -1154,6 +1156,66 @@ export function batchGetDirectLinks(index: number, files: FileResponse[]): AppTh }; } +export function resetThumbnails(files: FileResponse[]): AppThunk { + return async (dispatch, _getState) => { + const cache = getCachedThumbExts(); + const uris = files + .filter((f) => f.type == FileType.file) + .filter((f) => + cache === undefined || cache === null ? true : cache.has((fileExtension(f.name) || "").toLowerCase()), + ) + .map((f) => getFileLinkedUri(f)); + + if (uris.length === 0) { + enqueueSnackbar({ + message: i18next.t("application:fileManager.noFileCanResetThumbnail"), + preventDuplicate: true, + variant: "warning", + action: DefaultCloseAction, + }); + return; + } + + try { + await dispatch( + sendResetFileThumbs({ + uris, + }), + ); + + enqueueSnackbar({ + message: i18next.t("application:fileManager.resetThumbnailRequested"), + variant: "success", + action: DefaultCloseAction, + }); + } catch (_e) { + // Error snackbar is handled in send() + } finally { + // Clear cached thumbnails so they will be reloaded next time + files + .filter((f) => f.type == FileType.file) + .forEach((f) => + dispatch( + fileUpdated({ + index: 0, + value: [ + { + file: f, + oldPath: f.path, + }, + ], + }), + ), + ); + + // Use the same refresh approach as uploader: refresh after a short delay + setTimeout(() => { + dispatch(refreshFileList(0)); + }, 1000); + } + }; +} + // Single file symbolic links might be invalid if original file is renamed by its owner, // we need to refresh the symbolic links by getting the latest file list export function refreshSingleFileSymbolicLinks(file: FileResponse): AppThunk> {