feat(recipes): add prompt-aware duplicate detection toggle

This commit is contained in:
Will Miao
2026-08-10 00:07:14 +08:00
parent 8237e5f9ea
commit 95fb3c7fc9
20 changed files with 582 additions and 39 deletions

View File

@@ -1,5 +1,6 @@
// Duplicates Manager Component
import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { RecipeCard } from './RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js';
import { recreateVirtualScroll } from '../utils/infiniteScroll.js';
@@ -10,11 +11,87 @@ export class DuplicatesManager {
this.duplicateGroups = [];
this.inDuplicateMode = false;
this.selectedForDeletion = new Set();
this._initPromptMatchToggle();
this._initHelpTooltip();
}
_getPromptMatchPreference() {
return localStorage.getItem('recipes_duplicates_include_prompt') === '1';
}
_setPromptMatchPreference(enabled) {
localStorage.setItem('recipes_duplicates_include_prompt', enabled ? '1' : '0');
}
updateBasisDisplay() {
const basisEl = document.getElementById('duplicatesBasis');
const helpTextEl = document.getElementById('duplicatesHelpText');
const checkbox = document.getElementById('promptMatchInput');
const includePrompt = this._getPromptMatchPreference();
if (checkbox) {
checkbox.checked = includePrompt;
}
if (basisEl) {
basisEl.textContent = translate(
includePrompt
? 'recipes.duplicates.basis.loraComboAndPrompt'
: 'recipes.duplicates.basis.loraCombo'
);
}
if (helpTextEl) {
helpTextEl.textContent = translate(
includePrompt
? 'recipes.duplicates.basis.hintPromptIncluded'
: 'recipes.duplicates.basis.hintLoraCombo'
);
}
}
_initPromptMatchToggle() {
const checkbox = document.getElementById('promptMatchInput');
if (!checkbox) return;
checkbox.addEventListener('change', async (e) => {
this._setPromptMatchPreference(e.target.checked);
this.updateBasisDisplay();
checkbox.disabled = true;
try {
await this.findDuplicates();
} finally {
checkbox.disabled = false;
}
});
}
_initHelpTooltip() {
const helpIcon = document.getElementById('duplicatesHelp');
const helpTooltip = document.getElementById('duplicatesHelpTooltip');
if (!helpIcon || !helpTooltip) return;
helpIcon.addEventListener('mouseenter', () => {
const bannerContent = helpIcon.closest('.banner-content');
if (!bannerContent) return;
const iconRect = helpIcon.getBoundingClientRect();
const bannerRect = bannerContent.getBoundingClientRect();
helpTooltip.style.display = 'block';
helpTooltip.style.top = `${iconRect.bottom - bannerRect.top + 10}px`;
helpTooltip.style.left = `${iconRect.left - bannerRect.left - 10}px`;
const tooltipRect = helpTooltip.getBoundingClientRect();
if (tooltipRect.right > window.innerWidth - 20) {
helpTooltip.style.left = `${bannerContent.offsetWidth - tooltipRect.width - 20}px`;
}
});
helpIcon.addEventListener('mouseleave', () => {
helpTooltip.style.display = 'none';
});
}
async findDuplicates() {
try {
const response = await fetch('/api/lm/recipes/find-duplicates');
const includePrompt = this._getPromptMatchPreference();
const endpoint = includePrompt
? '/api/lm/recipes/find-duplicates?include_prompt=1'
: '/api/lm/recipes/find-duplicates';
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error('Failed to find duplicates');
}
@@ -28,7 +105,14 @@ export class DuplicatesManager {
if (this.duplicateGroups.length === 0) {
showToast('toast.duplicates.noDuplicatesFound', { type: 'recipes' }, 'info');
return false;
// Keep (or enter) the duplicates view when the user is tuning
// the matching basis, so the prompt-matching toggle stays
// reachable; otherwise just toast and stay on the library grid.
if (!this.inDuplicateMode && !includePrompt) {
return false;
}
this.enterDuplicateMode();
return true;
}
this.enterDuplicateMode();
@@ -53,9 +137,14 @@ export class DuplicatesManager {
const countSpan = document.getElementById('duplicatesCount');
if (banner && countSpan) {
countSpan.textContent = `Found ${this.duplicateGroups.length} duplicate group${this.duplicateGroups.length !== 1 ? 's' : ''}`;
countSpan.textContent = this.duplicateGroups.length === 0
? translate('recipes.duplicates.noGroups')
: translate('recipes.duplicates.found', { count: this.duplicateGroups.length });
banner.style.display = 'block';
}
// Restore the prompt-matching preference and show the matching basis
this.updateBasisDisplay();
// Disable virtual scrolling if active
if (state.virtualScroller) {
@@ -113,12 +202,23 @@ export class DuplicatesManager {
// Clear existing content
recipeGrid.innerHTML = '';
// Empty-state view: keep the banner (and the matching-basis toggle)
// reachable when no groups match the current basis
if (this.duplicateGroups.length === 0) {
const emptyState = document.createElement('div');
emptyState.className = 'duplicates-empty-state';
emptyState.textContent = translate('recipes.duplicates.noGroups');
recipeGrid.appendChild(emptyState);
return;
}
// Render each duplicate group
this.duplicateGroups.forEach((group, groupIndex) => {
const groupKey = group.key;
const groupDiv = document.createElement('div');
groupDiv.className = 'duplicate-group';
groupDiv.dataset.fingerprint = group.fingerprint;
groupDiv.dataset.groupKey = groupKey;
// Create group header
const header = document.createElement('div');
@@ -126,10 +226,10 @@ export class DuplicatesManager {
header.innerHTML = `
<span>Duplicate Group #${groupIndex + 1} (${group.recipes.length} recipes)</span>
<span>
<button class="btn-select-all" onclick="recipeManager.duplicatesManager.toggleSelectAllInGroup('${group.fingerprint}')">
<button class="btn-select-all" onclick="recipeManager.duplicatesManager.toggleSelectAllInGroup('${groupKey}')">
Select All
</button>
<button class="btn-select-latest" onclick="recipeManager.duplicatesManager.selectLatestInGroup('${group.fingerprint}')">
<button class="btn-select-latest" onclick="recipeManager.duplicatesManager.selectLatestInGroup('${groupKey}')">
Keep Latest
</button>
</span>
@@ -182,7 +282,7 @@ export class DuplicatesManager {
checkbox.type = 'checkbox';
checkbox.className = 'selector-checkbox';
checkbox.dataset.recipeId = recipe.id;
checkbox.dataset.groupFingerprint = group.fingerprint;
checkbox.dataset.groupKey = groupKey;
// Check if already selected
if (this.selectedForDeletion.has(recipe.id)) {
@@ -244,8 +344,8 @@ export class DuplicatesManager {
}
}
toggleSelectAllInGroup(fingerprint) {
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-fingerprint="${fingerprint}"]`);
toggleSelectAllInGroup(groupKey) {
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-key="${groupKey}"]`);
const allSelected = Array.from(checkboxes).every(checkbox => checkbox.checked);
// If all are selected, deselect all; otherwise select all
@@ -264,7 +364,7 @@ export class DuplicatesManager {
});
// Update the button text
const button = document.querySelector(`.duplicate-group[data-fingerprint="${fingerprint}"] .btn-select-all`);
const button = document.querySelector(`.duplicate-group[data-group-key="${groupKey}"] .btn-select-all`);
if (button) {
button.textContent = !allSelected ? "Deselect All" : "Select All";
}
@@ -272,8 +372,8 @@ export class DuplicatesManager {
this.updateSelectedCount();
}
selectAllInGroup(fingerprint) {
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-fingerprint="${fingerprint}"]`);
selectAllInGroup(groupKey) {
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-key="${groupKey}"]`);
checkboxes.forEach(checkbox => {
checkbox.checked = true;
this.selectedForDeletion.add(checkbox.dataset.recipeId);
@@ -281,7 +381,7 @@ export class DuplicatesManager {
});
// Update the button text
const button = document.querySelector(`.duplicate-group[data-fingerprint="${fingerprint}"] .btn-select-all`);
const button = document.querySelector(`.duplicate-group[data-group-key="${groupKey}"] .btn-select-all`);
if (button) {
button.textContent = "Deselect All";
}
@@ -289,12 +389,12 @@ export class DuplicatesManager {
this.updateSelectedCount();
}
selectLatestInGroup(fingerprint) {
selectLatestInGroup(groupKey) {
// Find all checkboxes in this group
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-fingerprint="${fingerprint}"]`);
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-key="${groupKey}"]`);
// Get all the recipes in this group
const group = this.duplicateGroups.find(g => g.fingerprint === fingerprint);
const group = this.duplicateGroups.find(g => g.key === groupKey);
if (!group) return;
// Sort recipes by date (newest first)
@@ -328,7 +428,7 @@ export class DuplicatesManager {
selectLatestDuplicates() {
// For each duplicate group, select all but the latest recipe
this.duplicateGroups.forEach(group => {
this.selectLatestInGroup(group.fingerprint);
this.selectLatestInGroup(group.key);
});
}