mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-23 11:41:17 -03:00
Compare commits
22 Commits
faf64f8986
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3bf0a164b | ||
|
|
afb6ca1b8d | ||
|
|
94f43426d7 | ||
|
|
2b361f4f5d | ||
|
|
7438072f8c | ||
|
|
26c54fd358 | ||
|
|
7cb6b04c63 | ||
|
|
fc29cde82a | ||
|
|
559ca946dc | ||
|
|
2b8e7c7504 | ||
|
|
6816d75933 | ||
|
|
b58abbad7c | ||
|
|
999814ca87 | ||
|
|
3c2760a803 | ||
|
|
0edbd7bcca | ||
|
|
21e89fa7de | ||
|
|
968d6d1d1f | ||
|
|
cf0fd0e0ad | ||
|
|
16e5dcf7b2 | ||
|
|
ab6bb25d46 | ||
|
|
07f49559be | ||
|
|
b24b1a7e57 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,6 +19,7 @@ model_cache/
|
||||
.codex
|
||||
.omo
|
||||
reasonix.toml
|
||||
.reasonix/
|
||||
.codegraph/
|
||||
|
||||
# Vue widgets development cache (but keep build output)
|
||||
|
||||
@@ -11,14 +11,15 @@
|
||||
"Insomnia Art Designs",
|
||||
"2018cfh",
|
||||
"Arlecchino Shion",
|
||||
"Charles Blakemore",
|
||||
"Rob Williams",
|
||||
"W+K+White",
|
||||
"$MetaSamsara",
|
||||
"wackop",
|
||||
"Phil",
|
||||
"Carl G.",
|
||||
"Charles Blakemore",
|
||||
"stone9k",
|
||||
"Rosenthal",
|
||||
"itismyelement",
|
||||
"Mozzel",
|
||||
"Gingko Biloba",
|
||||
@@ -28,7 +29,6 @@
|
||||
"DM",
|
||||
"Sen314",
|
||||
"Estragon",
|
||||
"Rosenthal",
|
||||
"ClockDaemon",
|
||||
"Francisco Tatis",
|
||||
"Tobi_Swagg",
|
||||
@@ -80,11 +80,13 @@
|
||||
"Release Cabrakan",
|
||||
"JW Sin",
|
||||
"Alex",
|
||||
"bh",
|
||||
"carozzz",
|
||||
"Marlon Daniels",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"Buzzard",
|
||||
"Aaron Bleuer",
|
||||
"Adam Shaw",
|
||||
"Mark Corneglio",
|
||||
"SarcasticHashtag",
|
||||
@@ -95,6 +97,7 @@
|
||||
"James Todd",
|
||||
"Wicked Choices by ASLPro3D",
|
||||
"FinalyFree",
|
||||
"Weasyl",
|
||||
"Steven Pfeiffer",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
@@ -105,7 +108,7 @@
|
||||
"Luc Job",
|
||||
"dl0901dm",
|
||||
"corde",
|
||||
"Nick Walker",
|
||||
"nwalker94",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Bishoujoker",
|
||||
@@ -118,9 +121,12 @@
|
||||
"BadassArabianMofo",
|
||||
"Pascal Dahle",
|
||||
"Greg",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Akira_HentAI",
|
||||
"lmsupporter",
|
||||
"andrew.tappan",
|
||||
"N/A",
|
||||
"Greenmoustache",
|
||||
"zounic",
|
||||
"wfpearl",
|
||||
@@ -128,20 +134,19 @@
|
||||
"Jack B Nimble",
|
||||
"JaxMax",
|
||||
"contrite831",
|
||||
"bh",
|
||||
"Jwk0205",
|
||||
"Starkselle",
|
||||
"Olive",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"greebles",
|
||||
"Some Guy Named Barry",
|
||||
"M Postkasse",
|
||||
"Gooohokrbe",
|
||||
"wamekukyouzin",
|
||||
"OldBones",
|
||||
"Jacob Hoehler",
|
||||
"Dogmaster",
|
||||
"Matt Wenzel",
|
||||
"Weasyl",
|
||||
"Lex Song",
|
||||
"Cory Paza",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
@@ -151,20 +156,18 @@
|
||||
"Philip Hempel",
|
||||
"dan",
|
||||
"aai",
|
||||
"Mouthlessman",
|
||||
"otaku fra",
|
||||
"jean jahren",
|
||||
"MiraiKuriyamaSy",
|
||||
"Ran C",
|
||||
"ViperC",
|
||||
"Penfore",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Karl P.",
|
||||
"Gordon Cole",
|
||||
"Adam Taylor",
|
||||
"AbstractAss",
|
||||
"Weird_With_A_Beard",
|
||||
"N/A",
|
||||
"The Spawn",
|
||||
"graysock",
|
||||
"Pozadine1",
|
||||
@@ -187,15 +190,15 @@
|
||||
"太郎 ゲーム",
|
||||
"Roslynd",
|
||||
"jinxedx",
|
||||
"Neco28",
|
||||
"Cosmosis",
|
||||
"David Ortega",
|
||||
"AELOX",
|
||||
"Dankin",
|
||||
"Nicfit23",
|
||||
"FloPro4Sho",
|
||||
"Cristian Vazquez",
|
||||
"wamekukyouzin",
|
||||
"drum matthieu",
|
||||
"Dogmaster",
|
||||
"Frank Nitty",
|
||||
"Magic Noob",
|
||||
"Christopher Michel",
|
||||
@@ -210,7 +213,6 @@
|
||||
"Kevin John Duck",
|
||||
"Dustin Chen",
|
||||
"Blackfish95",
|
||||
"Mouthlessman",
|
||||
"Paul Kroll",
|
||||
"Bas Imagineer",
|
||||
"John Statham",
|
||||
@@ -232,8 +234,11 @@
|
||||
"MJG",
|
||||
"David LaVallee",
|
||||
"linnfrey",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"IamAyam",
|
||||
"skaterb949",
|
||||
"Brian M",
|
||||
"Josef Lanzl",
|
||||
"Nerezza",
|
||||
"sanborondon",
|
||||
@@ -248,11 +253,10 @@
|
||||
"Tee Gee",
|
||||
"Geolog",
|
||||
"tarek helmi",
|
||||
"Neco28",
|
||||
"Eris3D",
|
||||
"Max Marklund",
|
||||
"David Ortega",
|
||||
"Pronredn",
|
||||
"Jamie Ogletree",
|
||||
"a _",
|
||||
"Jeff",
|
||||
"lh qwe",
|
||||
@@ -272,8 +276,6 @@
|
||||
"George",
|
||||
"dw",
|
||||
"地獄の禄",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"Gamalonia",
|
||||
"WRL_SPR",
|
||||
"capn",
|
||||
@@ -289,13 +291,14 @@
|
||||
"Hailshem",
|
||||
"kudari",
|
||||
"Naomi Hale Danchi",
|
||||
"ken",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"Richard",
|
||||
"奚明 刘",
|
||||
"Andrew",
|
||||
"Brian M",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"준희 김",
|
||||
@@ -303,6 +306,7 @@
|
||||
"Thought2Form",
|
||||
"Kevin Picco",
|
||||
"Sadlip",
|
||||
"Joey Callahan",
|
||||
"Tomohiro Baba",
|
||||
"m",
|
||||
"Noora",
|
||||
@@ -311,10 +315,10 @@
|
||||
"Mattssn",
|
||||
"Mikko Hemilä",
|
||||
"Jacob McDaniel",
|
||||
"Jamie Ogletree",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Derek Baker",
|
||||
"Martial",
|
||||
"Michael Anthony Scott",
|
||||
"Emil Andersson",
|
||||
@@ -338,10 +342,8 @@
|
||||
"momokai",
|
||||
"starbugx",
|
||||
"dc7431",
|
||||
"ken",
|
||||
"Crocket",
|
||||
"keemun",
|
||||
"RedPIXel",
|
||||
"Wind",
|
||||
"Nexus",
|
||||
"Ramneek“Guy”Ashok",
|
||||
@@ -370,12 +372,13 @@
|
||||
"Vir",
|
||||
"Skyfire83",
|
||||
"Adam Rinehart",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"gzmzmvp",
|
||||
"Gregory Kozhemiak",
|
||||
"Draven T",
|
||||
"mrjuan",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
"Aquatic Coffee",
|
||||
"Ivan Tadic",
|
||||
"Mike Simone",
|
||||
@@ -389,13 +392,13 @@
|
||||
"X",
|
||||
"Sloan Steddy",
|
||||
"hexxish",
|
||||
"Derek Baker",
|
||||
"Anthony Faxlandez",
|
||||
"battu",
|
||||
"Nathan",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Pat Hen",
|
||||
"Xeeosat",
|
||||
"Saya",
|
||||
"Ed Wang",
|
||||
"Jordan Shaw",
|
||||
"g unit",
|
||||
@@ -411,8 +414,6 @@
|
||||
"Raku",
|
||||
"smart.edge5178",
|
||||
"Menard",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"moonpetal",
|
||||
"SomeDude",
|
||||
"g9p0o",
|
||||
@@ -444,9 +445,11 @@
|
||||
"Shock Shockor",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"Михал Михалыч",
|
||||
"Matt",
|
||||
"Goldwaters",
|
||||
"Kauffy",
|
||||
"Zude",
|
||||
"SPJ",
|
||||
"Kyler",
|
||||
"Edward Kennedy",
|
||||
"Justin Blaylock",
|
||||
@@ -467,7 +470,6 @@
|
||||
"Distortik",
|
||||
"Filippo Ferrari",
|
||||
"Youguang",
|
||||
"Saya",
|
||||
"andrewzpong",
|
||||
"BossGame",
|
||||
"lrdchs",
|
||||
@@ -479,6 +481,8 @@
|
||||
"Whitepinetrader",
|
||||
"POPPIN",
|
||||
"nanana",
|
||||
"D",
|
||||
"Dark_Pest",
|
||||
"Alex",
|
||||
"Karru",
|
||||
"ChaChanoKo",
|
||||
@@ -506,18 +510,20 @@
|
||||
"Kalli Core",
|
||||
"Christian Schäfer",
|
||||
"りん あめ",
|
||||
"Matt",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Locrospiel",
|
||||
"Frogmilk",
|
||||
"SPJ",
|
||||
"Sean voets",
|
||||
"Kor",
|
||||
"Joseph Hanson",
|
||||
"John Rednoulf",
|
||||
"Kyron Mahan",
|
||||
"Bryan Rutkowski",
|
||||
"TBitz33",
|
||||
"Anonym dkjglfleeoeldldldlkf",
|
||||
"Ezokewn",
|
||||
"SendingRavens",
|
||||
"Steven",
|
||||
"JackJohnnyJim",
|
||||
"TenaciousD",
|
||||
"Dmitry Ryzhov",
|
||||
@@ -558,6 +564,9 @@
|
||||
"Scott",
|
||||
"Muratoraccio",
|
||||
"D",
|
||||
"Mobius2020",
|
||||
"ExLightSaber",
|
||||
"YaboiRay",
|
||||
"nickname",
|
||||
"Sildoren",
|
||||
"Darv",
|
||||
@@ -583,8 +592,6 @@
|
||||
"Inkognito",
|
||||
"G",
|
||||
"Tan+Huynh",
|
||||
"D",
|
||||
"Dark_Pest",
|
||||
"Jacky+Ho",
|
||||
"generic404",
|
||||
"abattoirblues",
|
||||
@@ -604,12 +611,9 @@
|
||||
"Doug Mason",
|
||||
"Jeremy Townsend",
|
||||
"Dave Abraham",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Sean voets",
|
||||
"Owen Gwosdz",
|
||||
"Jarrid Lee",
|
||||
"Poophead27 Blyat",
|
||||
"John Rednoulf",
|
||||
"Spire",
|
||||
"AZ Party Oasis",
|
||||
"Boba Smith",
|
||||
@@ -619,11 +623,12 @@
|
||||
"Jack Dole",
|
||||
"matt",
|
||||
"somethingtosay8",
|
||||
"Terminuz",
|
||||
"ivistorm",
|
||||
"max blo",
|
||||
"Sauv",
|
||||
"Steven",
|
||||
"CptNeo",
|
||||
"Borte",
|
||||
"Maso",
|
||||
"Ted Cart",
|
||||
"Sage Himeros",
|
||||
@@ -642,6 +647,7 @@
|
||||
"Teriak47",
|
||||
"Just me",
|
||||
"Raf Stahelin",
|
||||
"Nacho Ferrando",
|
||||
"Вячеслав Маринин",
|
||||
"Marcos Tortosa Carmona",
|
||||
"Dkommander22",
|
||||
@@ -688,6 +694,8 @@
|
||||
"SelfishMedic",
|
||||
"adderleighn",
|
||||
"EnragedAntelope",
|
||||
"shw",
|
||||
"Celestial+Kitten",
|
||||
"bakeliteboy",
|
||||
"TequiTequi",
|
||||
"Homero+Banda",
|
||||
@@ -717,9 +725,6 @@
|
||||
"PoorStudent",
|
||||
"lucites",
|
||||
"Alex+Zaw",
|
||||
"Mobius2020",
|
||||
"ExLightSaber",
|
||||
"YaboiRay",
|
||||
"Drizzly",
|
||||
"Nebuleux",
|
||||
"Join+Chun",
|
||||
@@ -745,6 +750,7 @@
|
||||
"Nico",
|
||||
"Maximilian Krischan",
|
||||
"Banana Joe",
|
||||
"proto merp",
|
||||
"_ G3n",
|
||||
"Donovan Jenkins",
|
||||
"Hans Meier",
|
||||
@@ -766,6 +772,7 @@
|
||||
"jumpd",
|
||||
"John C",
|
||||
"Rim",
|
||||
"yfx507",
|
||||
"Room Light",
|
||||
"Jairus Knudsen",
|
||||
"Xan Dionysus",
|
||||
@@ -783,19 +790,20 @@
|
||||
"TheFusion",
|
||||
"Jean-françois SEMA",
|
||||
"3zS4QNQ4",
|
||||
"Terminuz",
|
||||
"Kurt",
|
||||
"Matt M.",
|
||||
"Ivan Imes",
|
||||
"J M",
|
||||
"Slacks",
|
||||
"Bouya shaka",
|
||||
"john Greene",
|
||||
"Faburizu",
|
||||
"Jack Lawfield",
|
||||
"jimyjomson",
|
||||
"Borte",
|
||||
"JaeHyun Jang",
|
||||
"Homero Banda",
|
||||
"Chase Kwon",
|
||||
"Bob Ling",
|
||||
"yyuvuvu",
|
||||
"Inyoshu",
|
||||
"Chad Barnes",
|
||||
@@ -821,5 +829,5 @@
|
||||
"Somebody",
|
||||
"CK"
|
||||
],
|
||||
"totalCount": 818
|
||||
"totalCount": 826
|
||||
}
|
||||
101
locales/de.json
101
locales/de.json
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Verwendungsanzahl"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} Versionen",
|
||||
"viewAllVersions": "Alle lokalen Versionen anzeigen"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Ausgeschlossene Modelle verwalten"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Nach Modell gruppieren"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -274,6 +281,9 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
|
||||
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
|
||||
"civitaiApiKeyConfigured": "Konfiguriert",
|
||||
"civitaiApiKeyNotConfigured": "Nicht konfiguriert",
|
||||
"civitaiApiKeySet": "Einrichten",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai-Host",
|
||||
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
|
||||
@@ -322,7 +332,7 @@
|
||||
"extraFolderPaths": "Zusätzliche Ordnerpfade",
|
||||
"downloadPathTemplates": "Download-Pfad-Vorlagen",
|
||||
"priorityTags": "Prioritäts-Tags",
|
||||
"updateFlags": "Update-Markierungen",
|
||||
"versionScope": "Update-Markierungen",
|
||||
"exampleImages": "Beispielbilder",
|
||||
"autoOrganize": "Auto-Organisierung",
|
||||
"metadata": "Metadaten",
|
||||
@@ -427,6 +437,8 @@
|
||||
"help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Nach Modell gruppieren",
|
||||
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes Civitai-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
|
||||
"displayDensity": "Anzeige-Dichte",
|
||||
"displayDensityOptions": {
|
||||
"default": "Standard",
|
||||
@@ -583,7 +595,7 @@
|
||||
"download": "Herunterladen",
|
||||
"restartRequired": "Neustart erforderlich"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "Strategie für Update-Markierungen",
|
||||
"help": "Entscheide, ob Update-Badges nur dann erscheinen, wenn eine neue Version dasselbe Basismodell wie deine lokalen Dateien verwendet, oder sobald es irgendein neueres Release für dieses Modell gibt.",
|
||||
"options": {
|
||||
@@ -667,7 +679,10 @@
|
||||
"sizeAsc": "Kleinste",
|
||||
"usage": "Anzahl Nutzung",
|
||||
"usageDesc": "Meiste",
|
||||
"usageAsc": "Wenigste"
|
||||
"usageAsc": "Wenigste",
|
||||
"versionsCount": "Lokale Versionen",
|
||||
"versionsCountDesc": "Meiste Versionen zuerst",
|
||||
"versionsCountAsc": "Wenigste Versionen zuerst"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Modelliste aktualisieren",
|
||||
@@ -1013,6 +1028,18 @@
|
||||
"storage": "Speicher",
|
||||
"insights": "Erkenntnisse"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "Modelle gesamt",
|
||||
"totalStorage": "Speicher gesamt",
|
||||
"totalGenerations": "Generationen gesamt",
|
||||
"usageRate": "Nutzungsrate",
|
||||
"loras": "LoRAs",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"uniqueTags": "Einzigartige Tags",
|
||||
"unusedModels": "Ungenutzte Modelle",
|
||||
"avgUsesPerModel": "Ø Nutzungen/Modell"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "Meistgenutzte LoRAs",
|
||||
"mostUsedCheckpoints": "Meistgenutzte Checkpoints",
|
||||
@@ -1030,13 +1057,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "Intelligente Erkenntnisse",
|
||||
"recommendations": "Empfehlungen"
|
||||
"recommendations": "Empfehlungen",
|
||||
"noInsights": "Keine Erkenntnisse verfügbar",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "Hohe Anzahl ungenutzter LoRAs",
|
||||
"description": "{percent}% Ihrer LoRAs ({count}/{total}) wurden noch nie verwendet.",
|
||||
"suggestion": "Erwägen Sie, ungenutzte Modelle zu organisieren oder zu archivieren, um Speicherplatz freizugeben."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "Ungenutzte Checkpoints erkannt",
|
||||
"description": "{percent}% Ihrer Checkpoints ({count}/{total}) wurden noch nie verwendet.",
|
||||
"suggestion": "Überprüfen Sie nicht mehr benötigte Checkpoints und erwägen Sie deren Entfernung."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "Hohe Anzahl ungenutzter Embeddings",
|
||||
"description": "{percent}% Ihrer Embeddings ({count}/{total}) wurden noch nie verwendet.",
|
||||
"suggestion": "Organisieren oder archivieren Sie ungenutzte Embeddings, um Ihre Sammlung zu optimieren."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "Große Sammlung erkannt",
|
||||
"description": "Ihre Modellsammlung verwendet {size} Speicher.",
|
||||
"suggestion": "Erwägen Sie externe Speicher- oder Cloud-Lösungen für eine bessere Organisation."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "Aktiver Benutzer",
|
||||
"description": "Sie haben {count} Generationen abgeschlossen!",
|
||||
"suggestion": "Entdecken und erstellen Sie weiterhin großartige Inhalte mit Ihren Modellen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "Sammlungsübersicht",
|
||||
"baseModelDistribution": "Basis-Modell-Verteilung",
|
||||
"usageTrends": "Nutzungstrends (Letzte 30 Tage)",
|
||||
"usageDistribution": "Nutzungsverteilung"
|
||||
"usageDistribution": "Nutzungsverteilung",
|
||||
"date": "Datum",
|
||||
"usageCount": "Nutzungsanzahl",
|
||||
"fileSizeBytes": "Dateigröße (Bytes)",
|
||||
"models": "Modelle",
|
||||
"loraUsage": "LoRA-Nutzung",
|
||||
"checkpointUsage": "Checkpoint-Nutzung",
|
||||
"embeddingUsage": "Embedding-Nutzung"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusionsmodell",
|
||||
"embedding": "Embeddings"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "Lädt...",
|
||||
"noModels": "Keine Modelle gefunden",
|
||||
"errorLoading": "Fehler beim Laden der Daten",
|
||||
"noStorageData": "Keine Speicherdaten verfügbar",
|
||||
"rootFolder": "Root",
|
||||
"chartLibraryMissing": "Diagramm benötigt Chart.js-Bibliothek"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} Modelle",
|
||||
"chartUsage": "{name}: {size}, {count} Nutzungen",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
|
||||
111
locales/en.json
111
locales/en.json
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Times used"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} versions",
|
||||
"viewAllVersions": "View all local versions"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Manage Excluded Models"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Group by Model"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -274,6 +281,9 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
||||
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
||||
"civitaiApiKeyConfigured": "Configured",
|
||||
"civitaiApiKeyNotConfigured": "Not configured",
|
||||
"civitaiApiKeySet": "Set up",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai host",
|
||||
"help": "Choose which Civitai site opens when using View on Civitai links.",
|
||||
@@ -322,7 +332,7 @@
|
||||
"extraFolderPaths": "Extra Folder Paths",
|
||||
"downloadPathTemplates": "Download Path Templates",
|
||||
"priorityTags": "Priority Tags",
|
||||
"updateFlags": "Update Flags",
|
||||
"versionScope": "Version Scope",
|
||||
"exampleImages": "Example Images",
|
||||
"autoOrganize": "Auto-organize",
|
||||
"metadata": "Metadata",
|
||||
@@ -427,6 +437,8 @@
|
||||
"help": "When enabled, versions downloaded before will be skipped."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Group by Model",
|
||||
"groupByModelHelp": "When enabled, only the latest version of each Civitai model is shown as a single card. Older versions are hidden.",
|
||||
"displayDensity": "Display Density",
|
||||
"displayDensityOptions": {
|
||||
"default": "Default",
|
||||
@@ -583,12 +595,12 @@
|
||||
"download": "Download",
|
||||
"restartRequired": "Requires restart"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "Update Flag Strategy",
|
||||
"help": "Decide whether update badges should only appear when a new release shares the same base model as your local files or whenever any newer version exists for that model.",
|
||||
"versionGrouping": {
|
||||
"label": "Version Grouping",
|
||||
"help": "Decide how versions are grouped for display: by base model or all together. Also controls update badge logic and the VLM version list filtering.",
|
||||
"options": {
|
||||
"sameBase": "Match updates by base model",
|
||||
"any": "Flag any available update"
|
||||
"sameBase": "Group by base model (same_base)",
|
||||
"any": "Show all versions (any)"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
@@ -667,7 +679,10 @@
|
||||
"sizeAsc": "Smallest",
|
||||
"usage": "Use Count",
|
||||
"usageDesc": "Most",
|
||||
"usageAsc": "Least"
|
||||
"usageAsc": "Least",
|
||||
"versionsCount": "Local Versions",
|
||||
"versionsCountDesc": "Most versions first",
|
||||
"versionsCountAsc": "Fewest versions first"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Refresh model list",
|
||||
@@ -1013,6 +1028,18 @@
|
||||
"storage": "Storage",
|
||||
"insights": "Insights"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "Total Models",
|
||||
"totalStorage": "Total Storage",
|
||||
"totalGenerations": "Total Generations",
|
||||
"usageRate": "Usage Rate",
|
||||
"loras": "LoRAs",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"uniqueTags": "Unique Tags",
|
||||
"unusedModels": "Unused Models",
|
||||
"avgUsesPerModel": "Avg. Uses/Model"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "Most Used LoRAs",
|
||||
"mostUsedCheckpoints": "Most Used Checkpoints",
|
||||
@@ -1030,13 +1057,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "Smart Insights",
|
||||
"recommendations": "Recommendations"
|
||||
"recommendations": "Recommendations",
|
||||
"noInsights": "No insights available",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "High Number of Unused LoRAs",
|
||||
"description": "{percent}% of your LoRAs ({count}/{total}) have never been used.",
|
||||
"suggestion": "Consider organizing or archiving unused models to free up storage space."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "Unused Checkpoints Detected",
|
||||
"description": "{percent}% of your checkpoints ({count}/{total}) have never been used.",
|
||||
"suggestion": "Review and consider removing checkpoints you no longer need."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "High Number of Unused Embeddings",
|
||||
"description": "{percent}% of your embeddings ({count}/{total}) have never been used.",
|
||||
"suggestion": "Consider organizing or archiving unused embeddings to optimize your collection."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "Large Collection Detected",
|
||||
"description": "Your model collection is using {size} of storage.",
|
||||
"suggestion": "Consider using external storage or cloud solutions for better organization."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "Active User",
|
||||
"description": "You've completed {count} generations so far!",
|
||||
"suggestion": "Keep exploring and creating amazing content with your models."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "Collection Overview",
|
||||
"baseModelDistribution": "Base Model Distribution",
|
||||
"usageTrends": "Usage Trends (Last 30 Days)",
|
||||
"usageDistribution": "Usage Distribution"
|
||||
"usageDistribution": "Usage Distribution",
|
||||
"date": "Date",
|
||||
"usageCount": "Usage Count",
|
||||
"fileSizeBytes": "File Size (bytes)",
|
||||
"models": "Models",
|
||||
"loraUsage": "LoRA Usage",
|
||||
"checkpointUsage": "Checkpoint Usage",
|
||||
"embeddingUsage": "Embedding Usage"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusion Model",
|
||||
"embedding": "Embeddings"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "Loading...",
|
||||
"noModels": "No models found",
|
||||
"errorLoading": "Error loading data",
|
||||
"noStorageData": "No storage data available",
|
||||
"rootFolder": "Root",
|
||||
"chartLibraryMissing": "Chart requires Chart.js library"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} models",
|
||||
"chartUsage": "{name}: {size}, {count} uses",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -1384,7 +1475,7 @@
|
||||
"resumeModelUpdates": "Resume updates for this model",
|
||||
"ignoreModelUpdates": "Ignore updates for this model",
|
||||
"viewLocalVersions": "View all local versions",
|
||||
"viewLocalTooltip": "Coming soon"
|
||||
"viewLocalTooltip": "Show all local versions of this model on the main page"
|
||||
},
|
||||
"filters": {
|
||||
"label": "Base filter",
|
||||
|
||||
101
locales/es.json
101
locales/es.json
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Veces usado"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} versiones",
|
||||
"viewAllVersions": "Ver todas las versiones locales"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Gestionar modelos excluidos"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Agrupar por modelo"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -274,6 +281,9 @@
|
||||
"civitaiApiKey": "Clave API de Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
|
||||
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
|
||||
"civitaiApiKeyConfigured": "Configurado",
|
||||
"civitaiApiKeyNotConfigured": "No configurado",
|
||||
"civitaiApiKeySet": "Configurar",
|
||||
"civitaiHost": {
|
||||
"label": "Host de Civitai",
|
||||
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
|
||||
@@ -322,7 +332,7 @@
|
||||
"extraFolderPaths": "Rutas de carpetas adicionales",
|
||||
"downloadPathTemplates": "Plantillas de rutas de descarga",
|
||||
"priorityTags": "Etiquetas prioritarias",
|
||||
"updateFlags": "Indicadores de actualización",
|
||||
"versionScope": "Indicadores de actualización",
|
||||
"exampleImages": "Imágenes de ejemplo",
|
||||
"autoOrganize": "Organización automática",
|
||||
"metadata": "Metadatos",
|
||||
@@ -427,6 +437,8 @@
|
||||
"help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Agrupar por modelo",
|
||||
"groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de Civitai como una tarjeta única. Las versiones anteriores están ocultas.",
|
||||
"displayDensity": "Densidad de visualización",
|
||||
"displayDensityOptions": {
|
||||
"default": "Predeterminado",
|
||||
@@ -583,7 +595,7 @@
|
||||
"download": "Descargar",
|
||||
"restartRequired": "Requiere reinicio"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "Estrategia de indicadores de actualización",
|
||||
"help": "Decide si las insignias de actualización deben mostrarse solo cuando una nueva versión comparte el mismo modelo base que tus archivos locales o siempre que exista cualquier versión más reciente de ese modelo.",
|
||||
"options": {
|
||||
@@ -667,7 +679,10 @@
|
||||
"sizeAsc": "Menor",
|
||||
"usage": "Número de usos",
|
||||
"usageDesc": "Más",
|
||||
"usageAsc": "Menos"
|
||||
"usageAsc": "Menos",
|
||||
"versionsCount": "Versiones locales",
|
||||
"versionsCountDesc": "Más versiones primero",
|
||||
"versionsCountAsc": "Menos versiones primero"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualizar lista de modelos",
|
||||
@@ -1013,6 +1028,18 @@
|
||||
"storage": "Almacenamiento",
|
||||
"insights": "Perspectivas"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "Total de modelos",
|
||||
"totalStorage": "Almacenamiento total",
|
||||
"totalGenerations": "Generaciones totales",
|
||||
"usageRate": "Tasa de uso",
|
||||
"loras": "LoRAs",
|
||||
"checkpoints": "Puntos de control",
|
||||
"embeddings": "Embeddings",
|
||||
"uniqueTags": "Etiquetas únicas",
|
||||
"unusedModels": "Modelos no usados",
|
||||
"avgUsesPerModel": "Prom. usos/modelo"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "LoRAs más utilizados",
|
||||
"mostUsedCheckpoints": "Checkpoints más utilizados",
|
||||
@@ -1030,13 +1057,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "Perspectivas inteligentes",
|
||||
"recommendations": "Recomendaciones"
|
||||
"recommendations": "Recomendaciones",
|
||||
"noInsights": "No hay información disponible",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "Alta cantidad de LoRAs no utilizadas",
|
||||
"description": "El {percent}% de tus LoRAs ({count}/{total}) nunca se han utilizado.",
|
||||
"suggestion": "Considera organizar o archivar modelos no utilizados para liberar espacio."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "Puntos de control no utilizados detectados",
|
||||
"description": "El {percent}% de tus puntos de control ({count}/{total}) nunca se han utilizado.",
|
||||
"suggestion": "Revisa y considera eliminar los puntos de control que ya no necesites."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "Alta cantidad de Embeddings no utilizados",
|
||||
"description": "El {percent}% de tus embeddings ({count}/{total}) nunca se han utilizado.",
|
||||
"suggestion": "Considera organizar o archivar embeddings no utilizados para optimizar tu colección."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "Colección grande detectada",
|
||||
"description": "Tu colección de modelos está usando {size} de almacenamiento.",
|
||||
"suggestion": "Considera usar almacenamiento externo o soluciones en la nube para una mejor organización."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "Usuario activo",
|
||||
"description": "¡Has completado {count} generaciones hasta ahora!",
|
||||
"suggestion": "Sigue explorando y creando contenido increíble con tus modelos."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "Resumen de colección",
|
||||
"baseModelDistribution": "Distribución de modelo base",
|
||||
"usageTrends": "Tendencias de uso (Últimos 30 días)",
|
||||
"usageDistribution": "Distribución de uso"
|
||||
"usageDistribution": "Distribución de uso",
|
||||
"date": "Fecha",
|
||||
"usageCount": "Conteo de uso",
|
||||
"fileSizeBytes": "Tamaño del archivo (bytes)",
|
||||
"models": "Modelos",
|
||||
"loraUsage": "Uso de LoRA",
|
||||
"checkpointUsage": "Uso de Checkpoint",
|
||||
"embeddingUsage": "Uso de Embedding"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Punto de control",
|
||||
"diffusion_model": "Modelo de difusión",
|
||||
"embedding": "Embeddings"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "Cargando...",
|
||||
"noModels": "No se encontraron modelos",
|
||||
"errorLoading": "Error al cargar datos",
|
||||
"noStorageData": "No hay datos de almacenamiento disponibles",
|
||||
"rootFolder": "Raíz",
|
||||
"chartLibraryMissing": "El gráfico requiere la librería Chart.js"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} modelos",
|
||||
"chartUsage": "{name}: {size}, {count} usos",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
|
||||
101
locales/fr.json
101
locales/fr.json
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Nombre d'utilisations"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} versions",
|
||||
"viewAllVersions": "Voir toutes les versions locales"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Gérer les modèles exclus"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Grouper par modèle"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -274,6 +281,9 @@
|
||||
"civitaiApiKey": "Clé API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
|
||||
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
|
||||
"civitaiApiKeyConfigured": "Configuré",
|
||||
"civitaiApiKeyNotConfigured": "Non configuré",
|
||||
"civitaiApiKeySet": "Configurer",
|
||||
"civitaiHost": {
|
||||
"label": "Hôte Civitai",
|
||||
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
|
||||
@@ -322,7 +332,7 @@
|
||||
"extraFolderPaths": "Chemins de dossiers supplémentaires",
|
||||
"downloadPathTemplates": "Modèles de chemin de téléchargement",
|
||||
"priorityTags": "Étiquettes prioritaires",
|
||||
"updateFlags": "Indicateurs de mise à jour",
|
||||
"versionScope": "Indicateurs de mise à jour",
|
||||
"exampleImages": "Images d'exemple",
|
||||
"autoOrganize": "Organisation automatique",
|
||||
"metadata": "Métadonnées",
|
||||
@@ -427,6 +437,8 @@
|
||||
"help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Grouper par modèle",
|
||||
"groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle Civitai s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.",
|
||||
"displayDensity": "Densité d'affichage",
|
||||
"displayDensityOptions": {
|
||||
"default": "Par défaut",
|
||||
@@ -583,7 +595,7 @@
|
||||
"download": "Télécharger",
|
||||
"restartRequired": "Redémarrage requis"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "Stratégie des indicateurs de mise à jour",
|
||||
"help": "Choisissez si les badges de mise à jour doivent apparaître uniquement lorsqu’une nouvelle version partage le même modèle de base que vos fichiers locaux, ou dès qu’il existe une version plus récente pour ce modèle.",
|
||||
"options": {
|
||||
@@ -667,7 +679,10 @@
|
||||
"sizeAsc": "Plus petit",
|
||||
"usage": "Nombre d'utilisations",
|
||||
"usageDesc": "Plus",
|
||||
"usageAsc": "Moins"
|
||||
"usageAsc": "Moins",
|
||||
"versionsCount": "Versions locales",
|
||||
"versionsCountDesc": "Plus de versions d'abord",
|
||||
"versionsCountAsc": "Moins de versions d'abord"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualiser la liste des modèles",
|
||||
@@ -1013,6 +1028,18 @@
|
||||
"storage": "Stockage",
|
||||
"insights": "Aperçus"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "Total des modèles",
|
||||
"totalStorage": "Stockage total",
|
||||
"totalGenerations": "Générations totales",
|
||||
"usageRate": "Taux d'utilisation",
|
||||
"loras": "LoRAs",
|
||||
"checkpoints": "Points de contrôle",
|
||||
"embeddings": "Embeddings",
|
||||
"uniqueTags": "Tags uniques",
|
||||
"unusedModels": "Modèles inutilisés",
|
||||
"avgUsesPerModel": "Moy. utilisations/modèle"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "LoRAs les plus utilisés",
|
||||
"mostUsedCheckpoints": "Checkpoints les plus utilisés",
|
||||
@@ -1030,13 +1057,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "Aperçus intelligents",
|
||||
"recommendations": "Recommandations"
|
||||
"recommendations": "Recommandations",
|
||||
"noInsights": "Aucun aperçu disponible",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "Nombre élevé de LoRAs inutilisées",
|
||||
"description": "{percent}% de vos LoRAs ({count}/{total}) n'ont jamais été utilisées.",
|
||||
"suggestion": "Envisagez d'organiser ou d'archiver les modèles inutilisés pour libérer de l'espace."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "Points de contrôle inutilisés détectés",
|
||||
"description": "{percent}% de vos points de contrôle ({count}/{total}) n'ont jamais été utilisés.",
|
||||
"suggestion": "Examinez et envisagez de supprimer les points de contrôle dont vous n'avez plus besoin."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "Nombre élevé d'Embeddings inutilisées",
|
||||
"description": "{percent}% de vos embeddings ({count}/{total}) n'ont jamais été utilisées.",
|
||||
"suggestion": "Envisagez d'organiser ou d'archiver les embeddings inutilisées pour optimiser votre collection."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "Grande collection détectée",
|
||||
"description": "Votre collection de modèles utilise {size} de stockage.",
|
||||
"suggestion": "Envisagez d'utiliser un stockage externe ou des solutions cloud pour une meilleure organisation."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "Utilisateur actif",
|
||||
"description": "Vous avez effectué {count} générations jusqu'à présent !",
|
||||
"suggestion": "Continuez à explorer et à créer du contenu formidable avec vos modèles."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "Aperçu de la collection",
|
||||
"baseModelDistribution": "Distribution des modèles de base",
|
||||
"usageTrends": "Tendances d'utilisation (30 derniers jours)",
|
||||
"usageDistribution": "Distribution de l'utilisation"
|
||||
"usageDistribution": "Distribution de l'utilisation",
|
||||
"date": "Date",
|
||||
"usageCount": "Nombre d'utilisations",
|
||||
"fileSizeBytes": "Taille du fichier (octets)",
|
||||
"models": "Modèles",
|
||||
"loraUsage": "Utilisation LoRA",
|
||||
"checkpointUsage": "Utilisation Checkpoint",
|
||||
"embeddingUsage": "Utilisation Embedding"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Point de contrôle",
|
||||
"diffusion_model": "Modèle de diffusion",
|
||||
"embedding": "Embeddings"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "Chargement...",
|
||||
"noModels": "Aucun modèle trouvé",
|
||||
"errorLoading": "Erreur de chargement des données",
|
||||
"noStorageData": "Aucune donnée de stockage disponible",
|
||||
"rootFolder": "Racine",
|
||||
"chartLibraryMissing": "Le graphique nécessite la bibliothèque Chart.js"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} modèles",
|
||||
"chartUsage": "{name}: {size}, {count} utilisations",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
|
||||
101
locales/he.json
101
locales/he.json
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "מספר שימושים"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} גרסאות",
|
||||
"viewAllVersions": "הצג את כל הגרסאות המקומיות"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "ניהול מודלים מוחרגים"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "קיבוץ לפי דגם"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -274,6 +281,9 @@
|
||||
"civitaiApiKey": "מפתח API של Civitai",
|
||||
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
|
||||
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
|
||||
"civitaiApiKeyConfigured": "מוגדר",
|
||||
"civitaiApiKeyNotConfigured": "לא מוגדר",
|
||||
"civitaiApiKeySet": "הגדר",
|
||||
"civitaiHost": {
|
||||
"label": "מארח Civitai",
|
||||
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
|
||||
@@ -322,7 +332,7 @@
|
||||
"extraFolderPaths": "נתיבי תיקיות נוספים",
|
||||
"downloadPathTemplates": "תבניות נתיב הורדה",
|
||||
"priorityTags": "תגיות עדיפות",
|
||||
"updateFlags": "תגי עדכון",
|
||||
"versionScope": "תגי עדכון",
|
||||
"exampleImages": "תמונות דוגמה",
|
||||
"autoOrganize": "ארגון אוטומטי",
|
||||
"metadata": "מטא-נתונים",
|
||||
@@ -427,6 +437,8 @@
|
||||
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "קיבוץ לפי דגם",
|
||||
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
|
||||
"displayDensity": "צפיפות תצוגה",
|
||||
"displayDensityOptions": {
|
||||
"default": "ברירת מחדל",
|
||||
@@ -583,7 +595,7 @@
|
||||
"download": "הורד",
|
||||
"restartRequired": "דורש הפעלה מחדש"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "אסטרטגיית תגי עדכון",
|
||||
"help": "בחרו אם תוויות העדכון יוצגו רק כאשר גרסה חדשה חולקת את אותו דגם בסיס כמו הקבצים המקומיים שלכם או בכל מקרה שבו קיימת גרסה חדשה עבור אותו דגם.",
|
||||
"options": {
|
||||
@@ -667,7 +679,10 @@
|
||||
"sizeAsc": "הקטן ביותר",
|
||||
"usage": "מספר שימושים",
|
||||
"usageDesc": "הכי הרבה",
|
||||
"usageAsc": "הכי פחות"
|
||||
"usageAsc": "הכי פחות",
|
||||
"versionsCount": "גרסאות מקומיות",
|
||||
"versionsCountDesc": "הכי הרבה גרסאות ראשונות",
|
||||
"versionsCountAsc": "הכי מעט גרסאות ראשונות"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "רענן רשימת מודלים",
|
||||
@@ -1013,6 +1028,18 @@
|
||||
"storage": "אחסון",
|
||||
"insights": "תובנות"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "סה\"כ דגמים",
|
||||
"totalStorage": "סה\"כ אחסון",
|
||||
"totalGenerations": "סה\"כ יצירות",
|
||||
"usageRate": "שיעור שימוש",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "נקודות ביקורת",
|
||||
"embeddings": "הטמעות",
|
||||
"uniqueTags": "תגיות ייחודיות",
|
||||
"unusedModels": "דגמים שאינם בשימוש",
|
||||
"avgUsesPerModel": "ממוצע שימושים/דגם"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "LoRAs הנפוצים ביותר",
|
||||
"mostUsedCheckpoints": "Checkpoints הנפוצים ביותר",
|
||||
@@ -1030,13 +1057,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "תובנות חכמות",
|
||||
"recommendations": "המלצות"
|
||||
"recommendations": "המלצות",
|
||||
"noInsights": "אין תובנות זמינות",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "כמות גבוהה של LoRAs שאינן בשימוש",
|
||||
"description": "{percent}% מה-LoRAs שלך ({count}/{total}) מעולם לא נעשה בהם שימוש.",
|
||||
"suggestion": "שקול לארגן או לאחסן בארכיון מודלים שאינם בשימוש כדי לפנות שטח אחסון."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "התגלו נקודות ביקורת שאינן בשימוש",
|
||||
"description": "{percent}% מנקודות הביקורת שלך ({count}/{total}) מעולם לא נעשה בהן שימוש.",
|
||||
"suggestion": "בדוק ושקול להסיר נקודות ביקורת שאינך צריך עוד."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "כמות גבוהה של Embeddings שאינם בשימוש",
|
||||
"description": "{percent}% מה-Embeddings שלך ({count}/{total}) מעולם לא נעשה בהם שימוש.",
|
||||
"suggestion": "שקול לארגן או לאחסן בארכיון Embeddings שאינם בשימוש כדי לייעל את האוסף."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "התגלה אוסף גדול",
|
||||
"description": "אוסף המודלים שלך משתמש ב-{size} של אחסון.",
|
||||
"suggestion": "שקול להשתמש באחסון חיצוני או בפתרונות ענן לארגון טוב יותר."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "משתמש פעיל",
|
||||
"description": "השלמת {count} יצירות עד כה!",
|
||||
"suggestion": "המשך לחקור וליצור תוכן מדהים עם המודלים שלך."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "סקירת אוסף",
|
||||
"baseModelDistribution": "התפלגות מודלי בסיס",
|
||||
"usageTrends": "מגמות שימוש (30 יום אחרונים)",
|
||||
"usageDistribution": "התפלגות שימוש"
|
||||
"usageDistribution": "התפלגות שימוש",
|
||||
"date": "תאריך",
|
||||
"usageCount": "מספר שימושים",
|
||||
"fileSizeBytes": "גודל קובץ (בתים)",
|
||||
"models": "דגמים",
|
||||
"loraUsage": "שימוש ב-LoRA",
|
||||
"checkpointUsage": "שימוש ב-Checkpoint",
|
||||
"embeddingUsage": "שימוש ב-Embedding"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "נקודת ביקורת",
|
||||
"diffusion_model": "מודל דיפוזיה",
|
||||
"embedding": "הטמעות"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "טוען...",
|
||||
"noModels": "לא נמצאו דגמים",
|
||||
"errorLoading": "שגיאה בטעינת נתונים",
|
||||
"noStorageData": "אין נתוני אחסון זמינים",
|
||||
"rootFolder": "שורש",
|
||||
"chartLibraryMissing": "הגרף דורש את ספריית Chart.js"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} דגמים",
|
||||
"chartUsage": "{name}: {size}, {count} שימושים",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
|
||||
101
locales/ja.json
101
locales/ja.json
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用回数"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} バージョン",
|
||||
"viewAllVersions": "ローカルの全バージョンを表示"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "除外モデルを管理"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "モデルでグループ化"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -274,6 +281,9 @@
|
||||
"civitaiApiKey": "Civitai APIキー",
|
||||
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
||||
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
|
||||
"civitaiApiKeyConfigured": "設定済み",
|
||||
"civitaiApiKeyNotConfigured": "未設定",
|
||||
"civitaiApiKeySet": "設定",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai ホスト",
|
||||
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
|
||||
@@ -322,7 +332,7 @@
|
||||
"extraFolderPaths": "追加フォルダーパス",
|
||||
"downloadPathTemplates": "ダウンロードパステンプレート",
|
||||
"priorityTags": "優先タグ",
|
||||
"updateFlags": "アップデートフラグ",
|
||||
"versionScope": "アップデートフラグ",
|
||||
"exampleImages": "例画像",
|
||||
"autoOrganize": "自動整理",
|
||||
"metadata": "メタデータ",
|
||||
@@ -427,6 +437,8 @@
|
||||
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "モデルでグループ化",
|
||||
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
|
||||
"displayDensity": "表示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "デフォルト",
|
||||
@@ -583,7 +595,7 @@
|
||||
"download": "ダウンロード",
|
||||
"restartRequired": "再起動が必要"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "アップデートフラグの表示戦略",
|
||||
"help": "新リリースがローカルファイルと同じベースモデルを共有する場合にのみ更新バッジを表示するか、そのモデルに新しいバージョンがあれば常に表示するかを決めます。",
|
||||
"options": {
|
||||
@@ -667,7 +679,10 @@
|
||||
"sizeAsc": "小さい順",
|
||||
"usage": "使用回数",
|
||||
"usageDesc": "多い",
|
||||
"usageAsc": "少ない"
|
||||
"usageAsc": "少ない",
|
||||
"versionsCount": "ローカルバージョン数",
|
||||
"versionsCountDesc": "バージョン数の多い順",
|
||||
"versionsCountAsc": "バージョン数の少ない順"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "モデルリストを更新",
|
||||
@@ -1013,6 +1028,18 @@
|
||||
"storage": "ストレージ",
|
||||
"insights": "インサイト"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "モデル総数",
|
||||
"totalStorage": "ストレージ合計",
|
||||
"totalGenerations": "生成回数合計",
|
||||
"usageRate": "使用率",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"uniqueTags": "ユニークタグ",
|
||||
"unusedModels": "未使用モデル",
|
||||
"avgUsesPerModel": "平均使用回数/モデル"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "最も使用されているLoRA",
|
||||
"mostUsedCheckpoints": "最も使用されているCheckpoint",
|
||||
@@ -1030,13 +1057,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "スマートインサイト",
|
||||
"recommendations": "推奨事項"
|
||||
"recommendations": "推奨事項",
|
||||
"noInsights": "インサイトはありません",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "未使用のLoRAが多数あります",
|
||||
"description": "LoRAの{percent}%({count}/{total})が一度も使用されていません。",
|
||||
"suggestion": "未使用のモデルを整理またはアーカイブしてストレージを解放してください。"
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "未使用のCheckpointを検出",
|
||||
"description": "Checkpointの{percent}%({count}/{total})が一度も使用されていません。",
|
||||
"suggestion": "不要なCheckpointを確認して削除を検討してください。"
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "未使用のEmbeddingが多数あります",
|
||||
"description": "Embeddingの{percent}%({count}/{total})が一度も使用されていません。",
|
||||
"suggestion": "未使用のEmbeddingを整理またはアーカイブしてコレクションを最適化してください。"
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "大規模コレクションを検出",
|
||||
"description": "モデルコレクションが{size}のストレージを使用しています。",
|
||||
"suggestion": "外部ストレージやクラウドソリューションの使用を検討してください。"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "アクティブユーザー",
|
||||
"description": "これまでに{count}回の生成を完了しました!",
|
||||
"suggestion": "モデルを使って素晴らしいコンテンツを作り続けてください。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "コレクション概要",
|
||||
"baseModelDistribution": "ベースモデル分布",
|
||||
"usageTrends": "使用傾向(過去30日)",
|
||||
"usageDistribution": "使用分布"
|
||||
"usageDistribution": "使用分布",
|
||||
"date": "日付",
|
||||
"usageCount": "使用回数",
|
||||
"fileSizeBytes": "ファイルサイズ(バイト)",
|
||||
"models": "モデル",
|
||||
"loraUsage": "LoRA 使用量",
|
||||
"checkpointUsage": "Checkpoint 使用量",
|
||||
"embeddingUsage": "Embedding 使用量"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "拡散モデル",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "読み込み中...",
|
||||
"noModels": "モデルが見つかりません",
|
||||
"errorLoading": "データ読み込みエラー",
|
||||
"noStorageData": "ストレージデータがありません",
|
||||
"rootFolder": "ルート",
|
||||
"chartLibraryMissing": "Chart.js ライブラリが必要です"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} モデル",
|
||||
"chartUsage": "{name}: {size}, {count} 回使用",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
|
||||
101
locales/ko.json
101
locales/ko.json
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "사용 횟수"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count}개 버전",
|
||||
"viewAllVersions": "모든 로컬 버전 보기"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "제외된 모델 관리"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "모델별 그룹화"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -274,6 +281,9 @@
|
||||
"civitaiApiKey": "Civitai API 키",
|
||||
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
||||
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
|
||||
"civitaiApiKeyConfigured": "설정됨",
|
||||
"civitaiApiKeyNotConfigured": "설정되지 않음",
|
||||
"civitaiApiKeySet": "설정",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 호스트",
|
||||
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
|
||||
@@ -322,7 +332,7 @@
|
||||
"extraFolderPaths": "추가 폴다 경로",
|
||||
"downloadPathTemplates": "다운로드 경로 템플릿",
|
||||
"priorityTags": "우선순위 태그",
|
||||
"updateFlags": "업데이트 표시",
|
||||
"versionScope": "업데이트 표시",
|
||||
"exampleImages": "예시 이미지",
|
||||
"autoOrganize": "자동 정리",
|
||||
"metadata": "메타데이터",
|
||||
@@ -427,6 +437,8 @@
|
||||
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "모델별 그룹화",
|
||||
"groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
|
||||
"displayDensity": "표시 밀도",
|
||||
"displayDensityOptions": {
|
||||
"default": "기본",
|
||||
@@ -583,7 +595,7 @@
|
||||
"download": "다운로드",
|
||||
"restartRequired": "재시작 필요"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "업데이트 표시 전략",
|
||||
"help": "새 릴리스가 로컬 파일과 동일한 베이스 모델을 공유할 때만 업데이트 배지를 표시할지, 또는 해당 모델에 사용 가능한 새 버전이 있으면 항상 표시할지 결정합니다.",
|
||||
"options": {
|
||||
@@ -667,7 +679,10 @@
|
||||
"sizeAsc": "작은 순서",
|
||||
"usage": "사용 횟수",
|
||||
"usageDesc": "많은 순",
|
||||
"usageAsc": "적은 순"
|
||||
"usageAsc": "적은 순",
|
||||
"versionsCount": "로컬 버전 수",
|
||||
"versionsCountDesc": "버전 수 많은 순",
|
||||
"versionsCountAsc": "버전 수 적은 순"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "모델 목록 새로고침",
|
||||
@@ -1013,6 +1028,18 @@
|
||||
"storage": "저장소",
|
||||
"insights": "인사이트"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "모델 총계",
|
||||
"totalStorage": "총 저장 공간",
|
||||
"totalGenerations": "총 생성 횟수",
|
||||
"usageRate": "사용률",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"uniqueTags": "고유 태그",
|
||||
"unusedModels": "미사용 모델",
|
||||
"avgUsesPerModel": "모델당 평균 사용"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "가장 많이 사용된 LoRA",
|
||||
"mostUsedCheckpoints": "가장 많이 사용된 Checkpoint",
|
||||
@@ -1030,13 +1057,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "스마트 인사이트",
|
||||
"recommendations": "추천"
|
||||
"recommendations": "추천",
|
||||
"noInsights": "인사이트 없음",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "사용하지 않은 LoRA가 많음",
|
||||
"description": "LoRA의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
|
||||
"suggestion": "사용하지 않는 모델을 정리하거나 보관하여 저장 공간을 확보하세요."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "사용하지 않은 Checkpoint 감지",
|
||||
"description": "Checkpoint의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
|
||||
"suggestion": "더 이상 필요하지 않은 Checkpoint를 검토하고 제거하세요."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "사용하지 않은 Embedding이 많음",
|
||||
"description": "Embedding의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
|
||||
"suggestion": "사용하지 않는 Embedding을 정리하여 컬렉션을 최적화하세요."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "대규모 컬렉션 감지",
|
||||
"description": "모델 컬렉션이 {size}의 저장 공간을 사용 중입니다.",
|
||||
"suggestion": "더 나은 관리를 위해 외부 저장소나 클라우드 솔루션을 고려하세요."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "활성 사용자",
|
||||
"description": "지금까지 {count}번의 생성을 완료했습니다!",
|
||||
"suggestion": "모델로 계속해서 멋진 콘텐츠를 탐색하고 만들어보세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "컬렉션 개요",
|
||||
"baseModelDistribution": "베이스 모델 분포",
|
||||
"usageTrends": "사용량 트렌드 (최근 30일)",
|
||||
"usageDistribution": "사용량 분포"
|
||||
"usageDistribution": "사용량 분포",
|
||||
"date": "날짜",
|
||||
"usageCount": "사용 횟수",
|
||||
"fileSizeBytes": "파일 크기(바이트)",
|
||||
"models": "모델",
|
||||
"loraUsage": "LoRA 사용량",
|
||||
"checkpointUsage": "Checkpoint 사용량",
|
||||
"embeddingUsage": "Embedding 사용량"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "확산 모델",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "로딩 중...",
|
||||
"noModels": "모델을 찾을 수 없음",
|
||||
"errorLoading": "데이터 로딩 오류",
|
||||
"noStorageData": "저장 데이터 없음",
|
||||
"rootFolder": "루트",
|
||||
"chartLibraryMissing": "Chart.js 라이브러리가 필요합니다"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count}개 모델",
|
||||
"chartUsage": "{name}: {size}, {count}회 사용",
|
||||
"chartPercentage": "{label}: {value}({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
|
||||
101
locales/ru.json
101
locales/ru.json
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Количество использований"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} версий",
|
||||
"viewAllVersions": "Показать все локальные версии"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Управление исключёнными моделями"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Группировать по модели"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -274,6 +281,9 @@
|
||||
"civitaiApiKey": "Ключ API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
|
||||
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
|
||||
"civitaiApiKeyConfigured": "Настроен",
|
||||
"civitaiApiKeyNotConfigured": "Не настроен",
|
||||
"civitaiApiKeySet": "Настроить",
|
||||
"civitaiHost": {
|
||||
"label": "Хост Civitai",
|
||||
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
|
||||
@@ -322,7 +332,7 @@
|
||||
"extraFolderPaths": "Дополнительные пути к папкам",
|
||||
"downloadPathTemplates": "Шаблоны путей загрузки",
|
||||
"priorityTags": "Приоритетные теги",
|
||||
"updateFlags": "Метки обновлений",
|
||||
"versionScope": "Метки обновлений",
|
||||
"exampleImages": "Примеры изображений",
|
||||
"autoOrganize": "Автоорганизация",
|
||||
"metadata": "Метаданные",
|
||||
@@ -427,6 +437,8 @@
|
||||
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Группировать по модели",
|
||||
"groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.",
|
||||
"displayDensity": "Плотность отображения",
|
||||
"displayDensityOptions": {
|
||||
"default": "По умолчанию",
|
||||
@@ -583,7 +595,7 @@
|
||||
"download": "Загрузить",
|
||||
"restartRequired": "Требует перезапуска"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "Стратегия меток обновлений",
|
||||
"help": "Выберите, отображать ли значки обновления только когда новая версия имеет тот же базовый модель, что и локальные файлы, или всегда при наличии любого нового релиза для этой модели.",
|
||||
"options": {
|
||||
@@ -667,7 +679,10 @@
|
||||
"sizeAsc": "Наименьшим",
|
||||
"usage": "Число использований",
|
||||
"usageDesc": "Больше",
|
||||
"usageAsc": "Меньше"
|
||||
"usageAsc": "Меньше",
|
||||
"versionsCount": "Локальные версии",
|
||||
"versionsCountDesc": "Сначала больше версий",
|
||||
"versionsCountAsc": "Сначала меньше версий"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Обновить список моделей",
|
||||
@@ -1013,6 +1028,18 @@
|
||||
"storage": "Хранение",
|
||||
"insights": "Аналитика"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "Всего моделей",
|
||||
"totalStorage": "Всего хранилища",
|
||||
"totalGenerations": "Всего генераций",
|
||||
"usageRate": "Коэффициент использования",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Контрольные точки",
|
||||
"embeddings": "Эмбеддинги",
|
||||
"uniqueTags": "Уникальные теги",
|
||||
"unusedModels": "Неиспользуемые модели",
|
||||
"avgUsesPerModel": "Сред. использований/модель"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "Наиболее используемые LoRAs",
|
||||
"mostUsedCheckpoints": "Наиболее используемые Checkpoints",
|
||||
@@ -1030,13 +1057,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "Умная аналитика",
|
||||
"recommendations": "Рекомендации"
|
||||
"recommendations": "Рекомендации",
|
||||
"noInsights": "Нет доступных данных",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "Большое количество неиспользуемых LoRA",
|
||||
"description": "{percent}% ваших LoRA ({count}/{total}) никогда не использовались.",
|
||||
"suggestion": "Рассмотрите возможность организации или архивирования неиспользуемых моделей для освобождения места."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "Обнаружены неиспользуемые контрольные точки",
|
||||
"description": "{percent}% ваших контрольных точек ({count}/{total}) никогда не использовались.",
|
||||
"suggestion": "Проверьте и удалите ненужные контрольные точки."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "Большое количество неиспользуемых эмбеддингов",
|
||||
"description": "{percent}% ваших эмбеддингов ({count}/{total}) никогда не использовались.",
|
||||
"suggestion": "Организуйте или архивируйте неиспользуемые эмбеддинги для оптимизации коллекции."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "Обнаружена большая коллекция",
|
||||
"description": "Ваша коллекция моделей использует {size} хранилища.",
|
||||
"suggestion": "Рассмотрите внешнее хранилище или облачные решения для лучшей организации."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "Активный пользователь",
|
||||
"description": "Вы завершили {count} генераций!",
|
||||
"suggestion": "Продолжайте исследовать и создавать удивительный контент с вашими моделями."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "Обзор коллекции",
|
||||
"baseModelDistribution": "Распределение базовых моделей",
|
||||
"usageTrends": "Тенденции использования (за последние 30 дней)",
|
||||
"usageDistribution": "Распределение использования"
|
||||
"usageDistribution": "Распределение использования",
|
||||
"date": "Дата",
|
||||
"usageCount": "Количество использований",
|
||||
"fileSizeBytes": "Размер файла (байты)",
|
||||
"models": "Модели",
|
||||
"loraUsage": "Использование LoRA",
|
||||
"checkpointUsage": "Использование Checkpoint",
|
||||
"embeddingUsage": "Использование Embedding"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Контрольная точка",
|
||||
"diffusion_model": "Диффузионная модель",
|
||||
"embedding": "Эмбеддинги"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "Загрузка...",
|
||||
"noModels": "Модели не найдены",
|
||||
"errorLoading": "Ошибка загрузки данных",
|
||||
"noStorageData": "Нет данных о хранилище",
|
||||
"rootFolder": "Корень",
|
||||
"chartLibraryMissing": "Для графика требуется библиотека Chart.js"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} моделей",
|
||||
"chartUsage": "{name}: {size}, {count} использований",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
|
||||
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用次数"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} 个版本",
|
||||
"viewAllVersions": "查看所有本地版本"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "按模型分组"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -274,6 +281,9 @@
|
||||
"civitaiApiKey": "Civitai API 密钥",
|
||||
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
||||
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
|
||||
"civitaiApiKeyConfigured": "已配置",
|
||||
"civitaiApiKeyNotConfigured": "未配置",
|
||||
"civitaiApiKeySet": "设置",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站点",
|
||||
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
|
||||
@@ -322,7 +332,7 @@
|
||||
"extraFolderPaths": "额外文件夹路径",
|
||||
"downloadPathTemplates": "下载路径模板",
|
||||
"priorityTags": "优先标签",
|
||||
"updateFlags": "更新标记",
|
||||
"versionScope": "版本范围",
|
||||
"exampleImages": "示例图片",
|
||||
"autoOrganize": "自动整理",
|
||||
"metadata": "元数据",
|
||||
@@ -427,6 +437,8 @@
|
||||
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "按模型分组",
|
||||
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
|
||||
"displayDensity": "显示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "默认",
|
||||
@@ -583,12 +595,12 @@
|
||||
"download": "下载",
|
||||
"restartRequired": "需要重启"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "更新标记策略",
|
||||
"help": "决定更新徽章是否仅在新版本与本地文件共享相同基础模型时显示,或只要该模型有任何更新版本就显示。",
|
||||
"versionGrouping": {
|
||||
"label": "版本分组",
|
||||
"help": "控制版本在 UI 中的分组方式:按基础模型分组或合并显示。同时影响更新徽章逻辑和版本列表的筛选行为。",
|
||||
"options": {
|
||||
"sameBase": "按基础模型匹配更新",
|
||||
"any": "显示任何可用更新"
|
||||
"sameBase": "按基础模型分组",
|
||||
"any": "显示所有版本"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
@@ -667,7 +679,10 @@
|
||||
"sizeAsc": "最小",
|
||||
"usage": "使用次数",
|
||||
"usageDesc": "最多",
|
||||
"usageAsc": "最少"
|
||||
"usageAsc": "最少",
|
||||
"versionsCount": "本地版本数",
|
||||
"versionsCountDesc": "版本数从多到少",
|
||||
"versionsCountAsc": "版本数从少到多"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "刷新模型列表",
|
||||
@@ -1013,6 +1028,18 @@
|
||||
"storage": "存储",
|
||||
"insights": "洞察"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "模型总数",
|
||||
"totalStorage": "总存储空间",
|
||||
"totalGenerations": "总生成次数",
|
||||
"usageRate": "使用率",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"uniqueTags": "唯一标签",
|
||||
"unusedModels": "未使用模型",
|
||||
"avgUsesPerModel": "平均使用次数/模型"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "最常用 LoRA",
|
||||
"mostUsedCheckpoints": "最常用 Checkpoint",
|
||||
@@ -1030,13 +1057,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "智能洞察",
|
||||
"recommendations": "推荐"
|
||||
"recommendations": "推荐",
|
||||
"noInsights": "暂无可用洞察",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "大量未使用的 LoRA",
|
||||
"description": "你的 LoRA 中有 {percent}%({count}/{total})从未被使用过。",
|
||||
"suggestion": "考虑整理或归档未使用的模型以释放存储空间。"
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "检测到未使用的 Checkpoint",
|
||||
"description": "你的 Checkpoint 中有 {percent}%({count}/{total})从未被使用过。",
|
||||
"suggestion": "审查并考虑删除不再需要的 Checkpoint。"
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "大量未使用的 Embedding",
|
||||
"description": "你的 Embedding 中有 {percent}%({count}/{total})从未被使用过。",
|
||||
"suggestion": "考虑整理或归档未使用的 Embedding 以优化你的收藏。"
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "检测到大型收藏",
|
||||
"description": "你的模型收藏正在使用 {size} 的存储空间。",
|
||||
"suggestion": "考虑使用外部存储或云解决方案以获得更好的组织。"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "活跃用户",
|
||||
"description": "你已经完成了 {count} 次生成!",
|
||||
"suggestion": "继续探索并用你的模型创作精彩内容。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "收藏概览",
|
||||
"baseModelDistribution": "基础模型分布",
|
||||
"usageTrends": "使用趋势(最近30天)",
|
||||
"usageDistribution": "使用分布"
|
||||
"usageDistribution": "使用分布",
|
||||
"date": "日期",
|
||||
"usageCount": "使用次数",
|
||||
"fileSizeBytes": "文件大小(字节)",
|
||||
"models": "模型",
|
||||
"loraUsage": "LoRA 使用量",
|
||||
"checkpointUsage": "Checkpoint 使用量",
|
||||
"embeddingUsage": "Embedding 使用量"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "扩散模型",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "加载中...",
|
||||
"noModels": "未找到模型",
|
||||
"errorLoading": "数据加载失败",
|
||||
"noStorageData": "暂无存储数据",
|
||||
"rootFolder": "根目录",
|
||||
"chartLibraryMissing": "需要 Chart.js 库来显示图表"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}:{count} 个模型",
|
||||
"chartUsage": "{name}:{size},{count} 次使用",
|
||||
"chartPercentage": "{label}:{value}({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
|
||||
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用次數"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} 個版本",
|
||||
"viewAllVersions": "檢視所有本地版本"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "按模型分組"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -274,6 +281,9 @@
|
||||
"civitaiApiKey": "Civitai API 金鑰",
|
||||
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
||||
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
|
||||
"civitaiApiKeyConfigured": "已設定",
|
||||
"civitaiApiKeyNotConfigured": "未設定",
|
||||
"civitaiApiKeySet": "設定",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站點",
|
||||
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
|
||||
@@ -322,7 +332,7 @@
|
||||
"extraFolderPaths": "額外資料夾路徑",
|
||||
"downloadPathTemplates": "下載路徑範本",
|
||||
"priorityTags": "優先標籤",
|
||||
"updateFlags": "更新標記",
|
||||
"versionScope": "版本範圍",
|
||||
"exampleImages": "範例圖片",
|
||||
"autoOrganize": "自動整理",
|
||||
"metadata": "中繼資料",
|
||||
@@ -427,6 +437,8 @@
|
||||
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "按模型分組",
|
||||
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
|
||||
"displayDensity": "顯示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "預設",
|
||||
@@ -583,7 +595,7 @@
|
||||
"download": "下載",
|
||||
"restartRequired": "需要重新啟動"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "更新標記策略",
|
||||
"help": "決定更新徽章是否僅在新版本與本地檔案共享相同基礎模型時顯示,或只要該模型有任何更新版本就顯示。",
|
||||
"options": {
|
||||
@@ -667,7 +679,10 @@
|
||||
"sizeAsc": "最小",
|
||||
"usage": "使用次數",
|
||||
"usageDesc": "最多",
|
||||
"usageAsc": "最少"
|
||||
"usageAsc": "最少",
|
||||
"versionsCount": "本地版本數",
|
||||
"versionsCountDesc": "版本數從多到少",
|
||||
"versionsCountAsc": "版本數從少到多"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "重新整理模型列表",
|
||||
@@ -1013,6 +1028,18 @@
|
||||
"storage": "儲存空間",
|
||||
"insights": "洞察"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "模型總數",
|
||||
"totalStorage": "總儲存空間",
|
||||
"totalGenerations": "總生成次數",
|
||||
"usageRate": "使用率",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"uniqueTags": "唯一標籤",
|
||||
"unusedModels": "未使用模型",
|
||||
"avgUsesPerModel": "平均使用次數/模型"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "最常用的 LoRA",
|
||||
"mostUsedCheckpoints": "最常用的 Checkpoint",
|
||||
@@ -1030,13 +1057,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "智慧洞察",
|
||||
"recommendations": "推薦"
|
||||
"recommendations": "推薦",
|
||||
"noInsights": "暫無可用洞察",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "大量未使用的 LoRA",
|
||||
"description": "你的 LoRA 中有 {percent}%({count}/{total})從未被使用過。",
|
||||
"suggestion": "考慮整理或封存未使用的模型以釋放儲存空間。"
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "檢測到未使用的 Checkpoint",
|
||||
"description": "你的 Checkpoint 中有 {percent}%({count}/{total})從未被使用過。",
|
||||
"suggestion": "審查並考慮刪除不再需要的 Checkpoint。"
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "大量未使用的 Embedding",
|
||||
"description": "你的 Embedding 中有 {percent}%({count}/{total})從未被使用過。",
|
||||
"suggestion": "考慮整理或封存未使用的 Embedding 以優化你的收藏。"
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "檢測到大型收藏",
|
||||
"description": "你的模型收藏正在使用 {size} 的儲存空間。",
|
||||
"suggestion": "考慮使用外部儲存或雲端解決方案以獲得更好的組織。"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "活躍用戶",
|
||||
"description": "你已經完成了 {count} 次生成!",
|
||||
"suggestion": "繼續探索並用你的模型創作精彩內容。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "收藏總覽",
|
||||
"baseModelDistribution": "基礎模型分布",
|
||||
"usageTrends": "使用趨勢(最近 30 天)",
|
||||
"usageDistribution": "使用分布"
|
||||
"usageDistribution": "使用分布",
|
||||
"date": "日期",
|
||||
"usageCount": "使用次數",
|
||||
"fileSizeBytes": "檔案大小(位元組)",
|
||||
"models": "模型",
|
||||
"loraUsage": "LoRA 使用量",
|
||||
"checkpointUsage": "Checkpoint 使用量",
|
||||
"embeddingUsage": "Embedding 使用量"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "擴散模型",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "載入中...",
|
||||
"noModels": "找不到模型",
|
||||
"errorLoading": "資料載入失敗",
|
||||
"noStorageData": "暫無儲存資料",
|
||||
"rootFolder": "根目錄",
|
||||
"chartLibraryMissing": "需要 Chart.js 函式庫來顯示圖表"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}:{count} 個模型",
|
||||
"chartUsage": "{name}:{size},{count} 次使用",
|
||||
"chartPercentage": "{label}:{value}({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
|
||||
@@ -901,6 +901,55 @@ class LoraLoaderManagerExtractor(NodeMetadataExtractor):
|
||||
"node_id": node_id
|
||||
}
|
||||
|
||||
class LoraTextLoaderManagerExtractor(NodeMetadataExtractor):
|
||||
"""Extract LoRA metadata from LoraTextLoaderLM (LoRA Text Loader).
|
||||
|
||||
The node accepts a `lora_syntax` STRING containing <lora:name:strength> tags
|
||||
(same format as the ComfyUI prompt), plus an optional `lora_stack`.
|
||||
This extractor parses the syntax string using the same regex as the node.
|
||||
"""
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs:
|
||||
return
|
||||
|
||||
active_loras = []
|
||||
|
||||
# Process lora_stack if available (optional input)
|
||||
if "lora_stack" in inputs:
|
||||
lora_stack = inputs.get("lora_stack", [])
|
||||
for item in lora_stack:
|
||||
# lora_stack entries are (path, model_strength, clip_strength) tuples
|
||||
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
lora_path = item[0]
|
||||
model_strength = item[1]
|
||||
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
|
||||
active_loras.append({
|
||||
"name": lora_name,
|
||||
"strength": round(float(model_strength), 2)
|
||||
})
|
||||
|
||||
# Process lora_syntax string input
|
||||
if "lora_syntax" in inputs:
|
||||
lora_syntax = inputs.get("lora_syntax", "")
|
||||
if lora_syntax and isinstance(lora_syntax, str):
|
||||
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
|
||||
matches = re.findall(pattern, lora_syntax, re.IGNORECASE)
|
||||
for match in matches:
|
||||
lora_name = match[0]
|
||||
model_strength = float(match[1])
|
||||
active_loras.append({
|
||||
"name": lora_name,
|
||||
"strength": round(model_strength, 2)
|
||||
})
|
||||
|
||||
if active_loras:
|
||||
metadata[LORAS][node_id] = {
|
||||
"lora_list": active_loras,
|
||||
"node_id": node_id
|
||||
}
|
||||
|
||||
|
||||
class FluxGuidanceExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
@@ -1146,6 +1195,7 @@ NODE_EXTRACTORS = {
|
||||
"UNETLoaderLM": UNETLoaderExtractor, # LoRA Manager
|
||||
"LoraLoader": LoraLoaderExtractor,
|
||||
"LoraLoaderLM": LoraLoaderManagerExtractor,
|
||||
"LoraTextLoaderLM": LoraTextLoaderManagerExtractor,
|
||||
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
|
||||
"TensorRTLoader": TensorRTLoaderExtractor,
|
||||
# Conditioning
|
||||
|
||||
@@ -608,7 +608,7 @@ class SaveImageLM:
|
||||
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
|
||||
|
||||
# Generate filename with counter if needed
|
||||
base_filename = filename
|
||||
base_filename = filename.replace("%batch_num%", str(i))
|
||||
if add_counter_to_filename:
|
||||
# Use counter + i to ensure unique filenames for all images in batch
|
||||
current_counter = counter + i
|
||||
|
||||
@@ -49,7 +49,10 @@ from ...utils.constants import (
|
||||
VALID_LORA_TYPES,
|
||||
)
|
||||
from ...utils.civitai_utils import rewrite_preview_url
|
||||
from ...utils.example_images_paths import is_valid_example_images_root
|
||||
from ...utils.example_images_paths import (
|
||||
find_non_compliant_items_in_example_images_root,
|
||||
is_valid_example_images_root,
|
||||
)
|
||||
from ...utils.lora_metadata import extract_trained_words
|
||||
from ...utils.session_logging import get_standalone_session_log_snapshot
|
||||
from ...utils.usage_stats import UsageStats
|
||||
@@ -1328,6 +1331,9 @@ class SettingsHandler:
|
||||
"folder_paths",
|
||||
"libraries",
|
||||
"active_library",
|
||||
# Sensitive — never expose the actual value to the frontend;
|
||||
# frontend receives a boolean instead (civitai_api_key_set).
|
||||
"civitai_api_key",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1382,6 +1388,9 @@ class SettingsHandler:
|
||||
value = self._settings.get(key)
|
||||
if value is not None:
|
||||
response_data[key] = value
|
||||
# Sensitive fields: only expose a boolean indicating whether set
|
||||
raw_key = self._settings.get("civitai_api_key")
|
||||
response_data["civitai_api_key_set"] = bool(raw_key)
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
response_data["settings_file"] = settings_file
|
||||
@@ -1492,6 +1501,16 @@ class SettingsHandler:
|
||||
if not os.path.isdir(folder_path):
|
||||
return "Please set a dedicated folder for example images."
|
||||
if not self._is_dedicated_example_images_folder(folder_path):
|
||||
offending = find_non_compliant_items_in_example_images_root(folder_path)
|
||||
if offending:
|
||||
items_str = ", ".join(repr(item) for item in offending[:5])
|
||||
if len(offending) > 5:
|
||||
items_str += f" … and {len(offending) - 5} more"
|
||||
return (
|
||||
f"The folder contains items that are not valid example image "
|
||||
f"folders: {items_str}. Please use a dedicated, empty folder "
|
||||
f"for example images to prevent accidental data loss."
|
||||
)
|
||||
return "Please set a dedicated folder for example images."
|
||||
return None
|
||||
|
||||
|
||||
@@ -233,6 +233,8 @@ class ModelListingHandler:
|
||||
start_time = time.perf_counter()
|
||||
try:
|
||||
params = self._parse_common_params(request)
|
||||
# group_by_model is meaningless for excluded view; strip it
|
||||
params.pop("group_by_model", None)
|
||||
result = await self._service.get_excluded_paginated_data(**params)
|
||||
|
||||
format_start = time.perf_counter()
|
||||
@@ -366,6 +368,19 @@ class ModelListingHandler:
|
||||
request.query.get("name_pattern_use_regex", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# Group-by-model flag: deduplicate versions sharing the same civitai modelId
|
||||
group_by_model = (
|
||||
request.query.get("group_by_model", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# View-local-versions filter: show all local versions of a specific model
|
||||
civitai_model_id = request.query.get("civitai_model_id")
|
||||
if civitai_model_id is not None:
|
||||
try:
|
||||
civitai_model_id = int(civitai_model_id)
|
||||
except (TypeError, ValueError):
|
||||
civitai_model_id = None
|
||||
|
||||
return {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -389,6 +404,8 @@ class ModelListingHandler:
|
||||
"name_pattern_include": name_pattern_include,
|
||||
"name_pattern_exclude": name_pattern_exclude,
|
||||
"name_pattern_use_regex": name_pattern_use_regex,
|
||||
"group_by_model": group_by_model,
|
||||
"civitai_model_id": civitai_model_id,
|
||||
**self._parse_specific_params(request),
|
||||
}
|
||||
|
||||
|
||||
@@ -477,9 +477,12 @@ class StatsRoutes:
|
||||
if unused_lora_percent > 50:
|
||||
insights.append({
|
||||
'type': 'warning',
|
||||
'title': 'High Number of Unused LoRAs',
|
||||
'description': f'{unused_lora_percent:.1f}% of your LoRAs ({unused_loras}/{total_loras}) have never been used.',
|
||||
'suggestion': 'Consider organizing or archiving unused models to free up storage space.'
|
||||
'key': 'insights.unusedLoras.high',
|
||||
'params': {
|
||||
'percent': f'{unused_lora_percent:.1f}',
|
||||
'count': str(unused_loras),
|
||||
'total': str(total_loras)
|
||||
}
|
||||
})
|
||||
|
||||
if total_checkpoints > 0:
|
||||
@@ -487,9 +490,12 @@ class StatsRoutes:
|
||||
if unused_checkpoint_percent > 30:
|
||||
insights.append({
|
||||
'type': 'warning',
|
||||
'title': 'Unused Checkpoints Detected',
|
||||
'description': f'{unused_checkpoint_percent:.1f}% of your checkpoints ({unused_checkpoints}/{total_checkpoints}) have never been used.',
|
||||
'suggestion': 'Review and consider removing checkpoints you no longer need.'
|
||||
'key': 'insights.unusedCheckpoints.detected',
|
||||
'params': {
|
||||
'percent': f'{unused_checkpoint_percent:.1f}',
|
||||
'count': str(unused_checkpoints),
|
||||
'total': str(total_checkpoints)
|
||||
}
|
||||
})
|
||||
|
||||
if total_embeddings > 0:
|
||||
@@ -497,9 +503,12 @@ class StatsRoutes:
|
||||
if unused_embedding_percent > 50:
|
||||
insights.append({
|
||||
'type': 'warning',
|
||||
'title': 'High Number of Unused Embeddings',
|
||||
'description': f'{unused_embedding_percent:.1f}% of your embeddings ({unused_embeddings}/{total_embeddings}) have never been used.',
|
||||
'suggestion': 'Consider organizing or archiving unused embeddings to optimize your collection.'
|
||||
'key': 'insights.unusedEmbeddings.high',
|
||||
'params': {
|
||||
'percent': f'{unused_embedding_percent:.1f}',
|
||||
'count': str(unused_embeddings),
|
||||
'total': str(total_embeddings)
|
||||
}
|
||||
})
|
||||
|
||||
# Storage insights
|
||||
@@ -510,18 +519,20 @@ class StatsRoutes:
|
||||
if total_size > 100 * 1024 * 1024 * 1024: # 100GB
|
||||
insights.append({
|
||||
'type': 'info',
|
||||
'title': 'Large Collection Detected',
|
||||
'description': f'Your model collection is using {self._format_size(total_size)} of storage.',
|
||||
'suggestion': 'Consider using external storage or cloud solutions for better organization.'
|
||||
'key': 'insights.collection.large',
|
||||
'params': {
|
||||
'size': self._format_size(total_size)
|
||||
}
|
||||
})
|
||||
|
||||
# Recent activity insight
|
||||
if usage_data.get('total_executions', 0) > 100:
|
||||
insights.append({
|
||||
'type': 'success',
|
||||
'title': 'Active User',
|
||||
'description': f'You\'ve completed {usage_data["total_executions"]} generations so far!',
|
||||
'suggestion': 'Keep exploring and creating amazing content with your models.'
|
||||
'key': 'insights.activity.active',
|
||||
'params': {
|
||||
'count': str(usage_data['total_executions'])
|
||||
}
|
||||
})
|
||||
|
||||
return web.json_response({
|
||||
|
||||
@@ -104,6 +104,61 @@ class BaseModelService(ABC):
|
||||
fetch_duration = time.perf_counter() - t0
|
||||
initial_count = len(sorted_data)
|
||||
|
||||
# Optionally filter by civitai model ID (shows all local versions of a specific model)
|
||||
civitai_model_id = kwargs.get("civitai_model_id")
|
||||
if civitai_model_id is not None:
|
||||
sorted_data = [
|
||||
item for item in sorted_data
|
||||
if self._extract_model_id(item) == civitai_model_id
|
||||
]
|
||||
|
||||
# Optionally group by civitai modelId, showing only the latest version per model
|
||||
dedup_lost = 0
|
||||
if kwargs.get("group_by_model") and civitai_model_id is None:
|
||||
# Determine whether to further sub-group by base model
|
||||
# When version_grouping is "same_base", versions with different
|
||||
# base models are effectively different groups — the dedup key
|
||||
# needs to include base_model so the version count and VLM flow
|
||||
# stay consistent (card shows correct count for its base model).
|
||||
ufs = self.settings.get("version_grouping", "same_base")
|
||||
group_by_base = ufs == "same_base"
|
||||
|
||||
dedup_map = {} # (modelId [,base_model]) -> (item, version_id)
|
||||
version_counter = {} # same-key -> count
|
||||
standalone = []
|
||||
for item in sorted_data:
|
||||
mid = self._extract_model_id(item)
|
||||
if mid is None:
|
||||
standalone.append(item)
|
||||
continue
|
||||
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
||||
# Count all versions per key
|
||||
version_counter[key] = version_counter.get(key, 0) + 1
|
||||
vid = self._extract_version_id(item) or 0
|
||||
if key not in dedup_map or vid > dedup_map[key][1]:
|
||||
dedup_map[key] = (item, vid)
|
||||
# Attach version_count to each surviving grouped item (shallow copy
|
||||
# to avoid mutating cached dicts — the cache is shared across requests)
|
||||
for key, (item, vid) in dedup_map.items():
|
||||
item = dict(item)
|
||||
item["version_count"] = version_counter[key]
|
||||
dedup_map[key] = (item, vid)
|
||||
dedup_lost = len(sorted_data) - (len(dedup_map) + len(standalone))
|
||||
sorted_data = [entry[0] for entry in dedup_map.values()] + standalone
|
||||
|
||||
# Re-sort by version_count after dedup (only makes sense in group_by_model mode)
|
||||
is_group_by_active = kwargs.get("group_by_model") and civitai_model_id is None
|
||||
if sort_params.key == "versions_count" and is_group_by_active:
|
||||
reverse = sort_params.order == "desc"
|
||||
sorted_data.sort(
|
||||
key=lambda x: (
|
||||
x.get("version_count", 0),
|
||||
(x.get("model_name") or x.get("file_name") or "").lower(),
|
||||
x.get("file_path", "").lower(),
|
||||
),
|
||||
reverse=reverse,
|
||||
)
|
||||
|
||||
t1 = time.perf_counter()
|
||||
if hash_filters:
|
||||
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
|
||||
@@ -172,7 +227,7 @@ class BaseModelService(ABC):
|
||||
overall_duration = time.perf_counter() - overall_start
|
||||
logger.debug(
|
||||
"%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). "
|
||||
"Counts: initial=%d, post_filter=%d, final=%d",
|
||||
"Counts: initial=%d, dedup=%d, post_filter=%d, final=%d",
|
||||
self.__class__.__name__,
|
||||
overall_duration,
|
||||
fetch_duration,
|
||||
@@ -181,6 +236,7 @@ class BaseModelService(ABC):
|
||||
pagination_duration,
|
||||
annotate_duration,
|
||||
initial_count,
|
||||
dedup_lost,
|
||||
post_filter_count,
|
||||
final_count,
|
||||
)
|
||||
@@ -495,7 +551,7 @@ class BaseModelService(ABC):
|
||||
if not ordered_ids:
|
||||
return annotated
|
||||
|
||||
strategy_value = self.settings.get("update_flag_strategy")
|
||||
strategy_value = self.settings.get("version_grouping")
|
||||
if isinstance(strategy_value, str) and strategy_value.strip():
|
||||
strategy = strategy_value.strip().lower()
|
||||
else:
|
||||
|
||||
@@ -48,6 +48,7 @@ class CheckpointService(BaseModelService):
|
||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
||||
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
||||
"version_count": checkpoint_data.get("version_count"),
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
|
||||
@@ -48,6 +48,7 @@ class EmbeddingService(BaseModelService):
|
||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
||||
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
||||
"version_count": embedding_data.get("version_count"),
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
|
||||
@@ -59,6 +59,7 @@ class LoraService(BaseModelService):
|
||||
lora_data.get("civitai", {}), minimal=True
|
||||
),
|
||||
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
||||
"version_count": lora_data.get("version_count"),
|
||||
}
|
||||
|
||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||
|
||||
@@ -427,7 +427,18 @@ class MetadataSyncService:
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
|
||||
for key, value in updates.items():
|
||||
if isinstance(value, dict) and isinstance(metadata.get(key), dict):
|
||||
if key == "tags" and isinstance(value, list):
|
||||
# Normalize tags: trim, lowercase, deduplicate
|
||||
normalized = []
|
||||
seen = set()
|
||||
for tag in value:
|
||||
if isinstance(tag, str):
|
||||
t = tag.strip().lower()
|
||||
if t and t not in seen:
|
||||
normalized.append(t)
|
||||
seen.add(t)
|
||||
metadata[key] = normalized
|
||||
elif isinstance(value, dict) and isinstance(metadata.get(key), dict):
|
||||
metadata[key].update(value)
|
||||
else:
|
||||
metadata[key] = value
|
||||
|
||||
@@ -18,6 +18,8 @@ SUPPORTED_SORT_MODES = [
|
||||
('size', 'desc'),
|
||||
('usage', 'asc'),
|
||||
('usage', 'desc'),
|
||||
('versions_count', 'asc'),
|
||||
('versions_count', 'desc'),
|
||||
]
|
||||
# Is this in use?
|
||||
|
||||
@@ -263,6 +265,17 @@ class ModelCache:
|
||||
),
|
||||
reverse=reverse
|
||||
)
|
||||
elif sort_key == 'versions_count':
|
||||
# Pre-dedup sort: fall back to name sort.
|
||||
# Actual re-sort by version_count happens in get_paginated_data after dedup.
|
||||
result = natsorted(
|
||||
data,
|
||||
key=lambda x: (
|
||||
self._get_display_name(x).lower(),
|
||||
x.get('file_path', '').lower()
|
||||
),
|
||||
reverse=reverse
|
||||
)
|
||||
else:
|
||||
# Fallback: no sort
|
||||
result = list(data)
|
||||
|
||||
@@ -294,12 +294,14 @@ class ModelFilterSet:
|
||||
for tag, state in tag_filters.items():
|
||||
if not tag:
|
||||
continue
|
||||
# Normalize to lowercase for case-insensitive matching
|
||||
normalized = tag.strip().lower()
|
||||
if state == "exclude":
|
||||
exclude_tags.add(tag)
|
||||
exclude_tags.add(normalized)
|
||||
else:
|
||||
include_tags.add(tag)
|
||||
include_tags.add(normalized)
|
||||
else:
|
||||
include_tags = {tag for tag in tag_filters if tag}
|
||||
include_tags = {tag.strip().lower() for tag in tag_filters if tag}
|
||||
|
||||
if include_tags:
|
||||
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
|
||||
@@ -318,13 +320,17 @@ class ModelFilterSet:
|
||||
return True
|
||||
# Otherwise, check if all non-special tags match
|
||||
if non_special_tags:
|
||||
return all(tag in (item_tags or []) for tag in non_special_tags)
|
||||
# Case-insensitive: normalize item tags too
|
||||
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
|
||||
return all(tag in normalized_item_tags for tag in non_special_tags)
|
||||
return True
|
||||
# Normal case: all tags must match
|
||||
return all(tag in (item_tags or []) for tag in non_special_tags)
|
||||
# Normal case: all tags must match (case-insensitive)
|
||||
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
|
||||
return all(tag in normalized_item_tags for tag in non_special_tags)
|
||||
else:
|
||||
# OR logic (default): item must have ANY include tag
|
||||
return any(tag in include_tags for tag in (item_tags or []))
|
||||
# OR logic (default): item must have ANY include tag (case-insensitive)
|
||||
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
|
||||
return bool(normalized_item_tags & include_tags)
|
||||
|
||||
items = [item for item in items if matches_include(item.get("tags"))]
|
||||
|
||||
@@ -333,7 +339,9 @@ class ModelFilterSet:
|
||||
def matches_exclude(item_tags):
|
||||
if not item_tags and "__no_tags__" in exclude_tags:
|
||||
return True
|
||||
return any(tag in exclude_tags for tag in (item_tags or []))
|
||||
# Case-insensitive: normalize item tags
|
||||
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
|
||||
return bool(normalized_item_tags & exclude_tags)
|
||||
|
||||
items = [
|
||||
item for item in items if not matches_exclude(item.get("tags"))
|
||||
|
||||
@@ -98,7 +98,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"lora_syntax_format": "legacy",
|
||||
"model_card_footer_action": "replace_preview",
|
||||
"show_version_on_card": True,
|
||||
"update_flag_strategy": "same_base",
|
||||
"version_grouping": "same_base",
|
||||
"auto_organize_exclusions": [],
|
||||
"metadata_refresh_skip_paths": [],
|
||||
"skip_previously_downloaded_model_versions": False,
|
||||
@@ -106,6 +106,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"backup_auto_enabled": True,
|
||||
"backup_retention_count": 5,
|
||||
"use_new_license_icons": True,
|
||||
"group_by_model": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +135,9 @@ class SettingsManager:
|
||||
self._template_path = (
|
||||
Path(__file__).resolve().parents[2] / "settings.json.example"
|
||||
)
|
||||
# Known placeholder value in settings.json.example; any file containing
|
||||
# this value should be treated as "not configured".
|
||||
self._TEMPLATE_PLACEHOLDER_API_KEY = "your_civitai_api_key_here"
|
||||
self.settings = self._load_settings()
|
||||
self._migrate_setting_keys()
|
||||
self._ensure_default_settings()
|
||||
@@ -165,6 +169,12 @@ class SettingsManager:
|
||||
self._original_disk_payload = copy.deepcopy(data)
|
||||
if self._matches_template_payload(data):
|
||||
self._preserve_disk_template = True
|
||||
# Clean up the template placeholder so it is not treated
|
||||
# as a real key (affects both the frontend boolean and
|
||||
# the downloader's Authorization header).
|
||||
placeholder = self._TEMPLATE_PLACEHOLDER_API_KEY
|
||||
if data.get("civitai_api_key") == placeholder:
|
||||
data["civitai_api_key"] = ""
|
||||
return data
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.error("Failed to parse settings.json: %s", exc)
|
||||
@@ -735,6 +745,7 @@ class SettingsManager:
|
||||
"includeTriggerWords": "include_trigger_words",
|
||||
"compactMode": "compact_mode",
|
||||
"modelCardFooterAction": "model_card_footer_action",
|
||||
"update_flag_strategy": "version_grouping",
|
||||
}
|
||||
|
||||
updated = False
|
||||
|
||||
@@ -36,9 +36,9 @@ class TagUpdateService:
|
||||
if isinstance(tag, str) and tag.strip():
|
||||
# Convert all tags to lowercase to avoid case sensitivity issues on Windows
|
||||
normalized = tag.strip().lower()
|
||||
if normalized.lower() not in existing_lower:
|
||||
if normalized not in existing_lower:
|
||||
existing_tags.append(normalized)
|
||||
existing_lower.append(normalized.lower())
|
||||
existing_lower.append(normalized)
|
||||
tags_added.append(normalized)
|
||||
|
||||
metadata["tags"] = existing_tags
|
||||
|
||||
@@ -12,6 +12,18 @@ from ..services.settings_manager import get_settings_manager
|
||||
|
||||
_HEX_PATTERN = re.compile(r"[a-fA-F0-9]{64}")
|
||||
|
||||
# Filesystem/metadata files that are never created by the example images system
|
||||
# and are safe to ignore during validation. The cleanup service only operates on
|
||||
# directories, so these files pose no data-loss risk.
|
||||
_SAFE_FILENAMES: frozenset[str] = frozenset({
|
||||
".DS_Store", # macOS folder metadata
|
||||
"Thumbs.db", # Windows thumbnail cache
|
||||
"desktop.ini", # Windows folder customization
|
||||
".localized", # macOS folder name localization
|
||||
".gitkeep", # Placeholder to keep empty dirs in git
|
||||
".gitignore", # Git ignore rules
|
||||
})
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -180,6 +192,22 @@ def is_hash_folder(name: str) -> bool:
|
||||
return bool(_HEX_PATTERN.fullmatch(name or ""))
|
||||
|
||||
|
||||
def _is_safe_ignorable_entry(item: str, item_path: str) -> bool:
|
||||
"""Return True if *item* is a harmless system/hidden file we can skip.
|
||||
|
||||
These files are never created by the example images system and are safe to
|
||||
ignore because the cleanup/delete operations only act on **directories**,
|
||||
never on individual files (other than ``.download_progress.json``).
|
||||
"""
|
||||
if item in _SAFE_FILENAMES:
|
||||
return True
|
||||
# Hide Unix hidden files (dotfiles) that are regular files,
|
||||
# since the cleanup system never deletes or moves files.
|
||||
if item.startswith(".") and os.path.isfile(item_path):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_valid_example_images_root(folder_path: str) -> bool:
|
||||
"""Check whether a folder looks like a dedicated example images root."""
|
||||
|
||||
@@ -190,9 +218,16 @@ def is_valid_example_images_root(folder_path: str) -> bool:
|
||||
|
||||
for item in items:
|
||||
item_path = os.path.join(folder_path, item)
|
||||
|
||||
# .download_progress.json is an expected metadata file — check before
|
||||
# the generic dotfile rule so it stays explicitly documented.
|
||||
if item == ".download_progress.json" and os.path.isfile(item_path):
|
||||
continue
|
||||
|
||||
# Skip harmless system/hidden files — cleanup only touches directories
|
||||
if _is_safe_ignorable_entry(item, item_path):
|
||||
continue
|
||||
|
||||
if os.path.isdir(item_path):
|
||||
if is_hash_folder(item):
|
||||
continue
|
||||
@@ -211,6 +246,41 @@ def is_valid_example_images_root(folder_path: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def find_non_compliant_items_in_example_images_root(folder_path: str) -> list[str]:
|
||||
"""Return the names of items that prevent *folder_path* from being a valid
|
||||
example images root, or an empty list if the folder is valid.
|
||||
|
||||
This mirrors ``is_valid_example_images_root`` but **returns** the offending
|
||||
names instead of a boolean, so callers can produce actionable error messages.
|
||||
"""
|
||||
try:
|
||||
items = os.listdir(folder_path)
|
||||
except OSError as exc:
|
||||
return [f"<cannot list directory: {exc}>"]
|
||||
|
||||
offending: list[str] = []
|
||||
|
||||
for item in items:
|
||||
item_path = os.path.join(folder_path, item)
|
||||
|
||||
# Same skip rules as is_valid_example_images_root
|
||||
if item == ".download_progress.json" and os.path.isfile(item_path):
|
||||
continue
|
||||
if _is_safe_ignorable_entry(item, item_path):
|
||||
continue
|
||||
if os.path.isdir(item_path):
|
||||
if is_hash_folder(item):
|
||||
continue
|
||||
if item == "_deleted":
|
||||
continue
|
||||
if _library_folder_has_only_hash_dirs(item_path):
|
||||
continue
|
||||
|
||||
offending.append(item)
|
||||
|
||||
return offending
|
||||
|
||||
|
||||
def _library_folder_has_only_hash_dirs(path: str) -> bool:
|
||||
"""Return True when a library subfolder only contains hash folders or metadata files."""
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.1.3"
|
||||
version = "1.1.4"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -509,6 +509,50 @@
|
||||
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
|
||||
}
|
||||
|
||||
/* Clickable version count link (shown in group-by-model mode) */
|
||||
.version-count-link {
|
||||
display: inline-block;
|
||||
color: var(--color-accent);
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
border: 1px solid var(--color-accent-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 1px 6px;
|
||||
background: var(--color-accent-subtle);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.version-count-link:hover {
|
||||
background: var(--color-accent-border);
|
||||
border-color: var(--color-accent-transparent);
|
||||
}
|
||||
|
||||
/* Medium density adjustments for version count link */
|
||||
.medium-density .version-count-link {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.medium-density .badge-version-unit .version-count-link {
|
||||
max-width: 90px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Compact density adjustments for version count link */
|
||||
.compact-density .version-count-link {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.compact-density .badge-version-unit .version-count-link {
|
||||
max-width: 70px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Version row — flex container for badges + version names */
|
||||
.version-row {
|
||||
display: flex;
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-tag-compact {
|
||||
@@ -28,6 +30,9 @@
|
||||
font-size: 0.75em;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Style for empty tags placeholder */
|
||||
@@ -118,8 +123,9 @@
|
||||
/* Model Tags Edit Mode */
|
||||
.model-tags-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.edit-tags-btn {
|
||||
@@ -132,6 +138,7 @@
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: var(--transition-base);
|
||||
margin-left: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-tags-btn.visible,
|
||||
|
||||
@@ -335,7 +335,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* API key input specific styles */
|
||||
/* API key input — CSS masking (prevents Chrome password manager triggers) */
|
||||
.api-key-masked {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
|
||||
/* API key input specific styles (shared with proxy password) */
|
||||
.api-key-input {
|
||||
width: 100%; /* Take full width of parent */
|
||||
position: relative;
|
||||
@@ -345,7 +350,7 @@
|
||||
|
||||
.api-key-input input {
|
||||
width: 100%;
|
||||
padding: 6px 40px 6px 10px; /* Add left padding */
|
||||
padding: 6px 40px 6px 10px; /* Right padding for eye button */
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--border-radius-xs);
|
||||
@@ -353,6 +358,13 @@
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.api-key-input input:focus {
|
||||
border-color: var(--lora-accent);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
|
||||
}
|
||||
|
||||
.api-key-input .toggle-visibility {
|
||||
@@ -364,12 +376,98 @@
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.api-key-input .toggle-visibility:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* API key item — stack status/edit views vertically for smooth cross-fade */
|
||||
.api-key-item .setting-control {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* API key status display (shown when not editing) */
|
||||
.api-key-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
|
||||
max-height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.api-key-status.is-hidden {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateY(-4px);
|
||||
pointer-events: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.api-key-status-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.95em;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Status color modifiers — replace inline styles */
|
||||
.api-key-status--configured .fa-check-circle {
|
||||
color: var(--lora-success);
|
||||
}
|
||||
|
||||
.api-key-status--unconfigured .fa-times-circle {
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* Utility classes for status icon colors (used by JS) */
|
||||
.text-success {
|
||||
color: var(--lora-success);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* API key inline edit container — flex row with input + buttons */
|
||||
.api-key-edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
|
||||
max-height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.api-key-edit.is-hidden {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateY(-4px);
|
||||
pointer-events: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.api-key-edit .api-key-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.api-key-edit .primary-btn,
|
||||
.api-key-edit .secondary-btn {
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Text input wrapper styles for consistent input styling */
|
||||
.text-input-wrapper {
|
||||
width: 100%;
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#recipeTagsContainer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recipe-modal-header h2 {
|
||||
margin: 0 0 var(--space-1);
|
||||
padding: var(--space-1);
|
||||
@@ -95,127 +99,11 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-editor.tags-editor input {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Remove obsolete button styles */
|
||||
.editor-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Special styling for tags content */
|
||||
.tags-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tags-display {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.no-tags {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Recipe Tags styles */
|
||||
.recipe-tags-container {
|
||||
position: relative;
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.recipe-tags-compact {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recipe-tag-compact {
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75em;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .recipe-tag-compact {
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.recipe-tag-more {
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75em;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recipe-tags-tooltip {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: var(--shadow-dropdown);
|
||||
padding: 10px 14px;
|
||||
max-width: 400px;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-4px);
|
||||
transition: var(--transition-base);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.recipe-tags-tooltip.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tooltip-tag {
|
||||
background: var(--surface-hover);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 3px 8px;
|
||||
font-size: 0.75em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tooltip-tag {
|
||||
background: var(--surface-hover);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
#recipeModal .modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1153,7 +1041,7 @@
|
||||
max-height: 2.4em;
|
||||
}
|
||||
|
||||
.recipe-tags-container {
|
||||
#recipeTagsContainer {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,3 +59,8 @@
|
||||
.initialization-notice .loading-spinner {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* Hide versions_count sort option when group-by-model is off */
|
||||
body:not(.group-by-model) .sort-option-versions-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
|
||||
import { getStorageItem, getSessionItem, removeSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
|
||||
import {
|
||||
getCompleteApiConfig,
|
||||
getCurrentModelType,
|
||||
@@ -133,6 +133,16 @@ export class BaseModelApiClient {
|
||||
pageState.hasMore = result.hasMore;
|
||||
pageState.currentPage = pageState.currentPage + 1;
|
||||
|
||||
// When resetting to page 1, scroll back to the top
|
||||
// This covers: folder selection, filter/sort/search changes,
|
||||
// favorites/update/excluded view toggles, alphabet filter, etc.
|
||||
if (resetPage) {
|
||||
const scrollContainer = document.querySelector('.page-content');
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (updateFolders) {
|
||||
sidebarManager.refresh();
|
||||
}
|
||||
@@ -1261,6 +1271,12 @@ export class BaseModelApiClient {
|
||||
|
||||
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
|
||||
|
||||
// Pass group-by-model mode to backend (skip when showing all versions of a specific model)
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
if (state.global.settings.group_by_model && !vlmModelId) {
|
||||
params.append('group_by_model', 'true');
|
||||
}
|
||||
|
||||
if (!isExcludedView && pageState.filters) {
|
||||
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
|
||||
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
|
||||
@@ -1342,6 +1358,24 @@ export class BaseModelApiClient {
|
||||
}
|
||||
|
||||
_addModelSpecificParams(params, pageState) {
|
||||
// Check for View Local Versions filter (takes priority over recipe filters)
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
const vlmPageType = getSessionItem('vlm_page_type');
|
||||
if (vlmModelId && vlmPageType === this.modelType) {
|
||||
params.append('civitai_model_id', vlmModelId);
|
||||
const vlmBaseModel = getSessionItem('vlm_base_model');
|
||||
if (vlmBaseModel) {
|
||||
params.append('base_model', vlmBaseModel);
|
||||
}
|
||||
return;
|
||||
} else if (vlmModelId && vlmPageType !== this.modelType) {
|
||||
// Stale VLM data from a different page type — clean up
|
||||
removeSessionItem('vlm_model_id');
|
||||
removeSessionItem('vlm_model_name');
|
||||
removeSessionItem('vlm_base_model');
|
||||
removeSessionItem('vlm_page_type');
|
||||
}
|
||||
|
||||
if (this.modelType === 'loras') {
|
||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||
|
||||
@@ -9,6 +9,13 @@ export class LoraApiClient extends BaseModelApiClient {
|
||||
* Add LoRA-specific parameters to query
|
||||
*/
|
||||
_addModelSpecificParams(params, pageState) {
|
||||
// Let parent handle View Local Versions filter first
|
||||
super._addModelSpecificParams(params, pageState);
|
||||
// If VLM filter was applied, skip recipe-specific filters
|
||||
if (params.has('civitai_model_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||
|
||||
|
||||
@@ -24,6 +24,14 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
||||
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
|
||||
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
|
||||
const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
|
||||
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
|
||||
|
||||
// Update check indicator for group-by-model
|
||||
if (groupByModelCheck) {
|
||||
const isEnabled = !!state.global.settings.group_by_model;
|
||||
groupByModelCheck.style.display = isEnabled ? 'block' : 'none';
|
||||
}
|
||||
|
||||
if (isRecipesPage) {
|
||||
modelUpdateItem?.classList.add('hidden');
|
||||
@@ -31,6 +39,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
downloadExamplesItem?.classList.add('hidden');
|
||||
cleanupExamplesItem?.classList.add('hidden');
|
||||
excludedModelsItem?.classList.add('hidden');
|
||||
groupByModelItem?.classList.add('hidden');
|
||||
repairRecipesItem?.classList.remove('hidden');
|
||||
} else {
|
||||
modelUpdateItem?.classList.remove('hidden');
|
||||
@@ -38,6 +47,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
downloadExamplesItem?.classList.remove('hidden');
|
||||
cleanupExamplesItem?.classList.remove('hidden');
|
||||
excludedModelsItem?.classList.remove('hidden');
|
||||
groupByModelItem?.classList.remove('hidden');
|
||||
repairRecipesItem?.classList.add('hidden');
|
||||
}
|
||||
|
||||
@@ -74,6 +84,9 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
case 'manage-excluded-models':
|
||||
this.manageExcludedModels();
|
||||
break;
|
||||
case 'toggle-group-by-model':
|
||||
this.toggleGroupByModel();
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unhandled global context menu action: ${action}`);
|
||||
break;
|
||||
@@ -86,6 +99,30 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
});
|
||||
}
|
||||
|
||||
toggleGroupByModel() {
|
||||
const sm = window.settingsManager;
|
||||
if (!sm) {
|
||||
console.error('settingsManager not available on window');
|
||||
return;
|
||||
}
|
||||
const newValue = !state.global.settings.group_by_model;
|
||||
state.global.settings.group_by_model = newValue;
|
||||
|
||||
// Save/restore sort preference when toggling group_by_model
|
||||
if (window.pageControls?.onGroupByModelToggled) {
|
||||
window.pageControls.onGroupByModelToggled(newValue);
|
||||
}
|
||||
|
||||
sm.saveSetting('group_by_model', newValue).catch((error) => {
|
||||
console.error('Failed to save group_by_model setting:', error);
|
||||
// Revert state on failure
|
||||
state.global.settings.group_by_model = !newValue;
|
||||
});
|
||||
|
||||
sm.applyFrontendSettings();
|
||||
sm.reloadContent();
|
||||
}
|
||||
|
||||
async downloadExampleImages(menuItem) {
|
||||
const downloadPath = state?.global?.settings?.example_images_path;
|
||||
if (!downloadPath) {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||
import { downloadManager } from '../managers/DownloadManager.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { openMediaViewer } from './shared/MediaViewer.js';
|
||||
import { renderCompactTags, setupTagTooltip } from './shared/utils.js';
|
||||
import { setupTagEditMode } from './shared/ModelTags.js';
|
||||
|
||||
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
||||
'prompt',
|
||||
@@ -139,14 +141,6 @@ class RecipeModal {
|
||||
this.saveTitleEdit();
|
||||
}
|
||||
|
||||
// Handle tags edit
|
||||
const tagsEditor = document.getElementById('recipeTagsEditor');
|
||||
if (tagsEditor && tagsEditor.classList.contains('active') &&
|
||||
!tagsEditor.contains(event.target) &&
|
||||
!event.target.closest('.edit-icon')) {
|
||||
this.saveTagsEdit();
|
||||
}
|
||||
|
||||
// Handle reconnect input
|
||||
const reconnectContainers = document.querySelectorAll('.lora-reconnect-container');
|
||||
reconnectContainers.forEach(container => {
|
||||
@@ -236,98 +230,10 @@ class RecipeModal {
|
||||
this.filePath = hydratedRecipe.file_path;
|
||||
this.listFilePath = hydratedRecipe.file_path;
|
||||
|
||||
// Set recipe tags if they exist
|
||||
const tagsCompactElement = document.getElementById('recipeTagsCompact');
|
||||
const tagsTooltipContent = document.getElementById('recipeTagsTooltipContent');
|
||||
|
||||
if (tagsCompactElement) {
|
||||
// Add tags container with edit functionality
|
||||
tagsCompactElement.innerHTML = `
|
||||
<div class="editable-content tags-content">
|
||||
<div class="tags-display"></div>
|
||||
<button class="edit-icon" title="Edit tags"><i class="fas fa-pencil-alt"></i></button>
|
||||
</div>
|
||||
<div id="recipeTagsEditor" class="content-editor tags-editor">
|
||||
<input type="text" class="tags-input" placeholder="Enter tags separated by commas">
|
||||
</div>
|
||||
`;
|
||||
|
||||
const tagsDisplay = tagsCompactElement.querySelector('.tags-display');
|
||||
|
||||
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
|
||||
// Limit displayed tags to 5, show a "+X more" button if needed
|
||||
const maxVisibleTags = 5;
|
||||
const visibleTags = hydratedRecipe.tags.slice(0, maxVisibleTags);
|
||||
const remainingTags = hydratedRecipe.tags.length > maxVisibleTags ? hydratedRecipe.tags.slice(maxVisibleTags) : [];
|
||||
|
||||
// Add visible tags
|
||||
visibleTags.forEach(tag => {
|
||||
const tagElement = document.createElement('div');
|
||||
tagElement.className = 'recipe-tag-compact';
|
||||
tagElement.textContent = tag;
|
||||
tagsDisplay.appendChild(tagElement);
|
||||
});
|
||||
|
||||
// Add "more" button if needed
|
||||
if (remainingTags.length > 0) {
|
||||
const moreButton = document.createElement('div');
|
||||
moreButton.className = 'recipe-tag-more';
|
||||
moreButton.textContent = `+${remainingTags.length} more`;
|
||||
tagsDisplay.appendChild(moreButton);
|
||||
|
||||
// Add tooltip functionality
|
||||
moreButton.addEventListener('mouseenter', () => {
|
||||
document.getElementById('recipeTagsTooltip').classList.add('visible');
|
||||
});
|
||||
|
||||
moreButton.addEventListener('mouseleave', () => {
|
||||
setTimeout(() => {
|
||||
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
|
||||
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
document.getElementById('recipeTagsTooltip').addEventListener('mouseleave', () => {
|
||||
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
||||
});
|
||||
|
||||
// Add all tags to tooltip
|
||||
if (tagsTooltipContent) {
|
||||
tagsTooltipContent.innerHTML = '';
|
||||
hydratedRecipe.tags.forEach(tag => {
|
||||
const tooltipTag = document.createElement('div');
|
||||
tooltipTag.className = 'tooltip-tag';
|
||||
tooltipTag.textContent = tag;
|
||||
tagsTooltipContent.appendChild(tooltipTag);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
|
||||
}
|
||||
|
||||
// Add event listeners for tags editing
|
||||
const editTagsIcon = tagsCompactElement.querySelector('.edit-icon');
|
||||
const tagsInput = tagsCompactElement.querySelector('.tags-input');
|
||||
|
||||
// Set current tags in the input
|
||||
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
|
||||
tagsInput.value = hydratedRecipe.tags.join(', ');
|
||||
}
|
||||
|
||||
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
|
||||
|
||||
// Add key event listener for Enter key
|
||||
tagsInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.saveTagsEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.cancelTagsEdit();
|
||||
}
|
||||
});
|
||||
// Render tags using shared utility
|
||||
const tagsContainer = document.getElementById('recipeTagsContainer');
|
||||
if (tagsContainer) {
|
||||
this.updateTagsDisplay(tagsContainer, hydratedRecipe.tags || []);
|
||||
}
|
||||
|
||||
// Set recipe image
|
||||
@@ -609,17 +515,35 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
syncTagsDisplay(tags) {
|
||||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
||||
if (!tagsContainer) {
|
||||
return;
|
||||
}
|
||||
const container = document.getElementById('recipeTagsContainer');
|
||||
if (!container) return;
|
||||
this.updateTagsDisplay(container, tags || []);
|
||||
}
|
||||
|
||||
this.updateTagsDisplay(tagsContainer, tags || []);
|
||||
// Re-render tags display using shared utility, wire edit mode with ModelTags
|
||||
updateTagsDisplay(container, tags) {
|
||||
const filePath = this.filePath || '';
|
||||
|
||||
const tagsInput = tagsContainer.querySelector('.tags-input');
|
||||
if (tagsInput) {
|
||||
tagsInput.value = tags && tags.length > 0 ? tags.join(', ') : '';
|
||||
}
|
||||
container.innerHTML = renderCompactTags(tags, filePath);
|
||||
|
||||
// Setup tooltip for all tags
|
||||
setupTagTooltip(container);
|
||||
|
||||
// Wire edit button using shared tag editing (no suggestions for recipes)
|
||||
setupTagEditMode(null, {
|
||||
container: container,
|
||||
showSuggestions: false,
|
||||
normalizeTag: false,
|
||||
saveHandler: async (filePath, tags) => {
|
||||
await updateRecipeMetadata(filePath, { tags }, this.getMetadataUpdateOptions());
|
||||
},
|
||||
onSaved: (tags) => {
|
||||
this.currentRecipe.tags = tags;
|
||||
this.commitField('tags');
|
||||
const c = document.getElementById('recipeTagsContainer');
|
||||
if (c) this.updateTagsDisplay(c, tags);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
syncPromptField(field, value, placeholder) {
|
||||
@@ -976,139 +900,6 @@ class RecipeModal {
|
||||
}
|
||||
}
|
||||
|
||||
// Tags editing methods
|
||||
showTagsEditor() {
|
||||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
||||
if (tagsContainer) {
|
||||
tagsContainer.querySelector('.editable-content').classList.add('hide');
|
||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||||
editor.classList.add('active');
|
||||
const input = editor.querySelector('input');
|
||||
input.oninput = () => this.markFieldDirty('tags');
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
saveTagsEdit() {
|
||||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
||||
if (tagsContainer) {
|
||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||||
const input = editor.querySelector('input');
|
||||
const tagsText = input.value.trim();
|
||||
|
||||
// Parse tags
|
||||
let newTags = [];
|
||||
if (tagsText) {
|
||||
newTags = tagsText.split(',')
|
||||
.map(tag => tag.trim())
|
||||
.filter(tag => tag.length > 0);
|
||||
}
|
||||
|
||||
// Check if tags changed
|
||||
const oldTags = this.currentRecipe.tags || [];
|
||||
const tagsChanged =
|
||||
newTags.length !== oldTags.length ||
|
||||
newTags.some((tag, index) => tag !== oldTags[index]);
|
||||
|
||||
if (tagsChanged) {
|
||||
// Update the recipe on the server
|
||||
updateRecipeMetadata(this.filePath, { tags: newTags }, this.getMetadataUpdateOptions())
|
||||
.then(data => {
|
||||
// Show success toast
|
||||
showToast('toast.recipes.tagsUpdated', {}, 'success');
|
||||
|
||||
// Update the current recipe object
|
||||
this.currentRecipe.tags = newTags;
|
||||
this.commitField('tags');
|
||||
|
||||
// Update tags in the UI
|
||||
this.updateTagsDisplay(tagsContainer, newTags);
|
||||
})
|
||||
.catch(error => {
|
||||
// Error is handled in the API function
|
||||
this.clearFieldDirty('tags');
|
||||
});
|
||||
} else {
|
||||
this.clearFieldDirty('tags');
|
||||
}
|
||||
|
||||
// Hide editor
|
||||
editor.classList.remove('active');
|
||||
tagsContainer.querySelector('.editable-content').classList.remove('hide');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to update tags display
|
||||
updateTagsDisplay(tagsContainer, tags) {
|
||||
const tagsDisplay = tagsContainer.querySelector('.tags-display');
|
||||
tagsDisplay.innerHTML = '';
|
||||
|
||||
if (tags.length > 0) {
|
||||
// Limit displayed tags to 5, show a "+X more" button if needed
|
||||
const maxVisibleTags = 5;
|
||||
const visibleTags = tags.slice(0, maxVisibleTags);
|
||||
const remainingTags = tags.length > maxVisibleTags ? tags.slice(maxVisibleTags) : [];
|
||||
|
||||
// Add visible tags
|
||||
visibleTags.forEach(tag => {
|
||||
const tagElement = document.createElement('div');
|
||||
tagElement.className = 'recipe-tag-compact';
|
||||
tagElement.textContent = tag;
|
||||
tagsDisplay.appendChild(tagElement);
|
||||
});
|
||||
|
||||
// Add "more" button if needed
|
||||
if (remainingTags.length > 0) {
|
||||
const moreButton = document.createElement('div');
|
||||
moreButton.className = 'recipe-tag-more';
|
||||
moreButton.textContent = `+${remainingTags.length} more`;
|
||||
tagsDisplay.appendChild(moreButton);
|
||||
|
||||
// Update tooltip content
|
||||
const tooltipContent = document.getElementById('recipeTagsTooltipContent');
|
||||
if (tooltipContent) {
|
||||
tooltipContent.innerHTML = '';
|
||||
tags.forEach(tag => {
|
||||
const tooltipTag = document.createElement('div');
|
||||
tooltipTag.className = 'tooltip-tag';
|
||||
tooltipTag.textContent = tag;
|
||||
tooltipContent.appendChild(tooltipTag);
|
||||
});
|
||||
}
|
||||
|
||||
// Re-add tooltip functionality
|
||||
moreButton.addEventListener('mouseenter', () => {
|
||||
document.getElementById('recipeTagsTooltip').classList.add('visible');
|
||||
});
|
||||
|
||||
moreButton.addEventListener('mouseleave', () => {
|
||||
setTimeout(() => {
|
||||
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
|
||||
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
|
||||
}
|
||||
}
|
||||
|
||||
cancelTagsEdit() {
|
||||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
||||
if (tagsContainer) {
|
||||
// Reset input value
|
||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||||
const input = editor.querySelector('input');
|
||||
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
|
||||
this.clearFieldDirty('tags');
|
||||
|
||||
// Hide editor
|
||||
editor.classList.remove('active');
|
||||
tagsContainer.querySelector('.editable-content').classList.remove('hide');
|
||||
}
|
||||
}
|
||||
|
||||
setupPromptEditors() {
|
||||
const promptConfigs = [
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
||||
@@ -457,21 +457,69 @@ export class SidebarManager {
|
||||
|
||||
try {
|
||||
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
||||
let movedFiles = []; // Array of { original_file_path, new_file_path }
|
||||
|
||||
if (useBulkMove) {
|
||||
await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
|
||||
const results = await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
|
||||
movedFiles = (results || [])
|
||||
.filter(r => r.success)
|
||||
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
||||
} else {
|
||||
await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
|
||||
const result = await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
|
||||
if (result) {
|
||||
movedFiles.push({
|
||||
original_file_path: result.original_file_path || this.draggedFilePaths[0],
|
||||
new_file_path: result.new_file_path
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log('[SidebarManager] apiClient.move successful');
|
||||
|
||||
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
|
||||
console.log('[SidebarManager] calling resetAndReload');
|
||||
await this.pageControls.resetAndReload(true);
|
||||
} else {
|
||||
console.log('[SidebarManager] calling refresh');
|
||||
await this.refresh();
|
||||
// Update VirtualScroller in-place instead of full reload
|
||||
if (movedFiles.length > 0 && state.virtualScroller) {
|
||||
const pageState = getCurrentPageState();
|
||||
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
|
||||
const isRecursive = pageState.searchOptions?.recursive ?? true;
|
||||
const isFolderFiltered = pageState.activeFolder !== null;
|
||||
|
||||
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
|
||||
// Determine if items in the target folder are visible in the current view
|
||||
let itemsRemainVisible = true;
|
||||
if (isFolderFiltered) {
|
||||
if (isRecursive) {
|
||||
itemsRemainVisible = normalizedActive === '' ||
|
||||
normalizedTarget === normalizedActive ||
|
||||
normalizedTarget.startsWith(normalizedActive + '/');
|
||||
} else {
|
||||
itemsRemainVisible = normalizedTarget === normalizedActive;
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsRemainVisible) {
|
||||
// Items stay visible — update each item's file_path to reflect new location
|
||||
for (const moved of movedFiles) {
|
||||
if (moved.original_file_path && moved.new_file_path) {
|
||||
state.virtualScroller.updateSingleItem(moved.original_file_path, {
|
||||
file_path: moved.new_file_path,
|
||||
folder: normalizedTarget
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Items no longer visible in current folder — remove from VirtualScroller
|
||||
const pathsToRemove = movedFiles
|
||||
.map(m => m.original_file_path)
|
||||
.filter(Boolean);
|
||||
if (pathsToRemove.length > 0) {
|
||||
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh sidebar folder tree only (no model data reload)
|
||||
await this.refresh();
|
||||
|
||||
if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
||||
bulkManager.toggleBulkMode();
|
||||
}
|
||||
@@ -530,21 +578,69 @@ export class SidebarManager {
|
||||
|
||||
try {
|
||||
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
||||
let movedFiles = []; // Array of { original_file_path, new_file_path }
|
||||
|
||||
if (useBulkMove) {
|
||||
await this.apiClient.moveBulkModels(draggedFilePaths, destination);
|
||||
const results = await this.apiClient.moveBulkModels(draggedFilePaths, destination);
|
||||
movedFiles = (results || [])
|
||||
.filter(r => r.success)
|
||||
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
||||
} else {
|
||||
await this.apiClient.moveSingleModel(draggedFilePaths[0], destination);
|
||||
const result = await this.apiClient.moveSingleModel(draggedFilePaths[0], destination);
|
||||
if (result) {
|
||||
movedFiles.push({
|
||||
original_file_path: result.original_file_path || draggedFilePaths[0],
|
||||
new_file_path: result.new_file_path
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log('[SidebarManager] apiClient.move successful');
|
||||
|
||||
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
|
||||
console.log('[SidebarManager] calling resetAndReload');
|
||||
await this.pageControls.resetAndReload(true);
|
||||
} else {
|
||||
console.log('[SidebarManager] calling refresh');
|
||||
await this.refresh();
|
||||
// Update VirtualScroller in-place instead of full reload
|
||||
if (movedFiles.length > 0 && state.virtualScroller) {
|
||||
const pageState = getCurrentPageState();
|
||||
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
|
||||
const isRecursive = pageState.searchOptions?.recursive ?? true;
|
||||
const isFolderFiltered = pageState.activeFolder !== null;
|
||||
|
||||
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
|
||||
// Determine if items in the target folder are visible in the current view
|
||||
let itemsRemainVisible = true;
|
||||
if (isFolderFiltered) {
|
||||
if (isRecursive) {
|
||||
itemsRemainVisible = normalizedActive === '' ||
|
||||
normalizedTarget === normalizedActive ||
|
||||
normalizedTarget.startsWith(normalizedActive + '/');
|
||||
} else {
|
||||
itemsRemainVisible = normalizedTarget === normalizedActive;
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsRemainVisible) {
|
||||
// Items stay visible — update each item's file_path to reflect new location
|
||||
for (const moved of movedFiles) {
|
||||
if (moved.original_file_path && moved.new_file_path) {
|
||||
state.virtualScroller.updateSingleItem(moved.original_file_path, {
|
||||
file_path: moved.new_file_path,
|
||||
folder: normalizedTarget
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Items no longer visible in current folder — remove from VirtualScroller
|
||||
const pathsToRemove = movedFiles
|
||||
.map(m => m.original_file_path)
|
||||
.filter(Boolean);
|
||||
if (pathsToRemove.length > 0) {
|
||||
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh sidebar folder tree only (no model data reload)
|
||||
await this.refresh();
|
||||
|
||||
if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
||||
bulkManager.toggleBulkMode();
|
||||
}
|
||||
@@ -1346,7 +1442,7 @@ export class SidebarManager {
|
||||
this.pageControls.pageState.activeFolder = normalizedPath;
|
||||
setStorageItem(`${this.pageType}_activeFolder`, normalizedPath);
|
||||
|
||||
// Reload models with new filter
|
||||
// Reload models with new filter (loadMoreWithVirtualScroll will scroll to top)
|
||||
await this.pageControls.resetAndReload();
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,22 @@ export class CheckpointsControls extends PageControls {
|
||||
* Clear checkpoint custom filter and reload
|
||||
*/
|
||||
async clearCustomFilter() {
|
||||
// Check for View Local Versions filter first
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
if (vlmModelId) {
|
||||
removeSessionItem('vlm_model_id');
|
||||
removeSessionItem('vlm_model_name');
|
||||
removeSessionItem('vlm_base_model');
|
||||
removeSessionItem('vlm_page_type');
|
||||
// Hide the indicator
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
if (indicator) {
|
||||
indicator.classList.add('hidden');
|
||||
}
|
||||
await resetAndReload();
|
||||
return;
|
||||
}
|
||||
|
||||
removeSessionItem('recipe_to_checkpoint_filterHash');
|
||||
removeSessionItem('recipe_to_checkpoint_filterHashes');
|
||||
removeSessionItem('filterCheckpointRecipeName');
|
||||
@@ -106,14 +122,4 @@ export class CheckpointsControls extends PageControls {
|
||||
|
||||
await resetAndReload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to truncate text with ellipsis
|
||||
* @param {string} text
|
||||
* @param {number} maxLength
|
||||
* @returns {string}
|
||||
*/
|
||||
_truncateText(text, maxLength) {
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,21 @@ export class LorasControls extends PageControls {
|
||||
* Clear the custom filter and reload the page
|
||||
*/
|
||||
async clearCustomFilter() {
|
||||
// Check for View Local Versions filter first (handles VLM and reloads)
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
if (vlmModelId) {
|
||||
removeSessionItem('vlm_model_id');
|
||||
removeSessionItem('vlm_model_name');
|
||||
removeSessionItem('vlm_base_model');
|
||||
removeSessionItem('vlm_page_type');
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
if (indicator) {
|
||||
indicator.classList.add('hidden');
|
||||
}
|
||||
await resetAndReload();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Clearing custom filter...");
|
||||
// Remove filter parameters from session storage
|
||||
removeSessionItem('recipe_to_lora_filterLoraHash');
|
||||
@@ -134,16 +149,6 @@ export class LorasControls extends PageControls {
|
||||
await resetAndReload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to truncate text with ellipsis
|
||||
* @param {string} text - Text to truncate
|
||||
* @param {number} maxLength - Maximum length before truncating
|
||||
* @returns {string} - Truncated text
|
||||
*/
|
||||
_truncateText(text, maxLength) {
|
||||
return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the alphabet bar component
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
|
||||
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
|
||||
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
|
||||
import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
|
||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||
import { sidebarManager } from '../SidebarManager.js';
|
||||
@@ -129,6 +129,9 @@ export class PageControls {
|
||||
clearFilterBtn.addEventListener('click', () => this.clearCustomFilter());
|
||||
}
|
||||
|
||||
// Check for View Local Versions filter
|
||||
this.checkVlmFilter();
|
||||
|
||||
// Page-specific event listeners
|
||||
this.initPageSpecificListeners();
|
||||
}
|
||||
@@ -459,15 +462,138 @@ export class PageControls {
|
||||
this.api.toggleBulkMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear custom filter
|
||||
*/
|
||||
/**
|
||||
* Trigger View Local Versions without page reload
|
||||
* Sets sessionStorage and reloads data via the API.
|
||||
*/
|
||||
triggerVlmView(modelId, modelName, baseModel, pageType) {
|
||||
const targetPageType = pageType || this.pageType;
|
||||
setSessionItem('vlm_model_id', String(modelId));
|
||||
setSessionItem('vlm_model_name', modelName || String(modelId));
|
||||
setSessionItem('vlm_page_type', targetPageType);
|
||||
if (baseModel) {
|
||||
setSessionItem('vlm_base_model', baseModel);
|
||||
} else {
|
||||
removeSessionItem('vlm_base_model');
|
||||
}
|
||||
// Reload data via API (no page reload)
|
||||
this.resetAndReload(true).then(() => {
|
||||
// Show the VLM indicator after data loads
|
||||
this.checkVlmFilter();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when group_by_model is toggled.
|
||||
* Saves current sort when entering grouped mode, restores normal sort
|
||||
* when leaving — prevents "Most versions first" persisting after exit.
|
||||
*/
|
||||
onGroupByModelToggled(isEnabled) {
|
||||
const normalKey = `${this.pageType}_sort_normal`;
|
||||
const groupedKey = `${this.pageType}_sort_grouped`;
|
||||
|
||||
if (isEnabled) {
|
||||
// Entering group mode: save current sort for later restoration
|
||||
setStorageItem(normalKey, this.pageState.sortBy);
|
||||
// Restore previously saved grouped sort, if any
|
||||
const savedGroupedSort = getStorageItem(groupedKey);
|
||||
if (savedGroupedSort) {
|
||||
this.pageState.sortBy = savedGroupedSort;
|
||||
this.saveSortPreference(savedGroupedSort);
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
sortSelect.value = savedGroupedSort;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Leaving group mode: save current grouped sort aside, restore normal
|
||||
const currentSort = this.pageState.sortBy;
|
||||
if (currentSort && currentSort.startsWith('versions_count')) {
|
||||
setStorageItem(groupedKey, currentSort);
|
||||
}
|
||||
const savedNormalSort = getStorageItem(normalKey);
|
||||
if (savedNormalSort) {
|
||||
removeStorageItem(normalKey);
|
||||
this.pageState.sortBy = savedNormalSort;
|
||||
this.saveSortPreference(savedNormalSort);
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
sortSelect.value = savedNormalSort;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for View Local Versions filter in sessionStorage (page-type-scoped)
|
||||
*/
|
||||
checkVlmFilter() {
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
const vlmPageType = getSessionItem('vlm_page_type');
|
||||
|
||||
// Only show VLM indicator when it belongs to the current page type
|
||||
if (vlmModelId && vlmPageType !== this.pageType) {
|
||||
// Stale VLM data from a different page — clean up
|
||||
removeSessionItem('vlm_model_id');
|
||||
removeSessionItem('vlm_model_name');
|
||||
removeSessionItem('vlm_base_model');
|
||||
removeSessionItem('vlm_page_type');
|
||||
return;
|
||||
}
|
||||
|
||||
const vlmModelName = getSessionItem('vlm_model_name');
|
||||
const vlmBaseModel = getSessionItem('vlm_base_model');
|
||||
|
||||
if (vlmModelId && vlmModelName) {
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
const filterText = indicator?.querySelector('.customFilterText');
|
||||
|
||||
if (indicator && filterText) {
|
||||
indicator.classList.remove('hidden');
|
||||
|
||||
const prefix = vlmBaseModel
|
||||
? 'Showing same-base versions from'
|
||||
: 'Showing all versions from';
|
||||
const displayText = `${prefix}: ${vlmModelName}`;
|
||||
|
||||
filterText.textContent = this._truncateText(displayText, 40);
|
||||
filterText.setAttribute('title', displayText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear custom filter
|
||||
*/
|
||||
async clearCustomFilter() {
|
||||
// Check for View Local Versions filter first
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
if (vlmModelId) {
|
||||
removeSessionItem('vlm_model_id');
|
||||
removeSessionItem('vlm_model_name');
|
||||
removeSessionItem('vlm_base_model');
|
||||
removeSessionItem('vlm_page_type');
|
||||
|
||||
// Hide the indicator
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
if (indicator) {
|
||||
indicator.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Reload data via API (no page reload)
|
||||
await this.resetAndReload(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise delegate to subclass for recipe filters
|
||||
if (!this.api) {
|
||||
console.error('API methods not registered');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
await this.api.clearCustomFilter();
|
||||
} catch (error) {
|
||||
@@ -475,6 +601,14 @@ export class PageControls {
|
||||
showToast('toast.controls.clearFilterFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
*/
|
||||
_truncateText(text, maxLength) {
|
||||
if (!text) return '';
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the favorites filter button state
|
||||
|
||||
@@ -100,6 +100,12 @@ function handleModelCardEvent_internal(event, modelType) {
|
||||
return true; // Stop propagation
|
||||
}
|
||||
|
||||
if (event.target.closest('.version-count-link')) {
|
||||
event.stopPropagation();
|
||||
handleViewLocalVersionsFromCard(card, modelType);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no specific element was clicked, handle the card click (show modal or toggle selection)
|
||||
handleCardClick(card, modelType);
|
||||
return false; // Continue with other handlers (e.g., bulk selection)
|
||||
@@ -265,6 +271,22 @@ async function handleExampleImagesAccess(card, modelType) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewLocalVersionsFromCard(card, modelType) {
|
||||
const modelId = card.dataset.modelId;
|
||||
const modelName = card.dataset.name;
|
||||
if (!modelId) return;
|
||||
// Respect version_grouping: only filter by base model when the strategy says so
|
||||
const strategy = state.global?.settings?.version_grouping;
|
||||
const shouldFilterByBase = strategy === 'same_base';
|
||||
const baseModel = shouldFilterByBase && card.dataset.base_model !== 'Unknown'
|
||||
? card.dataset.base_model
|
||||
: undefined;
|
||||
// Use the no-reload VLM flow via PageControls
|
||||
if (window.pageControls && typeof window.pageControls.triggerVlmView === 'function') {
|
||||
window.pageControls.triggerVlmView(modelId, modelName, baseModel, modelType);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardClick(card, modelType) {
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
@@ -448,6 +470,10 @@ export function createModelCard(model, modelType) {
|
||||
const hasUpdateAvailable = Boolean(model.update_available);
|
||||
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
||||
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
||||
// Store version_count for group-by-model display
|
||||
if (model.version_count !== undefined) {
|
||||
card.dataset.version_count = model.version_count;
|
||||
}
|
||||
|
||||
// To only show usage_count when sorting by usage.
|
||||
const pageState = getCurrentPageState();
|
||||
@@ -659,16 +685,28 @@ export function createModelCard(model, modelType) {
|
||||
const autoTags = model.auto_tags || [];
|
||||
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
|
||||
const hasVersionName = model.civitai?.name;
|
||||
if (!hlTags.length && !hasVersionName) return '';
|
||||
// When group_by_model is active and model has multiple versions,
|
||||
// show clickable version count instead of version name (and hide badges)
|
||||
const isGroupByModel = state.global.settings.group_by_model;
|
||||
const versionCount = model.version_count;
|
||||
const showVersionCount = isGroupByModel && versionCount > 1;
|
||||
if (!hlTags.length && !hasVersionName && !showVersionCount) return '';
|
||||
const density = state.global.settings.display_density || 'default';
|
||||
const shortLabels = density === 'medium' || density === 'compact';
|
||||
const badges = hlTags.map(t => {
|
||||
// Don't show HIGH/LOW badges when showing version count (confusing in grouped mode)
|
||||
const badges = !showVersionCount ? hlTags.map(t => {
|
||||
const cls = t === 'HIGH' ? 'hl-badge hl-badge--high' : 'hl-badge hl-badge--low';
|
||||
const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t;
|
||||
const titleAttr = shortLabels ? ` title="${t}"` : '';
|
||||
return `<span class="${cls}"${titleAttr}>${label}</span>`;
|
||||
}).join('');
|
||||
const versionHtml = hasVersionName ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : '';
|
||||
}).join('') : '';
|
||||
let versionHtml = '';
|
||||
if (showVersionCount) {
|
||||
const countLabel = translate('modelCard.footer.versionCount', { count: versionCount }, `${versionCount} versions`);
|
||||
versionHtml = `<span class="version-count-link" title="${translate('modelCard.footer.viewAllVersions', {}, 'View all local versions')}">${countLabel}</span>`;
|
||||
} else if (hasVersionName) {
|
||||
versionHtml = `<span class="version-name civitai-version">${model.civitai.name}</span>`;
|
||||
}
|
||||
return `<span class="badge-version-unit">${badges}${versionHtml}</span>`;
|
||||
})()}
|
||||
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
|
||||
|
||||
@@ -752,6 +752,7 @@ export async function showModelModal(model, modelType) {
|
||||
modelId: civitaiModelId,
|
||||
currentVersionId: civitaiVersionId,
|
||||
currentBaseModel: modelWithFullData.base_model,
|
||||
modelName: model.model_name,
|
||||
onUpdateStatusChange: handleUpdateStatusChange,
|
||||
});
|
||||
setupEditableFields(modelWithFullData.file_path, modelType);
|
||||
|
||||
@@ -29,6 +29,14 @@ let priorityTagSuggestionsLoaded = false;
|
||||
let priorityTagSuggestionsPromise = null;
|
||||
let activeTagDragState = null;
|
||||
|
||||
// Configurable options for tag editing (set by setupTagEditMode)
|
||||
let tagEditOptions = {
|
||||
showSuggestions: true,
|
||||
saveHandler: null,
|
||||
onSaved: null,
|
||||
normalizeTag: true,
|
||||
};
|
||||
|
||||
function normalizeModelTypeKey(modelType) {
|
||||
if (!modelType) {
|
||||
return '';
|
||||
@@ -140,13 +148,30 @@ let saveTagsHandler = null;
|
||||
|
||||
/**
|
||||
* Set up tag editing mode
|
||||
* @param {string|null} modelType - Model type for suggestions (e.g. 'loras', 'checkpoints')
|
||||
* @param {Object} [options] - Optional configuration
|
||||
* @param {boolean} [options.showSuggestions=true] - Show priority tag suggestions dropdown
|
||||
* @param {Function} [options.saveHandler] - Custom save function, async (filePath, tags) => {}
|
||||
* @param {Function} [options.onSaved] - Called after successful save, (tags) => {}
|
||||
* @param {boolean} [options.normalizeTag=true] - Lowercase tag on add
|
||||
*/
|
||||
export function setupTagEditMode(modelType = null) {
|
||||
const editBtn = document.querySelector('.edit-tags-btn');
|
||||
export function setupTagEditMode(modelType = null, options = {}) {
|
||||
// Store options for use by saveTags and addNewTag
|
||||
tagEditOptions = {
|
||||
showSuggestions: options.showSuggestions !== false,
|
||||
saveHandler: options.saveHandler || null,
|
||||
onSaved: options.onSaved || null,
|
||||
normalizeTag: options.normalizeTag !== false,
|
||||
};
|
||||
|
||||
const root = options.container || document;
|
||||
const editBtn = root.querySelector('.edit-tags-btn');
|
||||
if (!editBtn) return;
|
||||
|
||||
setActiveModelTypeKey(modelType);
|
||||
ensurePriorityTagSuggestions();
|
||||
if (tagEditOptions.showSuggestions) {
|
||||
setActiveModelTypeKey(modelType);
|
||||
ensurePriorityTagSuggestions();
|
||||
}
|
||||
|
||||
// Store original tags for restoring on cancel
|
||||
let originalTags = [];
|
||||
@@ -158,7 +183,8 @@ export function setupTagEditMode(modelType = null) {
|
||||
|
||||
// Create new handler and store reference
|
||||
const editBtnClickHandler = function() {
|
||||
const tagsSection = document.querySelector('.model-tags-container');
|
||||
const tagsSection = this.closest('.model-tags-container');
|
||||
if (!tagsSection) return;
|
||||
const isEditMode = tagsSection.classList.toggle('edit-mode');
|
||||
const filePath = this.dataset.filePath;
|
||||
|
||||
@@ -193,16 +219,18 @@ export function setupTagEditMode(modelType = null) {
|
||||
tagsSection.appendChild(editContainer);
|
||||
|
||||
// Setup the tag input field behavior
|
||||
setupTagInput();
|
||||
setupTagInput(tagsSection);
|
||||
|
||||
// Create and add preset suggestions dropdown
|
||||
const tagForm = editContainer.querySelector('.metadata-add-form');
|
||||
const suggestionsDropdown = createSuggestionsDropdown(originalTags);
|
||||
tagForm.appendChild(suggestionsDropdown);
|
||||
if (tagEditOptions.showSuggestions) {
|
||||
const tagForm = editContainer.querySelector('.metadata-add-form');
|
||||
const suggestionsDropdown = createSuggestionsDropdown(originalTags);
|
||||
tagForm.appendChild(suggestionsDropdown);
|
||||
}
|
||||
|
||||
// Setup delete buttons for existing tags
|
||||
setupDeleteButtons();
|
||||
setupTagDragAndDrop();
|
||||
setupTagDragAndDrop(tagsSection);
|
||||
|
||||
// Transfer click event from original button to the cloned one
|
||||
const newEditBtn = editContainer.querySelector('.metadata-header-btn');
|
||||
@@ -218,7 +246,7 @@ export function setupTagEditMode(modelType = null) {
|
||||
// Just show the existing edit container
|
||||
tagsEditContainer.style.display = 'block';
|
||||
editBtn.style.display = 'none';
|
||||
setupTagDragAndDrop();
|
||||
setupTagDragAndDrop(tagsSection);
|
||||
}
|
||||
} else {
|
||||
// Exit edit mode
|
||||
@@ -255,7 +283,7 @@ export function setupTagEditMode(modelType = null) {
|
||||
saveTagsHandler = function(e) {
|
||||
if (e.target.classList.contains('save-tags-btn') ||
|
||||
e.target.closest('.save-tags-btn')) {
|
||||
saveTags();
|
||||
saveTags(e.target);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -267,19 +295,28 @@ export function setupTagEditMode(modelType = null) {
|
||||
|
||||
/**
|
||||
* Save tags
|
||||
* @param {Element} [triggerElement] - The element that triggered the save (e.g. save button)
|
||||
*/
|
||||
async function saveTags() {
|
||||
const editBtn = document.querySelector('.edit-tags-btn');
|
||||
if (!editBtn) return;
|
||||
async function saveTags(triggerElement = null) {
|
||||
let editBtn;
|
||||
let scope;
|
||||
if (triggerElement) {
|
||||
scope = triggerElement.closest('.model-tags-container');
|
||||
editBtn = scope ? scope.querySelector('.edit-tags-btn') : document.querySelector('.edit-tags-btn');
|
||||
} else {
|
||||
scope = document.querySelector('.model-tags-container');
|
||||
editBtn = scope ? scope.querySelector('.edit-tags-btn') : null;
|
||||
}
|
||||
if (!editBtn || !scope) return;
|
||||
|
||||
const filePath = editBtn.dataset.filePath;
|
||||
const tagElements = document.querySelectorAll('.metadata-item');
|
||||
const tagElements = scope.querySelectorAll('.metadata-item');
|
||||
let tags = Array.from(tagElements).map(tag => tag.dataset.tag);
|
||||
|
||||
// Flush uncommitted input as a tag so it's not silently lost on save
|
||||
const tagInput = document.querySelector('.metadata-input');
|
||||
const tagInput = scope.querySelector('.metadata-input');
|
||||
if (tagInput) {
|
||||
const pendingTag = tagInput.value.trim().toLowerCase();
|
||||
const pendingTag = tagEditOptions.normalizeTag ? tagInput.value.trim().toLowerCase() : tagInput.value.trim();
|
||||
if (pendingTag && !tags.includes(pendingTag)) {
|
||||
tags.push(pendingTag);
|
||||
}
|
||||
@@ -287,7 +324,7 @@ async function saveTags() {
|
||||
}
|
||||
|
||||
// Get original tags to compare
|
||||
const originalTagElements = document.querySelectorAll('.tooltip-tag');
|
||||
const originalTagElements = scope.querySelectorAll('.tooltip-tag');
|
||||
const originalTags = Array.from(originalTagElements).map(tag => tag.textContent);
|
||||
|
||||
// Check if tags have actually changed
|
||||
@@ -301,59 +338,68 @@ async function saveTags() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Save tags metadata
|
||||
await getModelApiClient().saveModelMetadata(filePath, { tags: tags });
|
||||
// Use custom save handler if provided, otherwise default model API
|
||||
if (tagEditOptions.saveHandler) {
|
||||
await tagEditOptions.saveHandler(filePath, tags);
|
||||
} else {
|
||||
await getModelApiClient().saveModelMetadata(filePath, { tags: tags });
|
||||
}
|
||||
|
||||
// Set flag to skip restoring original tags when exiting edit mode
|
||||
editBtn.dataset.skipRestore = "true";
|
||||
|
||||
// Update the compact tags display
|
||||
const compactTagsContainer = document.querySelector('.model-tags-container');
|
||||
if (compactTagsContainer) {
|
||||
// Generate new compact tags HTML
|
||||
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
|
||||
|
||||
if (compactTagsDisplay) {
|
||||
// Clear current tags
|
||||
compactTagsDisplay.innerHTML = '';
|
||||
// Use custom onSaved if provided (e.g. for recipe dirty state + re-render)
|
||||
if (tagEditOptions.onSaved) {
|
||||
tagEditOptions.onSaved(tags);
|
||||
} else {
|
||||
// Update the compact tags display
|
||||
const compactTagsContainer = scope;
|
||||
if (compactTagsContainer) {
|
||||
// Generate new compact tags HTML
|
||||
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
|
||||
|
||||
// Add visible tags (up to 5)
|
||||
const visibleTags = tags.slice(0, 5);
|
||||
visibleTags.forEach(tag => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'model-tag-compact';
|
||||
span.textContent = tag;
|
||||
compactTagsDisplay.appendChild(span);
|
||||
});
|
||||
if (compactTagsDisplay) {
|
||||
// Clear current tags
|
||||
compactTagsDisplay.innerHTML = '';
|
||||
|
||||
// Add visible tags (up to 5)
|
||||
const visibleTags = tags.slice(0, 5);
|
||||
visibleTags.forEach(tag => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'model-tag-compact';
|
||||
span.textContent = tag;
|
||||
compactTagsDisplay.appendChild(span);
|
||||
});
|
||||
|
||||
// Add more indicator if needed
|
||||
const remainingCount = Math.max(0, tags.length - 5);
|
||||
if (remainingCount > 0) {
|
||||
const more = document.createElement('span');
|
||||
more.className = 'model-tag-more';
|
||||
more.dataset.count = remainingCount;
|
||||
more.textContent = `+${remainingCount}`;
|
||||
compactTagsDisplay.appendChild(more);
|
||||
}
|
||||
}
|
||||
|
||||
// Add more indicator if needed
|
||||
const remainingCount = Math.max(0, tags.length - 5);
|
||||
if (remainingCount > 0) {
|
||||
const more = document.createElement('span');
|
||||
more.className = 'model-tag-more';
|
||||
more.dataset.count = remainingCount;
|
||||
more.textContent = `+${remainingCount}`;
|
||||
compactTagsDisplay.appendChild(more);
|
||||
// Update tooltip content
|
||||
const tooltipContent = compactTagsContainer.querySelector('.tooltip-content');
|
||||
if (tooltipContent) {
|
||||
tooltipContent.innerHTML = '';
|
||||
|
||||
tags.forEach(tag => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'tooltip-tag';
|
||||
span.textContent = tag;
|
||||
tooltipContent.appendChild(span);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update tooltip content
|
||||
const tooltipContent = compactTagsContainer.querySelector('.tooltip-content');
|
||||
if (tooltipContent) {
|
||||
tooltipContent.innerHTML = '';
|
||||
|
||||
tags.forEach(tag => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'tooltip-tag';
|
||||
span.textContent = tag;
|
||||
tooltipContent.appendChild(span);
|
||||
});
|
||||
}
|
||||
// Exit edit mode
|
||||
editBtn.click();
|
||||
}
|
||||
|
||||
// Exit edit mode
|
||||
editBtn.click();
|
||||
|
||||
showToast('modelTags.messages.updated', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error saving tags:', error);
|
||||
@@ -470,16 +516,19 @@ function renderPriorityTagSuggestions(container, existingTags = []) {
|
||||
|
||||
/**
|
||||
* Set up tag input behavior
|
||||
* @param {Element} scopeContainer - The .model-tags-container element
|
||||
*/
|
||||
function setupTagInput() {
|
||||
const tagInput = document.querySelector('.metadata-input');
|
||||
function setupTagInput(scopeContainer) {
|
||||
const tagInput = scopeContainer
|
||||
? scopeContainer.querySelector('.metadata-input')
|
||||
: document.querySelector('.metadata-input');
|
||||
|
||||
if (tagInput) {
|
||||
tagInput.focus();
|
||||
tagInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNewTag(this.value);
|
||||
addNewTag(this.value, this);
|
||||
this.value = ''; // Clear input after adding
|
||||
}
|
||||
});
|
||||
@@ -504,9 +553,12 @@ function setupDeleteButtons() {
|
||||
|
||||
/**
|
||||
* Enable drag-and-drop sorting for tag items
|
||||
* @param {Element} [scopeContainer] - Optional scoped .model-tags-container element
|
||||
*/
|
||||
function setupTagDragAndDrop() {
|
||||
const container = document.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR);
|
||||
function setupTagDragAndDrop(scopeContainer) {
|
||||
const container = scopeContainer
|
||||
? scopeContainer.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR)
|
||||
: document.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
@@ -712,12 +764,14 @@ function finishPointerDrag() {
|
||||
/**
|
||||
* Add a new tag
|
||||
* @param {string} tag - Tag to add
|
||||
* @param {Element} [scopeElement] - Element within the correct .model-tags-container for scoping
|
||||
*/
|
||||
function addNewTag(tag) {
|
||||
tag = tag.trim().toLowerCase();
|
||||
function addNewTag(tag, scopeElement = null) {
|
||||
tag = tagEditOptions.normalizeTag ? tag.trim().toLowerCase() : tag.trim();
|
||||
if (!tag) return;
|
||||
|
||||
const tagsContainer = document.querySelector('.metadata-items');
|
||||
const scope = scopeElement ? scopeElement.closest('.model-tags-container') : document;
|
||||
const tagsContainer = scope.querySelector('.metadata-items');
|
||||
if (!tagsContainer) return;
|
||||
|
||||
// Validation: Check length
|
||||
@@ -762,7 +816,7 @@ function addNewTag(tag) {
|
||||
});
|
||||
|
||||
tagsContainer.appendChild(newTag);
|
||||
setupTagDragAndDrop();
|
||||
setupTagDragAndDrop(scope);
|
||||
|
||||
// Update status of items in the suggestions dropdown
|
||||
updateSuggestionsDropdown();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
|
||||
import { formatFileSize } from './utils.js';
|
||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
|
||||
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
|
||||
@@ -306,7 +307,7 @@ function getToggleTooltipText(mode) {
|
||||
}
|
||||
|
||||
function getDefaultDisplayMode() {
|
||||
const strategy = state?.global?.settings?.update_flag_strategy;
|
||||
const strategy = state?.global?.settings?.version_grouping;
|
||||
return strategy === DISPLAY_FILTER_MODES.SAME_BASE
|
||||
? DISPLAY_FILTER_MODES.SAME_BASE
|
||||
: DISPLAY_FILTER_MODES.ANY;
|
||||
@@ -338,7 +339,7 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const strategy = state?.global?.settings?.update_flag_strategy;
|
||||
const strategy = state?.global?.settings?.version_grouping;
|
||||
const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
|
||||
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
|
||||
|
||||
@@ -744,7 +745,7 @@ function renderToolbar(record, toolbarState = {}) {
|
||||
<button class="versions-toolbar-btn versions-toolbar-btn-primary" data-versions-action="toggle-model-ignore">
|
||||
${escapeHtml(ignoreText)}
|
||||
</button>
|
||||
<button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Coming soon'))}" disabled>
|
||||
<button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Show all local versions of this model on the main page'))}">
|
||||
${escapeHtml(viewLocalText)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -792,6 +793,7 @@ export function initVersionsTab({
|
||||
modelId,
|
||||
currentVersionId,
|
||||
currentBaseModel,
|
||||
modelName,
|
||||
onUpdateStatusChange,
|
||||
}) {
|
||||
const pane = document.querySelector(`#${modalId} #versions-tab`);
|
||||
@@ -1019,6 +1021,39 @@ export function initVersionsTab({
|
||||
render(controller.record);
|
||||
}
|
||||
|
||||
function handleViewLocalVersions() {
|
||||
if (!controller.record || !modelId) {
|
||||
return;
|
||||
}
|
||||
// Determine base model filter based on current display mode
|
||||
const baseModelInfo = getCurrentVersionBaseModel(controller.record, normalizedCurrentVersionId);
|
||||
const isFilteringActive =
|
||||
displayMode === DISPLAY_FILTER_MODES.SAME_BASE &&
|
||||
Boolean(baseModelInfo.normalized);
|
||||
|
||||
// Write filter params to sessionStorage (page-scoped)
|
||||
setSessionItem('vlm_model_id', String(modelId));
|
||||
setSessionItem('vlm_model_name', modelName || String(modelId));
|
||||
setSessionItem('vlm_page_type', modelType);
|
||||
if (isFilteringActive) {
|
||||
// Use raw (non-normalized) base model for exact backend matching
|
||||
setSessionItem('vlm_base_model', baseModelInfo.raw);
|
||||
} else {
|
||||
removeSessionItem('vlm_base_model');
|
||||
}
|
||||
|
||||
// Close the modal and navigate via no-reload VLM flow
|
||||
modalManager.closeModal(modalId);
|
||||
if (window.pageControls && typeof window.pageControls.triggerVlmView === 'function') {
|
||||
window.pageControls.triggerVlmView(
|
||||
modelId,
|
||||
modelName || String(modelId),
|
||||
isFilteringActive ? baseModelInfo.raw : undefined,
|
||||
modelType
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleVersionIgnore(button, versionId) {
|
||||
if (!controller.record) {
|
||||
return;
|
||||
@@ -1348,6 +1383,10 @@ export function initVersionsTab({
|
||||
event.preventDefault();
|
||||
handleToggleVersionDisplayMode();
|
||||
break;
|
||||
case 'view-local':
|
||||
event.preventDefault();
|
||||
handleViewLocalVersions();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -78,10 +78,12 @@ export function renderCompactTags(tags, filePath = '') {
|
||||
|
||||
/**
|
||||
* Set up tag tooltip functionality
|
||||
* @param {Element} [scopeContainer] - Optional container to scope the querySelector
|
||||
*/
|
||||
export function setupTagTooltip() {
|
||||
const tagsContainer = document.querySelector('.model-tags-container');
|
||||
const tooltip = document.querySelector('.model-tags-tooltip');
|
||||
export function setupTagTooltip(scopeContainer = null) {
|
||||
const root = scopeContainer || document;
|
||||
const tagsContainer = root.querySelector('.model-tags-container');
|
||||
const tooltip = root.querySelector('.model-tags-tooltip');
|
||||
|
||||
if (tagsContainer && tooltip) {
|
||||
tagsContainer.addEventListener('mouseenter', () => {
|
||||
|
||||
@@ -327,10 +327,15 @@ export class DoctorManager {
|
||||
case 'open-settings':
|
||||
modalManager.showModal('settingsModal');
|
||||
window.setTimeout(() => {
|
||||
const input = document.getElementById('civitaiApiKey');
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Open the API key editor directly
|
||||
if (typeof settingsManager.editApiKey === 'function') {
|
||||
settingsManager.editApiKey();
|
||||
} else {
|
||||
const input = document.getElementById('civitaiApiKey');
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
break;
|
||||
|
||||
@@ -321,29 +321,94 @@ class MoveManager {
|
||||
}
|
||||
|
||||
try {
|
||||
let movedFiles = []; // Array of { original_file_path, new_file_path }
|
||||
|
||||
if (this.bulkFilePaths) {
|
||||
// Bulk move mode
|
||||
await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
|
||||
|
||||
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
|
||||
movedFiles = (results || [])
|
||||
.filter(r => r.success)
|
||||
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
||||
|
||||
// Deselect moving items
|
||||
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
|
||||
} else {
|
||||
// Single move mode
|
||||
await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
|
||||
|
||||
const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
|
||||
if (result) {
|
||||
movedFiles.push({
|
||||
original_file_path: result.original_file_path || this.currentFilePath,
|
||||
new_file_path: result.new_file_path
|
||||
});
|
||||
}
|
||||
|
||||
// Deselect moving item
|
||||
bulkManager.deselectItem(this.currentFilePath);
|
||||
}
|
||||
|
||||
// Refresh UI by reloading the current page, same as drag-and-drop behavior
|
||||
// This ensures all metadata (like preview URLs) are correctly formatted by the backend
|
||||
if (sidebarManager.pageControls && typeof sidebarManager.pageControls.resetAndReload === 'function') {
|
||||
await sidebarManager.pageControls.resetAndReload(true);
|
||||
} else if (sidebarManager.lastPageControls && typeof sidebarManager.lastPageControls.resetAndReload === 'function') {
|
||||
await sidebarManager.lastPageControls.resetAndReload(true);
|
||||
// Update VirtualScroller in-place instead of full reload
|
||||
if (movedFiles.length > 0 && state.virtualScroller) {
|
||||
// Get current page state for folder filter check
|
||||
const pageState = getCurrentPageState();
|
||||
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
|
||||
const isRecursive = pageState.searchOptions?.recursive ?? true;
|
||||
const isFolderFiltered = pageState.activeFolder !== null;
|
||||
|
||||
// Determine which items are still visible after the move
|
||||
const pathsToRemove = [];
|
||||
const pathsToUpdate = []; // { originalPath, newData }
|
||||
|
||||
for (const moved of movedFiles) {
|
||||
if (!moved.original_file_path) continue;
|
||||
|
||||
if (isFolderFiltered) {
|
||||
// Compute relative folder of the new path
|
||||
const newRelativeFolder = this._getRelativeFolder(moved.new_file_path);
|
||||
const normalizedNewFolder = newRelativeFolder.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
|
||||
// Check if the new location is still within the active folder
|
||||
let stillVisible;
|
||||
if (isRecursive) {
|
||||
stillVisible = normalizedActive === '' ||
|
||||
normalizedNewFolder === normalizedActive ||
|
||||
normalizedNewFolder.startsWith(normalizedActive + '/');
|
||||
} else {
|
||||
stillVisible = normalizedNewFolder === normalizedActive;
|
||||
}
|
||||
|
||||
if (stillVisible) {
|
||||
pathsToUpdate.push({
|
||||
originalPath: moved.original_file_path,
|
||||
newData: {
|
||||
file_path: moved.new_file_path,
|
||||
folder: newRelativeFolder
|
||||
}
|
||||
});
|
||||
} else {
|
||||
pathsToRemove.push(moved.original_file_path);
|
||||
}
|
||||
} else {
|
||||
// No folder filter active — items remain visible, just update path
|
||||
pathsToUpdate.push({
|
||||
originalPath: moved.original_file_path,
|
||||
newData: {
|
||||
file_path: moved.new_file_path,
|
||||
folder: this._getRelativeFolder(moved.new_file_path)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply updates to the VirtualScroller
|
||||
if (pathsToRemove.length > 0) {
|
||||
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
|
||||
}
|
||||
for (const update of pathsToUpdate) {
|
||||
state.virtualScroller.updateSingleItem(update.originalPath, update.newData);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh folder tree in sidebar
|
||||
// Refresh folder tree in sidebar (no model data reload)
|
||||
await sidebarManager.refresh();
|
||||
|
||||
modalManager.closeModal('moveModal');
|
||||
|
||||
@@ -347,9 +347,9 @@ export class SettingsManager {
|
||||
if (this.isOpen) {
|
||||
this.loadSettingsToUI();
|
||||
} else {
|
||||
// Clear sensitive fields on close to prevent browser save-password prompts
|
||||
const apiKeyInput = document.getElementById('civitaiApiKey');
|
||||
if (apiKeyInput) apiKeyInput.value = '';
|
||||
// Reset API key edit mode on close
|
||||
this.cancelEditApiKey(true);
|
||||
// Clear proxy password on close
|
||||
const proxyPasswordInput = document.getElementById('proxyPassword');
|
||||
if (proxyPasswordInput) proxyPasswordInput.value = '';
|
||||
}
|
||||
@@ -825,10 +825,8 @@ export class SettingsManager {
|
||||
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
||||
}
|
||||
|
||||
const civitaiApiKeyInput = document.getElementById('civitaiApiKey');
|
||||
if (civitaiApiKeyInput) {
|
||||
civitaiApiKeyInput.value = state.global.settings.civitai_api_key || '';
|
||||
}
|
||||
// Update API key status display (do NOT pre-fill the input)
|
||||
this.updateApiKeyStatus();
|
||||
|
||||
const civitaiHostSelect = document.getElementById('civitaiHost');
|
||||
if (civitaiHostSelect) {
|
||||
@@ -907,15 +905,21 @@ export class SettingsManager {
|
||||
showVersionOnCardCheckbox.checked = state.global.settings.show_version_on_card !== false;
|
||||
}
|
||||
|
||||
// Set group by model
|
||||
const groupByModelCheckbox = document.getElementById('groupByModel');
|
||||
if (groupByModelCheckbox) {
|
||||
groupByModelCheckbox.checked = !!state.global.settings.group_by_model;
|
||||
}
|
||||
|
||||
// Set model name display setting
|
||||
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
|
||||
if (modelNameDisplaySelect) {
|
||||
modelNameDisplaySelect.value = state.global.settings.model_name_display || 'model_name';
|
||||
}
|
||||
|
||||
const updateFlagStrategySelect = document.getElementById('updateFlagStrategy');
|
||||
if (updateFlagStrategySelect) {
|
||||
updateFlagStrategySelect.value = state.global.settings.update_flag_strategy || 'same_base';
|
||||
const versionGroupingSelect = document.getElementById('versionGrouping');
|
||||
if (versionGroupingSelect) {
|
||||
versionGroupingSelect.value = state.global.settings.version_grouping || 'same_base';
|
||||
}
|
||||
|
||||
// Set hide early access updates setting
|
||||
@@ -2013,7 +2017,11 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content') {
|
||||
if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content' || settingKey === 'group_by_model') {
|
||||
// Save/restore sort preference when toggling group_by_model
|
||||
if (settingKey === 'group_by_model' && window.pageControls?.onGroupByModelToggled) {
|
||||
window.pageControls.onGroupByModelToggled(value);
|
||||
}
|
||||
this.reloadContent();
|
||||
}
|
||||
|
||||
@@ -2062,7 +2070,7 @@ export class SettingsManager {
|
||||
if (
|
||||
settingKey === 'model_name_display'
|
||||
|| settingKey === 'model_card_footer_action'
|
||||
|| settingKey === 'update_flag_strategy'
|
||||
|| settingKey === 'version_grouping'
|
||||
|| settingKey === 'mature_blur_level'
|
||||
) {
|
||||
this.reloadContent();
|
||||
@@ -2898,16 +2906,97 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ── CivitAI API Key management ──────────────────────────────
|
||||
|
||||
updateApiKeyStatus() {
|
||||
const hasKey = !!(state.global.settings.civitai_api_key_set ||
|
||||
state.global.settings.civitai_api_key);
|
||||
const statusEl = document.getElementById('civitaiApiKeyStatus');
|
||||
const statusText = document.getElementById('civitaiApiKeyStatusText');
|
||||
const actionBtn = document.getElementById('civitaiApiKeyActionBtn');
|
||||
if (!statusText || !actionBtn) return;
|
||||
|
||||
if (hasKey) {
|
||||
statusText.classList.remove('api-key-status--unconfigured');
|
||||
statusText.classList.add('api-key-status--configured');
|
||||
statusText.innerHTML = '<i class="fas fa-check-circle text-success"></i> '
|
||||
+ translate('settings.civitaiApiKeyConfigured', {}, 'Configured');
|
||||
actionBtn.textContent = translate('common.actions.change', {}, 'Change');
|
||||
} else {
|
||||
statusText.classList.remove('api-key-status--configured');
|
||||
statusText.classList.add('api-key-status--unconfigured');
|
||||
statusText.innerHTML = '<i class="fas fa-times-circle text-error"></i> '
|
||||
+ translate('settings.civitaiApiKeyNotConfigured', {}, 'Not configured');
|
||||
actionBtn.textContent = translate('settings.civitaiApiKeySet', {}, 'Set up');
|
||||
}
|
||||
}
|
||||
|
||||
editApiKey() {
|
||||
const statusEl = document.getElementById('civitaiApiKeyStatus');
|
||||
if (statusEl) statusEl.classList.add('is-hidden');
|
||||
const editContainer = document.getElementById('civitaiApiKeyEdit');
|
||||
if (editContainer) editContainer.classList.remove('is-hidden');
|
||||
// Focus the input
|
||||
const input = document.getElementById('civitaiApiKey');
|
||||
if (input) {
|
||||
input.value = ''; // Never pre-fill the secret
|
||||
setTimeout(() => input.focus(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
cancelEditApiKey(silent) {
|
||||
const editContainer = document.getElementById('civitaiApiKeyEdit');
|
||||
if (editContainer) editContainer.classList.add('is-hidden');
|
||||
const statusContainer = document.getElementById('civitaiApiKeyStatus');
|
||||
if (statusContainer) statusContainer.classList.remove('is-hidden');
|
||||
// Clear any typed value
|
||||
const input = document.getElementById('civitaiApiKey');
|
||||
if (input) input.value = '';
|
||||
if (!silent) {
|
||||
this.updateApiKeyStatus();
|
||||
}
|
||||
}
|
||||
|
||||
async saveApiKey() {
|
||||
const input = document.getElementById('civitaiApiKey');
|
||||
if (!input) return;
|
||||
|
||||
const value = input.value.trim();
|
||||
|
||||
try {
|
||||
await this.saveSetting('civitai_api_key', value);
|
||||
showToast('toast.settings.settingsUpdated',
|
||||
{ setting: 'CivitAI API Key' }, 'success');
|
||||
} catch (error) {
|
||||
showToast('toast.settings.settingSaveFailed',
|
||||
{ message: error.message }, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the in-memory flag so the UI reflects the change
|
||||
state.global.settings.civitai_api_key_set = !!value;
|
||||
this.cancelEditApiKey(true);
|
||||
this.updateApiKeyStatus();
|
||||
}
|
||||
|
||||
toggleInputVisibility(button) {
|
||||
const input = button.parentElement.querySelector('input');
|
||||
if (!input) return;
|
||||
const icon = button.querySelector('i');
|
||||
|
||||
if (input.type === 'password') {
|
||||
if (input.dataset.mask === 'css') {
|
||||
// CSS-masked input (CivitAI API key) — toggle class, not type
|
||||
input.classList.toggle('api-key-masked');
|
||||
if (icon) {
|
||||
icon.className = input.classList.contains('api-key-masked')
|
||||
? 'fas fa-eye'
|
||||
: 'fas fa-eye-slash';
|
||||
}
|
||||
} else if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.className = 'fas fa-eye-slash';
|
||||
if (icon) icon.className = 'fas fa-eye-slash';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.className = 'fas fa-eye';
|
||||
if (icon) icon.className = 'fas fa-eye';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2967,6 +3056,10 @@ export class SettingsManager {
|
||||
const useNewLicenseIcons = state.global.settings.use_new_license_icons !== false;
|
||||
document.body.classList.toggle('use-new-license-icons', useNewLicenseIcons);
|
||||
|
||||
// Apply group-by-model mode
|
||||
const groupByModel = !!state.global.settings.group_by_model;
|
||||
document.body.classList.toggle('group-by-model', groupByModel);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -149,9 +149,10 @@ class RecipeManager {
|
||||
|
||||
_showCustomFilterIndicator() {
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
const textElement = document.getElementById('customFilterText');
|
||||
if (!indicator) return;
|
||||
const textElement = indicator.querySelector('.customFilterText');
|
||||
|
||||
if (!indicator || !textElement) return;
|
||||
if (!textElement) return;
|
||||
|
||||
// Update text based on filter type
|
||||
let filterText = '';
|
||||
@@ -250,6 +251,11 @@ class RecipeManager {
|
||||
bulkButton.addEventListener('click', () => window.bulkManager?.toggleBulkMode());
|
||||
}
|
||||
|
||||
const duplicatesButton = document.querySelector('[data-action="find-duplicates"]');
|
||||
if (duplicatesButton) {
|
||||
duplicatesButton.addEventListener('click', () => this.findDuplicateRecipes());
|
||||
}
|
||||
|
||||
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
||||
if (favoriteFilterBtn) {
|
||||
favoriteFilterBtn.addEventListener('click', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/co
|
||||
|
||||
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
civitai_api_key: '',
|
||||
civitai_api_key_set: false,
|
||||
civitai_host: 'civitai.com',
|
||||
download_backend: 'python',
|
||||
aria2c_path: '',
|
||||
@@ -43,7 +44,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
include_trigger_words: false,
|
||||
compact_mode: false,
|
||||
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
||||
update_flag_strategy: 'same_base',
|
||||
version_grouping: 'same_base',
|
||||
hide_early_access_updates: false,
|
||||
auto_organize_exclusions: [],
|
||||
metadata_refresh_skip_paths: [],
|
||||
@@ -53,6 +54,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
backup_retention_count: 5,
|
||||
strip_lora_on_copy: false,
|
||||
use_new_license_icons: true,
|
||||
group_by_model: false,
|
||||
});
|
||||
|
||||
export function createDefaultSettings() {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Statistics page functionality
|
||||
import { appCore } from './core.js';
|
||||
import { showToast } from './utils/uiHelpers.js';
|
||||
import { translate } from './utils/i18nHelpers.js';
|
||||
import { i18n } from './i18n/index.js';
|
||||
|
||||
// Chart.js import (assuming it's available globally or via CDN)
|
||||
// If Chart.js isn't available, we'll need to add it to the project
|
||||
@@ -124,43 +126,43 @@ export class StatisticsManager {
|
||||
{
|
||||
icon: 'fas fa-magic',
|
||||
value: this.data.collection.total_models,
|
||||
label: 'Total Models',
|
||||
label: translate('statistics.metrics.totalModels'),
|
||||
format: 'number'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-database',
|
||||
value: this.data.collection.total_size,
|
||||
label: 'Total Storage',
|
||||
label: translate('statistics.metrics.totalStorage'),
|
||||
format: 'size'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-play-circle',
|
||||
value: this.data.collection.total_generations,
|
||||
label: 'Total Generations',
|
||||
label: translate('statistics.metrics.totalGenerations'),
|
||||
format: 'number'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-chart-line',
|
||||
value: this.calculateUsageRate(),
|
||||
label: 'Usage Rate',
|
||||
label: translate('statistics.metrics.usageRate'),
|
||||
format: 'percentage'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-layer-group',
|
||||
value: this.data.collection.lora_count,
|
||||
label: 'LoRAs',
|
||||
label: translate('statistics.metrics.loras'),
|
||||
format: 'number'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-check-circle',
|
||||
value: this.data.collection.checkpoint_count,
|
||||
label: 'Checkpoints',
|
||||
label: translate('statistics.metrics.checkpoints'),
|
||||
format: 'number'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-code',
|
||||
value: this.data.collection.embedding_count,
|
||||
label: 'Embeddings',
|
||||
label: translate('statistics.metrics.embeddings'),
|
||||
format: 'number'
|
||||
}
|
||||
];
|
||||
@@ -189,18 +191,14 @@ export class StatisticsManager {
|
||||
case 'size':
|
||||
return this.formatFileSize(value);
|
||||
case 'percentage':
|
||||
return `${value.toFixed(1)}%`;
|
||||
return new Intl.NumberFormat(i18n.getCurrentLocale(), { style: 'percent', maximumFractionDigits: 1 }).format(value / 100);
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
return i18n.formatFileSize(bytes);
|
||||
}
|
||||
|
||||
calculateUsageRate() {
|
||||
@@ -250,7 +248,7 @@ export class StatisticsManager {
|
||||
if (!ctx || !this.data.collection) return;
|
||||
|
||||
const data = {
|
||||
labels: ['LoRAs', 'Checkpoints', 'Embeddings'],
|
||||
labels: [translate('statistics.metrics.loras'), translate('statistics.metrics.checkpoints'), translate('statistics.metrics.embeddings')],
|
||||
datasets: [{
|
||||
data: [
|
||||
this.data.collection.lora_count,
|
||||
@@ -290,28 +288,28 @@ export class StatisticsManager {
|
||||
const checkpointData = this.data.baseModels.checkpoints;
|
||||
const embeddingData = this.data.baseModels.embeddings;
|
||||
|
||||
const allModels = new Set([
|
||||
const allModels = Array.from(new Set([
|
||||
...Object.keys(loraData),
|
||||
...Object.keys(checkpointData),
|
||||
...Object.keys(embeddingData)
|
||||
]);
|
||||
])).sort();
|
||||
|
||||
const data = {
|
||||
labels: Array.from(allModels),
|
||||
labels: allModels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'LoRAs',
|
||||
data: Array.from(allModels).map(model => loraData[model] || 0),
|
||||
label: translate('statistics.metrics.loras'),
|
||||
data: allModels.map(model => loraData[model] || 0),
|
||||
backgroundColor: 'oklch(68% 0.28 256 / 0.7)'
|
||||
},
|
||||
{
|
||||
label: 'Checkpoints',
|
||||
data: Array.from(allModels).map(model => checkpointData[model] || 0),
|
||||
label: translate('statistics.metrics.checkpoints'),
|
||||
data: allModels.map(model => checkpointData[model] || 0),
|
||||
backgroundColor: 'oklch(68% 0.28 200 / 0.7)'
|
||||
},
|
||||
{
|
||||
label: 'Embeddings',
|
||||
data: Array.from(allModels).map(model => embeddingData[model] || 0),
|
||||
label: translate('statistics.metrics.embeddings'),
|
||||
data: allModels.map(model => embeddingData[model] || 0),
|
||||
backgroundColor: 'oklch(68% 0.28 120 / 0.7)'
|
||||
}
|
||||
]
|
||||
@@ -345,21 +343,21 @@ export class StatisticsManager {
|
||||
labels: timeline.map(item => new Date(item.date).toLocaleDateString()),
|
||||
datasets: [
|
||||
{
|
||||
label: 'LoRA Usage',
|
||||
label: translate('statistics.charts.loraUsage'),
|
||||
data: timeline.map(item => item.lora_usage),
|
||||
borderColor: 'oklch(68% 0.28 256)',
|
||||
backgroundColor: 'oklch(68% 0.28 256 / 0.1)',
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: 'Checkpoint Usage',
|
||||
label: translate('statistics.charts.checkpointUsage'),
|
||||
data: timeline.map(item => item.checkpoint_usage),
|
||||
borderColor: 'oklch(68% 0.28 200)',
|
||||
backgroundColor: 'oklch(68% 0.28 200 / 0.1)',
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: 'Embedding Usage',
|
||||
label: translate('statistics.charts.embeddingUsage'),
|
||||
data: timeline.map(item => item.embedding_usage),
|
||||
borderColor: 'oklch(68% 0.28 120)',
|
||||
backgroundColor: 'oklch(68% 0.28 120 / 0.1)',
|
||||
@@ -383,14 +381,14 @@ export class StatisticsManager {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Date'
|
||||
text: translate('statistics.charts.date')
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Usage Count'
|
||||
text: translate('statistics.charts.usageCount')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,7 +414,7 @@ export class StatisticsManager {
|
||||
const data = {
|
||||
labels: allModels.map(model => model.name),
|
||||
datasets: [{
|
||||
label: 'Usage Count',
|
||||
label: translate('statistics.charts.usageCount'),
|
||||
data: allModels.map(model => model.usage_count),
|
||||
backgroundColor: allModels.map(model => {
|
||||
switch(model.type) {
|
||||
@@ -450,7 +448,7 @@ export class StatisticsManager {
|
||||
if (!ctx || !this.data.collection) return;
|
||||
|
||||
const data = {
|
||||
labels: ['LoRAs', 'Checkpoints', 'Embeddings'],
|
||||
labels: [translate('statistics.metrics.loras'), translate('statistics.metrics.checkpoints'), translate('statistics.metrics.embeddings')],
|
||||
datasets: [{
|
||||
data: [
|
||||
this.data.collection.lora_size,
|
||||
@@ -504,7 +502,7 @@ export class StatisticsManager {
|
||||
|
||||
const data = {
|
||||
datasets: [{
|
||||
label: 'Models',
|
||||
label: translate('statistics.charts.models'),
|
||||
data: allData.map(item => ({
|
||||
x: item.size,
|
||||
y: item.usage_count,
|
||||
@@ -532,14 +530,14 @@ export class StatisticsManager {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'File Size (bytes)'
|
||||
text: translate('statistics.charts.fileSizeBytes')
|
||||
},
|
||||
type: 'logarithmic'
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Usage Count'
|
||||
text: translate('statistics.charts.usageCount')
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -548,7 +546,7 @@ export class StatisticsManager {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const point = context.raw;
|
||||
return `${point.name}: ${this.formatFileSize(point.x)}, ${point.y} uses`;
|
||||
return translate('statistics.tooltips.chartUsage', { name: point.name, size: this.formatFileSize(point.x), count: point.y });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -563,12 +561,12 @@ export class StatisticsManager {
|
||||
|
||||
const distribution = this.data.collection.model_types_distribution;
|
||||
const typeDisplayNames = {
|
||||
lora: 'LoRA',
|
||||
locon: 'LyCORIS',
|
||||
dora: 'DoRA',
|
||||
checkpoint: 'Checkpoint',
|
||||
diffusion_model: 'Diffusion Model',
|
||||
embedding: 'Embeddings'
|
||||
lora: translate('statistics.modelTypes.lora'),
|
||||
locon: translate('statistics.modelTypes.locon'),
|
||||
dora: translate('statistics.modelTypes.dora'),
|
||||
checkpoint: translate('statistics.modelTypes.checkpoint'),
|
||||
diffusion_model: translate('statistics.modelTypes.diffusion_model'),
|
||||
embedding: translate('statistics.modelTypes.embedding')
|
||||
};
|
||||
|
||||
const colorPalette = {
|
||||
@@ -610,7 +608,7 @@ export class StatisticsManager {
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const value = context.parsed;
|
||||
const pct = ((value / total) * 100).toFixed(1);
|
||||
return ` ${context.label}: ${value} (${pct}%)`;
|
||||
return translate('statistics.tooltips.chartPercentage', { label: context.label, value, pct });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -654,7 +652,7 @@ export class StatisticsManager {
|
||||
|
||||
// Show loading indicator on initial load
|
||||
if (state.offset === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
|
||||
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-spinner fa-spin"></i> ' + translate('statistics.placeholders.loading') + '</div>';
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -670,7 +668,7 @@ export class StatisticsManager {
|
||||
}
|
||||
|
||||
if (items.length === 0 && state.offset === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder">No models found</div>';
|
||||
container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.placeholders.noModels') + '</div>';
|
||||
state.hasMore = false;
|
||||
} else if (items.length < state.limit) {
|
||||
state.hasMore = false;
|
||||
@@ -683,7 +681,7 @@ export class StatisticsManager {
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
<div class="model-info">
|
||||
<div class="model-name" title="${model.name}">${model.name}</div>
|
||||
<div class="model-meta">${model.base_model} • ${model.folder || 'Root'}</div>
|
||||
<div class="model-meta">${model.base_model} • ${model.folder || translate('statistics.placeholders.rootFolder')}</div>
|
||||
</div>
|
||||
<div class="model-usage">${model.usage_count}</div>
|
||||
</div>
|
||||
@@ -695,7 +693,7 @@ export class StatisticsManager {
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${type} list:`, error);
|
||||
if (state.offset === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder">Error loading data</div>';
|
||||
container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.placeholders.errorLoading') + '</div>';
|
||||
}
|
||||
} finally {
|
||||
state.isLoading = false;
|
||||
@@ -718,7 +716,7 @@ export class StatisticsManager {
|
||||
].sort((a, b) => b.size - a.size).slice(0, 10);
|
||||
|
||||
if (allModels.length === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder">No storage data available</div>';
|
||||
container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.placeholders.noStorageData') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -726,7 +724,7 @@ export class StatisticsManager {
|
||||
<div class="model-item">
|
||||
<div class="model-info">
|
||||
<div class="model-name" title="${model.name}">${model.name}</div>
|
||||
<div class="model-meta">${model.type} • ${model.base_model}</div>
|
||||
<div class="model-meta">${translate('statistics.modelTypes.' + model.type.toLowerCase())} • ${model.base_model}</div>
|
||||
</div>
|
||||
<div class="model-usage">${this.formatFileSize(model.size)}</div>
|
||||
</div>
|
||||
@@ -744,7 +742,7 @@ export class StatisticsManager {
|
||||
const size = Math.ceil((tagData.count / maxCount) * 5);
|
||||
return `
|
||||
<span class="tag-cloud-item size-${size}"
|
||||
title="${tagData.tag}: ${tagData.count} models">
|
||||
title="${translate('statistics.tooltips.tagCount', { tag: tagData.tag, count: tagData.count })}">
|
||||
${tagData.tag}
|
||||
</span>
|
||||
`;
|
||||
@@ -758,17 +756,30 @@ export class StatisticsManager {
|
||||
const insights = this.data.insights.insights;
|
||||
|
||||
if (insights.length === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder">No insights available</div>';
|
||||
container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.insights.noInsights') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = insights.map(insight => `
|
||||
container.innerHTML = insights.map(insight => {
|
||||
const params = insight.params || {};
|
||||
let title, description, suggestion;
|
||||
if (insight.key) {
|
||||
title = translate('statistics.' + insight.key + '.title', params);
|
||||
description = translate('statistics.' + insight.key + '.description', params);
|
||||
suggestion = translate('statistics.' + insight.key + '.suggestion', params);
|
||||
} else {
|
||||
// Backward compatibility for insights without key/params
|
||||
title = insight.title || '';
|
||||
description = insight.description || '';
|
||||
suggestion = insight.suggestion || '';
|
||||
}
|
||||
return `
|
||||
<div class="insight-card type-${insight.type}">
|
||||
<div class="insight-title">${insight.title}</div>
|
||||
<div class="insight-description">${insight.description}</div>
|
||||
<div class="insight-suggestion">${insight.suggestion}</div>
|
||||
<div class="insight-title">${title}</div>
|
||||
<div class="insight-description">${description}</div>
|
||||
<div class="insight-suggestion">${suggestion}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
`}).join('');
|
||||
|
||||
// Render collection analysis cards
|
||||
this.renderCollectionAnalysis();
|
||||
@@ -782,25 +793,25 @@ export class StatisticsManager {
|
||||
{
|
||||
icon: 'fas fa-percentage',
|
||||
value: this.calculateUsageRate(),
|
||||
label: 'Usage Rate',
|
||||
label: translate('statistics.metrics.usageRate'),
|
||||
format: 'percentage'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-tags',
|
||||
value: this.data.tags?.total_unique_tags || 0,
|
||||
label: 'Unique Tags',
|
||||
label: translate('statistics.metrics.uniqueTags'),
|
||||
format: 'number'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-clock',
|
||||
value: this.data.collection.unused_loras + this.data.collection.unused_checkpoints,
|
||||
label: 'Unused Models',
|
||||
label: translate('statistics.metrics.unusedModels'),
|
||||
format: 'number'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-chart-line',
|
||||
value: this.calculateAverageUsage(),
|
||||
label: 'Avg. Uses/Model',
|
||||
label: translate('statistics.metrics.avgUsesPerModel'),
|
||||
format: 'decimal'
|
||||
}
|
||||
];
|
||||
@@ -829,7 +840,7 @@ export class StatisticsManager {
|
||||
const chartCanvases = document.querySelectorAll('canvas');
|
||||
chartCanvases.forEach(canvas => {
|
||||
const container = canvas.parentElement;
|
||||
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-chart-bar"></i> Chart requires Chart.js library</div>';
|
||||
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-chart-bar"></i> ' + translate('statistics.placeholders.chartLibraryMissing') + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -931,6 +931,38 @@ export class VirtualScroller {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove multiple items by their file paths.
|
||||
* More efficient than calling removeItemByFilePath individually.
|
||||
* @param {string[]} filePaths - Array of file paths to remove
|
||||
* @returns {boolean} - True if any items were removed
|
||||
*/
|
||||
removeMultipleItemsByFilePath(filePaths) {
|
||||
if (!Array.isArray(filePaths) || filePaths.length === 0 || this.disabled || this.items.length === 0) return false;
|
||||
|
||||
// Build a set for fast lookup
|
||||
const pathsToRemove = new Set(filePaths);
|
||||
const originalLength = this.items.length;
|
||||
|
||||
// Filter out removed items; keep those not in the set
|
||||
this.items = this.items.filter(item => !pathsToRemove.has(item.file_path));
|
||||
|
||||
const removedCount = originalLength - this.items.length;
|
||||
if (removedCount === 0) return false;
|
||||
|
||||
this.totalItems = Math.max(0, this.totalItems - removedCount);
|
||||
|
||||
// Update the spacer height
|
||||
this.updateSpacerHeight();
|
||||
|
||||
// Re-render to fill gaps left by removed items
|
||||
this.clearRenderedItems();
|
||||
this.scheduleRender();
|
||||
|
||||
console.log(`Removed ${removedCount} items from virtual scroller data`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Add keyboard navigation methods
|
||||
handlePageUpDown(direction) {
|
||||
// Prevent duplicate animations by checking last trigger time
|
||||
|
||||
@@ -158,6 +158,11 @@
|
||||
<div class="context-menu-item" data-action="manage-excluded-models">
|
||||
<i class="fas fa-eye-slash"></i> <span>{{ t('globalContextMenu.manageExcludedModels.label', default='Manage Excluded Models') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" data-action="toggle-group-by-model">
|
||||
<i class="fas fa-layer-group"></i> <span>{{ t('globalContextMenu.groupByModel.label') }}</span>
|
||||
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="repair-recipes">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<div class="controls">
|
||||
{% if page_id != 'recipes' %}
|
||||
<div id="excludedViewBanner" class="excluded-view-banner hidden">
|
||||
<div class="excluded-view-banner__content">
|
||||
<div class="excluded-view-banner__title">
|
||||
@@ -11,42 +12,58 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="actions">
|
||||
<div class="action-buttons">
|
||||
<div title="{{ t('loras.controls.sort.title') }}" class="control-group">
|
||||
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.title') }}{% else %}{{ t('loras.controls.sort.title') }}{% endif %}" class="control-group">
|
||||
<select id="sortSelect">
|
||||
<optgroup label="{{ t('loras.controls.sort.name') }}">
|
||||
<option value="name:asc">{{ t('loras.controls.sort.nameAsc') }}</option>
|
||||
<option value="name:desc">{{ t('loras.controls.sort.nameDesc') }}</option>
|
||||
</optgroup>
|
||||
<optgroup label="{{ t('loras.controls.sort.date') }}">
|
||||
<optgroup label="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.date') }}{% else %}{{ t('loras.controls.sort.date') }}{% endif %}">
|
||||
<option value="date:desc">{{ t('loras.controls.sort.dateDesc') }}</option>
|
||||
<option value="date:asc">{{ t('loras.controls.sort.dateAsc') }}</option>
|
||||
</optgroup>
|
||||
{% if page_id != 'recipes' %}
|
||||
<optgroup label="{{ t('loras.controls.sort.size') }}">
|
||||
<option value="size:desc">{{ t('loras.controls.sort.sizeDesc') }}</option>
|
||||
<option value="size:asc">{{ t('loras.controls.sort.sizeAsc') }}</option>
|
||||
</optgroup>
|
||||
{% if page_id != 'embeddings' %}
|
||||
{% endif %}
|
||||
{% if page_id != 'embeddings' and page_id != 'recipes' %}
|
||||
<optgroup label="{{ t('loras.controls.sort.usage', default='Usage') }}">
|
||||
<option value="usage:desc">{{ t('loras.controls.sort.usageDesc', default='Times used (high to low)') }}</option>
|
||||
<option value="usage:asc">{{ t('loras.controls.sort.usageAsc', default='Times used (low to high)') }}</option>
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
{% if page_id != 'recipes' %}
|
||||
<optgroup class="sort-option-versions-count" label="{{ t('loras.controls.sort.versionsCount', default='Local Versions') }}">
|
||||
<option value="versions_count:desc">{{ t('loras.controls.sort.versionsCountDesc', default='Most versions first') }}</option>
|
||||
<option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option>
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
{% if page_id == 'recipes' %}
|
||||
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
|
||||
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
|
||||
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div title="{{ t('loras.controls.refresh.title') }}" class="control-group dropdown-group">
|
||||
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
|
||||
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{ t('common.actions.refresh') }}</span></button>
|
||||
<button class="dropdown-toggle" aria-label="Show refresh options">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('loras.controls.refresh.fullTooltip') }}">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full') }}</span>
|
||||
<div class="dropdown-item" data-action="full-rebuild" title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}{% else %}{{ t('loras.controls.refresh.fullTooltip') }}{% endif %}">
|
||||
<i class="fas fa-tools"></i> <span>{% if page_id == 'recipes' %}{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}{% else %}{{ t('loras.controls.refresh.full') }}{% endif %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if page_id != 'recipes' %}
|
||||
<div class="control-group">
|
||||
<button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span>{{ t('loras.controls.fetch.action') }}</span></button>
|
||||
</div>
|
||||
@@ -55,6 +72,15 @@
|
||||
<i class="fas fa-cloud-download-alt"></i> <span>{{ t('loras.controls.download.action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if page_id == 'recipes' %}
|
||||
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
|
||||
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{ t('recipes.controls.import.action') }}</button>
|
||||
</div>
|
||||
<div title="{{ t('recipes.batchImport.title') }}" class="control-group">
|
||||
<button onclick="batchImportManager.showModal()"><i class="fas fa-layer-group"></i> {{ t('recipes.batchImport.action') }}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="control-group">
|
||||
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
|
||||
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span> <div class="shortcut-key">B</div></span>
|
||||
@@ -71,6 +97,7 @@
|
||||
<i class="fas fa-star"></i> <span>{{ t('loras.controls.favorites.action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
{% if page_id != 'recipes' %}
|
||||
<div class="control-group dropdown-group update-filter-group">
|
||||
<button id="updateFilterBtn" data-action="toggle-updates" class="dropdown-main update-filter" title="{{ t('loras.controls.updates.title') }}">
|
||||
<i class="fas fa-exclamation-circle"></i> <span>{{ t('loras.controls.updates.action') }}</span>
|
||||
@@ -84,6 +111,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="customFilterIndicator" class="control-group hidden">
|
||||
<div class="filter-active">
|
||||
<i class="fas fa-filter"></i> <span class="customFilterText" title=""></span>
|
||||
|
||||
@@ -95,21 +95,36 @@
|
||||
<div class="setting-item api-key-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="civitaiApiKey">{{ t('settings.civitaiApiKey') }}</label>
|
||||
<label>{{ t('settings.civitaiApiKey') }}</label>
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.civitaiApiKeyHelp') }}"></i>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<div class="api-key-input">
|
||||
<input type="password"
|
||||
id="civitaiApiKey"
|
||||
placeholder="{{ t('settings.civitaiApiKeyPlaceholder') }}"
|
||||
autocomplete="new-password"
|
||||
onblur="settingsManager.saveInputSetting('civitaiApiKey', 'civitai_api_key')"
|
||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||
<button class="toggle-visibility">
|
||||
<i class="fas fa-eye"></i>
|
||||
<!-- Status display (shown when not editing) -->
|
||||
<div id="civitaiApiKeyStatus" class="api-key-status">
|
||||
<span id="civitaiApiKeyStatusText" class="api-key-status-text api-key-status--unconfigured">
|
||||
<i class="fas fa-times-circle text-error"></i>
|
||||
{{ t('settings.civitaiApiKeyNotConfigured') }}
|
||||
</span>
|
||||
<button type="button" class="secondary-btn" id="civitaiApiKeyActionBtn" onclick="settingsManager.editApiKey()">
|
||||
{{ t('settings.civitaiApiKeySet') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Inline edit view (shown when editing) -->
|
||||
<div id="civitaiApiKeyEdit" class="api-key-edit is-hidden">
|
||||
<div class="api-key-input">
|
||||
<input type="text"
|
||||
id="civitaiApiKey"
|
||||
class="api-key-masked"
|
||||
placeholder="{{ t('settings.civitaiApiKeyPlaceholder') }}"
|
||||
autocomplete="off"
|
||||
data-mask="css" />
|
||||
<button type="button" class="toggle-visibility">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="primary-btn" onclick="settingsManager.saveApiKey()">{{ t('common.actions.save') }}</button>
|
||||
<button type="button" class="secondary-btn" onclick="settingsManager.cancelEditApiKey()">{{ t('common.actions.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -521,6 +536,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group by model toggle -->
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="groupByModel">
|
||||
{{ t('settings.layoutSettings.groupByModel') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.groupByModelHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="groupByModel"
|
||||
onchange="settingsManager.saveToggleSetting('groupByModel', 'group_by_model')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
@@ -1066,23 +1100,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Flags -->
|
||||
<!-- Version Scope -->
|
||||
<div class="settings-subsection">
|
||||
<div class="settings-subsection-header">
|
||||
<h4>{{ t('settings.sections.updateFlags') }}</h4>
|
||||
<h4>{{ t('settings.sections.versionScope') }}</h4>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="updateFlagStrategy">
|
||||
{{ t('settings.updateFlagStrategy.label') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.updateFlagStrategy.help') }}"></i>
|
||||
<label for="versionGrouping">
|
||||
{{ t('settings.versionGrouping.label') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.versionGrouping.help') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="updateFlagStrategy" onchange="settingsManager.saveSelectSetting('updateFlagStrategy', 'update_flag_strategy')">
|
||||
<option value="same_base">{{ t('settings.updateFlagStrategy.options.sameBase') }}</option>
|
||||
<option value="any">{{ t('settings.updateFlagStrategy.options.any') }}</option>
|
||||
<select id="versionGrouping" onchange="settingsManager.saveSelectSetting('versionGrouping', 'version_grouping')">
|
||||
<option value="same_base">{{ t('settings.versionGrouping.options.sameBase') }}</option>
|
||||
<option value="any">{{ t('settings.versionGrouping.options.any') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,13 +6,8 @@
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<!-- Header Actions: populated dynamically in RecipeModal.js -->
|
||||
<div class="recipe-header-actions" id="recipeHeaderActions"></div>
|
||||
<!-- Recipe Tags Container -->
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Recipe Tags Container (rendered by renderCompactTags) -->
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
@@ -62,91 +62,9 @@
|
||||
|
||||
{% block content %}
|
||||
<!-- Recipe controls -->
|
||||
<div class="controls">
|
||||
<div class="actions">
|
||||
<div class="action-buttons">
|
||||
<div class="control-group">
|
||||
<select id="sortSelect" title="{{ t('recipes.controls.sort.title') }}">
|
||||
<optgroup label="{{ t('recipes.controls.sort.name') }}">
|
||||
<option value="name:asc">{{ t('recipes.controls.sort.nameAsc') }}</option>
|
||||
<option value="name:desc">{{ t('recipes.controls.sort.nameDesc') }}</option>
|
||||
</optgroup>
|
||||
<optgroup label="{{ t('recipes.controls.sort.date') }}">
|
||||
<option value="date:desc">{{ t('recipes.controls.sort.dateDesc') }}</option>
|
||||
<option value="date:asc">{{ t('recipes.controls.sort.dateAsc') }}</option>
|
||||
</optgroup>
|
||||
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
|
||||
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
|
||||
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group dropdown-group">
|
||||
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{
|
||||
t('common.actions.refresh') }}</span></button>
|
||||
<button class="dropdown-toggle" aria-label="Show refresh options">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
|
||||
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{
|
||||
t('recipes.controls.import.action') }}</button>
|
||||
</div>
|
||||
<div title="{{ t('recipes.batchImport.title') }}" class="control-group">
|
||||
<button onclick="batchImportManager.showModal()"><i class="fas fa-layer-group"></i> {{
|
||||
t('recipes.batchImport.action') }}</button>
|
||||
</div>
|
||||
<div class="control-group" title="{{ t('loras.controls.bulk.title') }}">
|
||||
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
|
||||
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span>
|
||||
<div class="shortcut-key">B</div>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Add duplicate detection button -->
|
||||
<div title="{{ t('loras.controls.duplicates.title') }}" class="control-group">
|
||||
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{
|
||||
t('loras.controls.duplicates.action') }}</button>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button id="favoriteFilterBtn" data-action="toggle-favorites" class="favorite-filter"
|
||||
title="{{ t('recipes.controls.favorites.title') }}">
|
||||
<i class="fas fa-star"></i> <span>{{ t('recipes.controls.favorites.action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Custom filter indicator button (hidden by default) -->
|
||||
<div id="customFilterIndicator" class="control-group hidden">
|
||||
<div class="filter-active">
|
||||
<i class="fas fa-filter"></i> <span id="customFilterText">{{ t('recipes.controls.filteredByLora')
|
||||
}}</span>
|
||||
<i class="fas fa-times-circle clear-filter"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<div class="control-group doctor-control-group">
|
||||
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
|
||||
<i class="fas fa-stethoscope"></i>
|
||||
<span>{{ t('doctor.title', default='Doctor') }}</span>
|
||||
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
|
||||
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
|
||||
<!-- Breadcrumbs will be populated by JavaScript -->
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'components/controls.html' %}
|
||||
<!-- Breadcrumb Navigation -->
|
||||
{% include 'components/breadcrumb.html' %}
|
||||
|
||||
<!-- Duplicates banner (hidden by default) -->
|
||||
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
||||
|
||||
@@ -246,12 +246,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
@@ -375,12 +370,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
@@ -474,12 +464,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
@@ -588,12 +573,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
@@ -682,12 +662,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
@@ -790,12 +765,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
@@ -873,12 +843,10 @@ describe('Interaction-level regression coverage', () => {
|
||||
});
|
||||
|
||||
recipeModal.markFieldDirty('title');
|
||||
recipeModal.markFieldDirty('tags');
|
||||
recipeModal.markFieldDirty('prompt');
|
||||
recipeModal.markFieldDirty('negative_prompt');
|
||||
|
||||
document.querySelector('#recipeTitleEditor .title-input').value = 'Local Title';
|
||||
document.querySelector('#recipeTagsEditor .tags-input').value = 'local-tag-1, local-tag-2';
|
||||
document.getElementById('recipePromptInput').value = 'local prompt';
|
||||
document.getElementById('recipeNegativePromptInput').value = 'local negative';
|
||||
|
||||
@@ -899,7 +867,6 @@ describe('Interaction-level regression coverage', () => {
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(document.querySelector('#recipeTitleEditor .title-input').value).toBe('Local Title');
|
||||
expect(document.querySelector('#recipeTagsEditor .tags-input').value).toBe('local-tag-1, local-tag-2');
|
||||
expect(document.getElementById('recipePromptInput').value).toBe('local prompt');
|
||||
expect(document.getElementById('recipeNegativePromptInput').value).toBe('local negative');
|
||||
expect(recipeModal.currentRecipe.title).toBe('Hydrated Title');
|
||||
@@ -918,12 +885,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
@@ -1057,12 +1019,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
@@ -1170,8 +1127,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div id="recipeModal" class="modal">
|
||||
<div id="recipeModalTitle"></div>
|
||||
<div id="recipePreviewContainer"></div>
|
||||
<div id="recipeTagsCompact"></div>
|
||||
<div id="recipeTagsTooltip"><div id="recipeTagsTooltipContent"></div></div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
<div id="recipePrompt"></div>
|
||||
<textarea id="recipePromptInput"></textarea>
|
||||
<div id="recipeNegativePrompt"></div>
|
||||
@@ -1224,8 +1180,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div id="recipeModal" class="modal">
|
||||
<div id="recipeModalTitle"></div>
|
||||
<div id="recipePreviewContainer"></div>
|
||||
<div id="recipeTagsCompact"></div>
|
||||
<div id="recipeTagsTooltip"><div id="recipeTagsTooltipContent"></div></div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
<div id="recipePrompt"></div>
|
||||
<textarea id="recipePromptInput"></textarea>
|
||||
<div id="recipeNegativePrompt"></div>
|
||||
@@ -1300,12 +1255,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
@@ -1418,12 +1368,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
@@ -1541,12 +1486,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
@@ -1654,12 +1594,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
@@ -1776,12 +1711,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
@@ -1878,12 +1808,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
@@ -2007,12 +1932,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
|
||||
@@ -33,7 +33,7 @@ const stateMock = {
|
||||
global: {
|
||||
settings: {
|
||||
autoplay_on_hover: false,
|
||||
update_flag_strategy: 'any',
|
||||
version_grouping: 'any',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -67,7 +67,7 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
</div>
|
||||
`;
|
||||
stateMock.global.settings.autoplay_on_hover = false;
|
||||
stateMock.global.settings.update_flag_strategy = 'any';
|
||||
stateMock.global.settings.version_grouping = 'any';
|
||||
({ getModelApiClient } = await import(API_FACTORY_MODULE));
|
||||
fetchModelUpdateVersions = vi.fn();
|
||||
getModelApiClient.mockReturnValue({
|
||||
@@ -157,7 +157,7 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
});
|
||||
|
||||
it('shows a stable label with a short state indicator', async () => {
|
||||
stateMock.global.settings.update_flag_strategy = 'any';
|
||||
stateMock.global.settings.version_grouping = 'any';
|
||||
fetchModelUpdateVersions.mockResolvedValue({
|
||||
success: true,
|
||||
record: {
|
||||
@@ -192,7 +192,7 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
});
|
||||
|
||||
it('filters versions to the current base model when strategy is same_base', async () => {
|
||||
stateMock.global.settings.update_flag_strategy = 'same_base';
|
||||
stateMock.global.settings.version_grouping = 'same_base';
|
||||
fetchModelUpdateVersions.mockResolvedValue({
|
||||
success: true,
|
||||
record: {
|
||||
@@ -235,7 +235,7 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
});
|
||||
|
||||
it('toggle button can switch to display all versions', async () => {
|
||||
stateMock.global.settings.update_flag_strategy = 'same_base';
|
||||
stateMock.global.settings.version_grouping = 'same_base';
|
||||
fetchModelUpdateVersions.mockResolvedValue({
|
||||
success: true,
|
||||
record: {
|
||||
@@ -286,7 +286,7 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
});
|
||||
|
||||
it('shows a newer version badge when viewing same-base results', async () => {
|
||||
stateMock.global.settings.update_flag_strategy = 'same_base';
|
||||
stateMock.global.settings.version_grouping = 'same_base';
|
||||
fetchModelUpdateVersions.mockResolvedValue({
|
||||
success: true,
|
||||
record: {
|
||||
|
||||
@@ -143,6 +143,19 @@ describe('RecipeManager', () => {
|
||||
|
||||
renderRecipesPage();
|
||||
|
||||
// Inject controls DOM that would normally come from components/controls.html
|
||||
// (raw template rendering doesn't process Jinja2 {% include %} tags)
|
||||
const customFilterIndicator = document.createElement('div');
|
||||
customFilterIndicator.id = 'customFilterIndicator';
|
||||
customFilterIndicator.className = 'control-group hidden';
|
||||
customFilterIndicator.innerHTML = `
|
||||
<div class="filter-active">
|
||||
<i class="fas fa-filter"></i> <span class="customFilterText" title=""></span>
|
||||
<i class="fas fa-times-circle clear-filter"></i>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(customFilterIndicator);
|
||||
|
||||
({ RecipeManager } = await import('../../../static/js/recipes.js'));
|
||||
});
|
||||
|
||||
@@ -288,7 +301,7 @@ describe('RecipeManager', () => {
|
||||
});
|
||||
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
const filterText = indicator.querySelector('#customFilterText');
|
||||
const filterText = indicator.querySelector('.customFilterText');
|
||||
|
||||
expect(filterText.innerHTML).toContain('Recipes using checkpoint:');
|
||||
expect(filterText.innerHTML).toContain('Flux Base');
|
||||
|
||||
@@ -80,6 +80,8 @@ FALSE_POSITIVES = {
|
||||
"array",
|
||||
"object",
|
||||
"non.existent.key",
|
||||
"statistics.modelTypes.",
|
||||
"statistics.",
|
||||
}
|
||||
|
||||
SPECIAL_UI_HELPER_KEYS = {
|
||||
|
||||
@@ -733,6 +733,65 @@ def test_lora_manager_cache_updates_when_loras_removed(metadata_registry):
|
||||
assert "lora_node" not in metadata[LORAS]
|
||||
|
||||
|
||||
def test_lora_text_loader_extracts_loras_from_syntax(metadata_registry):
|
||||
"""LoraTextLoaderLM extractor parses <lora:name:strength> tags from lora_syntax string."""
|
||||
metadata_registry.start_collection("prompt1")
|
||||
|
||||
metadata_registry.record_node_execution(
|
||||
"text_loader",
|
||||
"LoraTextLoaderLM",
|
||||
{"lora_syntax": ["<lora:foo:0.8> <lora:bar:1.0>"]},
|
||||
None,
|
||||
)
|
||||
|
||||
metadata = metadata_registry.get_metadata("prompt1")
|
||||
|
||||
assert "text_loader" in metadata[LORAS]
|
||||
lora_list = metadata[LORAS]["text_loader"]["lora_list"]
|
||||
assert len(lora_list) == 2
|
||||
assert lora_list[0] == {"name": "foo", "strength": 0.8}
|
||||
assert lora_list[1] == {"name": "bar", "strength": 1.0}
|
||||
|
||||
|
||||
def test_lora_text_loader_extracts_loras_from_lora_stack(metadata_registry):
|
||||
"""LoraTextLoaderLM extractor also processes the optional lora_stack input."""
|
||||
metadata_registry.start_collection("prompt1")
|
||||
|
||||
metadata_registry.record_node_execution(
|
||||
"stack_loader",
|
||||
"LoraTextLoaderLM",
|
||||
{
|
||||
"lora_syntax": [""],
|
||||
"lora_stack": (("/models/loras/my-lora.safetensors", 0.6, 0.5),),
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
metadata = metadata_registry.get_metadata("prompt1")
|
||||
|
||||
assert "stack_loader" in metadata[LORAS]
|
||||
lora_list = metadata[LORAS]["stack_loader"]["lora_list"]
|
||||
assert len(lora_list) == 1
|
||||
assert lora_list[0] == {"name": "my-lora", "strength": 0.6}
|
||||
|
||||
|
||||
def test_lora_text_loader_handles_empty_syntax(metadata_registry):
|
||||
"""LoraTextLoaderLM extractor produces no metadata when no loras are provided."""
|
||||
metadata_registry.start_collection("prompt1")
|
||||
|
||||
metadata_registry.record_node_execution(
|
||||
"empty_loader",
|
||||
"LoraTextLoaderLM",
|
||||
{"lora_syntax": [""]},
|
||||
None,
|
||||
)
|
||||
|
||||
metadata = metadata_registry.get_metadata("prompt1")
|
||||
|
||||
assert "empty_loader" not in metadata[LORAS]
|
||||
|
||||
|
||||
|
||||
def test_lora_manager_checkpoint_and_unet_loaders_extract_models(metadata_registry):
|
||||
metadata_registry.start_collection("prompt1")
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
'messages': list([
|
||||
]),
|
||||
'settings': dict({
|
||||
'civitai_api_key': 'test-key',
|
||||
'civitai_api_key_set': True,
|
||||
'language': 'en',
|
||||
'theme': 'dark',
|
||||
}),
|
||||
|
||||
@@ -134,8 +134,10 @@ async def test_get_settings_excludes_no_sync_keys():
|
||||
|
||||
assert payload["success"] is True
|
||||
# Regular settings should be synced
|
||||
assert payload["settings"]["civitai_api_key"] == "abc"
|
||||
assert payload["settings"]["regular_setting"] == "value"
|
||||
# civitai_api_key is in _NO_SYNC_KEYS; only the boolean flag is returned
|
||||
assert payload["settings"].get("civitai_api_key") is None
|
||||
assert payload["settings"]["civitai_api_key_set"] is True
|
||||
# _NO_SYNC_KEYS should not be synced
|
||||
assert "hash_chunk_size_mb" not in payload["settings"]
|
||||
assert "folder_paths" not in payload["settings"]
|
||||
|
||||
@@ -302,15 +302,15 @@ async def test_get_insights(stats_routes):
|
||||
insights = payload["data"]["insights"]
|
||||
assert len(insights) == 3
|
||||
|
||||
titles = {entry["title"] for entry in insights}
|
||||
assert "High Number of Unused LoRAs" in titles
|
||||
assert "Unused Checkpoints Detected" in titles
|
||||
assert "High Number of Unused Embeddings" in titles
|
||||
keys = {entry["key"] for entry in insights}
|
||||
assert "insights.unusedLoras.high" in keys
|
||||
assert "insights.unusedCheckpoints.detected" in keys
|
||||
assert "insights.unusedEmbeddings.high" in keys
|
||||
|
||||
descriptions = {entry["description"] for entry in insights}
|
||||
assert any("2/3" in desc for desc in descriptions)
|
||||
assert any("1/2" in desc for desc in descriptions)
|
||||
assert any("1/1" in desc for desc in descriptions)
|
||||
params_list = [entry["params"] for entry in insights]
|
||||
assert any(p["total"] == "3" for p in params_list)
|
||||
assert any(p["total"] == "2" for p in params_list)
|
||||
assert any(p["total"] == "1" for p in params_list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -482,7 +482,7 @@ async def test_get_paginated_data_annotates_update_flags_with_bulk_dedup():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_flag_strategy_same_base_prefers_matching_base():
|
||||
async def test_version_grouping_same_base_prefers_matching_base():
|
||||
items = [
|
||||
{
|
||||
"model_name": "Pony Version",
|
||||
@@ -551,7 +551,7 @@ async def test_update_flag_strategy_same_base_prefers_matching_base():
|
||||
should_ignore_model=False,
|
||||
)
|
||||
update_service = StubUpdateServiceWithRecords({1: record})
|
||||
settings = StubSettings({"update_flag_strategy": "same_base"})
|
||||
settings = StubSettings({"version_grouping": "same_base"})
|
||||
|
||||
service = DummyService(
|
||||
model_type="stub",
|
||||
@@ -579,7 +579,7 @@ async def test_update_flag_strategy_same_base_prefers_matching_base():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_flag_strategy_same_base_honors_latest_local_version():
|
||||
async def test_version_grouping_same_base_honors_latest_local_version():
|
||||
items = [
|
||||
{
|
||||
"model_name": "Pony v0.1",
|
||||
@@ -648,7 +648,7 @@ async def test_update_flag_strategy_same_base_honors_latest_local_version():
|
||||
should_ignore_model=False,
|
||||
)
|
||||
update_service = StubUpdateServiceWithRecords({1: record})
|
||||
settings = StubSettings({"update_flag_strategy": "same_base"})
|
||||
settings = StubSettings({"version_grouping": "same_base"})
|
||||
|
||||
service = DummyService(
|
||||
model_type="stub",
|
||||
@@ -746,6 +746,134 @@ async def test_get_paginated_data_update_available_only_without_update_service()
|
||||
assert response["total_pages"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_paginated_data_group_by_model_dedup():
|
||||
"""group_by_model deduplicates items sharing the same civitai modelId,
|
||||
keeping only the item with the highest version (civitai.id)."""
|
||||
items = [
|
||||
# Two versions of the same model (modelId=1)
|
||||
{"model_name": "SameModel", "folder": "root", "civitai": {"modelId": 1, "id": 100}},
|
||||
{"model_name": "SameModel", "folder": "root", "civitai": {"modelId": 1, "id": 200}},
|
||||
# Another model with two versions
|
||||
{"model_name": "AnotherModel", "folder": "root", "civitai": {"modelId": 2, "id": 50}},
|
||||
{"model_name": "AnotherModel", "folder": "root", "civitai": {"modelId": 2, "id": 99}},
|
||||
# A standalone item with no civitai metadata (no modelId)
|
||||
{"model_name": "Standalone", "folder": "root"},
|
||||
]
|
||||
repository = StubRepository(items)
|
||||
filter_set = PassThroughFilterSet()
|
||||
search_strategy = NoSearchStrategy()
|
||||
settings = StubSettings({})
|
||||
|
||||
service = DummyService(
|
||||
model_type="stub",
|
||||
scanner=object(),
|
||||
metadata_class=BaseModelMetadata,
|
||||
cache_repository=repository,
|
||||
filter_set=filter_set,
|
||||
search_strategy=search_strategy,
|
||||
settings_provider=settings,
|
||||
)
|
||||
|
||||
# With group_by_model=True — modelId=1 keeps id=200, modelId=2 keeps id=99
|
||||
response = await service.get_paginated_data(
|
||||
page=1,
|
||||
page_size=10,
|
||||
sort_by="name:asc",
|
||||
group_by_model=True,
|
||||
)
|
||||
|
||||
names = {item["model_name"] for item in response["items"]}
|
||||
assert names == {"SameModel", "AnotherModel", "Standalone"}
|
||||
assert response["total"] == 3
|
||||
# Verify the kept items have the highest version id
|
||||
for item in response["items"]:
|
||||
if item.get("civitai", {}).get("modelId") == 1:
|
||||
assert item["civitai"]["id"] == 200
|
||||
# version_count should reflect total versions for this model
|
||||
assert item.get("version_count") == 2, f"Expected version_count=2, got {item.get('version_count')}"
|
||||
elif item.get("civitai", {}).get("modelId") == 2:
|
||||
assert item["civitai"]["id"] == 99
|
||||
assert item.get("version_count") == 2, f"Expected version_count=2, got {item.get('version_count')}"
|
||||
else:
|
||||
# Standalone item should NOT have version_count
|
||||
assert "version_count" not in item, f"Standalone should not have version_count"
|
||||
|
||||
# With group_by_model=False (default) — all 5 items pass through
|
||||
response_all = await service.get_paginated_data(
|
||||
page=1,
|
||||
page_size=10,
|
||||
sort_by="name:asc",
|
||||
)
|
||||
|
||||
assert response_all["total"] == 5
|
||||
|
||||
|
||||
async def test_get_paginated_data_filters_by_civitai_model_id():
|
||||
"""civitai_model_id filter returns only items matching the given modelId,
|
||||
and bypasses group_by_model dedup so all versions appear."""
|
||||
items = [
|
||||
# Two versions of modelId=1
|
||||
{"model_name": "Model1_v1", "folder": "root", "civitai": {"modelId": 1, "id": 100}},
|
||||
{"model_name": "Model1_v2", "folder": "root", "civitai": {"modelId": 1, "id": 200}},
|
||||
# One version of modelId=2
|
||||
{"model_name": "Model2", "folder": "root", "civitai": {"modelId": 2, "id": 50}},
|
||||
# Standalone (no civitai data)
|
||||
{"model_name": "Standalone", "folder": "root"},
|
||||
]
|
||||
repository = StubRepository(items)
|
||||
filter_set = PassThroughFilterSet()
|
||||
search_strategy = NoSearchStrategy()
|
||||
settings = StubSettings({})
|
||||
|
||||
service = DummyService(
|
||||
model_type="stub",
|
||||
scanner=object(),
|
||||
metadata_class=BaseModelMetadata,
|
||||
cache_repository=repository,
|
||||
filter_set=filter_set,
|
||||
search_strategy=search_strategy,
|
||||
settings_provider=settings,
|
||||
)
|
||||
|
||||
# Filter by modelId=1 — both versions should appear
|
||||
response = await service.get_paginated_data(
|
||||
page=1,
|
||||
page_size=10,
|
||||
sort_by="name:asc",
|
||||
civitai_model_id=1,
|
||||
)
|
||||
|
||||
names = {item["model_name"] for item in response["items"]}
|
||||
assert names == {"Model1_v1", "Model1_v2"}
|
||||
assert response["total"] == 2
|
||||
|
||||
# Filter by modelId=2 — single version
|
||||
response2 = await service.get_paginated_data(
|
||||
page=1,
|
||||
page_size=10,
|
||||
sort_by="name:asc",
|
||||
civitai_model_id=2,
|
||||
)
|
||||
|
||||
assert response2["total"] == 1
|
||||
assert response2["items"][0]["model_name"] == "Model2"
|
||||
|
||||
# civitai_model_id + group_by_model=True — still shows all versions (no dedup)
|
||||
response_dedup = await service.get_paginated_data(
|
||||
page=1,
|
||||
page_size=10,
|
||||
sort_by="name:asc",
|
||||
civitai_model_id=1,
|
||||
group_by_model=True,
|
||||
)
|
||||
|
||||
assert response_dedup["total"] == 2
|
||||
# Verify both versions are present (dedup was skipped)
|
||||
version_ids = {item["civitai"]["id"] for item in response_dedup["items"]}
|
||||
assert version_ids == {100, 200}
|
||||
|
||||
|
||||
def test_model_filter_set_handles_include_and_exclude_tag_filters():
|
||||
settings = StubSettings({})
|
||||
filter_set = ModelFilterSet(settings)
|
||||
|
||||
@@ -9,6 +9,7 @@ import pytest
|
||||
from py.services.settings_manager import get_settings_manager
|
||||
from py.utils.example_images_paths import (
|
||||
ensure_library_root_exists,
|
||||
find_non_compliant_items_in_example_images_root,
|
||||
get_model_folder,
|
||||
get_model_relative_path,
|
||||
is_valid_example_images_root,
|
||||
@@ -140,3 +141,68 @@ def test_is_valid_example_images_root_accepts_legacy_library_structure(tmp_path,
|
||||
(hash_folder / 'image.png').write_text('data', encoding='utf-8')
|
||||
|
||||
assert is_valid_example_images_root(str(tmp_path)) is True
|
||||
|
||||
|
||||
def test_find_non_compliant_items_returns_empty_for_valid_root(tmp_path, settings_manager):
|
||||
"""An empty folder or one with only hash dirs should return []."""
|
||||
settings_manager.settings['example_images_path'] = str(tmp_path)
|
||||
|
||||
# Empty folder
|
||||
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
|
||||
|
||||
# Only hash folders
|
||||
hash_folder = tmp_path / ('f' * 64)
|
||||
hash_folder.mkdir()
|
||||
(hash_folder / 'image.png').write_text('data', encoding='utf-8')
|
||||
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
|
||||
|
||||
|
||||
def test_find_non_compliant_items_returns_offending_names(tmp_path, settings_manager):
|
||||
"""A folder with non-hash items should return their names."""
|
||||
settings_manager.settings['example_images_path'] = str(tmp_path)
|
||||
|
||||
# Create a valid hash folder so the root is otherwise acceptable
|
||||
hash_folder = tmp_path / ('a' * 64)
|
||||
hash_folder.mkdir()
|
||||
|
||||
# Add an offending file
|
||||
(tmp_path / 'readme.txt').write_text('hello', encoding='utf-8')
|
||||
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == ['readme.txt']
|
||||
|
||||
# Add an offending directory with content (empty dirs are accepted as
|
||||
# potential legacy library folders by _library_folder_has_only_hash_dirs)
|
||||
offending_dir = tmp_path / 'not_a_hash'
|
||||
offending_dir.mkdir()
|
||||
(offending_dir / 'some_file.txt').write_text('data', encoding='utf-8')
|
||||
items = find_non_compliant_items_in_example_images_root(str(tmp_path))
|
||||
assert 'readme.txt' in items
|
||||
assert 'not_a_hash' in items
|
||||
|
||||
|
||||
def test_find_non_compliant_items_ignores_hidden_files(tmp_path, settings_manager):
|
||||
"""Hidden/system files should not appear in offending list."""
|
||||
settings_manager.settings['example_images_path'] = str(tmp_path)
|
||||
|
||||
# .DS_Store is an allowed file
|
||||
(tmp_path / '.DS_Store').write_text('', encoding='utf-8')
|
||||
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
|
||||
|
||||
# Thumbs.db too
|
||||
(tmp_path / 'Thumbs.db').write_text('', encoding='utf-8')
|
||||
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
|
||||
|
||||
|
||||
def test_find_non_compliant_items_accepts_download_progress_json(tmp_path, settings_manager):
|
||||
""".download_progress.json should be recognised as a valid metadata file."""
|
||||
settings_manager.settings['example_images_path'] = str(tmp_path)
|
||||
|
||||
(tmp_path / '.download_progress.json').write_text('{}', encoding='utf-8')
|
||||
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
|
||||
|
||||
|
||||
def test_find_non_compliant_items_reports_directory_error(tmp_path):
|
||||
"""When the directory cannot be listed, return an explanatory message."""
|
||||
non_existent = tmp_path / 'does-not-exist'
|
||||
result = find_non_compliant_items_in_example_images_root(str(non_existent))
|
||||
assert len(result) == 1
|
||||
assert 'cannot list directory' in result[0]
|
||||
|
||||
Reference in New Issue
Block a user