feat(relink): accept CivitArchive URLs when linking models

This commit is contained in:
Will Miao
2026-08-26 21:31:30 +08:00
parent 3025c64fea
commit 641a61f804
21 changed files with 756 additions and 121 deletions
+9 -7
View File
@@ -963,7 +963,7 @@
} }
}, },
"duplicates": { "duplicates": {
"finding": "[TODO: Translate] Scanning for duplicate recipes...", "finding": "Suche nach doppelten Rezepten...",
"found": "{count} Duplikat-Gruppen gefunden", "found": "{count} Duplikat-Gruppen gefunden",
"noGroups": "Keine Duplikat-Gruppen mit dem aktuellen Abgleichskriterium gefunden", "noGroups": "Keine Duplikat-Gruppen mit dem aktuellen Abgleichskriterium gefunden",
"keepLatest": "Neueste Versionen behalten", "keepLatest": "Neueste Versionen behalten",
@@ -1422,13 +1422,14 @@
}, },
"proceedText": "Fahren Sie nur fort, wenn Sie sicher sind, dass Sie das wollen.", "proceedText": "Fahren Sie nur fort, wenn Sie sicher sind, dass Sie das wollen.",
"urlLabel": "Civitai-Modell-URL:", "urlLabel": "Civitai-Modell-URL:",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"helpText": { "helpText": {
"title": "Fügen Sie eine beliebige Civitai-Modell-URL ein. Unterstützte Formate:", "title": "Fügen Sie eine beliebige Civitai- oder CivitArchive-Modell-URL ein. Unterstützte Formate:",
"format1": "https://civitai.com/models/649516", "format1": "https://civitai.com/models/12345",
"format2": "https://civitai.com/models/649516?modelVersionId=726676", "format2": "https://civitai.com/models/12345?modelVersionId=67890",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"note": "Hinweis: Wenn keine modelVersionId angegeben ist, wird die neueste Version verwendet." "note": "Hinweis: Wenn keine modelVersionId angegeben ist, wird die neueste Version verwendet.",
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
}, },
"confirmAction": "Neu-Verknüpfung bestätigen" "confirmAction": "Neu-Verknüpfung bestätigen"
}, },
@@ -2225,6 +2226,7 @@
"relinkFailed": "Fehler: {message}", "relinkFailed": "Fehler: {message}",
"linkHfSuccess": "Modell erfolgreich mit HuggingFace verknüpft", "linkHfSuccess": "Modell erfolgreich mit HuggingFace verknüpft",
"linkHfFailed": "Fehler: {message}", "linkHfFailed": "Fehler: {message}",
"linkCivArchSuccess": "Modell erfolgreich über CivitArchive neu verknüpft",
"fetchMetadataFirst": "Bitte rufen Sie zuerst Metadaten von CivitAI ab", "fetchMetadataFirst": "Bitte rufen Sie zuerst Metadaten von CivitAI ab",
"noCivitaiInfo": "Keine CivitAI-Informationen verfügbar", "noCivitaiInfo": "Keine CivitAI-Informationen verfügbar",
"missingHash": "Modell-Hash nicht verfügbar" "missingHash": "Modell-Hash nicht verfügbar"
+10 -8
View File
@@ -864,8 +864,8 @@
}, },
"navigation": { "navigation": {
"label": "Recipe navigation", "label": "Recipe navigation",
"previousWithShortcut": "Previous recipe (\u2190)", "previousWithShortcut": "Previous recipe ()",
"nextWithShortcut": "Next recipe (\u2192)" "nextWithShortcut": "Next recipe ()"
}, },
"workflow": { "workflow": {
"sendWorkflow": "Send Workflow to ComfyUI", "sendWorkflow": "Send Workflow to ComfyUI",
@@ -1422,13 +1422,14 @@
}, },
"proceedText": "Only proceed if you're sure this is what you want.", "proceedText": "Only proceed if you're sure this is what you want.",
"urlLabel": "Civitai Model URL:", "urlLabel": "Civitai Model URL:",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 or https://civitai.red/models/649516/model-name?modelVersionId=726676", "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890 or https://civitai.red/models/12345/model-name?modelVersionId=67890",
"helpText": { "helpText": {
"title": "Paste any Civitai model URL from civitai.com or civitai.red. Supported formats:", "title": "Paste any Civitai or CivitArchive model URL. Supported formats:",
"format1": "https://civitai.com/models/649516", "format1": "https://civitai.com/models/12345",
"format2": "https://civitai.com/models/649516?modelVersionId=726676", "format2": "https://civitai.com/models/12345?modelVersionId=67890",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"note": "Note: If no modelVersionId is provided, the latest version will be used." "note": "Note: If no modelVersionId is provided, the latest version will be used.",
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
}, },
"confirmAction": "Confirm Re-link" "confirmAction": "Confirm Re-link"
}, },
@@ -2225,6 +2226,7 @@
"relinkFailed": "Error: {message}", "relinkFailed": "Error: {message}",
"linkHfSuccess": "Model successfully linked to HuggingFace", "linkHfSuccess": "Model successfully linked to HuggingFace",
"linkHfFailed": "Error: {message}", "linkHfFailed": "Error: {message}",
"linkCivArchSuccess": "Model successfully re-linked via CivitArchive",
"fetchMetadataFirst": "Please fetch metadata from CivitAI first", "fetchMetadataFirst": "Please fetch metadata from CivitAI first",
"noCivitaiInfo": "No CivitAI information available", "noCivitaiInfo": "No CivitAI information available",
"missingHash": "Model hash not available" "missingHash": "Model hash not available"
+9 -7
View File
@@ -963,7 +963,7 @@
} }
}, },
"duplicates": { "duplicates": {
"finding": "[TODO: Translate] Scanning for duplicate recipes...", "finding": "Buscando recetas duplicadas...",
"found": "Se encontraron {count} grupos de duplicados", "found": "Se encontraron {count} grupos de duplicados",
"noGroups": "No se encontraron grupos de duplicados con el criterio de coincidencia actual", "noGroups": "No se encontraron grupos de duplicados con el criterio de coincidencia actual",
"keepLatest": "Mantener versiones más recientes", "keepLatest": "Mantener versiones más recientes",
@@ -1422,13 +1422,14 @@
}, },
"proceedText": "Solo procede si estás seguro de que esto es lo que quieres.", "proceedText": "Solo procede si estás seguro de que esto es lo que quieres.",
"urlLabel": "URL del modelo de Civitai:", "urlLabel": "URL del modelo de Civitai:",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"helpText": { "helpText": {
"title": "Pega cualquier URL de modelo de Civitai. Formatos soportados:", "title": "Pega cualquier URL de modelo de Civitai o CivitArchive. Formatos soportados:",
"format1": "https://civitai.com/models/649516", "format1": "https://civitai.com/models/12345",
"format2": "https://civitai.com/models/649516?modelVersionId=726676", "format2": "https://civitai.com/models/12345?modelVersionId=67890",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"note": "Nota: Si no se proporciona modelVersionId, se usará la versión más reciente." "note": "Nota: Si no se proporciona modelVersionId, se usará la versión más reciente.",
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
}, },
"confirmAction": "Confirmar re-vinculación" "confirmAction": "Confirmar re-vinculación"
}, },
@@ -2225,6 +2226,7 @@
"relinkFailed": "Error: {message}", "relinkFailed": "Error: {message}",
"linkHfSuccess": "Modelo vinculado a HuggingFace exitosamente", "linkHfSuccess": "Modelo vinculado a HuggingFace exitosamente",
"linkHfFailed": "Error: {message}", "linkHfFailed": "Error: {message}",
"linkCivArchSuccess": "Modelo re-vinculado exitosamente mediante CivitArchive",
"fetchMetadataFirst": "Por favor obtén metadatos de CivitAI primero", "fetchMetadataFirst": "Por favor obtén metadatos de CivitAI primero",
"noCivitaiInfo": "No hay información de CivitAI disponible", "noCivitaiInfo": "No hay información de CivitAI disponible",
"missingHash": "Hash del modelo no disponible" "missingHash": "Hash del modelo no disponible"
+9 -7
View File
@@ -963,7 +963,7 @@
} }
}, },
"duplicates": { "duplicates": {
"finding": "[TODO: Translate] Scanning for duplicate recipes...", "finding": "Recherche de recettes en doublon...",
"found": "Trouvé {count} groupes de doublons", "found": "Trouvé {count} groupes de doublons",
"noGroups": "Aucun groupe de doublons trouvé avec le critère de correspondance actuel", "noGroups": "Aucun groupe de doublons trouvé avec le critère de correspondance actuel",
"keepLatest": "Garder les dernières versions", "keepLatest": "Garder les dernières versions",
@@ -1422,13 +1422,14 @@
}, },
"proceedText": "Ne procédez que si vous êtes sûr que c'est ce que vous voulez.", "proceedText": "Ne procédez que si vous êtes sûr que c'est ce que vous voulez.",
"urlLabel": "URL du modèle Civitai :", "urlLabel": "URL du modèle Civitai :",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"helpText": { "helpText": {
"title": "Collez n'importe quelle URL de modèle Civitai. Formats supportés :", "title": "Collez n'importe quelle URL de modèle Civitai ou CivitArchive. Formats supportés :",
"format1": "https://civitai.com/models/649516", "format1": "https://civitai.com/models/12345",
"format2": "https://civitai.com/models/649516?modelVersionId=726676", "format2": "https://civitai.com/models/12345?modelVersionId=67890",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"note": "Note : Si aucun modelVersionId n'est fourni, la dernière version sera utilisée." "note": "Note : Si aucun modelVersionId n'est fourni, la dernière version sera utilisée.",
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
}, },
"confirmAction": "Confirmer la re-liaison" "confirmAction": "Confirmer la re-liaison"
}, },
@@ -2225,6 +2226,7 @@
"relinkFailed": "Erreur : {message}", "relinkFailed": "Erreur : {message}",
"linkHfSuccess": "Modèle lié à HuggingFace avec succès", "linkHfSuccess": "Modèle lié à HuggingFace avec succès",
"linkHfFailed": "Erreur : {message}", "linkHfFailed": "Erreur : {message}",
"linkCivArchSuccess": "Modèle relié via CivitArchive avec succès",
"fetchMetadataFirst": "Veuillez d'abord récupérer les métadonnées depuis CivitAI", "fetchMetadataFirst": "Veuillez d'abord récupérer les métadonnées depuis CivitAI",
"noCivitaiInfo": "Aucune information CivitAI disponible", "noCivitaiInfo": "Aucune information CivitAI disponible",
"missingHash": "Hash du modèle non disponible" "missingHash": "Hash du modèle non disponible"
+9 -7
View File
@@ -963,7 +963,7 @@
} }
}, },
"duplicates": { "duplicates": {
"finding": "[TODO: Translate] Scanning for duplicate recipes...", "finding": "סורק למציאת מתכונים כפולים...",
"found": "נמצאו {count} קבוצות כפולות", "found": "נמצאו {count} קבוצות כפולות",
"noGroups": "לא נמצאו קבוצות כפולות לפי קריטריון ההתאמה הנוכחי", "noGroups": "לא נמצאו קבוצות כפולות לפי קריטריון ההתאמה הנוכחי",
"keepLatest": "שמור גרסאות אחרונות", "keepLatest": "שמור גרסאות אחרונות",
@@ -1422,13 +1422,14 @@
}, },
"proceedText": "המשך רק אם אתה בטוח שזה מה שאתה רוצה.", "proceedText": "המשך רק אם אתה בטוח שזה מה שאתה רוצה.",
"urlLabel": "כתובת URL של מודל ב-Civitai:", "urlLabel": "כתובת URL של מודל ב-Civitai:",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"helpText": { "helpText": {
"title": "הדבק כל כתובת URL של מודל מ-Civitai. פורמטים נתמכים:", "title": "הדבק כל כתובת URL של מודל מ-Civitai או מ-CivitArchive. פורמטים נתמכים:",
"format1": "https://civitai.com/models/649516", "format1": "https://civitai.com/models/12345",
"format2": "https://civitai.com/models/649516?modelVersionId=726676", "format2": "https://civitai.com/models/12345?modelVersionId=67890",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"note": "הערה: אם לא סופק modelVersionId, תילקח הגרסה האחרונה." "note": "הערה: אם לא סופק modelVersionId, תילקח הגרסה האחרונה.",
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
}, },
"confirmAction": "אשר קישור מחדש" "confirmAction": "אשר קישור מחדש"
}, },
@@ -2225,6 +2226,7 @@
"relinkFailed": "שגיאה: {message}", "relinkFailed": "שגיאה: {message}",
"linkHfSuccess": "המודל נקשר בהצלחה ל-HuggingFace", "linkHfSuccess": "המודל נקשר בהצלחה ל-HuggingFace",
"linkHfFailed": "שגיאה: {message}", "linkHfFailed": "שגיאה: {message}",
"linkCivArchSuccess": "המודל קושר מחדש דרך CivitArchive בהצלחה",
"fetchMetadataFirst": "אנא אחזר מטא-דאטה מ-CivitAI תחילה", "fetchMetadataFirst": "אנא אחזר מטא-דאטה מ-CivitAI תחילה",
"noCivitaiInfo": "אין מידע מ-CivitAI זמין", "noCivitaiInfo": "אין מידע מ-CivitAI זמין",
"missingHash": "ה-hash של המודל אינו זמין" "missingHash": "ה-hash של המודל אינו זמין"
+9 -7
View File
@@ -963,7 +963,7 @@
} }
}, },
"duplicates": { "duplicates": {
"finding": "[TODO: Translate] Scanning for duplicate recipes...", "finding": "重複レシピをスキャンしています...",
"found": "{count} 個の重複グループが見つかりました", "found": "{count} 個の重複グループが見つかりました",
"noGroups": "現在の一致基準では重複グループが見つかりませんでした", "noGroups": "現在の一致基準では重複グループが見つかりませんでした",
"keepLatest": "最新バージョンを保持", "keepLatest": "最新バージョンを保持",
@@ -1422,13 +1422,14 @@
}, },
"proceedText": "これが本当に必要な場合のみ続行してください。", "proceedText": "これが本当に必要な場合のみ続行してください。",
"urlLabel": "CivitaiモデルURL", "urlLabel": "CivitaiモデルURL",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"helpText": { "helpText": {
"title": "CivitaiモデルURLを貼り付けてください。対応形式:", "title": "CivitaiまたはCivitArchiveのモデルURLを貼り付けてください。対応形式:",
"format1": "https://civitai.com/models/649516", "format1": "https://civitai.com/models/12345",
"format2": "https://civitai.com/models/649516?modelVersionId=726676", "format2": "https://civitai.com/models/12345?modelVersionId=67890",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"note": "注:modelVersionIdが提供されていない場合、最新バージョンが使用されます。" "note": "注:modelVersionIdが提供されていない場合、最新バージョンが使用されます。",
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
}, },
"confirmAction": "再リンクを確認" "confirmAction": "再リンクを確認"
}, },
@@ -2225,6 +2226,7 @@
"relinkFailed": "エラー:{message}", "relinkFailed": "エラー:{message}",
"linkHfSuccess": "モデルを HuggingFace にリンクしました", "linkHfSuccess": "モデルを HuggingFace にリンクしました",
"linkHfFailed": "エラー:{message}", "linkHfFailed": "エラー:{message}",
"linkCivArchSuccess": "モデルがCivitArchive経由で正常に再リンクされました",
"fetchMetadataFirst": "最初にCivitAIからメタデータを取得してください", "fetchMetadataFirst": "最初にCivitAIからメタデータを取得してください",
"noCivitaiInfo": "CivitAI情報が利用できません", "noCivitaiInfo": "CivitAI情報が利用できません",
"missingHash": "モデルハッシュが利用できません" "missingHash": "モデルハッシュが利用できません"
+9 -7
View File
@@ -963,7 +963,7 @@
} }
}, },
"duplicates": { "duplicates": {
"finding": "[TODO: Translate] Scanning for duplicate recipes...", "finding": "중복 레시피를 스캔하는 중...",
"found": "{count}개의 중복 그룹 발견", "found": "{count}개의 중복 그룹 발견",
"noGroups": "현재 일치 기준으로 중복 그룹을 찾을 수 없습니다", "noGroups": "현재 일치 기준으로 중복 그룹을 찾을 수 없습니다",
"keepLatest": "최신 버전 유지", "keepLatest": "최신 버전 유지",
@@ -1422,13 +1422,14 @@
}, },
"proceedText": "원하는 작업이 확실한 경우에만 진행하세요.", "proceedText": "원하는 작업이 확실한 경우에만 진행하세요.",
"urlLabel": "Civitai 모델 URL:", "urlLabel": "Civitai 모델 URL:",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"helpText": { "helpText": {
"title": "모든 Civitai 모델 URL을 붙여넣으세요. 지원되는 형식:", "title": "Civitai 또는 CivitArchive 모델 URL을 붙여넣으세요. 지원되는 형식:",
"format1": "https://civitai.com/models/649516", "format1": "https://civitai.com/models/12345",
"format2": "https://civitai.com/models/649516?modelVersionId=726676", "format2": "https://civitai.com/models/12345?modelVersionId=67890",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"note": "참고: modelVersionId가 제공되지 않으면 최신 버전이 사용됩니다." "note": "참고: modelVersionId가 제공되지 않으면 최신 버전이 사용됩니다.",
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
}, },
"confirmAction": "다시 연결 확인" "confirmAction": "다시 연결 확인"
}, },
@@ -2225,6 +2226,7 @@
"relinkFailed": "오류: {message}", "relinkFailed": "오류: {message}",
"linkHfSuccess": "모델이 HuggingFace에 연결되었습니다", "linkHfSuccess": "모델이 HuggingFace에 연결되었습니다",
"linkHfFailed": "오류: {message}", "linkHfFailed": "오류: {message}",
"linkCivArchSuccess": "모델이 CivitArchive을 통해 성공적으로 다시 연결되었습니다",
"fetchMetadataFirst": "먼저 CivitAI에서 메타데이터를 가져와주세요", "fetchMetadataFirst": "먼저 CivitAI에서 메타데이터를 가져와주세요",
"noCivitaiInfo": "사용 가능한 CivitAI 정보가 없습니다", "noCivitaiInfo": "사용 가능한 CivitAI 정보가 없습니다",
"missingHash": "모델 해시를 사용할 수 없습니다" "missingHash": "모델 해시를 사용할 수 없습니다"
+9 -7
View File
@@ -963,7 +963,7 @@
} }
}, },
"duplicates": { "duplicates": {
"finding": "[TODO: Translate] Scanning for duplicate recipes...", "finding": "Поиск дублирующихся рецептов...",
"found": "Найдено {count} групп дубликатов", "found": "Найдено {count} групп дубликатов",
"noGroups": "Дубликатов с текущим критерием не найдено", "noGroups": "Дубликатов с текущим критерием не найдено",
"keepLatest": "Оставить последние версии", "keepLatest": "Оставить последние версии",
@@ -1422,13 +1422,14 @@
}, },
"proceedText": "Продолжайте только если вы уверены, что это то, что вам нужно.", "proceedText": "Продолжайте только если вы уверены, что это то, что вам нужно.",
"urlLabel": "URL модели Civitai:", "urlLabel": "URL модели Civitai:",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"helpText": { "helpText": {
"title": "Вставьте любой URL модели Civitai. Поддерживаемые форматы:", "title": "Вставьте любой URL модели Civitai или CivitArchive. Поддерживаемые форматы:",
"format1": "https://civitai.com/models/649516", "format1": "https://civitai.com/models/12345",
"format2": "https://civitai.com/models/649516?modelVersionId=726676", "format2": "https://civitai.com/models/12345?modelVersionId=67890",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"note": "Примечание: Если modelVersionId не указан, будет использована последняя версия." "note": "Примечание: Если modelVersionId не указан, будет использована последняя версия.",
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
}, },
"confirmAction": "Подтвердить пересвязывание" "confirmAction": "Подтвердить пересвязывание"
}, },
@@ -2225,6 +2226,7 @@
"relinkFailed": "Ошибка: {message}", "relinkFailed": "Ошибка: {message}",
"linkHfSuccess": "Модель успешно связана с HuggingFace", "linkHfSuccess": "Модель успешно связана с HuggingFace",
"linkHfFailed": "Ошибка: {message}", "linkHfFailed": "Ошибка: {message}",
"linkCivArchSuccess": "Модель успешно пересвязана через CivitArchive",
"fetchMetadataFirst": "Пожалуйста, сначала получите метаданные с CivitAI", "fetchMetadataFirst": "Пожалуйста, сначала получите метаданные с CivitAI",
"noCivitaiInfo": "Информация CivitAI недоступна", "noCivitaiInfo": "Информация CivitAI недоступна",
"missingHash": "Хеш модели недоступен" "missingHash": "Хеш модели недоступен"
+9 -7
View File
@@ -963,7 +963,7 @@
} }
}, },
"duplicates": { "duplicates": {
"finding": "[TODO: Translate] Scanning for duplicate recipes...", "finding": "正在扫描重复配方...",
"found": "发现 {count} 个重复组", "found": "发现 {count} 个重复组",
"noGroups": "按当前判重依据未找到重复组", "noGroups": "按当前判重依据未找到重复组",
"keepLatest": "保留最新版本", "keepLatest": "保留最新版本",
@@ -1422,13 +1422,14 @@
}, },
"proceedText": "仅在你确定需要此操作时继续。", "proceedText": "仅在你确定需要此操作时继续。",
"urlLabel": "Civitai 模型 URL", "urlLabel": "Civitai 模型 URL",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 或 https://civitai.red/models/649516/model-name?modelVersionId=726676", "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890 或 https://civitai.red/models/12345/model-name?modelVersionId=67890",
"helpText": { "helpText": {
"title": "粘贴任意来自 civitai.comcivitai.red 的 Civitai 模型 URL。支持格式:", "title": "粘贴任意 Civitai 或 CivitArchive 模型 URL。支持格式:",
"format1": "https://civitai.com/models/649516", "format1": "https://civitai.com/models/12345",
"format2": "https://civitai.com/models/649516?modelVersionId=726676", "format2": "https://civitai.com/models/12345?modelVersionId=67890",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"note": "注意:如果未提供 modelVersionId,将使用最新版本。" "note": "注意:如果未提供 modelVersionId,将使用最新版本。",
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
}, },
"confirmAction": "确认重新关联" "confirmAction": "确认重新关联"
}, },
@@ -2225,6 +2226,7 @@
"relinkFailed": "错误:{message}", "relinkFailed": "错误:{message}",
"linkHfSuccess": "模型已成功链接到 HuggingFace", "linkHfSuccess": "模型已成功链接到 HuggingFace",
"linkHfFailed": "错误:{message}", "linkHfFailed": "错误:{message}",
"linkCivArchSuccess": "模型已成功通过 CivitArchive 重新关联",
"fetchMetadataFirst": "请先从 CivitAI 获取元数据", "fetchMetadataFirst": "请先从 CivitAI 获取元数据",
"noCivitaiInfo": "无 CivitAI 信息", "noCivitaiInfo": "无 CivitAI 信息",
"missingHash": "模型哈希不可用" "missingHash": "模型哈希不可用"
+9 -7
View File
@@ -963,7 +963,7 @@
} }
}, },
"duplicates": { "duplicates": {
"finding": "[TODO: Translate] Scanning for duplicate recipes...", "finding": "正在掃描重複配方...",
"found": "發現 {count} 組重複項", "found": "發現 {count} 組重複項",
"noGroups": "按目前判重依據未找到重複組", "noGroups": "按目前判重依據未找到重複組",
"keepLatest": "保留最新版本", "keepLatest": "保留最新版本",
@@ -1422,13 +1422,14 @@
}, },
"proceedText": "僅在確定需要執行時才繼續。", "proceedText": "僅在確定需要執行時才繼續。",
"urlLabel": "Civitai 模型網址:", "urlLabel": "Civitai 模型網址:",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"helpText": { "helpText": {
"title": "貼上任意 Civitai 模型網址。支援格式:", "title": "貼上任意 Civitai 或 CivitArchive 模型網址。支援格式:",
"format1": "https://civitai.com/models/649516", "format1": "https://civitai.com/models/12345",
"format2": "https://civitai.com/models/649516?modelVersionId=726676", "format2": "https://civitai.com/models/12345?modelVersionId=67890",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", "format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"note": "注意:若未提供 modelVersionId,將使用最新版本。" "note": "注意:若未提供 modelVersionId,將使用最新版本。",
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
}, },
"confirmAction": "確認重新連結" "confirmAction": "確認重新連結"
}, },
@@ -2225,6 +2226,7 @@
"relinkFailed": "錯誤:{message}", "relinkFailed": "錯誤:{message}",
"linkHfSuccess": "模型已成功連結到 HuggingFace", "linkHfSuccess": "模型已成功連結到 HuggingFace",
"linkHfFailed": "錯誤:{message}", "linkHfFailed": "錯誤:{message}",
"linkCivArchSuccess": "模型已成功透過 CivitArchive 重新連結",
"fetchMetadataFirst": "請先從 CivitAI 取得 metadata", "fetchMetadataFirst": "請先從 CivitAI 取得 metadata",
"noCivitaiInfo": "無 CivitAI 資訊", "noCivitaiInfo": "無 CivitAI 資訊",
"missingHash": "模型雜湊不可用" "missingHash": "模型雜湊不可用"
+32 -7
View File
@@ -634,6 +634,16 @@ class ModelManagementHandler:
file_path = data.get("file_path") file_path = data.get("file_path")
model_id = data.get("model_id") model_id = data.get("model_id")
model_version_id = data.get("model_version_id") model_version_id = data.get("model_version_id")
source = data.get("source")
if source not in (None, "", "civarchive"):
return web.json_response(
{
"success": False,
"error": f"Unsupported relink source: {source}",
},
status=400,
)
if not file_path or model_id is None: if not file_path or model_id is None:
return web.json_response( return web.json_response(
@@ -649,20 +659,33 @@ class ModelManagementHandler:
metadata_path metadata_path
) )
relink_kwargs = {
"file_path": file_path,
"metadata": local_metadata,
"model_id": int(model_id),
"model_version_id": int(model_version_id) if model_version_id else None,
}
if source == "civarchive":
relink_kwargs["provider_name"] = "civarchive_api"
updated_metadata = await self._metadata_sync.relink_metadata( updated_metadata = await self._metadata_sync.relink_metadata(
file_path=file_path, **relink_kwargs
metadata=local_metadata,
model_id=int(model_id),
model_version_id=int(model_version_id) if model_version_id else None,
) )
await self._service.scanner.update_single_model_cache( await self._service.scanner.update_single_model_cache(
file_path, file_path, updated_metadata file_path, file_path, updated_metadata
) )
message = f"Model successfully re-linked to Civitai model {model_id}" + ( if source == "civarchive":
f" version {model_version_id}" if model_version_id else "" message = (
) f"Model successfully re-linked to CivArchive model {model_id}"
+ (f" version {model_version_id}" if model_version_id else "")
)
else:
message = (
f"Model successfully re-linked to Civitai model {model_id}"
+ (f" version {model_version_id}" if model_version_id else "")
)
return web.json_response( return web.json_response(
{ {
"success": True, "success": True,
@@ -670,6 +693,8 @@ class ModelManagementHandler:
"hash": updated_metadata.get("sha256", ""), "hash": updated_metadata.get("sha256", ""),
} }
) )
except ValueError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc: except Exception as exc:
if is_expected_offline_error(str(exc)): if is_expected_offline_error(str(exc)):
return web.json_response( return web.json_response(
+26 -3
View File
@@ -419,14 +419,37 @@ class MetadataSyncService:
metadata: Dict[str, Any], metadata: Dict[str, Any],
model_id: int, model_id: int,
model_version_id: Optional[int], model_version_id: Optional[int],
provider_name: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Relink a local metadata record to a specific CivitAI model version.""" """Relink a local metadata record to a specific CivitAI model version.
When ``provider_name`` is given, the named provider is resolved via the
metadata provider selector instead of the default fallback chain. A
missing/disabled provider surfaces a user-friendly error instead of the
raw selector exception.
"""
if provider_name:
try:
provider = await self._get_provider(provider_name)
except ValueError as exc:
logger.warning(
"Unable to resolve metadata provider %s: %s", provider_name, exc
)
raise ValueError(
"CivitArchive is not available or not enabled. "
"Enable the CivitArchive API in settings to relink via CivArchive."
) from exc
else:
provider = await self._get_default_provider()
provider = await self._get_default_provider()
civitai_metadata = await provider.get_model_version(model_id, model_version_id) civitai_metadata = await provider.get_model_version(model_id, model_version_id)
if not civitai_metadata: if not civitai_metadata:
provider_label = (
"CivitArchive" if provider_name == "civarchive_api" else "CivitAI"
)
raise ValueError( raise ValueError(
f"Model version not found on CivitAI for ID: {model_id}" f"Model version not found on {provider_label} for ID: {model_id}"
+ (f" with version: {model_version_id}" if model_version_id else "") + (f" with version: {model_version_id}" if model_version_id else "")
) )
@@ -6,7 +6,7 @@ import { bulkManager } from '../../managers/BulkManager.js';
import { MODEL_CONFIG } from '../../api/apiConfig.js'; import { MODEL_CONFIG } from '../../api/apiConfig.js';
import { translate } from '../../utils/i18nHelpers.js'; import { translate } from '../../utils/i18nHelpers.js';
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js'; import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js'; import { classifyModelRelinkUrl } from '../../utils/civitaiUtils.js';
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu // Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
export const ModelContextMenuMixin = { export const ModelContextMenuMixin = {
@@ -106,6 +106,17 @@ export const ModelContextMenuMixin = {
}, },
// Civitai re-linking methods // Civitai re-linking methods
getModelTypePrefix() {
// Map the mixin model type to its API route prefix; the relink route
// exists for all model types via COMMON_ROUTE_DEFINITIONS.
const prefixMap = {
lora: 'loras',
checkpoint: 'checkpoints',
embedding: 'embeddings'
};
return prefixMap[this.modelType] || 'loras';
},
showRelinkCivitaiModal() { showRelinkCivitaiModal() {
const filePath = this.currentCard.dataset.filepath; const filePath = this.currentCard.dataset.filepath;
if (!filePath) return; if (!filePath) return;
@@ -123,10 +134,10 @@ export const ModelContextMenuMixin = {
// Create new bound handler // Create new bound handler
this._boundRelinkHandler = async () => { this._boundRelinkHandler = async () => {
const url = urlInput.value.trim(); const url = urlInput.value.trim();
const { modelId, modelVersionId } = this.extractModelVersionId(url); const { source, modelId, modelVersionId } = classifyModelRelinkUrl(url);
if (!modelId) { if (!source || !modelId) {
errorDiv.textContent = 'Invalid URL format. Must include model ID.'; errorDiv.textContent = 'Invalid URL format. Expected: https://civitai.com/models/{modelId} or https://civarchive.com/models/{modelId}';
return; return;
} }
@@ -134,22 +145,30 @@ export const ModelContextMenuMixin = {
modalManager.closeModal('relinkCivitaiModal'); modalManager.closeModal('relinkCivitaiModal');
try { try {
state.loadingManager.showSimpleLoading('Re-linking to Civitai...'); const isCivArchive = source === 'civarchive';
state.loadingManager.showSimpleLoading(
isCivArchive ? 'Re-linking via CivitArchive...' : 'Re-linking to Civitai...'
);
const endpoint = this.modelType === 'checkpoint' ? const endpoint = `/api/lm/${this.getModelTypePrefix()}/relink-civitai`;
'/api/lm/checkpoints/relink-civitai' :
'/api/lm/loras/relink-civitai'; const payload = {
file_path: filePath,
model_id: modelId,
model_version_id: modelVersionId
};
// Omitted source keeps backend default-provider behaviour; only
// civarchive pins the provider explicitly.
if (isCivArchive) {
payload.source = source;
}
const response = await fetch(endpoint, { const response = await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify(payload)
file_path: filePath,
model_id: modelId,
model_version_id: modelVersionId
})
}); });
if (!response.ok) { if (!response.ok) {
@@ -159,7 +178,11 @@ export const ModelContextMenuMixin = {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
showToast('toast.contextMenu.relinkSuccess', {}, 'success'); showToast(
isCivArchive ? 'toast.contextMenu.linkCivArchSuccess' : 'toast.contextMenu.relinkSuccess',
{},
'success'
);
// Reload the current view to show updated data // Reload the current view to show updated data
await this.resetAndReload(); await this.resetAndReload();
} else { } else {
@@ -255,10 +278,6 @@ export const ModelContextMenuMixin = {
setTimeout(() => urlInput.focus(), 50); setTimeout(() => urlInput.focus(), 50);
}, },
extractModelVersionId(url) {
return extractCivitaiModelUrlParts(url);
},
parseModelId(value) { parseModelId(value) {
if (value === undefined || value === null || value === '') { if (value === undefined || value === null || value === '') {
return null; return null;
+41
View File
@@ -207,6 +207,47 @@ export function extractCivitaiModelUrlParts(url) {
} }
} }
const CIVITARCHIVE_PAGE_HOSTS = new Set([
'civitaiarchive.com',
'civarchive.com',
]);
/**
* Classify a relink URL by its hosting source and extract ids.
* CivitArchive mirrors the Civitai id namespace, so both sources resolve to
* the same {modelId, modelVersionId} shape; only `source` differs.
*/
export function classifyModelRelinkUrl(url) {
if (!url || typeof url !== 'string') {
return { source: null, modelId: null, modelVersionId: null };
}
let parsedUrl;
try {
parsedUrl = new URL(url.trim());
} catch (e) {
return { source: null, modelId: null, modelVersionId: null };
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
return { source: null, modelId: null, modelVersionId: null };
}
const hostname = parsedUrl.hostname.toLowerCase().replace(/^www\./, '');
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
const modelId = pathMatch ? pathMatch[1] : null;
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
if (SUPPORTED_CIVITAI_PAGE_HOSTS.has(hostname)) {
return { source: 'civitai', modelId, modelVersionId };
}
if (CIVITARCHIVE_PAGE_HOSTS.has(hostname) && modelId) {
return { source: 'civarchive', modelId, modelVersionId };
}
return { source: null, modelId: null, modelVersionId: null };
}
export function extractCivitaiImageId(url) { export function extractCivitaiImageId(url) {
if (!url) { if (!url) {
return null; return null;
@@ -22,6 +22,7 @@
• {{ t('modals.relinkCivitai.helpText.format1') }}<br> • {{ t('modals.relinkCivitai.helpText.format1') }}<br>
• {{ t('modals.relinkCivitai.helpText.format2') }}<br> • {{ t('modals.relinkCivitai.helpText.format2') }}<br>
• {{ t('modals.relinkCivitai.helpText.format3') }}<br> • {{ t('modals.relinkCivitai.helpText.format3') }}<br>
• {{ t('modals.relinkCivitai.helpText.format4') }}<br>
<em>{{ t('modals.relinkCivitai.helpText.note') }}</em> <em>{{ t('modals.relinkCivitai.helpText.note') }}</em>
</div> </div>
</div> </div>
@@ -2504,4 +2504,172 @@ describe('Interaction-level regression coverage', () => {
delete stateStub.currentPageType; delete stateStub.currentPageType;
}); });
it('opens the relink modal from the relink-civitai menu action', async () => {
document.body.innerHTML = `
<div id="loraContextMenu" class="context-menu">
<div class="context-menu-item has-submenu" data-has-submenu="link-model">
<div class="context-submenu">
<div class="context-menu-item" data-action="relink-civitai"></div>
</div>
</div>
</div>
<div id="relinkCivitaiModal" class="modal">
<input type="text" id="civitaiModelUrl" />
<div class="input-error" id="civitaiModelUrlError"></div>
<button class="confirm-btn" id="confirmRelinkBtn"></button>
</div>
`;
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
const contextMenu = new LoraContextMenu();
const showModalSpy = vi.spyOn(contextMenu, 'showRelinkCivitaiModal').mockImplementation(() => {});
const card = document.createElement('div');
card.className = 'model-card';
card.dataset.filepath = '/models/test.safetensors';
document.body.appendChild(card);
contextMenu.showMenu(100, 100, card);
document.querySelector('[data-action="relink-civitai"]').dispatchEvent(new Event('click', { bubbles: true }));
expect(showModalSpy).toHaveBeenCalledTimes(1);
});
it('rejects an unsupported relink URL with an inline error and no fetch', async () => {
document.body.innerHTML = `
<div id="loraContextMenu" class="context-menu"></div>
<div id="relinkCivitaiModal" class="modal">
<input type="text" id="civitaiModelUrl" />
<div class="input-error" id="civitaiModelUrlError"></div>
<button class="confirm-btn" id="confirmRelinkBtn"></button>
</div>
`;
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
const contextMenu = new LoraContextMenu();
const card = document.createElement('div');
card.className = 'model-card';
card.dataset.filepath = '/models/test.safetensors';
document.body.appendChild(card);
contextMenu.showMenu(100, 100, card);
contextMenu.showRelinkCivitaiModal();
document.getElementById('civitaiModelUrl').value = 'https://example.com/models/123456';
await contextMenu._boundRelinkHandler();
expect(document.getElementById('civitaiModelUrlError').textContent)
.toBe('Invalid URL format. Expected: https://civitai.com/models/{modelId} or https://civarchive.com/models/{modelId}');
expect(global.fetch).not.toHaveBeenCalled();
expect(modalManagerMock.closeModal).not.toHaveBeenCalled();
});
it('posts a valid CivitArchive URL to the relink endpoint with the civarchive source', async () => {
document.body.innerHTML = `
<div id="loraContextMenu" class="context-menu"></div>
<div id="relinkCivitaiModal" class="modal">
<input type="text" id="civitaiModelUrl" />
<div class="input-error" id="civitaiModelUrlError"></div>
<button class="confirm-btn" id="confirmRelinkBtn"></button>
</div>
`;
global.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({ success: true }),
}));
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
const contextMenu = new LoraContextMenu();
const card = document.createElement('div');
card.className = 'model-card';
card.dataset.filepath = '/models/test.safetensors';
document.body.appendChild(card);
contextMenu.showMenu(100, 100, card);
contextMenu.showRelinkCivitaiModal();
document.getElementById('civitaiModelUrl').value = 'https://civarchive.com/models/123456?modelVersionId=789012';
await contextMenu._boundRelinkHandler();
await flushAsyncTasks();
expect(modalManagerMock.closeModal).toHaveBeenCalledWith('relinkCivitaiModal');
expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalledWith('Re-linking via CivitArchive...');
expect(global.fetch).toHaveBeenCalledWith('/api/lm/loras/relink-civitai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_path: '/models/test.safetensors',
model_id: '123456',
model_version_id: '789012',
source: 'civarchive',
}),
});
expect(showToastMock).toHaveBeenCalledWith('toast.contextMenu.linkCivArchSuccess', {}, 'success');
expect(resetAndReloadMock).toHaveBeenCalledTimes(1);
expect(loadingManagerStub.hide).toHaveBeenCalled();
});
it('posts a Civitai URL without a source key so backend defaults apply', async () => {
document.body.innerHTML = `
<div id="loraContextMenu" class="context-menu"></div>
<div id="relinkCivitaiModal" class="modal">
<input type="text" id="civitaiModelUrl" />
<div class="input-error" id="civitaiModelUrlError"></div>
<button class="confirm-btn" id="confirmRelinkBtn"></button>
</div>
`;
global.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({ success: true }),
}));
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
const contextMenu = new LoraContextMenu();
const card = document.createElement('div');
card.className = 'model-card';
card.dataset.filepath = '/models/test.safetensors';
document.body.appendChild(card);
contextMenu.showMenu(100, 100, card);
contextMenu.showRelinkCivitaiModal();
document.getElementById('civitaiModelUrl').value = 'https://civitai.com/models/65423?modelVersionId=777';
await contextMenu._boundRelinkHandler();
await flushAsyncTasks();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/loras/relink-civitai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_path: '/models/test.safetensors',
model_id: '65423',
model_version_id: '777',
}),
});
expect(showToastMock).toHaveBeenCalledWith('toast.contextMenu.relinkSuccess', {}, 'success');
});
it('derives relink endpoint prefixes for all model types', async () => {
document.body.innerHTML = `
<div id="loraContextMenu" class="context-menu"></div>
`;
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
const contextMenu = new LoraContextMenu();
contextMenu.modelType = 'lora';
expect(contextMenu.getModelTypePrefix()).toBe('loras');
contextMenu.modelType = 'checkpoint';
expect(contextMenu.getModelTypePrefix()).toBe('checkpoints');
contextMenu.modelType = 'embedding';
expect(contextMenu.getModelTypePrefix()).toBe('embeddings');
contextMenu.modelType = 'unknown';
expect(contextMenu.getModelTypePrefix()).toBe('loras');
});
}); });
@@ -2,20 +2,15 @@ import { describe, expect, it } from 'vitest';
import { ModelContextMenuMixin } from '../../../static/js/components/ContextMenu/ModelContextMenuMixin.js'; import { ModelContextMenuMixin } from '../../../static/js/components/ContextMenu/ModelContextMenuMixin.js';
describe('ModelContextMenuMixin.extractModelVersionId', () => { describe('ModelContextMenuMixin.getModelTypePrefix', () => {
it('accepts civitai.red model URLs', () => { it('maps every known model type to its API route prefix', () => {
expect( expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'lora' })).toBe('loras');
ModelContextMenuMixin.extractModelVersionId( expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'checkpoint' })).toBe('checkpoints');
'https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777' expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'embedding' })).toBe('embeddings');
)
).toEqual({ modelId: '65423', modelVersionId: '777' });
}); });
it('rejects model-like URLs from unsupported hosts', () => { it('falls back to the loras prefix for unknown types', () => {
expect( expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'unknown' })).toBe('loras');
ModelContextMenuMixin.extractModelVersionId( expect(ModelContextMenuMixin.getModelTypePrefix.call({})).toBe('loras');
'https://example.com/models/65423?modelVersionId=777'
)
).toEqual({ modelId: null, modelVersionId: null });
}); });
}); });
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync, statSync } from 'fs';
import path from 'path';
// Regression guard: every `.modal` element shipped via components/modals.html
// must be registered in ModalManager.initialize(). An unregistered modal makes
// modalManager.showModal(id) silently no-op (see getModal returning undefined),
// which manifests as "clicking the menu item does nothing" with no console
// error — exactly the Link-to-CivitArchive bug this file guards against.
describe('ModalManager registry parity', () => {
const repoRoot = path.resolve(__dirname, '../../..');
const modalsHtml = readFileSync(
path.join(repoRoot, 'templates/components/modals.html'),
'utf-8'
);
const modalManagerSrc = readFileSync(
path.join(repoRoot, 'static/js/managers/ModalManager.js'),
'utf-8'
);
const collectModalIds = (target, seen = new Set()) => {
if (statSync(target).isFile()) {
extractIds(readFileSync(target, 'utf-8'), seen);
return seen;
}
for (const entry of readdirSync(target, { withFileTypes: true })) {
collectModalIds(path.join(target, entry.name), seen);
}
return seen;
};
const extractIds = (content, seen) => {
for (const match of content.matchAll(/id="([A-Za-z][\w-]*)"[^>]*class="modal"/g)) {
seen.add(match[1]);
}
};
it('registers every modal declared in templates', () => {
const includeFiles = [
...modalsHtml.matchAll(/\{%\s*include\s*'([^']+\.html)'\s*%\}/g),
].map((m) => m[1]);
expect(includeFiles.length).toBeGreaterThan(0);
const declaredIds = new Set();
for (const relPath of includeFiles) {
collectModalIds(path.join(repoRoot, 'templates', relPath), declaredIds);
}
expect(declaredIds.size).toBeGreaterThan(0);
const unregistered = [...declaredIds].filter(
(id) => !modalManagerSrc.includes(`registerModal('${id}'`)
);
expect(
unregistered,
'Modal ids rendered on pages but never registered in ModalManager.initialize() — showModal() will silently do nothing for them'
).toEqual([]);
});
});
+45
View File
@@ -11,6 +11,7 @@ import {
getThumbnailUrl, getThumbnailUrl,
extractCivitaiImageId, extractCivitaiImageId,
extractCivitaiModelUrlParts, extractCivitaiModelUrlParts,
classifyModelRelinkUrl,
isCivitaiUrl, isCivitaiUrl,
isSupportedCivitaiPageHost, isSupportedCivitaiPageHost,
OptimizationMode OptimizationMode
@@ -305,4 +306,48 @@ describe('civitaiUtils', () => {
expect(extractCivitaiImageId('https://example.com/images/126920345')).toBe(null); expect(extractCivitaiImageId('https://example.com/images/126920345')).toBe(null);
}); });
}); });
describe('classifyModelRelinkUrl', () => {
it('classifies civitai.com model URLs', () => {
expect(
classifyModelRelinkUrl('https://civitai.com/models/649516/name?modelVersionId=726676')
).toEqual({ source: 'civitai', modelId: '649516', modelVersionId: '726676' });
});
it('classifies civitai.red model URLs without a version id', () => {
expect(
classifyModelRelinkUrl('https://civitai.red/models/65423/')
).toEqual({ source: 'civitai', modelId: '65423', modelVersionId: null });
});
it('classifies civarchive and civitaiarchive model URLs', () => {
expect(
classifyModelRelinkUrl('https://civarchive.com/models/1746460')
).toEqual({ source: 'civarchive', modelId: '1746460', modelVersionId: null });
expect(
classifyModelRelinkUrl('http://www.civitaiarchive.com/models/42?modelVersionId=43')
).toEqual({ source: 'civarchive', modelId: '42', modelVersionId: '43' });
});
it('rejects archive hosts when the path has no numeric model id', () => {
expect(
classifyModelRelinkUrl('https://civarchive.com/images/123')
).toEqual({ source: null, modelId: null, modelVersionId: null });
});
it('rejects unsupported hosts and malformed input', () => {
expect(
classifyModelRelinkUrl('https://example.com/models/65423')
).toEqual({ source: null, modelId: null, modelVersionId: null });
expect(
classifyModelRelinkUrl('not a url')
).toEqual({ source: null, modelId: null, modelVersionId: null });
expect(
classifyModelRelinkUrl('')
).toEqual({ source: null, modelId: null, modelVersionId: null });
expect(
classifyModelRelinkUrl(null)
).toEqual({ source: null, modelId: null, modelVersionId: null });
});
});
}); });
+113 -1
View File
@@ -3,11 +3,16 @@ import json
import logging import logging
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import AsyncMock
import pytest import pytest
from py.config import config from py.config import config
from py.routes.handlers.model_handlers import ModelCivitaiHandler, ModelUpdateHandler from py.routes.handlers.model_handlers import (
ModelCivitaiHandler,
ModelManagementHandler,
ModelUpdateHandler,
)
from py.services.service_registry import ServiceRegistry from py.services.service_registry import ServiceRegistry
from py.utils.metadata_manager import MetadataManager from py.utils.metadata_manager import MetadataManager
from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord
@@ -965,3 +970,110 @@ def test_serialize_version_file_count_defaults_to_none():
) )
serialized = ModelUpdateHandler._serialize_version(version, None) serialized = ModelUpdateHandler._serialize_version(version, None)
assert serialized["fileCount"] is None assert serialized["fileCount"] is None
def _build_relink_handler(metadata_sync):
service = SimpleNamespace(
scanner=SimpleNamespace(update_single_model_cache=AsyncMock())
)
return ModelManagementHandler(
service=service,
logger=logging.getLogger(__name__),
metadata_sync=metadata_sync,
preview_service=SimpleNamespace(),
tag_update_service=SimpleNamespace(),
lifecycle_service=SimpleNamespace(),
)
@pytest.mark.asyncio
async def test_relink_civitai_rejects_unsupported_source():
metadata_sync = SimpleNamespace(
load_local_metadata=AsyncMock(return_value={}),
relink_metadata=AsyncMock(),
)
handler = _build_relink_handler(metadata_sync)
request = SimpleNamespace(
json=AsyncMock(
return_value={
"file_path": "/tmp/model.safetensors",
"model_id": "123",
"model_version_id": "456",
"source": "huggingface",
}
)
)
response = await handler.relink_civitai(request)
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert "Unsupported relink source" in payload["error"]
metadata_sync.relink_metadata.assert_not_awaited()
@pytest.mark.asyncio
async def test_relink_civitai_passes_provider_name_for_civarchive_source():
metadata_sync = SimpleNamespace(
load_local_metadata=AsyncMock(return_value={"model_name": "Local"}),
relink_metadata=AsyncMock(
return_value={"model_name": "Archived", "sha256": "abc"}
),
)
handler = _build_relink_handler(metadata_sync)
request = SimpleNamespace(
json=AsyncMock(
return_value={
"file_path": "/tmp/model.safetensors",
"model_id": "123",
"model_version_id": "456",
"source": "civarchive",
}
)
)
response = await handler.relink_civitai(request)
assert response.status == 200
payload = json.loads(response.text)
assert payload["success"] is True
assert "CivArchive" in payload["message"]
metadata_sync.relink_metadata.assert_awaited_once_with(
file_path="/tmp/model.safetensors",
metadata={"model_name": "Local"},
model_id=123,
model_version_id=456,
provider_name="civarchive_api",
)
@pytest.mark.asyncio
async def test_relink_civitai_surfaces_provider_unavailable_without_500():
metadata_sync = SimpleNamespace(
load_local_metadata=AsyncMock(return_value={}),
relink_metadata=AsyncMock(
side_effect=ValueError(
"CivitArchive is not available or not enabled. "
"Enable the CivitArchive API in settings to relink via CivArchive."
)
),
)
handler = _build_relink_handler(metadata_sync)
request = SimpleNamespace(
json=AsyncMock(
return_value={
"file_path": "/tmp/model.safetensors",
"model_id": "123",
"model_version_id": None,
"source": "civarchive",
}
)
)
response = await handler.relink_civitai(request)
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert "CivitArchive" in payload["error"]
@@ -560,6 +560,131 @@ async def test_relink_metadata_raises_when_version_missing():
model_version_id=None, model_version_id=None,
) )
@pytest.mark.asyncio
async def test_relink_metadata_uses_named_civarchive_provider(tmp_path):
default_provider = SimpleNamespace(
get_model_by_hash=AsyncMock(),
get_model_version=AsyncMock(),
)
civarchive_provider = SimpleNamespace(
get_model_by_hash=AsyncMock(),
get_model_version=AsyncMock(
return_value={
"files": [
{
"primary": True,
"type": "Model",
"hashes": {"SHA256": "ABCDEF"},
}
],
"model": {"name": "Archived"},
"images": [],
}
),
)
async def select_provider(name: str):
return civarchive_provider if name == "civarchive_api" else default_provider
provider_selector = AsyncMock(side_effect=select_provider)
helpers = build_service(
default_provider=default_provider,
provider_selector=provider_selector,
)
metadata = {"model_name": "Local", "sha256": "original"}
result = await helpers.service.relink_metadata(
file_path=str(tmp_path / "model.safetensors"),
metadata=metadata,
model_id=1,
model_version_id=2,
provider_name="civarchive_api",
)
assert result["model_name"] == "Archived"
assert result["sha256"] == "original"
provider_selector.assert_awaited_with("civarchive_api")
civarchive_provider.get_model_version.assert_awaited_once_with(1, 2)
helpers.default_provider_factory.assert_not_awaited()
helpers.metadata_manager.save_metadata.assert_awaited_once()
@pytest.mark.asyncio
async def test_relink_metadata_raises_when_version_missing_with_civarchive():
default_provider = SimpleNamespace(
get_model_by_hash=AsyncMock(),
get_model_version=AsyncMock(),
)
civarchive_provider = SimpleNamespace(
get_model_by_hash=AsyncMock(),
get_model_version=AsyncMock(return_value=None),
)
async def select_provider(name: str):
return civarchive_provider if name == "civarchive_api" else default_provider
provider_selector = AsyncMock(side_effect=select_provider)
helpers = build_service(
default_provider=default_provider,
provider_selector=provider_selector,
)
with pytest.raises(ValueError, match="CivitArchive"):
await helpers.service.relink_metadata(
file_path="/tmp/model.safetensors",
metadata={},
model_id=9,
model_version_id=None,
provider_name="civarchive_api",
)
@pytest.mark.asyncio
async def test_relink_metadata_raises_friendly_error_when_provider_unavailable():
provider_selector = AsyncMock(
side_effect=ValueError("Provider 'civarchive_api' is not registered")
)
helpers = build_service(provider_selector=provider_selector)
with pytest.raises(ValueError, match="CivitArchive is not available or not enabled"):
await helpers.service.relink_metadata(
file_path="/tmp/model.safetensors",
metadata={},
model_id=9,
model_version_id=None,
provider_name="civarchive_api",
)
@pytest.mark.asyncio
async def test_relink_metadata_default_call_uses_default_provider_factory(tmp_path):
helpers = build_service()
helpers.default_provider.get_model_version.return_value = {
"files": [
{
"primary": True,
"type": "Model",
"hashes": {"SHA256": "ABCDEF"},
}
],
"model": {"name": "Remote"},
"images": [],
}
result = await helpers.service.relink_metadata(
file_path=str(tmp_path / "model.safetensors"),
metadata={"model_name": "Local", "sha256": "original"},
model_id=1,
model_version_id=None,
)
assert result["model_name"] == "Remote"
assert result["sha256"] == "original"
helpers.default_provider_factory.assert_awaited_once()
helpers.provider_selector.assert_not_awaited()
helpers.metadata_manager.save_metadata.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fetch_and_update_model_persists_db_checked_when_sqlite_fails(tmp_path): async def test_fetch_and_update_model_persists_db_checked_when_sqlite_fails(tmp_path):
""" """