mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-18 09:29:24 +00:00
Basic file history
This commit is contained in:
parent
1a3e8e7ecf
commit
d4e0fb581f
@ -72,12 +72,19 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
|||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{currentFile ? getFileSize(currentFile) : ''}
|
{currentFile ? getFileSize(currentFile) : ''}
|
||||||
{selectedFiles.length > 1 && ` • ${selectedFiles.length} files`}
|
{selectedFiles.length > 1 && ` • ${selectedFiles.length} files`}
|
||||||
|
{currentFile && ` • v${currentFile.versionNumber || 0}`}
|
||||||
</Text>
|
</Text>
|
||||||
{hasMultipleFiles && (
|
{hasMultipleFiles && (
|
||||||
<Text size="xs" c="blue">
|
<Text size="xs" c="blue">
|
||||||
{currentFileIndex + 1} of {selectedFiles.length}
|
{currentFileIndex + 1} of {selectedFiles.length}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{/* Compact tool chain for mobile */}
|
||||||
|
{currentFile?.historyInfo?.toolChain && currentFile.historyInfo.toolChain.length > 0 && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{currentFile.historyInfo.toolChain.map(tool => tool.toolName).join(' → ')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Navigation arrows for multiple files */}
|
{/* Navigation arrows for multiple files */}
|
||||||
|
@ -1,15 +1,25 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Group, Text, ActionIcon, Tooltip } from "@mantine/core";
|
import { Group, Text, ActionIcon, Tooltip, Switch } from "@mantine/core";
|
||||||
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import HistoryIcon from "@mui/icons-material/History";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
||||||
|
|
||||||
const FileActions: React.FC = () => {
|
const FileActions: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } =
|
const {
|
||||||
useFileManagerContext();
|
recentFiles,
|
||||||
|
selectedFileIds,
|
||||||
|
filteredFiles,
|
||||||
|
showAllVersions,
|
||||||
|
fileGroups,
|
||||||
|
onSelectAll,
|
||||||
|
onDeleteSelected,
|
||||||
|
onDownloadSelected,
|
||||||
|
onToggleVersions
|
||||||
|
} = useFileManagerContext();
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
onSelectAll();
|
onSelectAll();
|
||||||
@ -35,6 +45,9 @@ const FileActions: React.FC = () => {
|
|||||||
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
|
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
|
||||||
const hasSelection = selectedFileIds.length > 0;
|
const hasSelection = selectedFileIds.length > 0;
|
||||||
|
|
||||||
|
// Check if there are any files with version history
|
||||||
|
const hasVersionedFiles = Array.from(fileGroups.values()).some(versions => versions.length > 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -47,8 +60,8 @@ const FileActions: React.FC = () => {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left: Select All */}
|
{/* Left: Select All and Version Toggle */}
|
||||||
<div>
|
<Group gap="md">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={allFilesSelected ? t("fileManager.deselectAll", "Deselect All") : t("fileManager.selectAll", "Select All")}
|
label={allFilesSelected ? t("fileManager.deselectAll", "Deselect All") : t("fileManager.selectAll", "Select All")}
|
||||||
>
|
>
|
||||||
@ -63,7 +76,30 @@ const FileActions: React.FC = () => {
|
|||||||
<SelectAllIcon style={{ fontSize: "1rem" }} />
|
<SelectAllIcon style={{ fontSize: "1rem" }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
|
||||||
|
{/* Version Toggle - only show if there are versioned files */}
|
||||||
|
{hasVersionedFiles && (
|
||||||
|
<Tooltip
|
||||||
|
label={showAllVersions ?
|
||||||
|
t("fileManager.showLatestOnly", "Show latest versions only") :
|
||||||
|
t("fileManager.showAllVersions", "Show all versions")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Group gap="xs" style={{ cursor: 'pointer' }} onClick={onToggleVersions}>
|
||||||
|
<HistoryIcon style={{ fontSize: "1rem", color: 'var(--mantine-color-blue-6)' }} />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{showAllVersions ? t("fileManager.allVersions", "All") : t("fileManager.latestOnly", "Latest")}
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
size="xs"
|
||||||
|
checked={showAllVersions}
|
||||||
|
onChange={onToggleVersions}
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
{/* Center: Selected count */}
|
{/* Center: Selected count */}
|
||||||
<div
|
<div
|
||||||
|
@ -74,9 +74,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`}>
|
<Stack gap="md" h={`calc(${modalHeight} - 2rem)`}>
|
||||||
{/* Section 1: Thumbnail Preview */}
|
{/* Section 1: Thumbnail Preview */}
|
||||||
<Box style={{ width: '100%', height: `calc(${modalHeight} * 0.5 - 2rem)`, textAlign: 'center', padding: 'xs' }}>
|
<Box style={{ width: '100%', height: `calc(${modalHeight} * 0.42 - 1rem)`, textAlign: 'center', padding: 'xs' }}>
|
||||||
<FilePreview
|
<FilePreview
|
||||||
file={currentFile}
|
file={currentFile}
|
||||||
thumbnail={getCurrentThumbnail()}
|
thumbnail={getCurrentThumbnail()}
|
||||||
|
@ -16,7 +16,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder p={0} h={`calc(${modalHeight} * 0.32 - 1rem)`} style={{ flex: 1, overflow: 'hidden' }}>
|
<Card withBorder p={0} h={`calc(${modalHeight} * 0.38 - 1rem)`} style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<Box bg="gray.4" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
|
<Box bg="gray.4" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
|
||||||
<Text size="sm" fw={500} ta="center" c="white">
|
<Text size="sm" fw={500} ta="center" c="white">
|
||||||
{t('fileManager.details', 'File Details')}
|
{t('fileManager.details', 'File Details')}
|
||||||
@ -54,10 +54,28 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
|
|
||||||
<Group justify="space-between" py="xs">
|
<Group justify="space-between" py="xs">
|
||||||
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
|
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
|
||||||
<Text size="sm" fw={500}>
|
{currentFile &&
|
||||||
{currentFile ? '1.0' : ''}
|
<Badge size="sm" variant="light" color={currentFile?.versionNumber ? 'blue' : 'gray'}>
|
||||||
</Text>
|
v{currentFile ? (currentFile.versionNumber || 0) : ''}
|
||||||
|
</Badge>}
|
||||||
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{/* Tool Chain Display - Compact */}
|
||||||
|
{currentFile?.historyInfo?.toolChain && currentFile.historyInfo.toolChain.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Box py="xs">
|
||||||
|
<Text size="xs" style={{
|
||||||
|
color: 'var(--mantine-color-blue-6)',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
}}>
|
||||||
|
{currentFile.historyInfo.toolChain.map(tool => tool.toolName).join(' → ')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Card>
|
</Card>
|
||||||
|
@ -3,9 +3,12 @@ import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@m
|
|||||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import DownloadIcon from '@mui/icons-material/Download';
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
|
import HistoryIcon from '@mui/icons-material/History';
|
||||||
|
import RestoreIcon from '@mui/icons-material/Restore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
||||||
import { FileMetadata } from '../../types/file';
|
import { FileMetadata } from '../../types/file';
|
||||||
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
|
||||||
interface FileListItemProps {
|
interface FileListItemProps {
|
||||||
file: FileMetadata;
|
file: FileMetadata;
|
||||||
@ -30,10 +33,17 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { fileGroups, onRestoreVersion } = useFileManagerContext();
|
||||||
|
|
||||||
// Keep item in hovered state if menu is open
|
// Keep item in hovered state if menu is open
|
||||||
const shouldShowHovered = isHovered || isMenuOpen;
|
const shouldShowHovered = isHovered || isMenuOpen;
|
||||||
|
|
||||||
|
// Get version information for this file
|
||||||
|
const originalFileId = file.originalFileId || file.id;
|
||||||
|
const fileVersions = fileGroups.get(originalFileId) || [];
|
||||||
|
const hasVersionHistory = fileVersions.length > 1;
|
||||||
|
const currentVersion = file.versionNumber || 0; // Display original files as v0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box
|
<Box
|
||||||
@ -77,8 +87,18 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
DRAFT
|
DRAFT
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{hasVersionHistory && (
|
||||||
|
<Badge size="xs" variant="light" color="blue">
|
||||||
|
v{currentVersion}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" c="dimmed">{getFileSize(file)} • {getFileDate(file)}</Text>
|
<Text size="xs" c="dimmed">
|
||||||
|
{getFileSize(file)} • {getFileDate(file)}
|
||||||
|
{hasVersionHistory && (
|
||||||
|
<Text span c="dimmed"> • {fileVersions.length} versions</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Three dots menu - fades in/out on hover */}
|
{/* Three dots menu - fades in/out on hover */}
|
||||||
@ -117,6 +137,42 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
{t('fileManager.download', 'Download')}
|
{t('fileManager.download', 'Download')}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Version History Menu */}
|
||||||
|
{hasVersionHistory && (
|
||||||
|
<>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Label>{t('fileManager.versions', 'Version History')}</Menu.Label>
|
||||||
|
{fileVersions.map((version, index) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={version.id}
|
||||||
|
leftSection={
|
||||||
|
version.id === file.id ?
|
||||||
|
<Badge size="xs" color="blue">Current</Badge> :
|
||||||
|
<RestoreIcon style={{ fontSize: 16 }} />
|
||||||
|
}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (version.id !== file.id) {
|
||||||
|
onRestoreVersion(version);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={version.id === file.id}
|
||||||
|
>
|
||||||
|
<Group gap="xs" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Text size="sm">
|
||||||
|
v{version.versionNumber || 0}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{new Date(version.lastModified).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
<Menu.Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
@ -3,6 +3,7 @@ import { FileMetadata } from '../types/file';
|
|||||||
import { StoredFile, fileStorage } from '../services/fileStorage';
|
import { StoredFile, fileStorage } from '../services/fileStorage';
|
||||||
import { downloadFiles } from '../utils/downloadUtils';
|
import { downloadFiles } from '../utils/downloadUtils';
|
||||||
import { FileId } from '../types/file';
|
import { FileId } from '../types/file';
|
||||||
|
import { getLatestVersions, groupFilesByOriginal, getVersionHistory } from '../utils/fileHistoryUtils';
|
||||||
|
|
||||||
// Type for the context value - now contains everything directly
|
// Type for the context value - now contains everything directly
|
||||||
interface FileManagerContextValue {
|
interface FileManagerContextValue {
|
||||||
@ -14,6 +15,8 @@ interface FileManagerContextValue {
|
|||||||
filteredFiles: FileMetadata[];
|
filteredFiles: FileMetadata[];
|
||||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
selectedFilesSet: Set<string>;
|
selectedFilesSet: Set<string>;
|
||||||
|
showAllVersions: boolean;
|
||||||
|
fileGroups: Map<string, FileMetadata[]>;
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
||||||
@ -28,6 +31,9 @@ interface FileManagerContextValue {
|
|||||||
onDeleteSelected: () => void;
|
onDeleteSelected: () => void;
|
||||||
onDownloadSelected: () => void;
|
onDownloadSelected: () => void;
|
||||||
onDownloadSingle: (file: FileMetadata) => void;
|
onDownloadSingle: (file: FileMetadata) => void;
|
||||||
|
onToggleVersions: () => void;
|
||||||
|
onRestoreVersion: (file: FileMetadata) => void;
|
||||||
|
onNewFilesSelect: (files: File[]) => void;
|
||||||
|
|
||||||
// External props
|
// External props
|
||||||
recentFiles: FileMetadata[];
|
recentFiles: FileMetadata[];
|
||||||
@ -68,6 +74,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
|
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
|
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
|
||||||
|
const [showAllVersions, setShowAllVersions] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Track blob URLs for cleanup
|
// Track blob URLs for cleanup
|
||||||
@ -76,11 +83,44 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
// Computed values (with null safety)
|
// Computed values (with null safety)
|
||||||
const selectedFilesSet = new Set(selectedFileIds);
|
const selectedFilesSet = new Set(selectedFileIds);
|
||||||
|
|
||||||
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
// Group files by original file ID for version management
|
||||||
(recentFiles || []).filter(file => selectedFilesSet.has(file.id));
|
const fileGroups = useMemo(() => {
|
||||||
|
if (!recentFiles || recentFiles.length === 0) return new Map();
|
||||||
|
|
||||||
const filteredFiles = !searchTerm ? recentFiles || [] :
|
// Convert FileMetadata to FileRecord-like objects for grouping utility
|
||||||
(recentFiles || []).filter(file =>
|
const recordsForGrouping = recentFiles.map(file => ({
|
||||||
|
...file,
|
||||||
|
originalFileId: file.originalFileId,
|
||||||
|
versionNumber: file.versionNumber || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
return groupFilesByOriginal(recordsForGrouping);
|
||||||
|
}, [recentFiles]);
|
||||||
|
|
||||||
|
// Get files to display (latest versions only or all versions)
|
||||||
|
const displayFiles = useMemo(() => {
|
||||||
|
if (!recentFiles || recentFiles.length === 0) return [];
|
||||||
|
|
||||||
|
if (showAllVersions) {
|
||||||
|
return recentFiles;
|
||||||
|
} else {
|
||||||
|
// Show only latest versions
|
||||||
|
const recordsForFiltering = recentFiles.map(file => ({
|
||||||
|
...file,
|
||||||
|
originalFileId: file.originalFileId,
|
||||||
|
versionNumber: file.versionNumber || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
const latestVersions = getLatestVersions(recordsForFiltering);
|
||||||
|
return latestVersions.map(record => recentFiles.find(file => file.id === record.id)!);
|
||||||
|
}
|
||||||
|
}, [recentFiles, showAllVersions]);
|
||||||
|
|
||||||
|
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
||||||
|
displayFiles.filter(file => selectedFilesSet.has(file.id));
|
||||||
|
|
||||||
|
const filteredFiles = !searchTerm ? displayFiles :
|
||||||
|
displayFiles.filter(file =>
|
||||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -243,6 +283,42 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleVersions = useCallback(() => {
|
||||||
|
setShowAllVersions(prev => !prev);
|
||||||
|
// Clear selection when toggling versions
|
||||||
|
setSelectedFileIds([]);
|
||||||
|
setLastClickedIndex(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRestoreVersion = useCallback(async (file: FileMetadata) => {
|
||||||
|
try {
|
||||||
|
console.log('Restoring version:', file.name, 'version:', file.versionNumber);
|
||||||
|
|
||||||
|
// 1. Load the file from IndexedDB storage
|
||||||
|
const storedFile = await fileStorage.getFile(file.id);
|
||||||
|
if (!storedFile) {
|
||||||
|
console.error('File not found in storage:', file.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create new File object from stored data
|
||||||
|
const restoredFile = new File([storedFile.data], file.name, {
|
||||||
|
type: file.type,
|
||||||
|
lastModified: file.lastModified
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Add the restored file as a new version through the normal file upload flow
|
||||||
|
// This will trigger the file processing and create a new entry in recent files
|
||||||
|
onNewFilesSelect([restoredFile]);
|
||||||
|
|
||||||
|
// 4. Refresh the recent files list to show the new version
|
||||||
|
await refreshRecentFiles();
|
||||||
|
|
||||||
|
console.log('Successfully restored version:', file.name, 'v' + file.versionNumber);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to restore version:', error);
|
||||||
|
}
|
||||||
|
}, [refreshRecentFiles, onNewFilesSelect]);
|
||||||
|
|
||||||
// Cleanup blob URLs when component unmounts
|
// Cleanup blob URLs when component unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -274,6 +350,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
filteredFiles,
|
filteredFiles,
|
||||||
fileInputRef,
|
fileInputRef,
|
||||||
selectedFilesSet,
|
selectedFilesSet,
|
||||||
|
showAllVersions,
|
||||||
|
fileGroups,
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
onSourceChange: handleSourceChange,
|
onSourceChange: handleSourceChange,
|
||||||
@ -288,6 +366,9 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
onDeleteSelected: handleDeleteSelected,
|
onDeleteSelected: handleDeleteSelected,
|
||||||
onDownloadSelected: handleDownloadSelected,
|
onDownloadSelected: handleDownloadSelected,
|
||||||
onDownloadSingle: handleDownloadSingle,
|
onDownloadSingle: handleDownloadSingle,
|
||||||
|
onToggleVersions: handleToggleVersions,
|
||||||
|
onRestoreVersion: handleRestoreVersion,
|
||||||
|
onNewFilesSelect,
|
||||||
|
|
||||||
// External props
|
// External props
|
||||||
recentFiles,
|
recentFiles,
|
||||||
@ -300,6 +381,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
selectedFiles,
|
selectedFiles,
|
||||||
filteredFiles,
|
filteredFiles,
|
||||||
fileInputRef,
|
fileInputRef,
|
||||||
|
showAllVersions,
|
||||||
|
fileGroups,
|
||||||
handleSourceChange,
|
handleSourceChange,
|
||||||
handleLocalFileClick,
|
handleLocalFileClick,
|
||||||
handleFileSelect,
|
handleFileSelect,
|
||||||
@ -311,6 +394,9 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
handleSelectAll,
|
handleSelectAll,
|
||||||
handleDeleteSelected,
|
handleDeleteSelected,
|
||||||
handleDownloadSelected,
|
handleDownloadSelected,
|
||||||
|
handleToggleVersions,
|
||||||
|
handleRestoreVersion,
|
||||||
|
onNewFilesSelect,
|
||||||
recentFiles,
|
recentFiles,
|
||||||
isFileSupported,
|
isFileSupported,
|
||||||
modalHeight,
|
modalHeight,
|
||||||
|
@ -10,6 +10,7 @@ import { fileStorage, StoredFile } from '../services/fileStorage';
|
|||||||
import { FileId } from '../types/file';
|
import { FileId } from '../types/file';
|
||||||
import { FileMetadata } from '../types/file';
|
import { FileMetadata } from '../types/file';
|
||||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||||
|
import { createFileMetadataWithHistory } from '../utils/fileHistoryUtils';
|
||||||
|
|
||||||
interface IndexedDBContextValue {
|
interface IndexedDBContextValue {
|
||||||
// Core CRUD operations
|
// Core CRUD operations
|
||||||
@ -67,15 +68,11 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
|||||||
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||||
evictLRUEntries();
|
evictLRUEntries();
|
||||||
|
|
||||||
// Return metadata
|
// Extract history metadata for PDFs and return enhanced metadata
|
||||||
return {
|
const metadata = await createFileMetadataWithHistory(file, fileId, thumbnail);
|
||||||
id: fileId,
|
|
||||||
name: file.name,
|
|
||||||
type: file.type,
|
return metadata;
|
||||||
size: file.size,
|
|
||||||
lastModified: file.lastModified,
|
|
||||||
thumbnail
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadFile = useCallback(async (fileId: FileId): Promise<File | null> => {
|
const loadFile = useCallback(async (fileId: FileId): Promise<File | null> => {
|
||||||
@ -145,14 +142,46 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
|||||||
const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => {
|
const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => {
|
||||||
const metadata = await fileStorage.getAllFileMetadata();
|
const metadata = await fileStorage.getAllFileMetadata();
|
||||||
|
|
||||||
return metadata.map(m => ({
|
// For each PDF file, extract history metadata
|
||||||
|
const metadataWithHistory = await Promise.all(metadata.map(async (m) => {
|
||||||
|
// For non-PDF files, return basic metadata
|
||||||
|
if (!m.type.includes('pdf')) {
|
||||||
|
return {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
type: m.type,
|
type: m.type,
|
||||||
size: m.size,
|
size: m.size,
|
||||||
lastModified: m.lastModified,
|
lastModified: m.lastModified,
|
||||||
thumbnail: m.thumbnail
|
thumbnail: m.thumbnail
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For PDF files, load and extract history
|
||||||
|
const storedFile = await fileStorage.getFile(m.id);
|
||||||
|
if (storedFile?.data) {
|
||||||
|
const file = new File([storedFile.data], m.name, { type: m.type });
|
||||||
|
const enhancedMetadata = await createFileMetadataWithHistory(file, m.id, m.thumbnail);
|
||||||
|
|
||||||
|
|
||||||
|
return enhancedMetadata;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (DEBUG) console.warn('🗂️ IndexedDB.loadAllMetadata: Failed to extract history from stored file:', m.name, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to basic metadata if history extraction fails
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
type: m.type,
|
||||||
|
size: m.size,
|
||||||
|
lastModified: m.lastModified,
|
||||||
|
thumbnail: m.thumbnail
|
||||||
|
};
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
return metadataWithHistory;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => {
|
const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => {
|
||||||
|
@ -15,6 +15,7 @@ import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
|
|||||||
import { FileLifecycleManager } from './lifecycle';
|
import { FileLifecycleManager } from './lifecycle';
|
||||||
import { fileProcessingService } from '../../services/fileProcessingService';
|
import { fileProcessingService } from '../../services/fileProcessingService';
|
||||||
import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors';
|
import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors';
|
||||||
|
import { extractFileHistory } from '../../utils/fileHistoryUtils';
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === 'development';
|
const DEBUG = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
@ -183,6 +184,27 @@ export async function addFiles(
|
|||||||
if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract file history from PDF metadata (async)
|
||||||
|
extractFileHistory(file, record).then(updatedRecord => {
|
||||||
|
if (updatedRecord !== record && (updatedRecord.originalFileId || updatedRecord.versionNumber)) {
|
||||||
|
// History was found, dispatch update to trigger re-render
|
||||||
|
dispatch({
|
||||||
|
type: 'UPDATE_FILE_RECORD',
|
||||||
|
payload: {
|
||||||
|
id: fileId,
|
||||||
|
updates: {
|
||||||
|
originalFileId: updatedRecord.originalFileId,
|
||||||
|
versionNumber: updatedRecord.versionNumber,
|
||||||
|
parentFileId: updatedRecord.parentFileId,
|
||||||
|
toolHistory: updatedRecord.toolHistory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(error => {
|
||||||
|
if (DEBUG) console.warn(`📄 addFiles(raw): Failed to extract history for ${file.name}:`, error);
|
||||||
|
});
|
||||||
|
|
||||||
existingQuickKeys.add(quickKey);
|
existingQuickKeys.add(quickKey);
|
||||||
fileRecords.push(record);
|
fileRecords.push(record);
|
||||||
addedFiles.push({ file, id: fileId, thumbnail });
|
addedFiles.push({ file, id: fileId, thumbnail });
|
||||||
@ -225,6 +247,27 @@ export async function addFiles(
|
|||||||
if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract file history from PDF metadata (async)
|
||||||
|
extractFileHistory(file, record).then(updatedRecord => {
|
||||||
|
if (updatedRecord !== record && (updatedRecord.originalFileId || updatedRecord.versionNumber)) {
|
||||||
|
// History was found, dispatch update to trigger re-render
|
||||||
|
dispatch({
|
||||||
|
type: 'UPDATE_FILE_RECORD',
|
||||||
|
payload: {
|
||||||
|
id: fileId,
|
||||||
|
updates: {
|
||||||
|
originalFileId: updatedRecord.originalFileId,
|
||||||
|
versionNumber: updatedRecord.versionNumber,
|
||||||
|
parentFileId: updatedRecord.parentFileId,
|
||||||
|
toolHistory: updatedRecord.toolHistory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(error => {
|
||||||
|
if (DEBUG) console.warn(`📄 addFiles(processed): Failed to extract history for ${file.name}:`, error);
|
||||||
|
});
|
||||||
|
|
||||||
existingQuickKeys.add(quickKey);
|
existingQuickKeys.add(quickKey);
|
||||||
fileRecords.push(record);
|
fileRecords.push(record);
|
||||||
addedFiles.push({ file, id: fileId, thumbnail });
|
addedFiles.push({ file, id: fileId, thumbnail });
|
||||||
|
@ -10,6 +10,7 @@ import { createOperation } from '../../../utils/toolOperationTracker';
|
|||||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||||
import { FileId } from '../../../types/file';
|
import { FileId } from '../../../types/file';
|
||||||
import { FileRecord } from '../../../types/fileContext';
|
import { FileRecord } from '../../../types/fileContext';
|
||||||
|
import { prepareFilesWithHistory } from '../../../utils/fileHistoryUtils';
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
export type { ProcessingProgress, ResponseHandler };
|
export type { ProcessingProgress, ResponseHandler };
|
||||||
@ -170,6 +171,20 @@ export const useToolOperation = <TParams>(
|
|||||||
actions.resetResults();
|
actions.resetResults();
|
||||||
cleanupBlobUrls();
|
cleanupBlobUrls();
|
||||||
|
|
||||||
|
// Prepare files with history metadata injection (for PDFs)
|
||||||
|
actions.setStatus('Preparing files...');
|
||||||
|
const getFileRecord = (file: File) => {
|
||||||
|
const fileId = findFileId(file);
|
||||||
|
return fileId ? selectors.getFileRecord(fileId) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filesWithHistory = await prepareFilesWithHistory(
|
||||||
|
validFiles,
|
||||||
|
getFileRecord,
|
||||||
|
config.operationType,
|
||||||
|
params as Record<string, any>
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let processedFiles: File[];
|
let processedFiles: File[];
|
||||||
|
|
||||||
@ -184,7 +199,7 @@ export const useToolOperation = <TParams>(
|
|||||||
};
|
};
|
||||||
processedFiles = await processFiles(
|
processedFiles = await processFiles(
|
||||||
params,
|
params,
|
||||||
validFiles,
|
filesWithHistory,
|
||||||
apiCallsConfig,
|
apiCallsConfig,
|
||||||
actions.setProgress,
|
actions.setProgress,
|
||||||
actions.setStatus
|
actions.setStatus
|
||||||
@ -194,7 +209,7 @@ export const useToolOperation = <TParams>(
|
|||||||
case ToolType.multiFile:
|
case ToolType.multiFile:
|
||||||
// Multi-file processing - single API call with all files
|
// Multi-file processing - single API call with all files
|
||||||
actions.setStatus('Processing files...');
|
actions.setStatus('Processing files...');
|
||||||
const formData = config.buildFormData(params, validFiles);
|
const formData = config.buildFormData(params, filesWithHistory);
|
||||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||||
|
|
||||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||||
@ -202,11 +217,11 @@ export const useToolOperation = <TParams>(
|
|||||||
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
|
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
|
||||||
if (config.responseHandler) {
|
if (config.responseHandler) {
|
||||||
// Use custom responseHandler for multi-file (handles ZIP extraction)
|
// Use custom responseHandler for multi-file (handles ZIP extraction)
|
||||||
processedFiles = await config.responseHandler(response.data, validFiles);
|
processedFiles = await config.responseHandler(response.data, filesWithHistory);
|
||||||
} else if (response.data.type === 'application/pdf' ||
|
} else if (response.data.type === 'application/pdf' ||
|
||||||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
|
(response.headers && response.headers['content-type'] === 'application/pdf')) {
|
||||||
// Single PDF response (e.g. split with merge option) - use original filename
|
// Single PDF response (e.g. split with merge option) - use original filename
|
||||||
const originalFileName = validFiles[0]?.name || 'document.pdf';
|
const originalFileName = filesWithHistory[0]?.name || 'document.pdf';
|
||||||
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
|
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
|
||||||
processedFiles = [singleFile];
|
processedFiles = [singleFile];
|
||||||
} else {
|
} else {
|
||||||
@ -222,7 +237,7 @@ export const useToolOperation = <TParams>(
|
|||||||
|
|
||||||
case ToolType.custom:
|
case ToolType.custom:
|
||||||
actions.setStatus('Processing files...');
|
actions.setStatus('Processing files...');
|
||||||
processedFiles = await config.customProcessor(params, validFiles);
|
processedFiles = await config.customProcessor(params, filesWithHistory);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
331
frontend/src/services/pdfMetadataService.ts
Normal file
331
frontend/src/services/pdfMetadataService.ts
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* PDF Metadata Service - File History Tracking with pdf-lib
|
||||||
|
*
|
||||||
|
* Handles injection and extraction of file history metadata in PDFs using pdf-lib.
|
||||||
|
* This service embeds file history directly into PDF metadata, making it persistent
|
||||||
|
* across all tool operations and downloads.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
import { FileId } from '../types/file';
|
||||||
|
|
||||||
|
const DEBUG = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool operation metadata for history tracking
|
||||||
|
*/
|
||||||
|
export interface ToolOperation {
|
||||||
|
toolName: string;
|
||||||
|
timestamp: number;
|
||||||
|
parameters?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete file history metadata structure
|
||||||
|
*/
|
||||||
|
export interface PDFHistoryMetadata {
|
||||||
|
stirlingHistory: {
|
||||||
|
originalFileId: string;
|
||||||
|
parentFileId?: string;
|
||||||
|
versionNumber: number;
|
||||||
|
toolChain: ToolOperation[];
|
||||||
|
createdBy: 'Stirling-PDF';
|
||||||
|
formatVersion: '1.0';
|
||||||
|
createdAt: number;
|
||||||
|
lastModified: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing PDF file history metadata
|
||||||
|
*/
|
||||||
|
export class PDFMetadataService {
|
||||||
|
private static readonly HISTORY_KEYWORD = 'stirling-history';
|
||||||
|
private static readonly FORMAT_VERSION = '1.0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject file history metadata into a PDF
|
||||||
|
*/
|
||||||
|
async injectHistoryMetadata(
|
||||||
|
pdfBytes: ArrayBuffer,
|
||||||
|
originalFileId: string,
|
||||||
|
parentFileId?: string,
|
||||||
|
toolChain: ToolOperation[] = [],
|
||||||
|
versionNumber: number = 1
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
try {
|
||||||
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||||
|
|
||||||
|
const historyMetadata: PDFHistoryMetadata = {
|
||||||
|
stirlingHistory: {
|
||||||
|
originalFileId,
|
||||||
|
parentFileId,
|
||||||
|
versionNumber,
|
||||||
|
toolChain: [...toolChain],
|
||||||
|
createdBy: 'Stirling-PDF',
|
||||||
|
formatVersion: PDFMetadataService.FORMAT_VERSION,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastModified: Date.now()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set basic metadata
|
||||||
|
pdfDoc.setCreator('Stirling-PDF');
|
||||||
|
pdfDoc.setProducer('Stirling-PDF');
|
||||||
|
pdfDoc.setModificationDate(new Date());
|
||||||
|
|
||||||
|
// Embed history metadata in keywords field (most compatible)
|
||||||
|
const historyJson = JSON.stringify(historyMetadata);
|
||||||
|
const existingKeywords = pdfDoc.getKeywords();
|
||||||
|
|
||||||
|
// Handle keywords as array (pdf-lib stores them as array)
|
||||||
|
let keywordList: string[] = [];
|
||||||
|
if (Array.isArray(existingKeywords)) {
|
||||||
|
// Remove any existing history keywords to avoid duplicates
|
||||||
|
keywordList = existingKeywords.filter(keyword =>
|
||||||
|
!keyword.startsWith(`${PDFMetadataService.HISTORY_KEYWORD}:`)
|
||||||
|
);
|
||||||
|
} else if (existingKeywords) {
|
||||||
|
// Remove history from single keyword string
|
||||||
|
const cleanKeyword = this.extractHistoryFromKeywords(existingKeywords, true);
|
||||||
|
if (cleanKeyword) {
|
||||||
|
keywordList = [cleanKeyword];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add our new history metadata as a keyword (replacing any previous history)
|
||||||
|
const historyKeyword = `${PDFMetadataService.HISTORY_KEYWORD}:${historyJson}`;
|
||||||
|
keywordList.push(historyKeyword);
|
||||||
|
|
||||||
|
pdfDoc.setKeywords(keywordList);
|
||||||
|
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log('📄 Injected PDF history metadata:', {
|
||||||
|
originalFileId,
|
||||||
|
parentFileId,
|
||||||
|
versionNumber,
|
||||||
|
toolCount: toolChain.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await pdfDoc.save();
|
||||||
|
} catch (error) {
|
||||||
|
if (DEBUG) console.error('📄 Failed to inject PDF metadata:', error);
|
||||||
|
// Return original bytes if metadata injection fails
|
||||||
|
return pdfBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract file history metadata from a PDF
|
||||||
|
*/
|
||||||
|
async extractHistoryMetadata(pdfBytes: ArrayBuffer): Promise<PDFHistoryMetadata | null> {
|
||||||
|
try {
|
||||||
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||||
|
const keywords = pdfDoc.getKeywords();
|
||||||
|
|
||||||
|
// Look for history keyword directly in array or convert to string
|
||||||
|
let historyJson: string | null = null;
|
||||||
|
|
||||||
|
if (Array.isArray(keywords)) {
|
||||||
|
// Search through keywords array for our history keyword - get the LATEST one
|
||||||
|
const historyKeywords = keywords.filter(keyword =>
|
||||||
|
keyword.startsWith(`${PDFMetadataService.HISTORY_KEYWORD}:`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (historyKeywords.length > 0) {
|
||||||
|
// If multiple history keywords exist, parse all and get the highest version number
|
||||||
|
let latestVersionNumber = 0;
|
||||||
|
|
||||||
|
for (const historyKeyword of historyKeywords) {
|
||||||
|
try {
|
||||||
|
const json = historyKeyword.substring(`${PDFMetadataService.HISTORY_KEYWORD}:`.length);
|
||||||
|
const parsed = JSON.parse(json) as PDFHistoryMetadata;
|
||||||
|
|
||||||
|
if (parsed.stirlingHistory.versionNumber > latestVersionNumber) {
|
||||||
|
latestVersionNumber = parsed.stirlingHistory.versionNumber;
|
||||||
|
historyJson = json;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Silent fallback for corrupted history
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (keywords) {
|
||||||
|
// Fallback to string parsing
|
||||||
|
historyJson = this.extractHistoryFromKeywords(keywords);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!historyJson) return null;
|
||||||
|
|
||||||
|
const metadata = JSON.parse(historyJson) as PDFHistoryMetadata;
|
||||||
|
|
||||||
|
// Validate metadata structure
|
||||||
|
if (!this.isValidHistoryMetadata(metadata)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
} catch (error) {
|
||||||
|
if (DEBUG) console.error('📄 pdfMetadataService.extractHistoryMetadata: Failed to extract:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a tool operation to existing PDF history
|
||||||
|
*/
|
||||||
|
async addToolOperation(
|
||||||
|
pdfBytes: ArrayBuffer,
|
||||||
|
toolOperation: ToolOperation
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
try {
|
||||||
|
// Extract existing history
|
||||||
|
const existingHistory = await this.extractHistoryMetadata(pdfBytes);
|
||||||
|
|
||||||
|
if (!existingHistory) {
|
||||||
|
if (DEBUG) console.warn('📄 No existing history found, cannot add tool operation');
|
||||||
|
return pdfBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new tool operation
|
||||||
|
const updatedToolChain = [...existingHistory.stirlingHistory.toolChain, toolOperation];
|
||||||
|
|
||||||
|
// Re-inject with updated history
|
||||||
|
return await this.injectHistoryMetadata(
|
||||||
|
pdfBytes,
|
||||||
|
existingHistory.stirlingHistory.originalFileId,
|
||||||
|
existingHistory.stirlingHistory.parentFileId,
|
||||||
|
updatedToolChain,
|
||||||
|
existingHistory.stirlingHistory.versionNumber
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (DEBUG) console.error('📄 Failed to add tool operation:', error);
|
||||||
|
return pdfBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new version of a PDF with incremented version number
|
||||||
|
*/
|
||||||
|
async createNewVersion(
|
||||||
|
pdfBytes: ArrayBuffer,
|
||||||
|
parentFileId: string,
|
||||||
|
toolOperation: ToolOperation
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
try {
|
||||||
|
const parentHistory = await this.extractHistoryMetadata(pdfBytes);
|
||||||
|
|
||||||
|
const originalFileId = parentHistory?.stirlingHistory.originalFileId || parentFileId;
|
||||||
|
const parentToolChain = parentHistory?.stirlingHistory.toolChain || [];
|
||||||
|
const newVersionNumber = (parentHistory?.stirlingHistory.versionNumber || 0) + 1;
|
||||||
|
|
||||||
|
// Create new tool chain with the new operation
|
||||||
|
const newToolChain = [...parentToolChain, toolOperation];
|
||||||
|
|
||||||
|
return await this.injectHistoryMetadata(
|
||||||
|
pdfBytes,
|
||||||
|
originalFileId,
|
||||||
|
parentFileId,
|
||||||
|
newToolChain,
|
||||||
|
newVersionNumber
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (DEBUG) console.error('📄 Failed to create new version:', error);
|
||||||
|
return pdfBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a PDF has Stirling history metadata
|
||||||
|
*/
|
||||||
|
async hasStirlingHistory(pdfBytes: ArrayBuffer): Promise<boolean> {
|
||||||
|
const metadata = await this.extractHistoryMetadata(pdfBytes);
|
||||||
|
return metadata !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get version information from PDF
|
||||||
|
*/
|
||||||
|
async getVersionInfo(pdfBytes: ArrayBuffer): Promise<{
|
||||||
|
originalFileId: string;
|
||||||
|
versionNumber: number;
|
||||||
|
toolCount: number;
|
||||||
|
parentFileId?: string;
|
||||||
|
} | null> {
|
||||||
|
const metadata = await this.extractHistoryMetadata(pdfBytes);
|
||||||
|
if (!metadata) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
originalFileId: metadata.stirlingHistory.originalFileId,
|
||||||
|
versionNumber: metadata.stirlingHistory.versionNumber,
|
||||||
|
toolCount: metadata.stirlingHistory.toolChain.length,
|
||||||
|
parentFileId: metadata.stirlingHistory.parentFileId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embed history JSON in keywords field with delimiter
|
||||||
|
*/
|
||||||
|
private embedHistoryInKeywords(existingKeywords: string, historyJson: string): string {
|
||||||
|
// Remove any existing history
|
||||||
|
const cleanKeywords = this.extractHistoryFromKeywords(existingKeywords, true) || existingKeywords;
|
||||||
|
|
||||||
|
// Add new history with delimiter
|
||||||
|
const historyKeyword = `${PDFMetadataService.HISTORY_KEYWORD}:${historyJson}`;
|
||||||
|
|
||||||
|
if (cleanKeywords.trim()) {
|
||||||
|
return `${cleanKeywords.trim()} ${historyKeyword}`;
|
||||||
|
}
|
||||||
|
return historyKeyword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract history JSON from keywords field
|
||||||
|
*/
|
||||||
|
private extractHistoryFromKeywords(keywords: string, returnRemainder = false): string | null {
|
||||||
|
const historyPrefix = `${PDFMetadataService.HISTORY_KEYWORD}:`;
|
||||||
|
const historyIndex = keywords.indexOf(historyPrefix);
|
||||||
|
|
||||||
|
if (historyIndex === -1) return null;
|
||||||
|
|
||||||
|
const historyStart = historyIndex + historyPrefix.length;
|
||||||
|
let historyEnd = keywords.length;
|
||||||
|
|
||||||
|
// Look for the next keyword (space followed by non-JSON content)
|
||||||
|
// Simple heuristic: find space followed by word that doesn't look like JSON
|
||||||
|
const afterHistory = keywords.substring(historyStart);
|
||||||
|
const nextSpaceIndex = afterHistory.indexOf(' ');
|
||||||
|
if (nextSpaceIndex > 0) {
|
||||||
|
const afterSpace = afterHistory.substring(nextSpaceIndex + 1);
|
||||||
|
if (afterSpace && !afterSpace.trim().startsWith('{')) {
|
||||||
|
historyEnd = historyStart + nextSpaceIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (returnRemainder) {
|
||||||
|
// Return keywords with history removed
|
||||||
|
const before = keywords.substring(0, historyIndex);
|
||||||
|
const after = keywords.substring(historyEnd);
|
||||||
|
return `${before}${after}`.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return keywords.substring(historyStart, historyEnd).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate metadata structure
|
||||||
|
*/
|
||||||
|
private isValidHistoryMetadata(metadata: any): metadata is PDFHistoryMetadata {
|
||||||
|
return metadata &&
|
||||||
|
metadata.stirlingHistory &&
|
||||||
|
typeof metadata.stirlingHistory.originalFileId === 'string' &&
|
||||||
|
typeof metadata.stirlingHistory.versionNumber === 'number' &&
|
||||||
|
Array.isArray(metadata.stirlingHistory.toolChain) &&
|
||||||
|
metadata.stirlingHistory.createdBy === 'Stirling-PDF' &&
|
||||||
|
metadata.stirlingHistory.formatVersion === PDFMetadataService.FORMAT_VERSION;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const pdfMetadataService = new PDFMetadataService();
|
@ -6,6 +6,27 @@
|
|||||||
declare const tag: unique symbol;
|
declare const tag: unique symbol;
|
||||||
export type FileId = string & { readonly [tag]: 'FileId' };
|
export type FileId = string & { readonly [tag]: 'FileId' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool operation metadata for history tracking
|
||||||
|
*/
|
||||||
|
export interface ToolOperation {
|
||||||
|
toolName: string;
|
||||||
|
timestamp: number;
|
||||||
|
parameters?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File history information extracted from PDF metadata
|
||||||
|
*/
|
||||||
|
export interface FileHistoryInfo {
|
||||||
|
originalFileId: string;
|
||||||
|
parentFileId?: string;
|
||||||
|
versionNumber: number;
|
||||||
|
toolChain: ToolOperation[];
|
||||||
|
createdAt: number;
|
||||||
|
lastModified: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File metadata for efficient operations without loading full file data
|
* File metadata for efficient operations without loading full file data
|
||||||
* Used by IndexedDBContext and FileContext for lazy file loading
|
* Used by IndexedDBContext and FileContext for lazy file loading
|
||||||
@ -18,6 +39,14 @@ export interface FileMetadata {
|
|||||||
lastModified: number;
|
lastModified: number;
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
isDraft?: boolean; // Marks files as draft versions
|
isDraft?: boolean; // Marks files as draft versions
|
||||||
|
|
||||||
|
// File history tracking (extracted from PDF metadata)
|
||||||
|
historyInfo?: FileHistoryInfo;
|
||||||
|
|
||||||
|
// Quick access version information
|
||||||
|
originalFileId?: string; // Root file ID for grouping versions
|
||||||
|
versionNumber?: number; // Version number in chain
|
||||||
|
parentFileId?: FileId; // Immediate parent file ID
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StorageConfig {
|
export interface StorageConfig {
|
||||||
|
@ -54,6 +54,17 @@ export interface FileRecord {
|
|||||||
processedFile?: ProcessedFileMetadata;
|
processedFile?: ProcessedFileMetadata;
|
||||||
insertAfterPageId?: string; // Page ID after which this file should be inserted
|
insertAfterPageId?: string; // Page ID after which this file should be inserted
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
|
|
||||||
|
// File history tracking (from PDF metadata)
|
||||||
|
originalFileId?: string; // Root file ID for grouping versions
|
||||||
|
versionNumber?: number; // Version number in chain
|
||||||
|
parentFileId?: FileId; // Immediate parent file ID
|
||||||
|
toolHistory?: Array<{
|
||||||
|
toolName: string;
|
||||||
|
timestamp: number;
|
||||||
|
parameters?: Record<string, any>;
|
||||||
|
}>;
|
||||||
|
|
||||||
// Note: File object stored in provider ref, not in state
|
// Note: File object stored in provider ref, not in state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
279
frontend/src/utils/fileHistoryUtils.ts
Normal file
279
frontend/src/utils/fileHistoryUtils.ts
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* File History Utilities
|
||||||
|
*
|
||||||
|
* Helper functions for integrating PDF metadata service with FileContext operations.
|
||||||
|
* Handles extraction of history from files and preparation for metadata injection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { pdfMetadataService, type ToolOperation } from '../services/pdfMetadataService';
|
||||||
|
import { FileRecord } from '../types/fileContext';
|
||||||
|
import { FileId, FileMetadata } from '../types/file';
|
||||||
|
import { createFileId } from '../types/fileContext';
|
||||||
|
|
||||||
|
const DEBUG = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract history information from a PDF file and update FileRecord
|
||||||
|
*/
|
||||||
|
export async function extractFileHistory(
|
||||||
|
file: File,
|
||||||
|
record: FileRecord
|
||||||
|
): Promise<FileRecord> {
|
||||||
|
// Only process PDF files
|
||||||
|
if (!file.type.includes('pdf')) {
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const historyMetadata = await pdfMetadataService.extractHistoryMetadata(arrayBuffer);
|
||||||
|
|
||||||
|
if (historyMetadata) {
|
||||||
|
const history = historyMetadata.stirlingHistory;
|
||||||
|
|
||||||
|
// Update record with history information
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
originalFileId: history.originalFileId,
|
||||||
|
versionNumber: history.versionNumber,
|
||||||
|
parentFileId: history.parentFileId as FileId | undefined,
|
||||||
|
toolHistory: history.toolChain
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (DEBUG) console.warn('📄 Failed to extract file history:', file.name, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject history metadata into a PDF file for tool operations
|
||||||
|
*/
|
||||||
|
export async function injectHistoryForTool(
|
||||||
|
file: File,
|
||||||
|
sourceFileRecord: FileRecord,
|
||||||
|
toolName: string,
|
||||||
|
parameters?: Record<string, any>
|
||||||
|
): Promise<File> {
|
||||||
|
// Only process PDF files
|
||||||
|
if (!file.type.includes('pdf')) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
|
// Create tool operation record
|
||||||
|
const toolOperation: ToolOperation = {
|
||||||
|
toolName,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
parameters
|
||||||
|
};
|
||||||
|
|
||||||
|
let modifiedBytes: ArrayBuffer;
|
||||||
|
|
||||||
|
// Extract version info directly from the PDF metadata to ensure accuracy
|
||||||
|
const existingHistoryMetadata = await pdfMetadataService.extractHistoryMetadata(arrayBuffer);
|
||||||
|
|
||||||
|
let newVersionNumber: number;
|
||||||
|
let originalFileId: string;
|
||||||
|
let parentFileId: string;
|
||||||
|
let parentToolChain: ToolOperation[];
|
||||||
|
|
||||||
|
if (existingHistoryMetadata) {
|
||||||
|
// File already has embedded history - increment version
|
||||||
|
const history = existingHistoryMetadata.stirlingHistory;
|
||||||
|
newVersionNumber = history.versionNumber + 1;
|
||||||
|
originalFileId = history.originalFileId;
|
||||||
|
parentFileId = sourceFileRecord.id; // This file becomes the parent
|
||||||
|
parentToolChain = history.toolChain || [];
|
||||||
|
|
||||||
|
} else if (sourceFileRecord.originalFileId && sourceFileRecord.versionNumber) {
|
||||||
|
// File record has history but PDF doesn't (shouldn't happen, but fallback)
|
||||||
|
newVersionNumber = sourceFileRecord.versionNumber + 1;
|
||||||
|
originalFileId = sourceFileRecord.originalFileId;
|
||||||
|
parentFileId = sourceFileRecord.id;
|
||||||
|
parentToolChain = sourceFileRecord.toolHistory || [];
|
||||||
|
} else {
|
||||||
|
// File has no history - this becomes version 1
|
||||||
|
newVersionNumber = 1;
|
||||||
|
originalFileId = sourceFileRecord.id; // Use source file ID as original
|
||||||
|
parentFileId = sourceFileRecord.id; // Parent is the source file
|
||||||
|
parentToolChain = []; // No previous tools
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new tool chain with the new operation
|
||||||
|
const newToolChain = [...parentToolChain, toolOperation];
|
||||||
|
|
||||||
|
modifiedBytes = await pdfMetadataService.injectHistoryMetadata(
|
||||||
|
arrayBuffer,
|
||||||
|
originalFileId,
|
||||||
|
parentFileId,
|
||||||
|
newToolChain,
|
||||||
|
newVersionNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create new file with updated metadata
|
||||||
|
return new File([modifiedBytes], file.name, { type: file.type });
|
||||||
|
} catch (error) {
|
||||||
|
if (DEBUG) console.warn('📄 Failed to inject history for tool operation:', error);
|
||||||
|
return file; // Return original file if injection fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare FormData with history-injected PDFs for tool operations
|
||||||
|
*/
|
||||||
|
export async function prepareFilesWithHistory(
|
||||||
|
files: File[],
|
||||||
|
getFileRecord: (file: File) => FileRecord | undefined,
|
||||||
|
toolName: string,
|
||||||
|
parameters?: Record<string, any>
|
||||||
|
): Promise<File[]> {
|
||||||
|
const processedFiles: File[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const record = getFileRecord(file);
|
||||||
|
if (!record) {
|
||||||
|
processedFiles.push(file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileWithHistory = await injectHistoryForTool(file, record, toolName, parameters);
|
||||||
|
processedFiles.push(fileWithHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group files by their original file ID for version management
|
||||||
|
*/
|
||||||
|
export function groupFilesByOriginal(fileRecords: FileRecord[]): Map<string, FileRecord[]> {
|
||||||
|
const groups = new Map<string, FileRecord[]>();
|
||||||
|
|
||||||
|
for (const record of fileRecords) {
|
||||||
|
const originalId = record.originalFileId || record.id;
|
||||||
|
|
||||||
|
if (!groups.has(originalId)) {
|
||||||
|
groups.set(originalId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.get(originalId)!.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort each group by version number
|
||||||
|
for (const [_, records] of groups) {
|
||||||
|
records.sort((a, b) => (b.versionNumber || 0) - (a.versionNumber || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest version of each file group
|
||||||
|
*/
|
||||||
|
export function getLatestVersions(fileRecords: FileRecord[]): FileRecord[] {
|
||||||
|
const groups = groupFilesByOriginal(fileRecords);
|
||||||
|
const latestVersions: FileRecord[] = [];
|
||||||
|
|
||||||
|
for (const [_, records] of groups) {
|
||||||
|
if (records.length > 0) {
|
||||||
|
// First item is the latest version (sorted desc by version number)
|
||||||
|
latestVersions.push(records[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestVersions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get version history for a file
|
||||||
|
*/
|
||||||
|
export function getVersionHistory(
|
||||||
|
targetRecord: FileRecord,
|
||||||
|
allRecords: FileRecord[]
|
||||||
|
): FileRecord[] {
|
||||||
|
const originalId = targetRecord.originalFileId || targetRecord.id;
|
||||||
|
|
||||||
|
return allRecords
|
||||||
|
.filter(record => {
|
||||||
|
const recordOriginalId = record.originalFileId || record.id;
|
||||||
|
return recordOriginalId === originalId;
|
||||||
|
})
|
||||||
|
.sort((a, b) => (b.versionNumber || 0) - (a.versionNumber || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file has version history
|
||||||
|
*/
|
||||||
|
export function hasVersionHistory(record: FileRecord): boolean {
|
||||||
|
return !!(record.originalFileId && record.versionNumber && record.versionNumber > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a descriptive name for a file version
|
||||||
|
*/
|
||||||
|
export function generateVersionName(record: FileRecord): string {
|
||||||
|
const baseName = record.name.replace(/\.pdf$/i, '');
|
||||||
|
|
||||||
|
if (!hasVersionHistory(record)) {
|
||||||
|
return record.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionInfo = record.versionNumber ? ` (v${record.versionNumber})` : '';
|
||||||
|
const toolInfo = record.toolHistory && record.toolHistory.length > 0
|
||||||
|
? ` - ${record.toolHistory[record.toolHistory.length - 1].toolName}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `${baseName}${versionInfo}${toolInfo}.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create metadata for storing files with history information
|
||||||
|
*/
|
||||||
|
export async function createFileMetadataWithHistory(
|
||||||
|
file: File,
|
||||||
|
fileId: FileId,
|
||||||
|
thumbnail?: string
|
||||||
|
): Promise<FileMetadata> {
|
||||||
|
const baseMetadata: FileMetadata = {
|
||||||
|
id: fileId,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
thumbnail
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract history for PDF files
|
||||||
|
if (file.type.includes('pdf')) {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const historyMetadata = await pdfMetadataService.extractHistoryMetadata(arrayBuffer);
|
||||||
|
|
||||||
|
if (historyMetadata) {
|
||||||
|
const history = historyMetadata.stirlingHistory;
|
||||||
|
return {
|
||||||
|
...baseMetadata,
|
||||||
|
originalFileId: history.originalFileId,
|
||||||
|
versionNumber: history.versionNumber,
|
||||||
|
parentFileId: history.parentFileId as FileId | undefined,
|
||||||
|
historyInfo: {
|
||||||
|
originalFileId: history.originalFileId,
|
||||||
|
parentFileId: history.parentFileId,
|
||||||
|
versionNumber: history.versionNumber,
|
||||||
|
toolChain: history.toolChain,
|
||||||
|
createdAt: history.createdAt,
|
||||||
|
lastModified: history.lastModified
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (DEBUG) console.warn('📄 Failed to extract history for metadata:', file.name, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseMetadata;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user