Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 47 additions & 16 deletions src/main/ipc/analysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,9 @@ function registerAnalysisIpc(servicesOrParams) {
stringSchema,
z.object({
filePath: z.string().min(1).max(4096),
namingSettings: namingSettingsSchema.optional()
namingSettings: namingSettingsSchema.optional(),
// A reanalysis the user asked for: skip the cached result.
bypassCache: z.boolean().optional()
})
])
: {
Expand All @@ -316,15 +318,16 @@ function registerAnalysisIpc(servicesOrParams) {
/** @param {any} payload */
function normalizeAnalyzeFilePayload(payload) {
if (typeof payload === 'string') {
return { filePath: payload, namingOverride: null };
return { filePath: payload, namingOverride: null, bypassCache: false };
}
if (payload && typeof payload === 'object') {
return {
filePath: payload.filePath,
namingOverride: payload.namingSettings || null
namingOverride: payload.namingSettings || null,
bypassCache: payload.bypassCache === true
};
}
return { filePath: '', namingOverride: null };
return { filePath: '', namingOverride: null, bypassCache: false };
}
// Defense-in-depth path refine for batch entries: the preload validator
// rejects UNC/URL/etc, but a compromised renderer could bypass the preload
Expand Down Expand Up @@ -513,8 +516,14 @@ function registerAnalysisIpc(servicesOrParams) {
* @param {string} filePath
* @param {AbortSignal|undefined} abortSignal
* @param {Record<string, any>|null} [namingOverride]
* @param {boolean} [bypassCache]
*/
async function performDocumentAnalysis(filePath, abortSignal, namingOverride = null) {
async function performDocumentAnalysis(
filePath,
abortSignal,
namingOverride = null,
bypassCache = false
) {
const serviceIntegration = getServiceIntegration?.();
const cleanPath = await validateAnalysisPath(filePath);

Expand Down Expand Up @@ -551,7 +560,10 @@ function registerAnalysisIpc(servicesOrParams) {
);

const statsBefore = await statBeforeAnalysis(cleanPath);
const result = await analyzeDocumentFile(cleanPath, folderCategories, { abortSignal });
const result = await analyzeDocumentFile(cleanPath, folderCategories, {
abortSignal,
bypassCache
});
await applyUserNamingFromSettings(
cleanPath,
result,
Expand Down Expand Up @@ -622,9 +634,9 @@ function registerAnalysisIpc(servicesOrParams) {
context: 'Analysis',
schema: analyzeFilePayloadSchema,
handler: (event, payload) => {
const { filePath, namingOverride } = normalizeAnalyzeFilePayload(payload);
const { filePath, namingOverride, bypassCache } = normalizeAnalyzeFilePayload(payload);
return withUserAnalysis(filePath, () =>
performDocumentAnalysis(filePath, event?.abortSignal, namingOverride)
performDocumentAnalysis(filePath, event?.abortSignal, namingOverride, bypassCache)
);
}
});
Expand All @@ -638,8 +650,14 @@ function registerAnalysisIpc(servicesOrParams) {
* @param {string} filePath
* @param {AbortSignal|undefined} abortSignal
* @param {Record<string, any>|null} [namingOverride]
* @param {boolean} [bypassCache]
*/
async function performImageAnalysis(filePath, abortSignal, namingOverride = null) {
async function performImageAnalysis(
filePath,
abortSignal,
namingOverride = null,
bypassCache = false
) {
const serviceIntegration = getServiceIntegration?.();
const cleanPath = await validateAnalysisPath(filePath);

Expand Down Expand Up @@ -676,7 +694,10 @@ function registerAnalysisIpc(servicesOrParams) {
);

const statsBefore = await statBeforeAnalysis(cleanPath);
const result = await analyzeImageFile(cleanPath, folderCategories, { abortSignal });
const result = await analyzeImageFile(cleanPath, folderCategories, {
abortSignal,
bypassCache
});
await applyUserNamingFromSettings(
cleanPath,
result,
Expand Down Expand Up @@ -747,9 +768,9 @@ function registerAnalysisIpc(servicesOrParams) {
context: 'Analysis',
schema: analyzeFilePayloadSchema,
handler: (event, payload) => {
const { filePath, namingOverride } = normalizeAnalyzeFilePayload(payload);
const { filePath, namingOverride, bypassCache } = normalizeAnalyzeFilePayload(payload);
return withUserAnalysis(filePath, () =>
performImageAnalysis(filePath, event?.abortSignal, namingOverride)
performImageAnalysis(filePath, event?.abortSignal, namingOverride, bypassCache)
);
}
});
Expand All @@ -766,8 +787,14 @@ function registerAnalysisIpc(servicesOrParams) {
* @param {string} filePath
* @param {AbortSignal|undefined} abortSignal
* @param {Record<string, any>|null} [namingOverride]
* @param {boolean} [bypassCache]
*/
async function performMediaAnalysis(filePath, abortSignal, namingOverride = null) {
async function performMediaAnalysis(
filePath,
abortSignal,
namingOverride = null,
bypassCache = false
) {
const serviceIntegration = getServiceIntegration?.();
const cleanPath = await validateAnalysisPath(filePath);

Expand Down Expand Up @@ -807,7 +834,11 @@ function registerAnalysisIpc(servicesOrParams) {
);
const isCancelled = () => Boolean(abortSignal?.aborted);
const statsBefore = await statBeforeAnalysis(cleanPath);
const result = await analyzer(cleanPath, folderCategories, { isCancelled, abortSignal });
const result = await analyzer(cleanPath, folderCategories, {
isCancelled,
abortSignal,
bypassCache
});
await applyUserNamingFromSettings(
cleanPath,
result,
Expand Down Expand Up @@ -878,9 +909,9 @@ function registerAnalysisIpc(servicesOrParams) {
context: 'Analysis',
schema: analyzeFilePayloadSchema,
handler: (event, payload) => {
const { filePath, namingOverride } = normalizeAnalyzeFilePayload(payload);
const { filePath, namingOverride, bypassCache } = normalizeAnalyzeFilePayload(payload);
return withUserAnalysis(filePath, () =>
performMediaAnalysis(filePath, event?.abortSignal, namingOverride)
performMediaAnalysis(filePath, event?.abortSignal, namingOverride, bypassCache)
);
}
});
Expand Down
15 changes: 13 additions & 2 deletions src/preload/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -1781,9 +1781,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
let ext = '';
if (lastDot > lastSlash && lastDot > 0) ext = filePath.slice(lastDot + 1).toLowerCase();

const payload =
const namingSettings =
options?.namingSettings && typeof options.namingSettings === 'object'
? { filePath, namingSettings: options.namingSettings }
? options.namingSettings
: null;
// bypassCache: a reanalysis the user asked for must not return the
// cached result of the analysis it replaces.
const bypassCache = options?.bypassCache === true;
const payload =
namingSettings || bypassCache
? {
filePath,
...(namingSettings ? { namingSettings } : {}),
...(bypassCache ? { bypassCache: true } : {})
}
: filePath;

// Fix 1B: Use IMAGE_EXT_SET derived from constants.js instead of hardcoded list
Expand Down
84 changes: 16 additions & 68 deletions src/renderer/components/organize/UncategorizedActionsBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,12 @@ function UncategorizedActionsBar({
defaultLocation,
existingFolders,
onFolderCreated,
onReanalyze,
isAnalyzing = false,
addNotification
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [prefillName, setPrefillName] = useState('');
const [isReanalyzing, setIsReanalyzing] = useState(false);
const earlierFiles = useEarlierUncategorizedFiles();

// Derive uncategorized files + pattern suggestions in one pass.
Expand Down Expand Up @@ -165,68 +166,16 @@ function UncategorizedActionsBar({
[addNotification, onFolderCreated]
);

const handleReanalyzeAll = useCallback(async () => {
if (!window?.electronAPI?.embeddings?.reanalyzeFile) {
// The analysis run does the work and reports the outcome. The button is
// disabled while any analysis runs: the run refuses a reanalysis then.
const handleReanalyzeAll = useCallback(() => {
if (typeof onReanalyze !== 'function') {
addNotification?.('Reanalyze is not available in this build.', 'warning');
return;
}
if (uncategorizedFiles.length === 0) return;

setIsReanalyzing(true);
let succeeded = 0;
let outOfScope = 0;
let otherFailed = 0;
try {
// Sequential rather than parallel: the embedding queue serializes
// anyway, and parallel calls just queue depth without finishing faster.
// Sequential also keeps the notification count predictable.
for (const file of uncategorizedFiles) {
if (!file?.path) {
otherFailed += 1;
continue;
}
try {
const result = await window.electronAPI.embeddings.reanalyzeFile(file.path);
if (result?.success !== false) {
succeeded += 1;
} else if (result?.errorCode === 'NOT_IN_SMART_FOLDER') {
outOfScope += 1;
} else {
otherFailed += 1;
}
} catch (err) {
logger.warn('Reanalyze single file failed', {
path: file.path,
error: err?.message
});
otherFailed += 1;
}
}
// Tailor the toast to what actually happened so users understand the
// scope guard: out-of-scope rejections need a different remediation
// (add the source folder as a Smart Folder) than transient failures.
if (succeeded > 0 && outOfScope === 0 && otherFailed === 0) {
addNotification?.(
`Reanalyzed ${succeeded} ${succeeded === 1 ? 'file' : 'files'}.`,
'success'
);
} else if (outOfScope > 0 && otherFailed === 0) {
const skipNote = `${outOfScope} skipped (not inside a configured Smart Folder). Add the source folder as a Smart Folder destination to enable reanalysis.`;
addNotification?.(
succeeded > 0 ? `Reanalyzed ${succeeded}. ${skipNote}` : skipNote,
'warning'
);
} else {
const parts = [];
if (succeeded > 0) parts.push(`Reanalyzed ${succeeded}`);
if (outOfScope > 0) parts.push(`${outOfScope} not in a Smart Folder`);
if (otherFailed > 0) parts.push(`${otherFailed} failed`);
addNotification?.(parts.join(', ') + '.', 'warning');
}
} finally {
setIsReanalyzing(false);
}
}, [uncategorizedFiles, addNotification]);
const paths = uncategorizedFiles.map((file) => file?.path).filter(Boolean);
if (paths.length > 0) onReanalyze(paths);
}, [uncategorizedFiles, onReanalyze, addNotification]);

if (uncategorizedFiles.length === 0) return null;

Expand Down Expand Up @@ -288,15 +237,11 @@ function UncategorizedActionsBar({
variant="ghost"
size="sm"
onClick={handleReanalyzeAll}
disabled={isReanalyzing}
aria-label={
isReanalyzing
? 'Reanalyzing…'
: `Reanalyze ${uncategorizedFiles.length} uncategorized files`
}
disabled={isAnalyzing}
aria-label={`Reanalyze ${uncategorizedFiles.length} uncategorized files`}
>
<RefreshCw className={`w-4 h-4 mr-1.5 ${isReanalyzing ? 'animate-spin' : ''}`} />
{isReanalyzing ? 'Reanalyzing…' : `Reanalyze ${uncategorizedFiles.length}`}
<RefreshCw className="w-4 h-4 mr-1.5" />
Reanalyze {uncategorizedFiles.length}
</Button>
</div>
</div>
Expand Down Expand Up @@ -326,6 +271,9 @@ UncategorizedActionsBar.propTypes = {
defaultLocation: PropTypes.string,
existingFolders: PropTypes.array,
onFolderCreated: PropTypes.func,
// Analyzes the given paths again (the analysis run's reanalyzeFiles).
onReanalyze: PropTypes.func,
isAnalyzing: PropTypes.bool,
addNotification: PropTypes.func
};

Expand Down
4 changes: 3 additions & 1 deletion src/renderer/phases/DiscoverPhase.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ function DiscoverPhase() {
cancelAnalysis,
clearAnalysisQueue,
retryFailedFiles,
reanalyzeFiles,
pruneBatchResultsForPath,
totalAnalysisFailure,
setTotalAnalysisFailure
Expand All @@ -306,7 +307,8 @@ function DiscoverPhase() {
addNotification,
showConfirm,
phaseData: stablePhaseData,
onFileRemoved: pruneBatchResultsForPath
onFileRemoved: pruneBatchResultsForPath,
onReanalyze: reanalyzeFiles
});

const handleFileDrop = useCallback(
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/phases/OrganizePhase.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,12 @@ import { PHASE_PREREQUISITE_REASONS } from '../store/slices/uiSlice';
import { formatDisplayPath } from '../utils/pathDisplay';
import { toCanonicalPath } from '../utils/canonicalPath';
import { useFileActions } from '../hooks/useFileActions';
import { useAnalysisRun } from './discover/AnalysisRunProvider';

const logger = createLogger('OrganizePhase');
function OrganizePhase() {
const { addNotification } = useNotification();
const { reanalyzeFiles } = useAnalysisRun();
const { executeAction } = useUndoRedo();
const [viewingFileDetails, setViewingFileDetails] = React.useState(null);
const redactPaths = useAppSelector(selectRedactPaths);
Expand Down Expand Up @@ -716,6 +718,8 @@ function OrganizePhase() {
defaultLocation={defaultLocation}
existingFolders={safeSmartFolders}
onFolderCreated={reloadSmartFolders}
onReanalyze={reanalyzeFiles}
isAnalyzing={isAnalysisRunning}
addNotification={addNotification}
/>
</ErrorBoundaryCore>
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/phases/discover/AnalysisRunProvider.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export function AnalysisRunProvider({ children }) {
cancelAnalysis,
clearAnalysisQueue,
retryFailedFiles,
reanalyzeFiles,
pruneBatchResultsForPath
} = useAnalysis({
selectedFiles,
Expand Down Expand Up @@ -143,6 +144,7 @@ export function AnalysisRunProvider({ children }) {
cancelAnalysis,
clearAnalysisQueue,
retryFailedFiles,
reanalyzeFiles,
pruneBatchResultsForPath,
totalAnalysisFailure,
setTotalAnalysisFailure
Expand All @@ -152,6 +154,7 @@ export function AnalysisRunProvider({ children }) {
cancelAnalysis,
clearAnalysisQueue,
retryFailedFiles,
reanalyzeFiles,
pruneBatchResultsForPath,
totalAnalysisFailure
]
Expand Down
Loading
Loading