fix(recipes): resolve stale LoRA hash on import and add hashInvalid state

- import: prefer A1111 Lora hashes (12-char AutoV3) over conflicting Hashes
  JSON values; recover the quote-wrapped AutoV3 from CivitAI image API meta;
  merge EXIF-parsed LoRAs when the API-only parse yields none (meta=null)
- rematch: treat entries whose hash failed CivitAI resolution (hashInvalid)
  as unresolved candidates; clear the flag on rematch/reconnect write-back
- download: persist hashInvalid and show a distinct toast when hash lookup
  returns "Model not found", so unresolvable entries become recoverable
- ui: add Unresolvable Hash badge styling and reconnect affordance
- i18n: translate the new keys across all 10 locales
This commit is contained in:
Will Miao
2026-08-28 22:24:07 +08:00
parent a7d65fe84a
commit 856c9a87ac
24 changed files with 757 additions and 28 deletions
@@ -142,6 +142,14 @@ const hashOnlyLora = {
hash: 'deadbeefcafe',
};
const hashInvalidLora = {
name: 'invalid-hash-lora',
modelName: 'Invalid Hash LoRA',
inLibrary: false,
hash: 'a2a12bfa01',
hashInvalid: true,
};
const recipeWithResources = {
id: 'recipe-resources',
file_path: '/recipes/resources.json',
@@ -157,6 +165,7 @@ const recipeWithResources = {
{ name: 'present-lora', modelName: 'Present LoRA', inLibrary: true, hash: 'ABC123' },
missingLora,
{ name: 'deleted-lora', modelName: 'Deleted LoRA', inLibrary: false, isDeleted: true },
hashInvalidLora,
{ name: 'mystery-lora', modelName: 'Mystery LoRA', inLibrary: false },
hashOnlyLora,
],
@@ -321,11 +330,67 @@ describe('RecipeModal resource item interactions', () => {
expect(container.classList.contains('active')).toBe(true);
});
it('renders hash-invalid LoRAs with a dedicated badge and reconnect instead of download', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring();
const invalidItem = document.querySelector('[data-lora-index="3"]');
const badge = invalidItem.querySelector('.invalid-hash-badge');
expect(badge).not.toBeNull();
expect(badge.title).toContain('cannot be resolved on CivitAI');
expect(badge.textContent).toContain('Unresolvable Hash');
expect(invalidItem.querySelector('.lora-download')).toBeNull();
expect(invalidItem.querySelector('.lora-reconnect')).not.toBeNull();
});
it('marks the entry hash-invalid when hash resolution returns Model not found', async () => {
const recipeModal = await createRecipeModal();
const requests = [];
// Deep copy so the mark step mutating loras[5].hashInvalid does not
// leak into the shared fixture used by later tests.
const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources));
fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe);
global.fetch = vi.fn(async (url, options) => {
requests.push({ url: String(url), options });
const urlStr = String(url);
if (urlStr.includes('/civitai/model/hash/')) {
return { ok: false, json: async () => ({ success: false, error: 'Model not found' }) };
}
return { ok: true, json: async () => ({}) };
});
recipeModal.showRecipeDetails(isolatedRecipe);
await flushWiring();
const hashItem = document.querySelector('[data-lora-index="5"]');
const downloadButton = hashItem.querySelector('.lora-download');
downloadButton.click();
await vi.waitFor(() => {
expect(
requests.some(r => r.url.includes('/recipe/lora/mark-hash-invalid'))
).toBe(true);
});
const markRequest = requests.find(r => r.url.includes('/mark-hash-invalid'));
expect(JSON.parse(markRequest.options.body)).toEqual({
recipe_id: 'recipe-resources',
lora_index: 5,
});
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.hashNotFoundOnCivitai',
{},
'error'
);
expect(downloadVersionWithDefaultsMock).not.toHaveBeenCalled();
});
it('renders no action row when neither identifiers nor hash are available', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeWithResources);
const mysteryItem = document.querySelector('[data-lora-index="3"]');
const mysteryItem = document.querySelector('[data-lora-index="4"]');
expect(mysteryItem.querySelector('.lora-download')).toBeNull();
// No actions at all -> no empty action row taking vertical space
expect(mysteryItem.querySelector('.recipe-lora-actions')).toBeNull();
@@ -348,7 +413,7 @@ describe('RecipeModal resource item interactions', () => {
recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring();
const hashItem = document.querySelector('[data-lora-index="4"]');
const hashItem = document.querySelector('[data-lora-index="5"]');
const downloadButton = hashItem.querySelector('.lora-download');
expect(downloadButton).not.toBeNull();