mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-19 08:52:05 -03:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf32d8b6fd | ||
|
|
8299881024 | ||
|
|
da02268196 | ||
|
|
8c4b9a1e70 | ||
|
|
0906c484e9 | ||
|
|
4199c30fec | ||
|
|
4a8084cdbc | ||
|
|
6263e6848c | ||
|
|
58c266ad07 | ||
|
|
2939813e1a | ||
|
|
a9e5ee7e79 | ||
|
|
a17b0e9901 | ||
|
|
8f23d966bf | ||
|
|
7a76fc72d0 | ||
|
|
518a4dd5ee | ||
|
|
2b6d4e5d8b |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,12 +12,14 @@ coverage/
|
|||||||
.coverage
|
.coverage
|
||||||
model_cache/
|
model_cache/
|
||||||
|
|
||||||
# agent
|
# agent / dev tooling
|
||||||
.opencode/
|
.opencode/
|
||||||
.claude/
|
.claude/
|
||||||
.sisyphus/
|
.sisyphus/
|
||||||
.codex
|
.codex
|
||||||
.omo
|
.omo
|
||||||
|
reasonix.toml
|
||||||
|
.codegraph/
|
||||||
|
|
||||||
# Vue widgets development cache (but keep build output)
|
# Vue widgets development cache (but keep build output)
|
||||||
vue-widgets/node_modules/
|
vue-widgets/node_modules/
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -185,12 +185,25 @@ The Save Image Node supports dynamic filename generation using pattern codes. Yo
|
|||||||
|
|
||||||
#### Available Pattern Codes
|
#### Available Pattern Codes
|
||||||
|
|
||||||
|
##### Cross-Node Placeholders (ComfyUI Standard)
|
||||||
|
|
||||||
|
- `%NodeTitle.WidgetName%` - Reference any widget value from any node in your workflow, for example:
|
||||||
|
- `%KSampler.seed%` - The seed from a KSampler node
|
||||||
|
- `%Empty Latent Image.width%` - The width from an Empty Latent Image node
|
||||||
|
- `%KSampler.steps%` - The steps value from a KSampler node
|
||||||
|
- Nodes are matched by their "Node name for S&R" property, then by their title
|
||||||
|
|
||||||
|
##### Generation Metadata Placeholders (LoRA Manager)
|
||||||
|
|
||||||
- `%seed%` - Inserts the generation seed number
|
- `%seed%` - Inserts the generation seed number
|
||||||
- `%width%` - Inserts the image width
|
- `%width%` - Inserts the image width
|
||||||
- `%height%` - Inserts the image height
|
- `%height%` - Inserts the image height
|
||||||
- `%pprompt:N%` - Inserts the positive prompt (limited to N characters)
|
- `%pprompt:N%` - Inserts the positive prompt (limited to N characters)
|
||||||
- `%nprompt:N%` - Inserts the negative prompt (limited to N characters)
|
- `%nprompt:N%` - Inserts the negative prompt (limited to N characters)
|
||||||
- `%model:N%` - Inserts the model/checkpoint name (limited to N characters)
|
- `%model:N%` - Inserts the model/checkpoint name (limited to N characters)
|
||||||
|
|
||||||
|
##### Date/Time Placeholders
|
||||||
|
|
||||||
- `%date%` - Inserts current date/time as "yyyyMMddhhmmss"
|
- `%date%` - Inserts current date/time as "yyyyMMddhhmmss"
|
||||||
- `%date:FORMAT%` - Inserts date using custom format with:
|
- `%date:FORMAT%` - Inserts date using custom format with:
|
||||||
- `yyyy` - 4-digit year
|
- `yyyy` - 4-digit year
|
||||||
@@ -209,8 +222,25 @@ The Save Image Node supports dynamic filename generation using pattern codes. Yo
|
|||||||
- `%date:yyyy-MM-dd%` → `2025-04-28`
|
- `%date:yyyy-MM-dd%` → `2025-04-28`
|
||||||
- `%pprompt:20%_%seed%` → `beautiful landscape_1234567890`
|
- `%pprompt:20%_%seed%` → `beautiful landscape_1234567890`
|
||||||
- `%model%_%date:yyMMdd%_%seed%` → `dreamshaper_v8_250428_1234567890`
|
- `%model%_%date:yyMMdd%_%seed%` → `dreamshaper_v8_250428_1234567890`
|
||||||
|
- `%KSampler.seed%` → `1234567890` (resolved from the KSampler node's widget)
|
||||||
|
- `%Empty Latent Image.width%x%Empty Latent Image.height%` → `512x768`
|
||||||
|
- `%KSampler.seed%_%KSampler.steps%` → `1234567890_25`
|
||||||
|
|
||||||
You can combine multiple patterns to create detailed, organized filenames for your generated images.
|
You can combine multiple patterns to create detailed, organized filenames for your generated images. Cross-node and metadata placeholders can be mixed freely — for example: `%KSampler.seed%_%model%_%date:yyyyMMdd%`.
|
||||||
|
|
||||||
|
##### Organizing Images into Subdirectories
|
||||||
|
|
||||||
|
Including a path separator (`/` on all platforms) in the filename prefix creates subdirectories automatically, which is especially powerful when combined with placeholders:
|
||||||
|
|
||||||
|
| Pattern | Result |
|
||||||
|
|---|---|
|
||||||
|
| `%date:yyyy-MM-dd%/%seed%` | Saves to `2025-04-28/1234567890.png` |
|
||||||
|
| `%model%/%date:yyMMdd%_%seed%` | Saves to `dreamshaper_v8/250428_1234567890.png` |
|
||||||
|
| `%KSampler.seed%/%model%` | Saves to `1234567890/dreamshaper_v8.png` |
|
||||||
|
| `%date:yyyy/MM/dd%/%seed%` | Saves to `2025/04/28/1234567890.png` (nested year/month/day) |
|
||||||
|
| `%model%/training/%seed%` | Saves to `dreamshaper_v8/training/1234567890.png` |
|
||||||
|
|
||||||
|
> **Note**: The subdirectory is created relative to your ComfyUI output directory (configurable via `--output-directory`). Characters invalid for folder names are automatically replaced with underscores.
|
||||||
|
|
||||||
### Standalone Mode
|
### Standalone Mode
|
||||||
|
|
||||||
|
|||||||
@@ -6,20 +6,21 @@
|
|||||||
"Scott R"
|
"Scott R"
|
||||||
],
|
],
|
||||||
"allSupporters": [
|
"allSupporters": [
|
||||||
|
"megakirbs",
|
||||||
"Brennok",
|
"Brennok",
|
||||||
"Insomnia Art Designs",
|
"Insomnia Art Designs",
|
||||||
"2018cfh",
|
"2018cfh",
|
||||||
"megakirbs",
|
|
||||||
"Arlecchino Shion",
|
"Arlecchino Shion",
|
||||||
"Rob Williams",
|
"Rob Williams",
|
||||||
"W+K+White",
|
"W+K+White",
|
||||||
|
"$MetaSamsara",
|
||||||
"wackop",
|
"wackop",
|
||||||
"Phil",
|
"Phil",
|
||||||
"Carl G.",
|
"Carl G.",
|
||||||
"Charles Blakemore",
|
"Charles Blakemore",
|
||||||
"stone9k",
|
"stone9k",
|
||||||
"itismyelement",
|
"itismyelement",
|
||||||
"$MetaSamsara",
|
"Mozzel",
|
||||||
"Gingko Biloba",
|
"Gingko Biloba",
|
||||||
"Kiba",
|
"Kiba",
|
||||||
"onesecondinosaur",
|
"onesecondinosaur",
|
||||||
@@ -31,17 +32,25 @@
|
|||||||
"ClockDaemon",
|
"ClockDaemon",
|
||||||
"Francisco Tatis",
|
"Francisco Tatis",
|
||||||
"Tobi_Swagg",
|
"Tobi_Swagg",
|
||||||
|
"SG",
|
||||||
|
"jmack",
|
||||||
"Andrew Wilson",
|
"Andrew Wilson",
|
||||||
"Greybush",
|
"Greybush",
|
||||||
"Ricky Carter",
|
"Ricky Carter",
|
||||||
"JongWon Han",
|
"JongWon Han",
|
||||||
"VantAI",
|
"VantAI",
|
||||||
|
"レプサイ",
|
||||||
|
"Michael Wong",
|
||||||
"runte3221",
|
"runte3221",
|
||||||
"Illrigger",
|
"Illrigger",
|
||||||
"Tom Corrigan",
|
"Tom Corrigan",
|
||||||
|
"JackieWang",
|
||||||
"FreelancerZ",
|
"FreelancerZ",
|
||||||
|
"fnkylove",
|
||||||
"Echo",
|
"Echo",
|
||||||
|
"Lilleman",
|
||||||
"Robert Stacey",
|
"Robert Stacey",
|
||||||
|
"PM",
|
||||||
"Edgar Tejeda",
|
"Edgar Tejeda",
|
||||||
"Fraser Cross",
|
"Fraser Cross",
|
||||||
"Liam MacDougal",
|
"Liam MacDougal",
|
||||||
@@ -51,7 +60,7 @@
|
|||||||
"Marc Whiffen",
|
"Marc Whiffen",
|
||||||
"Skalabananen",
|
"Skalabananen",
|
||||||
"Birdy",
|
"Birdy",
|
||||||
"Mozzel",
|
"quarz",
|
||||||
"Reno Lam",
|
"Reno Lam",
|
||||||
"JSST",
|
"JSST",
|
||||||
"sig",
|
"sig",
|
||||||
@@ -64,17 +73,18 @@
|
|||||||
"KD",
|
"KD",
|
||||||
"Omnidex",
|
"Omnidex",
|
||||||
"Nazono_hito",
|
"Nazono_hito",
|
||||||
|
"Melville Parrish",
|
||||||
"daniel dove",
|
"daniel dove",
|
||||||
|
"Lustre",
|
||||||
"Tyler Trebuchon",
|
"Tyler Trebuchon",
|
||||||
"Release Cabrakan",
|
"Release Cabrakan",
|
||||||
"JW Sin",
|
"JW Sin",
|
||||||
"Alex",
|
"Alex",
|
||||||
"SG",
|
|
||||||
"carozzz",
|
"carozzz",
|
||||||
|
"Marlon Daniels",
|
||||||
"James Dooley",
|
"James Dooley",
|
||||||
"zenbound",
|
"zenbound",
|
||||||
"Buzzard",
|
"Buzzard",
|
||||||
"jmack",
|
|
||||||
"Adam Shaw",
|
"Adam Shaw",
|
||||||
"Mark Corneglio",
|
"Mark Corneglio",
|
||||||
"SarcasticHashtag",
|
"SarcasticHashtag",
|
||||||
@@ -84,44 +94,44 @@
|
|||||||
"Wolffen",
|
"Wolffen",
|
||||||
"James Todd",
|
"James Todd",
|
||||||
"Wicked Choices by ASLPro3D",
|
"Wicked Choices by ASLPro3D",
|
||||||
|
"FinalyFree",
|
||||||
"Steven Pfeiffer",
|
"Steven Pfeiffer",
|
||||||
"レプサイ",
|
|
||||||
"Timmy",
|
"Timmy",
|
||||||
"Johnny",
|
"Johnny",
|
||||||
"Tak",
|
"Tak",
|
||||||
"Lisster",
|
"Lisster",
|
||||||
"Michael Wong",
|
|
||||||
"Big Red",
|
"Big Red",
|
||||||
"whudunit",
|
"whudunit",
|
||||||
|
"Luc Job",
|
||||||
"dl0901dm",
|
"dl0901dm",
|
||||||
"JackieWang",
|
"corde",
|
||||||
"fnkylove",
|
"Nick Walker",
|
||||||
"Yushio",
|
"Yushio",
|
||||||
"Vik71it",
|
"Vik71it",
|
||||||
"Bishoujoker",
|
"Bishoujoker",
|
||||||
"Lilleman",
|
|
||||||
"PM",
|
|
||||||
"Todd Keck",
|
"Todd Keck",
|
||||||
"Briton Heilbrun",
|
"Briton Heilbrun",
|
||||||
|
"Tori",
|
||||||
"wildnut",
|
"wildnut",
|
||||||
"Aleksander Wujczyk",
|
"Aleksander Wujczyk",
|
||||||
"AM Kuro",
|
"AM Kuro",
|
||||||
"BadassArabianMofo",
|
"BadassArabianMofo",
|
||||||
"Pascal Dahle",
|
"Pascal Dahle",
|
||||||
"quarz",
|
|
||||||
"Greg",
|
"Greg",
|
||||||
|
"Akira_HentAI",
|
||||||
"lmsupporter",
|
"lmsupporter",
|
||||||
"andrew.tappan",
|
"andrew.tappan",
|
||||||
|
"Greenmoustache",
|
||||||
"zounic",
|
"zounic",
|
||||||
"wfpearl",
|
"wfpearl",
|
||||||
|
"Eldithor",
|
||||||
"Jack B Nimble",
|
"Jack B Nimble",
|
||||||
"Melville Parrish",
|
|
||||||
"Lustre",
|
|
||||||
"JaxMax",
|
"JaxMax",
|
||||||
"contrite831",
|
"contrite831",
|
||||||
"bh",
|
"bh",
|
||||||
"Marlon Daniels",
|
"Jwk0205",
|
||||||
"Starkselle",
|
"Starkselle",
|
||||||
|
"Olive",
|
||||||
"Aaron Bleuer",
|
"Aaron Bleuer",
|
||||||
"LacesOut!",
|
"LacesOut!",
|
||||||
"greebles",
|
"greebles",
|
||||||
@@ -130,21 +140,17 @@
|
|||||||
"Gooohokrbe",
|
"Gooohokrbe",
|
||||||
"OldBones",
|
"OldBones",
|
||||||
"Jacob Hoehler",
|
"Jacob Hoehler",
|
||||||
"FinalyFree",
|
|
||||||
"Matt Wenzel",
|
"Matt Wenzel",
|
||||||
"Weasyl",
|
"Weasyl",
|
||||||
"Lex Song",
|
"Lex Song",
|
||||||
"Cory Paza",
|
"Cory Paza",
|
||||||
"Gonzalo Andre Allendes Lopez",
|
"Gonzalo Andre Allendes Lopez",
|
||||||
"Zach Gonser",
|
"Zach Gonser",
|
||||||
|
"Serge Bekenkamp",
|
||||||
"Jimmy Ledbetter",
|
"Jimmy Ledbetter",
|
||||||
"Luc Job",
|
|
||||||
"Philip Hempel",
|
"Philip Hempel",
|
||||||
"corde",
|
|
||||||
"Nick Walker",
|
|
||||||
"dan",
|
"dan",
|
||||||
"aai",
|
"aai",
|
||||||
"Tori",
|
|
||||||
"otaku fra",
|
"otaku fra",
|
||||||
"jean jahren",
|
"jean jahren",
|
||||||
"MiraiKuriyamaSy",
|
"MiraiKuriyamaSy",
|
||||||
@@ -154,7 +160,6 @@
|
|||||||
"Sangheili460",
|
"Sangheili460",
|
||||||
"MagnaInsomnia",
|
"MagnaInsomnia",
|
||||||
"Karl P.",
|
"Karl P.",
|
||||||
"Akira_HentAI",
|
|
||||||
"Gordon Cole",
|
"Gordon Cole",
|
||||||
"Adam Taylor",
|
"Adam Taylor",
|
||||||
"AbstractAss",
|
"AbstractAss",
|
||||||
@@ -166,21 +171,19 @@
|
|||||||
"Qarob",
|
"Qarob",
|
||||||
"AIGooner",
|
"AIGooner",
|
||||||
"Luc",
|
"Luc",
|
||||||
"Greenmoustache",
|
"ProtonPrince",
|
||||||
|
"DiffDuck",
|
||||||
"Jackthemind",
|
"Jackthemind",
|
||||||
"fancypants",
|
"fancypants",
|
||||||
"Eldithor",
|
|
||||||
"Joboshy",
|
"Joboshy",
|
||||||
"Digital",
|
"Digital",
|
||||||
"takyamtom",
|
"takyamtom",
|
||||||
"Bohemian Corporal",
|
"Bohemian Corporal",
|
||||||
"Dan",
|
"Dan",
|
||||||
"Jwk0205",
|
|
||||||
"Bro Xie",
|
"Bro Xie",
|
||||||
"yer fey",
|
"yer fey",
|
||||||
"batblue",
|
"batblue",
|
||||||
"carey6409",
|
"carey6409",
|
||||||
"Olive",
|
|
||||||
"太郎 ゲーム",
|
"太郎 ゲーム",
|
||||||
"Roslynd",
|
"Roslynd",
|
||||||
"jinxedx",
|
"jinxedx",
|
||||||
@@ -196,11 +199,11 @@
|
|||||||
"Frank Nitty",
|
"Frank Nitty",
|
||||||
"Magic Noob",
|
"Magic Noob",
|
||||||
"Christopher Michel",
|
"Christopher Michel",
|
||||||
"Serge Bekenkamp",
|
|
||||||
"DougPeterson",
|
"DougPeterson",
|
||||||
"LeoZero",
|
"LeoZero",
|
||||||
"Antonio Pontes",
|
"Antonio Pontes",
|
||||||
"ApathyJones",
|
"ApathyJones",
|
||||||
|
"Bruce",
|
||||||
"Julian V",
|
"Julian V",
|
||||||
"Steven Owens",
|
"Steven Owens",
|
||||||
"nahinahi9",
|
"nahinahi9",
|
||||||
@@ -210,11 +213,10 @@
|
|||||||
"Mouthlessman",
|
"Mouthlessman",
|
||||||
"Paul Kroll",
|
"Paul Kroll",
|
||||||
"Bas Imagineer",
|
"Bas Imagineer",
|
||||||
|
"John Statham",
|
||||||
"yuxz69",
|
"yuxz69",
|
||||||
"esthe",
|
"esthe",
|
||||||
"decoy",
|
"decoy",
|
||||||
"ProtonPrince",
|
|
||||||
"DiffDuck",
|
|
||||||
"elu3199",
|
"elu3199",
|
||||||
"Hasturkun",
|
"Hasturkun",
|
||||||
"Jon Sandman",
|
"Jon Sandman",
|
||||||
@@ -228,13 +230,16 @@
|
|||||||
"Ranzitho",
|
"Ranzitho",
|
||||||
"Gus",
|
"Gus",
|
||||||
"MJG",
|
"MJG",
|
||||||
|
"David LaVallee",
|
||||||
"linnfrey",
|
"linnfrey",
|
||||||
"IamAyam",
|
"IamAyam",
|
||||||
"skaterb949",
|
"skaterb949",
|
||||||
"Josef Lanzl",
|
"Josef Lanzl",
|
||||||
"Nerezza",
|
"Nerezza",
|
||||||
|
"sanborondon",
|
||||||
"confiscated Zyra",
|
"confiscated Zyra",
|
||||||
"Error_Rule34_Not_found",
|
"Error_Rule34_Not_found",
|
||||||
|
"Taylor Funk",
|
||||||
"aezin",
|
"aezin",
|
||||||
"jcay015",
|
"jcay015",
|
||||||
"Gerald Welly",
|
"Gerald Welly",
|
||||||
@@ -250,7 +255,6 @@
|
|||||||
"Pronredn",
|
"Pronredn",
|
||||||
"a _",
|
"a _",
|
||||||
"Jeff",
|
"Jeff",
|
||||||
"Bruce",
|
|
||||||
"lh qwe",
|
"lh qwe",
|
||||||
"James Coleman",
|
"James Coleman",
|
||||||
"conner",
|
"conner",
|
||||||
@@ -260,15 +264,14 @@
|
|||||||
"Princess Bright Eyes",
|
"Princess Bright Eyes",
|
||||||
"Dušan Ryban",
|
"Dušan Ryban",
|
||||||
"Felipe dos Santos",
|
"Felipe dos Santos",
|
||||||
|
"Sam",
|
||||||
"sjon kreutz",
|
"sjon kreutz",
|
||||||
"John Statham",
|
|
||||||
"Douglas Gaspar",
|
"Douglas Gaspar",
|
||||||
"Metryman55",
|
"Metryman55",
|
||||||
"AlexDuKaNa",
|
"AlexDuKaNa",
|
||||||
"George",
|
"George",
|
||||||
"dw",
|
"dw",
|
||||||
"地獄の禄",
|
"地獄の禄",
|
||||||
"David LaVallee",
|
|
||||||
"ae",
|
"ae",
|
||||||
"Tr4shP4nda",
|
"Tr4shP4nda",
|
||||||
"Gamalonia",
|
"Gamalonia",
|
||||||
@@ -287,14 +290,16 @@
|
|||||||
"kudari",
|
"kudari",
|
||||||
"Naomi Hale Danchi",
|
"Naomi Hale Danchi",
|
||||||
"epicgamer0020690",
|
"epicgamer0020690",
|
||||||
|
"Joshua Porrata",
|
||||||
|
"SuBu",
|
||||||
"Richard",
|
"Richard",
|
||||||
"奚明 刘",
|
"奚明 刘",
|
||||||
"Andrew",
|
"Andrew",
|
||||||
"Brian M",
|
"Brian M",
|
||||||
"Robert Wegemund",
|
"Robert Wegemund",
|
||||||
"sanborondon",
|
"Littlehuggy",
|
||||||
"준희 김",
|
"준희 김",
|
||||||
"Taylor Funk",
|
"Brian Buie",
|
||||||
"Thought2Form",
|
"Thought2Form",
|
||||||
"Kevin Picco",
|
"Kevin Picco",
|
||||||
"Sadlip",
|
"Sadlip",
|
||||||
@@ -305,9 +310,13 @@
|
|||||||
"Joshua Gray",
|
"Joshua Gray",
|
||||||
"Mattssn",
|
"Mattssn",
|
||||||
"Mikko Hemilä",
|
"Mikko Hemilä",
|
||||||
|
"Jacob McDaniel",
|
||||||
"Jamie Ogletree",
|
"Jamie Ogletree",
|
||||||
|
"Temikus",
|
||||||
|
"Artokun",
|
||||||
"Michael Taylor",
|
"Michael Taylor",
|
||||||
"Martial",
|
"Martial",
|
||||||
|
"Michael Anthony Scott",
|
||||||
"Emil Andersson",
|
"Emil Andersson",
|
||||||
"Ouro Boros",
|
"Ouro Boros",
|
||||||
"Atilla Berke Pekduyar",
|
"Atilla Berke Pekduyar",
|
||||||
@@ -318,9 +327,10 @@
|
|||||||
"Davaitamin",
|
"Davaitamin",
|
||||||
"Rops Alot",
|
"Rops Alot",
|
||||||
"tedcor",
|
"tedcor",
|
||||||
"Sam",
|
|
||||||
"Fotek Design",
|
"Fotek Design",
|
||||||
"Ace Ventura",
|
"Ace Ventura",
|
||||||
|
"四糸凜音",
|
||||||
|
"Nihongasuki",
|
||||||
"LarsesFPC",
|
"LarsesFPC",
|
||||||
"MadSpin",
|
"MadSpin",
|
||||||
"inbijiburu",
|
"inbijiburu",
|
||||||
@@ -330,9 +340,7 @@
|
|||||||
"dc7431",
|
"dc7431",
|
||||||
"ken",
|
"ken",
|
||||||
"Crocket",
|
"Crocket",
|
||||||
"Joshua Porrata",
|
|
||||||
"keemun",
|
"keemun",
|
||||||
"SuBu",
|
|
||||||
"RedPIXel",
|
"RedPIXel",
|
||||||
"Wind",
|
"Wind",
|
||||||
"Nexus",
|
"Nexus",
|
||||||
@@ -349,18 +357,23 @@
|
|||||||
"KitKatM",
|
"KitKatM",
|
||||||
"socrasteeze",
|
"socrasteeze",
|
||||||
"OrganicArtifact",
|
"OrganicArtifact",
|
||||||
|
"ResidentDeviant",
|
||||||
"MudkipMedkitz",
|
"MudkipMedkitz",
|
||||||
"deanbrian",
|
"deanbrian",
|
||||||
"Alex Wortman",
|
"Alex Wortman",
|
||||||
"Cody",
|
"Cody",
|
||||||
"emadsultan",
|
"emadsultan",
|
||||||
|
"InformedViewz",
|
||||||
|
"CHKeeho80",
|
||||||
|
"Bubbafett",
|
||||||
|
"leaf",
|
||||||
"Vir",
|
"Vir",
|
||||||
|
"Skyfire83",
|
||||||
|
"Adam Rinehart",
|
||||||
"gzmzmvp",
|
"gzmzmvp",
|
||||||
"Littlehuggy",
|
|
||||||
"Gregory Kozhemiak",
|
"Gregory Kozhemiak",
|
||||||
"Draven T",
|
"Draven T",
|
||||||
"mrjuan",
|
"mrjuan",
|
||||||
"Brian Buie",
|
|
||||||
"Eric Whitney",
|
"Eric Whitney",
|
||||||
"Joey Callahan",
|
"Joey Callahan",
|
||||||
"Aquatic Coffee",
|
"Aquatic Coffee",
|
||||||
@@ -373,16 +386,12 @@
|
|||||||
"Theerat Jiramate",
|
"Theerat Jiramate",
|
||||||
"Focuschannel",
|
"Focuschannel",
|
||||||
"Noah",
|
"Noah",
|
||||||
"Jacob McDaniel",
|
|
||||||
"X",
|
"X",
|
||||||
"Sloan Steddy",
|
"Sloan Steddy",
|
||||||
"Temikus",
|
|
||||||
"Artokun",
|
|
||||||
"hexxish",
|
"hexxish",
|
||||||
"Derek Baker",
|
"Derek Baker",
|
||||||
"Anthony Faxlandez",
|
"Anthony Faxlandez",
|
||||||
"battu",
|
"battu",
|
||||||
"Michael Anthony Scott",
|
|
||||||
"Nathan",
|
"Nathan",
|
||||||
"NICHOLAS BAXLEY",
|
"NICHOLAS BAXLEY",
|
||||||
"Pat Hen",
|
"Pat Hen",
|
||||||
@@ -391,8 +400,6 @@
|
|||||||
"Jordan Shaw",
|
"Jordan Shaw",
|
||||||
"g unit",
|
"g unit",
|
||||||
"Srdb",
|
"Srdb",
|
||||||
"四糸凜音",
|
|
||||||
"Nihongasuki",
|
|
||||||
"JC",
|
"JC",
|
||||||
"Prompt Pirate",
|
"Prompt Pirate",
|
||||||
"uwutismxd",
|
"uwutismxd",
|
||||||
@@ -400,17 +407,10 @@
|
|||||||
"zenobeus",
|
"zenobeus",
|
||||||
"ryoma",
|
"ryoma",
|
||||||
"Stryker",
|
"Stryker",
|
||||||
"ResidentDeviant",
|
|
||||||
"Ginnie",
|
"Ginnie",
|
||||||
"Raku",
|
"Raku",
|
||||||
"smart.edge5178",
|
"smart.edge5178",
|
||||||
"InformedViewz",
|
|
||||||
"CHKeeho80",
|
|
||||||
"Bubbafett",
|
|
||||||
"leaf",
|
|
||||||
"Menard",
|
"Menard",
|
||||||
"Skyfire83",
|
|
||||||
"Adam Rinehart",
|
|
||||||
"Pitpe11",
|
"Pitpe11",
|
||||||
"TheD1rtyD03",
|
"TheD1rtyD03",
|
||||||
"moonpetal",
|
"moonpetal",
|
||||||
@@ -423,6 +423,8 @@
|
|||||||
"SpringBootisTrash",
|
"SpringBootisTrash",
|
||||||
"carsten",
|
"carsten",
|
||||||
"ikok",
|
"ikok",
|
||||||
|
"quantenmecha",
|
||||||
|
"Jason+Nash",
|
||||||
"DarkRoast",
|
"DarkRoast",
|
||||||
"letzte",
|
"letzte",
|
||||||
"Nasty+Hobbit",
|
"Nasty+Hobbit",
|
||||||
@@ -437,9 +439,11 @@
|
|||||||
"Wolfe7D1",
|
"Wolfe7D1",
|
||||||
"blikkies",
|
"blikkies",
|
||||||
"Chris",
|
"Chris",
|
||||||
|
"Time Valentine",
|
||||||
"elleshar666",
|
"elleshar666",
|
||||||
"Shock Shockor",
|
"Shock Shockor",
|
||||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||||
|
"Михал Михалыч",
|
||||||
"Goldwaters",
|
"Goldwaters",
|
||||||
"Kauffy",
|
"Kauffy",
|
||||||
"Zude",
|
"Zude",
|
||||||
@@ -456,6 +460,7 @@
|
|||||||
"Billy Gladky",
|
"Billy Gladky",
|
||||||
"Michael Scott",
|
"Michael Scott",
|
||||||
"Probis",
|
"Probis",
|
||||||
|
"Solixer",
|
||||||
"Wes Sims",
|
"Wes Sims",
|
||||||
"ItsGeneralButtNaked",
|
"ItsGeneralButtNaked",
|
||||||
"Donor4115",
|
"Donor4115",
|
||||||
@@ -474,6 +479,8 @@
|
|||||||
"Whitepinetrader",
|
"Whitepinetrader",
|
||||||
"POPPIN",
|
"POPPIN",
|
||||||
"nanana",
|
"nanana",
|
||||||
|
"Alex",
|
||||||
|
"Karru",
|
||||||
"ChaChanoKo",
|
"ChaChanoKo",
|
||||||
"ghoulars",
|
"ghoulars",
|
||||||
"null",
|
"null",
|
||||||
@@ -489,8 +496,6 @@
|
|||||||
"Doug+Rintoul",
|
"Doug+Rintoul",
|
||||||
"Noor",
|
"Noor",
|
||||||
"Yorunai",
|
"Yorunai",
|
||||||
"quantenmecha",
|
|
||||||
"Jason+Nash",
|
|
||||||
"BillyBoy84",
|
"BillyBoy84",
|
||||||
"Buecyb99",
|
"Buecyb99",
|
||||||
"Welkor",
|
"Welkor",
|
||||||
@@ -499,13 +504,14 @@
|
|||||||
"JBsuede",
|
"JBsuede",
|
||||||
"moranqianlong",
|
"moranqianlong",
|
||||||
"Kalli Core",
|
"Kalli Core",
|
||||||
"Time Valentine",
|
|
||||||
"Christian Schäfer",
|
"Christian Schäfer",
|
||||||
"りん あめ",
|
"りん あめ",
|
||||||
"Михал Михалыч",
|
|
||||||
"Matt",
|
"Matt",
|
||||||
|
"Locrospiel",
|
||||||
"Frogmilk",
|
"Frogmilk",
|
||||||
"SPJ",
|
"SPJ",
|
||||||
|
"Kor",
|
||||||
|
"Joseph Hanson",
|
||||||
"Kyron Mahan",
|
"Kyron Mahan",
|
||||||
"Bryan Rutkowski",
|
"Bryan Rutkowski",
|
||||||
"TBitz33",
|
"TBitz33",
|
||||||
@@ -521,7 +527,6 @@
|
|||||||
"Jimmy Borup",
|
"Jimmy Borup",
|
||||||
"Paul Hartsuyker",
|
"Paul Hartsuyker",
|
||||||
"elitassj",
|
"elitassj",
|
||||||
"Solixer",
|
|
||||||
"Pete Pain",
|
"Pete Pain",
|
||||||
"Jacob Winter",
|
"Jacob Winter",
|
||||||
"Ryan Presley Ng",
|
"Ryan Presley Ng",
|
||||||
@@ -553,6 +558,10 @@
|
|||||||
"Scott",
|
"Scott",
|
||||||
"Muratoraccio",
|
"Muratoraccio",
|
||||||
"D",
|
"D",
|
||||||
|
"nickname",
|
||||||
|
"Sildoren",
|
||||||
|
"Darv",
|
||||||
|
"Seon+Song",
|
||||||
"2turbo",
|
"2turbo",
|
||||||
"Somebody",
|
"Somebody",
|
||||||
"Balut+Omelette",
|
"Balut+Omelette",
|
||||||
@@ -576,9 +585,7 @@
|
|||||||
"Tan+Huynh",
|
"Tan+Huynh",
|
||||||
"D",
|
"D",
|
||||||
"Dark_Pest",
|
"Dark_Pest",
|
||||||
"Alex",
|
|
||||||
"Jacky+Ho",
|
"Jacky+Ho",
|
||||||
"Karru",
|
|
||||||
"generic404",
|
"generic404",
|
||||||
"abattoirblues",
|
"abattoirblues",
|
||||||
"zounik",
|
"zounik",
|
||||||
@@ -593,24 +600,24 @@
|
|||||||
"G",
|
"G",
|
||||||
"Ronan Delevacq",
|
"Ronan Delevacq",
|
||||||
"ja s",
|
"ja s",
|
||||||
|
"Leslie Andrew Ridings",
|
||||||
"Doug Mason",
|
"Doug Mason",
|
||||||
"Jeremy Townsend",
|
"Jeremy Townsend",
|
||||||
"Dave Abraham",
|
"Dave Abraham",
|
||||||
"Joaquin Hierrezuelo",
|
"Joaquin Hierrezuelo",
|
||||||
"Locrospiel",
|
|
||||||
"Sean voets",
|
"Sean voets",
|
||||||
"Owen Gwosdz",
|
"Owen Gwosdz",
|
||||||
"Jarrid Lee",
|
"Jarrid Lee",
|
||||||
"Poophead27 Blyat",
|
"Poophead27 Blyat",
|
||||||
"Kor",
|
|
||||||
"Joseph Hanson",
|
|
||||||
"John Rednoulf",
|
"John Rednoulf",
|
||||||
"Spire",
|
"Spire",
|
||||||
|
"AZ Party Oasis",
|
||||||
"Boba Smith",
|
"Boba Smith",
|
||||||
"Devil Lude",
|
"Devil Lude",
|
||||||
"David Murcko",
|
"David Murcko",
|
||||||
"MR.Bear",
|
"MR.Bear",
|
||||||
"Jack Dole",
|
"Jack Dole",
|
||||||
|
"matt",
|
||||||
"somethingtosay8",
|
"somethingtosay8",
|
||||||
"ivistorm",
|
"ivistorm",
|
||||||
"max blo",
|
"max blo",
|
||||||
@@ -627,6 +634,7 @@
|
|||||||
"Tigon",
|
"Tigon",
|
||||||
"BastardSama",
|
"BastardSama",
|
||||||
"mercur",
|
"mercur",
|
||||||
|
"SkibidiRizzler",
|
||||||
"Tania Nayelli Fernandez",
|
"Tania Nayelli Fernandez",
|
||||||
"Draconach",
|
"Draconach",
|
||||||
"Yavizu3d",
|
"Yavizu3d",
|
||||||
@@ -635,6 +643,7 @@
|
|||||||
"Just me",
|
"Just me",
|
||||||
"Raf Stahelin",
|
"Raf Stahelin",
|
||||||
"Вячеслав Маринин",
|
"Вячеслав Маринин",
|
||||||
|
"Marcos Tortosa Carmona",
|
||||||
"Dkommander22",
|
"Dkommander22",
|
||||||
"Cola Matthew",
|
"Cola Matthew",
|
||||||
"OniNoKen",
|
"OniNoKen",
|
||||||
@@ -679,6 +688,11 @@
|
|||||||
"SelfishMedic",
|
"SelfishMedic",
|
||||||
"adderleighn",
|
"adderleighn",
|
||||||
"EnragedAntelope",
|
"EnragedAntelope",
|
||||||
|
"bakeliteboy",
|
||||||
|
"TequiTequi",
|
||||||
|
"Homero+Banda",
|
||||||
|
"Nick",
|
||||||
|
"Jim",
|
||||||
"Monix",
|
"Monix",
|
||||||
"Trolinka",
|
"Trolinka",
|
||||||
"IshouI;_;",
|
"IshouI;_;",
|
||||||
@@ -707,9 +721,6 @@
|
|||||||
"ExLightSaber",
|
"ExLightSaber",
|
||||||
"YaboiRay",
|
"YaboiRay",
|
||||||
"Drizzly",
|
"Drizzly",
|
||||||
"Sildoren",
|
|
||||||
"Darvidous",
|
|
||||||
"Seon+Song",
|
|
||||||
"Nebuleux",
|
"Nebuleux",
|
||||||
"Join+Chun",
|
"Join+Chun",
|
||||||
"GDS+DEV",
|
"GDS+DEV",
|
||||||
@@ -752,7 +763,6 @@
|
|||||||
"Seraphy",
|
"Seraphy",
|
||||||
"雨の心 落",
|
"雨の心 落",
|
||||||
"AllTimeNoobie",
|
"AllTimeNoobie",
|
||||||
"Leslie Andrew Ridings",
|
|
||||||
"jumpd",
|
"jumpd",
|
||||||
"John C",
|
"John C",
|
||||||
"Rim",
|
"Rim",
|
||||||
@@ -766,13 +776,11 @@
|
|||||||
"Forbidden Atelier",
|
"Forbidden Atelier",
|
||||||
"Thomas Sankowski",
|
"Thomas Sankowski",
|
||||||
"DrB",
|
"DrB",
|
||||||
"AZ Party Oasis",
|
|
||||||
"Adictedtohumping",
|
"Adictedtohumping",
|
||||||
"Snorklebort",
|
"Snorklebort",
|
||||||
|
"vinter",
|
||||||
"Towelie",
|
"Towelie",
|
||||||
"TheFusion",
|
"TheFusion",
|
||||||
"matt",
|
|
||||||
"dsffsdfsdfsdfsdfsdf",
|
|
||||||
"Jean-françois SEMA",
|
"Jean-françois SEMA",
|
||||||
"3zS4QNQ4",
|
"3zS4QNQ4",
|
||||||
"Terminuz",
|
"Terminuz",
|
||||||
@@ -786,12 +794,14 @@
|
|||||||
"jimyjomson",
|
"jimyjomson",
|
||||||
"Borte",
|
"Borte",
|
||||||
"JaeHyun Jang",
|
"JaeHyun Jang",
|
||||||
|
"Homero Banda",
|
||||||
"Chase Kwon",
|
"Chase Kwon",
|
||||||
"yyuvuvu",
|
"yyuvuvu",
|
||||||
"Inyoshu",
|
"Inyoshu",
|
||||||
"Chad Barnes",
|
"Chad Barnes",
|
||||||
"Person Y",
|
"Person Y",
|
||||||
"Nomki",
|
"Nomki",
|
||||||
|
"inusanorthcape",
|
||||||
"James Ming",
|
"James Ming",
|
||||||
"vanditking",
|
"vanditking",
|
||||||
"kripitonga",
|
"kripitonga",
|
||||||
@@ -804,7 +814,6 @@
|
|||||||
"hannibal",
|
"hannibal",
|
||||||
"Jo+Example",
|
"Jo+Example",
|
||||||
"BrentBertram",
|
"BrentBertram",
|
||||||
"inusanorthcape",
|
|
||||||
"eumelzocker",
|
"eumelzocker",
|
||||||
"dxjaymz",
|
"dxjaymz",
|
||||||
"L C",
|
"L C",
|
||||||
@@ -812,5 +821,5 @@
|
|||||||
"Somebody",
|
"Somebody",
|
||||||
"CK"
|
"CK"
|
||||||
],
|
],
|
||||||
"totalCount": 809
|
"totalCount": 818
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Wird geladen...",
|
"loading": "Wird geladen...",
|
||||||
|
"cancelling": "Abbrechen...",
|
||||||
"unknown": "Unbekannt",
|
"unknown": "Unbekannt",
|
||||||
"date": "Datum",
|
"date": "Datum",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
@@ -955,10 +956,7 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Stammverzeichnis",
|
"modelRoot": "Stammverzeichnis",
|
||||||
"moreOptions": "Weitere Optionen",
|
|
||||||
"collapseAll": "Alle Ordner einklappen",
|
"collapseAll": "Alle Ordner einklappen",
|
||||||
"pinSidebar": "Sidebar anheften",
|
|
||||||
"unpinSidebar": "Sidebar lösen",
|
|
||||||
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
|
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
|
||||||
"showSidebar": "Seitenleiste anzeigen",
|
"showSidebar": "Seitenleiste anzeigen",
|
||||||
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
|
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
|
||||||
@@ -1398,6 +1396,21 @@
|
|||||||
"versionDeleted": "Version gelöscht"
|
"versionDeleted": "Version gelöscht"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "Metadaten abrufen — Zusammenfassung",
|
||||||
|
"statSuccess": "Erfolgreich",
|
||||||
|
"statFailed": "Fehlgeschlagen",
|
||||||
|
"statSkipped": "Übersprungen",
|
||||||
|
"statTotal": "Gesamt geprüft",
|
||||||
|
"statDuration": "Dauer",
|
||||||
|
"successMessage": "Alle {count} {type}s erfolgreich aktualisiert!",
|
||||||
|
"failedItems": "Fehlgeschlagene Elemente ({count})",
|
||||||
|
"close": "Schließen",
|
||||||
|
"copyReport": "Bericht kopieren",
|
||||||
|
"downloadCsv": "CSV herunterladen",
|
||||||
|
"columnModelName": "Modellname",
|
||||||
|
"columnError": "Fehler"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1970,9 @@
|
|||||||
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
||||||
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
|
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
|
||||||
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
|
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "In die Zwischenablage kopiert",
|
||||||
|
"downloadStarted": "Download gestartet"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
|
"cancelling": "Cancelling...",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
@@ -955,10 +956,7 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Root",
|
"modelRoot": "Root",
|
||||||
"moreOptions": "More options",
|
|
||||||
"collapseAll": "Collapse All Folders",
|
"collapseAll": "Collapse All Folders",
|
||||||
"pinSidebar": "Pin Sidebar",
|
|
||||||
"unpinSidebar": "Unpin Sidebar",
|
|
||||||
"hideOnThisPage": "Hide sidebar on this page",
|
"hideOnThisPage": "Hide sidebar on this page",
|
||||||
"showSidebar": "Show sidebar",
|
"showSidebar": "Show sidebar",
|
||||||
"sidebarHiddenNotification": "Folder sidebar hidden on {page} page",
|
"sidebarHiddenNotification": "Folder sidebar hidden on {page} page",
|
||||||
@@ -1398,6 +1396,21 @@
|
|||||||
"versionDeleted": "Version deleted"
|
"versionDeleted": "Version deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "Metadata Fetch Summary",
|
||||||
|
"statSuccess": "Success",
|
||||||
|
"statFailed": "Failed",
|
||||||
|
"statSkipped": "Skipped",
|
||||||
|
"statTotal": "Total Scanned",
|
||||||
|
"statDuration": "Duration",
|
||||||
|
"successMessage": "All {count} {type}s updated successfully!",
|
||||||
|
"failedItems": "Failed Items ({count})",
|
||||||
|
"close": "Close",
|
||||||
|
"copyReport": "Copy Report",
|
||||||
|
"downloadCsv": "Download CSV",
|
||||||
|
"columnModelName": "Model Name",
|
||||||
|
"columnError": "Error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1970,9 @@
|
|||||||
"bulkMoveSuccess": "Successfully moved {successCount} {type}s",
|
"bulkMoveSuccess": "Successfully moved {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "Successfully downloaded example images!",
|
"exampleImagesDownloadSuccess": "Successfully downloaded example images!",
|
||||||
"exampleImagesDownloadFailed": "Failed to download example images: {message}",
|
"exampleImagesDownloadFailed": "Failed to download example images: {message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "Copied to clipboard",
|
||||||
|
"downloadStarted": "Download started"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
|
"cancelling": "Cancelando...",
|
||||||
"unknown": "Desconocido",
|
"unknown": "Desconocido",
|
||||||
"date": "Fecha",
|
"date": "Fecha",
|
||||||
"version": "Versión",
|
"version": "Versión",
|
||||||
@@ -955,10 +956,7 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Raíz",
|
"modelRoot": "Raíz",
|
||||||
"moreOptions": "Más opciones",
|
|
||||||
"collapseAll": "Colapsar todas las carpetas",
|
"collapseAll": "Colapsar todas las carpetas",
|
||||||
"pinSidebar": "Fijar barra lateral",
|
|
||||||
"unpinSidebar": "Desfijar barra lateral",
|
|
||||||
"hideOnThisPage": "Ocultar barra lateral en esta página",
|
"hideOnThisPage": "Ocultar barra lateral en esta página",
|
||||||
"showSidebar": "Mostrar barra lateral",
|
"showSidebar": "Mostrar barra lateral",
|
||||||
"sidebarHiddenNotification": "Barra lateral oculta en la página {page}",
|
"sidebarHiddenNotification": "Barra lateral oculta en la página {page}",
|
||||||
@@ -1398,6 +1396,21 @@
|
|||||||
"versionDeleted": "Versión eliminada"
|
"versionDeleted": "Versión eliminada"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "Resumen de obtención de metadatos",
|
||||||
|
"statSuccess": "Éxito",
|
||||||
|
"statFailed": "Fallido",
|
||||||
|
"statSkipped": "Omitido",
|
||||||
|
"statTotal": "Total escaneado",
|
||||||
|
"statDuration": "Duración",
|
||||||
|
"successMessage": "¡Todos los {count} {type}s actualizados correctamente!",
|
||||||
|
"failedItems": "Elementos fallidos ({count})",
|
||||||
|
"close": "Cerrar",
|
||||||
|
"copyReport": "Copiar informe",
|
||||||
|
"downloadCsv": "Descargar CSV",
|
||||||
|
"columnModelName": "Nombre del modelo",
|
||||||
|
"columnError": "Error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1970,9 @@
|
|||||||
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
|
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
|
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
|
||||||
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
|
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "Copiado al portapapeles",
|
||||||
|
"downloadStarted": "Descarga iniciada"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
|
"cancelling": "Annulation...",
|
||||||
"unknown": "Inconnu",
|
"unknown": "Inconnu",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
@@ -955,10 +956,7 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Racine",
|
"modelRoot": "Racine",
|
||||||
"moreOptions": "Plus d'options",
|
|
||||||
"collapseAll": "Réduire tous les dossiers",
|
"collapseAll": "Réduire tous les dossiers",
|
||||||
"pinSidebar": "Épingler la barre latérale",
|
|
||||||
"unpinSidebar": "Désépingler la barre latérale",
|
|
||||||
"hideOnThisPage": "Masquer la barre latérale sur cette page",
|
"hideOnThisPage": "Masquer la barre latérale sur cette page",
|
||||||
"showSidebar": "Afficher la barre latérale",
|
"showSidebar": "Afficher la barre latérale",
|
||||||
"sidebarHiddenNotification": "Barre latérale masquée sur la page {page}",
|
"sidebarHiddenNotification": "Barre latérale masquée sur la page {page}",
|
||||||
@@ -1398,6 +1396,21 @@
|
|||||||
"versionDeleted": "Version supprimée"
|
"versionDeleted": "Version supprimée"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "Récapitulatif de la récupération des métadonnées",
|
||||||
|
"statSuccess": "Réussi",
|
||||||
|
"statFailed": "Échoué",
|
||||||
|
"statSkipped": "Ignoré",
|
||||||
|
"statTotal": "Total scanné",
|
||||||
|
"statDuration": "Durée",
|
||||||
|
"successMessage": "Tous les {count} {type}s mis à jour avec succès !",
|
||||||
|
"failedItems": "Éléments échoués ({count})",
|
||||||
|
"close": "Fermer",
|
||||||
|
"copyReport": "Copier le rapport",
|
||||||
|
"downloadCsv": "Télécharger CSV",
|
||||||
|
"columnModelName": "Nom du modèle",
|
||||||
|
"columnError": "Erreur"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1970,9 @@
|
|||||||
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
|
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
|
||||||
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
|
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
|
||||||
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
|
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "Copié dans le presse-papiers",
|
||||||
|
"downloadStarted": "Téléchargement démarré"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "טוען...",
|
"loading": "טוען...",
|
||||||
|
"cancelling": "מבטל...",
|
||||||
"unknown": "לא ידוע",
|
"unknown": "לא ידוע",
|
||||||
"date": "תאריך",
|
"date": "תאריך",
|
||||||
"version": "גרסה",
|
"version": "גרסה",
|
||||||
@@ -955,10 +956,7 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "שורש",
|
"modelRoot": "שורש",
|
||||||
"moreOptions": "אפשרויות נוספות",
|
|
||||||
"collapseAll": "כווץ את כל התיקיות",
|
"collapseAll": "כווץ את כל התיקיות",
|
||||||
"pinSidebar": "נעל סרגל צד",
|
|
||||||
"unpinSidebar": "שחרר סרגל צד",
|
|
||||||
"hideOnThisPage": "הסתר סרגל צד בדף זה",
|
"hideOnThisPage": "הסתר סרגל צד בדף זה",
|
||||||
"showSidebar": "הצג סרגל צד",
|
"showSidebar": "הצג סרגל צד",
|
||||||
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
|
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
|
||||||
@@ -1398,6 +1396,21 @@
|
|||||||
"versionDeleted": "הגרסה נמחקה"
|
"versionDeleted": "הגרסה נמחקה"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "סיכום שליפת מטא-דאטה",
|
||||||
|
"statSuccess": "הצלחה",
|
||||||
|
"statFailed": "נכשל",
|
||||||
|
"statSkipped": "דולג",
|
||||||
|
"statTotal": "סה\"כ נסרק",
|
||||||
|
"statDuration": "משך",
|
||||||
|
"successMessage": "כל {count} {type}s עודכנו בהצלחה!",
|
||||||
|
"failedItems": "פריטים נכשלים ({count})",
|
||||||
|
"close": "סגור",
|
||||||
|
"copyReport": "העתק דוח",
|
||||||
|
"downloadCsv": "הורד CSV",
|
||||||
|
"columnModelName": "שם המודל",
|
||||||
|
"columnError": "שגיאה"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1970,9 @@
|
|||||||
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
|
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
|
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
|
||||||
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
|
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "הועתק ללוח",
|
||||||
|
"downloadStarted": "ההורדה החלה"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
|
"cancelling": "キャンセル中...",
|
||||||
"unknown": "不明",
|
"unknown": "不明",
|
||||||
"date": "日付",
|
"date": "日付",
|
||||||
"version": "バージョン",
|
"version": "バージョン",
|
||||||
@@ -955,10 +956,7 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "ルート",
|
"modelRoot": "ルート",
|
||||||
"moreOptions": "その他のオプション",
|
|
||||||
"collapseAll": "すべてのフォルダを折りたたむ",
|
"collapseAll": "すべてのフォルダを折りたたむ",
|
||||||
"pinSidebar": "サイドバーを固定",
|
|
||||||
"unpinSidebar": "サイドバーの固定を解除",
|
|
||||||
"hideOnThisPage": "このページでサイドバーを非表示",
|
"hideOnThisPage": "このページでサイドバーを非表示",
|
||||||
"showSidebar": "サイドバーを表示",
|
"showSidebar": "サイドバーを表示",
|
||||||
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
|
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
|
||||||
@@ -1398,6 +1396,21 @@
|
|||||||
"versionDeleted": "バージョンを削除しました"
|
"versionDeleted": "バージョンを削除しました"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "メタデータ取得サマリー",
|
||||||
|
"statSuccess": "成功",
|
||||||
|
"statFailed": "失敗",
|
||||||
|
"statSkipped": "スキップ",
|
||||||
|
"statTotal": "スキャン合計",
|
||||||
|
"statDuration": "所要時間",
|
||||||
|
"successMessage": "すべての{count}件の{type}を正常に更新しました",
|
||||||
|
"failedItems": "失敗したアイテム ({count})",
|
||||||
|
"close": "閉じる",
|
||||||
|
"copyReport": "レポートをコピー",
|
||||||
|
"downloadCsv": "CSVをダウンロード",
|
||||||
|
"columnModelName": "モデル名",
|
||||||
|
"columnError": "エラー"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1970,9 @@
|
|||||||
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
||||||
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
||||||
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
|
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "クリップボードにコピーしました",
|
||||||
|
"downloadStarted": "ダウンロードを開始しました"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "로딩 중...",
|
"loading": "로딩 중...",
|
||||||
|
"cancelling": "취소 중...",
|
||||||
"unknown": "알 수 없음",
|
"unknown": "알 수 없음",
|
||||||
"date": "날짜",
|
"date": "날짜",
|
||||||
"version": "버전",
|
"version": "버전",
|
||||||
@@ -955,10 +956,7 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "루트",
|
"modelRoot": "루트",
|
||||||
"moreOptions": "더 많은 옵션",
|
|
||||||
"collapseAll": "모든 폴더 접기",
|
"collapseAll": "모든 폴더 접기",
|
||||||
"pinSidebar": "사이드바 고정",
|
|
||||||
"unpinSidebar": "사이드바 고정 해제",
|
|
||||||
"hideOnThisPage": "이 페이지에서 사이드바 숨기기",
|
"hideOnThisPage": "이 페이지에서 사이드바 숨기기",
|
||||||
"showSidebar": "사이드바 표시",
|
"showSidebar": "사이드바 표시",
|
||||||
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
|
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
|
||||||
@@ -1398,6 +1396,21 @@
|
|||||||
"versionDeleted": "버전이 삭제되었습니다"
|
"versionDeleted": "버전이 삭제되었습니다"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "메타데이터 가져오기 요약",
|
||||||
|
"statSuccess": "성공",
|
||||||
|
"statFailed": "실패",
|
||||||
|
"statSkipped": "건너뜀",
|
||||||
|
"statTotal": "총 스캔",
|
||||||
|
"statDuration": "소요 시간",
|
||||||
|
"successMessage": "모든 {count}개 {type}이(가) 성공적으로 업데이트되었습니다",
|
||||||
|
"failedItems": "실패한 항목 ({count})",
|
||||||
|
"close": "닫기",
|
||||||
|
"copyReport": "보고서 복사",
|
||||||
|
"downloadCsv": "CSV 다운로드",
|
||||||
|
"columnModelName": "모델 이름",
|
||||||
|
"columnError": "오류"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1970,9 @@
|
|||||||
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
||||||
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
||||||
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
|
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "클립보드에 복사됨",
|
||||||
|
"downloadStarted": "다운로드 시작됨"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
|
"cancelling": "Отмена...",
|
||||||
"unknown": "Неизвестно",
|
"unknown": "Неизвестно",
|
||||||
"date": "Дата",
|
"date": "Дата",
|
||||||
"version": "Версия",
|
"version": "Версия",
|
||||||
@@ -955,10 +956,7 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Корень",
|
"modelRoot": "Корень",
|
||||||
"moreOptions": "Дополнительные параметры",
|
|
||||||
"collapseAll": "Свернуть все папки",
|
"collapseAll": "Свернуть все папки",
|
||||||
"pinSidebar": "Закрепить боковую панель",
|
|
||||||
"unpinSidebar": "Открепить боковую панель",
|
|
||||||
"hideOnThisPage": "Скрыть боковую панель на этой странице",
|
"hideOnThisPage": "Скрыть боковую панель на этой странице",
|
||||||
"showSidebar": "Показать боковую панель",
|
"showSidebar": "Показать боковую панель",
|
||||||
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
|
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
|
||||||
@@ -1398,6 +1396,21 @@
|
|||||||
"versionDeleted": "Версия удалена"
|
"versionDeleted": "Версия удалена"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "Сводка получения метаданных",
|
||||||
|
"statSuccess": "Успешно",
|
||||||
|
"statFailed": "Ошибка",
|
||||||
|
"statSkipped": "Пропущено",
|
||||||
|
"statTotal": "Всего проверено",
|
||||||
|
"statDuration": "Длительность",
|
||||||
|
"successMessage": "Все {count} {type}s успешно обновлены",
|
||||||
|
"failedItems": "Ошибочные элементы ({count})",
|
||||||
|
"close": "Закрыть",
|
||||||
|
"copyReport": "Копировать отчет",
|
||||||
|
"downloadCsv": "Скачать CSV",
|
||||||
|
"columnModelName": "Имя модели",
|
||||||
|
"columnError": "Ошибка"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1970,9 @@
|
|||||||
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
|
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
|
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
|
||||||
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
|
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "Скопировано в буфер обмена",
|
||||||
|
"downloadStarted": "Загрузка начата"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
|
"cancelling": "取消中...",
|
||||||
"unknown": "未知",
|
"unknown": "未知",
|
||||||
"date": "日期",
|
"date": "日期",
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
@@ -955,10 +956,7 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "根目录",
|
"modelRoot": "根目录",
|
||||||
"moreOptions": "更多选项",
|
|
||||||
"collapseAll": "折叠所有文件夹",
|
"collapseAll": "折叠所有文件夹",
|
||||||
"pinSidebar": "固定侧边栏",
|
|
||||||
"unpinSidebar": "取消固定侧边栏",
|
|
||||||
"hideOnThisPage": "隐藏此页面侧边栏",
|
"hideOnThisPage": "隐藏此页面侧边栏",
|
||||||
"showSidebar": "显示侧边栏",
|
"showSidebar": "显示侧边栏",
|
||||||
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
|
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
|
||||||
@@ -1398,6 +1396,21 @@
|
|||||||
"versionDeleted": "版本已删除"
|
"versionDeleted": "版本已删除"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "元数据获取摘要",
|
||||||
|
"statSuccess": "成功",
|
||||||
|
"statFailed": "失败",
|
||||||
|
"statSkipped": "已跳过",
|
||||||
|
"statTotal": "总计扫描",
|
||||||
|
"statDuration": "耗时",
|
||||||
|
"successMessage": "全部 {count} 个 {type} 更新成功!",
|
||||||
|
"failedItems": "失败项目 ({count})",
|
||||||
|
"close": "关闭",
|
||||||
|
"copyReport": "复制报告",
|
||||||
|
"downloadCsv": "下载 CSV",
|
||||||
|
"columnModelName": "模型名称",
|
||||||
|
"columnError": "错误"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1970,9 @@
|
|||||||
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
||||||
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
||||||
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
|
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "已复制到剪贴板",
|
||||||
|
"downloadStarted": "下载已开始"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "載入中...",
|
"loading": "載入中...",
|
||||||
|
"cancelling": "取消中...",
|
||||||
"unknown": "未知",
|
"unknown": "未知",
|
||||||
"date": "日期",
|
"date": "日期",
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
@@ -955,10 +956,7 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "根目錄",
|
"modelRoot": "根目錄",
|
||||||
"moreOptions": "更多選項",
|
|
||||||
"collapseAll": "全部摺疊資料夾",
|
"collapseAll": "全部摺疊資料夾",
|
||||||
"pinSidebar": "固定側邊欄",
|
|
||||||
"unpinSidebar": "取消固定側邊欄",
|
|
||||||
"hideOnThisPage": "隱藏此頁面側邊欄",
|
"hideOnThisPage": "隱藏此頁面側邊欄",
|
||||||
"showSidebar": "顯示側邊欄",
|
"showSidebar": "顯示側邊欄",
|
||||||
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
|
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
|
||||||
@@ -1398,6 +1396,21 @@
|
|||||||
"versionDeleted": "已刪除此版本"
|
"versionDeleted": "已刪除此版本"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"metadataFetchSummary": {
|
||||||
|
"title": "元資料獲取摘要",
|
||||||
|
"statSuccess": "成功",
|
||||||
|
"statFailed": "失敗",
|
||||||
|
"statSkipped": "已跳過",
|
||||||
|
"statTotal": "總計掃描",
|
||||||
|
"statDuration": "耗時",
|
||||||
|
"successMessage": "全部 {count} 個 {type} 更新成功!",
|
||||||
|
"failedItems": "失敗項目 ({count})",
|
||||||
|
"close": "關閉",
|
||||||
|
"copyReport": "複製報告",
|
||||||
|
"downloadCsv": "下載 CSV",
|
||||||
|
"columnModelName": "模型名稱",
|
||||||
|
"columnError": "錯誤"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1957,7 +1970,9 @@
|
|||||||
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
||||||
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
||||||
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
|
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
|
||||||
"moveFailed": "Failed to move item: {message}"
|
"moveFailed": "Failed to move item: {message}",
|
||||||
|
"copiedToClipboard": "已複製到剪貼簿",
|
||||||
|
"downloadStarted": "下載已開始"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -436,5 +436,14 @@ class LoraManager:
|
|||||||
try:
|
try:
|
||||||
logger.info("LoRA Manager: Cleaning up services")
|
logger.info("LoRA Manager: Cleaning up services")
|
||||||
|
|
||||||
|
# Cancel any in-flight scanner initialization tasks so thread-pool
|
||||||
|
# workers (e.g. _initialize_cache_sync) can break out of their loops
|
||||||
|
# when the server shuts down (e.g. Ctrl+C on WSL).
|
||||||
|
for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"):
|
||||||
|
scanner = ServiceRegistry.get_service_sync(name)
|
||||||
|
if scanner is not None and hasattr(scanner, "cancel_task"):
|
||||||
|
scanner.cancel_task()
|
||||||
|
logger.debug("LoRA Manager: Cancelled %s", name)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ IMG_EXTENSIONS = (
|
|||||||
".tif",
|
".tif",
|
||||||
".tiff",
|
".tiff",
|
||||||
".webp",
|
".webp",
|
||||||
|
".avif",
|
||||||
|
".jxl",
|
||||||
".mp4"
|
".mp4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -298,7 +298,12 @@ class SaveImageLM:
|
|||||||
key = parts[0]
|
key = parts[0]
|
||||||
|
|
||||||
if key == "seed" and "seed" in metadata_dict:
|
if key == "seed" and "seed" in metadata_dict:
|
||||||
filename = filename.replace(segment, str(metadata_dict.get("seed", "")))
|
seed_value = metadata_dict.get("seed")
|
||||||
|
if seed_value is not None:
|
||||||
|
filename = filename.replace(segment, str(seed_value))
|
||||||
|
else:
|
||||||
|
# Fallback if seed was not captured by metadata collector
|
||||||
|
filename = filename.replace(segment, "0")
|
||||||
elif key == "width" and "size" in metadata_dict:
|
elif key == "width" and "size" in metadata_dict:
|
||||||
size = metadata_dict.get("size", "x")
|
size = metadata_dict.get("size", "x")
|
||||||
w = size.split("x")[0] if isinstance(size, str) else size[0]
|
w = size.split("x")[0] if isinstance(size, str) else size[0]
|
||||||
|
|||||||
@@ -1861,7 +1861,9 @@ class ModelCivitaiHandler:
|
|||||||
return web.json_response(result)
|
return web.json_response(result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
"Error in fetch_all_civitai for %ss: %s", self._service.model_type, exc
|
"Error in fetch_all_civitai for %ss: %s",
|
||||||
|
self._service.model_type, exc,
|
||||||
|
exc_info=True,
|
||||||
)
|
)
|
||||||
return web.Response(text=str(exc), status=500)
|
return web.Response(text=str(exc), status=500)
|
||||||
|
|
||||||
|
|||||||
@@ -216,13 +216,19 @@ class MetadataSyncService:
|
|||||||
provider_used: Optional[str] = None
|
provider_used: Optional[str] = None
|
||||||
last_error: Optional[str] = None
|
last_error: Optional[str] = None
|
||||||
civitai_api_not_found = False
|
civitai_api_not_found = False
|
||||||
|
any_rate_limited = False
|
||||||
|
|
||||||
for provider_name, provider in provider_attempts:
|
for provider_name, provider in provider_attempts:
|
||||||
try:
|
try:
|
||||||
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
|
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
exc.provider = exc.provider or (provider_name or provider.__class__.__name__)
|
logger.warning(
|
||||||
raise
|
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||||
|
provider_name or provider.__class__.__name__,
|
||||||
|
exc.retry_after or 0,
|
||||||
|
)
|
||||||
|
any_rate_limited = True
|
||||||
|
continue
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Provider %s failed for hash %s: %s", provider_name, sha256, exc)
|
logger.error("Provider %s failed for hash %s: %s", provider_name, sha256, exc)
|
||||||
civitai_metadata_candidate, error = None, str(exc)
|
civitai_metadata_candidate, error = None, str(exc)
|
||||||
@@ -258,6 +264,14 @@ class MetadataSyncService:
|
|||||||
model_data["last_checked_at"] = datetime.now().timestamp()
|
model_data["last_checked_at"] = datetime.now().timestamp()
|
||||||
needs_save = True
|
needs_save = True
|
||||||
|
|
||||||
|
# When the model was already classified as "not on CivitAI" via
|
||||||
|
# .metadata.json (civitai_deleted=True) but the SQLite cache is
|
||||||
|
# stale (because the pre-fix code never persisted these flags),
|
||||||
|
# ensure the flags are written to the scanner cache + SQLite.
|
||||||
|
if not needs_save and model_data.get("civitai_deleted") is True:
|
||||||
|
model_data["last_checked_at"] = datetime.now().timestamp()
|
||||||
|
needs_save = True
|
||||||
|
|
||||||
# Save metadata if any state was updated
|
# Save metadata if any state was updated
|
||||||
if needs_save:
|
if needs_save:
|
||||||
data_to_save = model_data.copy()
|
data_to_save = model_data.copy()
|
||||||
@@ -266,6 +280,7 @@ class MetadataSyncService:
|
|||||||
if "last_checked_at" not in data_to_save:
|
if "last_checked_at" not in data_to_save:
|
||||||
data_to_save["last_checked_at"] = datetime.now().timestamp()
|
data_to_save["last_checked_at"] = datetime.now().timestamp()
|
||||||
await self._metadata_manager.save_metadata(file_path, data_to_save)
|
await self._metadata_manager.save_metadata(file_path, data_to_save)
|
||||||
|
await update_cache_func(file_path, file_path, data_to_save)
|
||||||
|
|
||||||
default_error = (
|
default_error = (
|
||||||
"CivitAI model is deleted and metadata archive DB is not enabled"
|
"CivitAI model is deleted and metadata archive DB is not enabled"
|
||||||
@@ -276,17 +291,18 @@ class MetadataSyncService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
resolved_error = last_error or default_error
|
resolved_error = last_error or default_error
|
||||||
|
if any_rate_limited and "Rate limited" not in resolved_error:
|
||||||
|
resolved_error = "Rate limited"
|
||||||
if is_expected_offline_error(resolved_error):
|
if is_expected_offline_error(resolved_error):
|
||||||
resolved_error = OFFLINE_FRIENDLY_MESSAGE
|
resolved_error = OFFLINE_FRIENDLY_MESSAGE
|
||||||
|
|
||||||
error_msg = (
|
error_msg = (
|
||||||
f"Error fetching metadata: {resolved_error} "
|
f"Error fetching metadata: {resolved_error} "
|
||||||
f"(model_name={model_data.get('model_name', '')})"
|
f"(file={os.path.basename(file_path)}, sha256={sha256})"
|
||||||
)
|
)
|
||||||
if is_expected_offline_error(resolved_error):
|
# Use case layer (BulkMetadataRefreshUseCase) logs failed models at WARNING level,
|
||||||
logger.info(error_msg)
|
# so this level is demoted to DEBUG to avoid duplicate user-visible logging.
|
||||||
else:
|
logger.debug(error_msg)
|
||||||
logger.error(error_msg)
|
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
|
|
||||||
model_data["from_civitai"] = True
|
model_data["from_civitai"] = True
|
||||||
|
|||||||
@@ -65,7 +65,14 @@ class _RateLimitRetryHelper:
|
|||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
attempt += 1
|
attempt += 1
|
||||||
if attempt >= self._retry_limit:
|
|
||||||
|
# Determine effective retry limit based on rate-limit magnitude
|
||||||
|
effective_retry_limit = self._retry_limit # default: 3
|
||||||
|
if exc.retry_after is not None and exc.retry_after >= 120.0:
|
||||||
|
# Long rate-limit window (>=2 min) — retries are futile
|
||||||
|
effective_retry_limit = 1 # total 1 attempt = 0 retries
|
||||||
|
|
||||||
|
if attempt >= effective_retry_limit:
|
||||||
exc.provider = exc.provider or label
|
exc.provider = exc.provider or label
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -478,8 +485,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
if result:
|
if result:
|
||||||
return result, error
|
return result, error
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
exc.provider = exc.provider or label
|
logger.warning(
|
||||||
raise exc
|
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||||
|
label,
|
||||||
|
exc.retry_after or 0,
|
||||||
|
)
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Provider %s failed for get_model_by_hash: %s", label, e)
|
logger.debug("Provider %s failed for get_model_by_hash: %s", label, e)
|
||||||
continue
|
continue
|
||||||
@@ -497,16 +508,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
if result:
|
if result:
|
||||||
return result
|
return result
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
if not_found_confirmed:
|
logger.warning(
|
||||||
logger.debug(
|
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||||
"Suppressing rate limit from %s for model %s: "
|
|
||||||
"already confirmed as not found by another provider",
|
|
||||||
label,
|
label,
|
||||||
model_id,
|
exc.retry_after or 0,
|
||||||
)
|
)
|
||||||
return None
|
continue
|
||||||
exc.provider = exc.provider or label
|
|
||||||
raise exc
|
|
||||||
except ResourceNotFoundError:
|
except ResourceNotFoundError:
|
||||||
not_found_confirmed = True
|
not_found_confirmed = True
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -532,8 +539,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
if result:
|
if result:
|
||||||
return result
|
return result
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
exc.provider = exc.provider or label
|
logger.warning(
|
||||||
raise exc
|
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||||
|
label,
|
||||||
|
exc.retry_after or 0,
|
||||||
|
)
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Provider %s failed for get_model_version: %s", label, e)
|
logger.debug("Provider %s failed for get_model_version: %s", label, e)
|
||||||
continue
|
continue
|
||||||
@@ -550,8 +561,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
if result:
|
if result:
|
||||||
return result, error
|
return result, error
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
exc.provider = exc.provider or label
|
logger.warning(
|
||||||
raise exc
|
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||||
|
label,
|
||||||
|
exc.retry_after or 0,
|
||||||
|
)
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Provider %s failed for get_model_version_info: %s", label, e)
|
logger.debug("Provider %s failed for get_model_version_info: %s", label, e)
|
||||||
continue
|
continue
|
||||||
@@ -572,8 +587,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
continue
|
continue
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
exc.provider = exc.provider or label
|
logger.warning(
|
||||||
raise exc
|
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||||
|
label,
|
||||||
|
exc.retry_after or 0,
|
||||||
|
)
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Provider %s failed for get_model_versions_by_hashes: %s",
|
"Provider %s failed for get_model_versions_by_hashes: %s",
|
||||||
@@ -594,8 +613,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
exc.provider = exc.provider or label
|
logger.warning(
|
||||||
raise exc
|
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||||
|
label,
|
||||||
|
exc.retry_after or 0,
|
||||||
|
)
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Provider %s failed for get_user_models: %s", label, e)
|
logger.debug("Provider %s failed for get_user_models: %s", label, e)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -532,6 +532,13 @@ class ModelScanner:
|
|||||||
if not scan_result or not getattr(self, '_persistent_cache', None):
|
if not scan_result or not getattr(self, '_persistent_cache', None):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self.is_cancelled():
|
||||||
|
logger.info(
|
||||||
|
f"{self.model_type.capitalize()} Scanner: Skipping _save_persistent_cache "
|
||||||
|
"after cancellation"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
hash_snapshot = self._build_hash_index_snapshot(scan_result.hash_index)
|
hash_snapshot = self._build_hash_index_snapshot(scan_result.hash_index)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
try:
|
try:
|
||||||
@@ -705,6 +712,7 @@ class ModelScanner:
|
|||||||
# Determine the page type based on model type
|
# Determine the page type based on model type
|
||||||
# Scan for new data
|
# Scan for new data
|
||||||
scan_result = await self._gather_model_data()
|
scan_result = await self._gather_model_data()
|
||||||
|
if not self.is_cancelled():
|
||||||
await self._apply_scan_result(scan_result)
|
await self._apply_scan_result(scan_result)
|
||||||
await self._save_persistent_cache(scan_result)
|
await self._save_persistent_cache(scan_result)
|
||||||
await self._sync_download_history(scan_result.raw_data, source='scan')
|
await self._sync_download_history(scan_result.raw_data, source='scan')
|
||||||
@@ -713,6 +721,11 @@ class ModelScanner:
|
|||||||
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
|
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
|
||||||
f"found {len(scan_result.raw_data)} models"
|
f"found {len(scan_result.raw_data)} models"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"{self.model_type.capitalize()} Scanner: Cache initialization cancelled "
|
||||||
|
f"after {time.time() - start_time:.2f} seconds"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}")
|
logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}")
|
||||||
# Ensure cache is at least an empty structure on error
|
# Ensure cache is at least an empty structure on error
|
||||||
@@ -1067,8 +1080,11 @@ class ModelScanner:
|
|||||||
|
|
||||||
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
|
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
|
||||||
|
|
||||||
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes)
|
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes).
|
||||||
if not model_data.get('sha256') and file_path:
|
# Respect hash_status='pending' (set by CheckpointScanner for large models) to defer
|
||||||
|
# hash calculation until on-demand — avoids reading entire checkpoint files at startup.
|
||||||
|
hash_status = model_data.get('hash_status', '')
|
||||||
|
if not model_data.get('sha256') and hash_status != 'pending' and file_path:
|
||||||
try:
|
try:
|
||||||
logger.info(f"Computing SHA256 hash for {file_path} (was empty from metadata)")
|
logger.info(f"Computing SHA256 hash for {file_path} (was empty from metadata)")
|
||||||
sha256 = await calculate_sha256(file_path)
|
sha256 = await calculate_sha256(file_path)
|
||||||
@@ -1093,6 +1109,13 @@ class ModelScanner:
|
|||||||
if scan_result is None:
|
if scan_result is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self.is_cancelled():
|
||||||
|
logger.info(
|
||||||
|
f"{self.model_type.capitalize()} Scanner: Skipping _apply_scan_result "
|
||||||
|
"after cancellation"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
self._hash_index = scan_result.hash_index
|
self._hash_index = scan_result.hash_index
|
||||||
self._tags_count = dict(scan_result.tags_count)
|
self._tags_count = dict(scan_result.tags_count)
|
||||||
self._excluded_models = list(scan_result.excluded_models)
|
self._excluded_models = list(scan_result.excluded_models)
|
||||||
@@ -1762,6 +1785,13 @@ class ModelScanner:
|
|||||||
if not file_paths or self._cache is None:
|
if not file_paths or self._cache is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if self.is_cancelled():
|
||||||
|
logger.info(
|
||||||
|
f"{self.model_type.capitalize()} Scanner: Skipping cache update "
|
||||||
|
"after cancelled bulk delete"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get all models that need to be removed from cache
|
# Get all models that need to be removed from cache
|
||||||
models_to_remove = [item for item in self._cache.raw_data if item['file_path'] in file_paths]
|
models_to_remove = [item for item in self._cache.raw_data if item['file_path'] in file_paths]
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"autoplay_on_hover": False,
|
"autoplay_on_hover": False,
|
||||||
"display_density": "default",
|
"display_density": "default",
|
||||||
"card_info_display": "always",
|
"card_info_display": "always",
|
||||||
"show_folder_sidebar": True,
|
|
||||||
"include_trigger_words": False,
|
"include_trigger_words": False,
|
||||||
"compact_mode": False,
|
"compact_mode": False,
|
||||||
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
||||||
|
|
||||||
from ..metadata_sync_service import MetadataSyncService
|
from ..metadata_sync_service import MetadataSyncService
|
||||||
@@ -62,26 +63,48 @@ class BulkMetadataRefreshUseCase:
|
|||||||
]
|
]
|
||||||
|
|
||||||
total_to_process = len(to_process)
|
total_to_process = len(to_process)
|
||||||
|
initial_skipped = total_models - total_to_process # models excluded from fetch queue
|
||||||
processed = 0
|
processed = 0
|
||||||
success = 0
|
success = 0
|
||||||
|
skipped_count = initial_skipped
|
||||||
|
handled_count = initial_skipped
|
||||||
needs_resort = False
|
needs_resort = False
|
||||||
|
start_time = time.monotonic()
|
||||||
|
failures: List[Dict[str, str]] = []
|
||||||
|
|
||||||
self._service.scanner.reset_cancellation()
|
self._service.scanner.reset_cancellation()
|
||||||
|
|
||||||
async def emit(status: str, **extra: Any) -> None:
|
async def emit(status: str, **extra: Any) -> None:
|
||||||
if progress_callback is None:
|
if progress_callback is None:
|
||||||
return
|
return
|
||||||
payload = {"status": status, "total": total_to_process, "processed": processed, "success": success}
|
payload = {
|
||||||
|
"status": status,
|
||||||
|
"total": total_models,
|
||||||
|
"processed": processed,
|
||||||
|
"success": success,
|
||||||
|
"failure_count": len(failures),
|
||||||
|
"skipped_count": skipped_count,
|
||||||
|
"handled": handled_count,
|
||||||
|
"elapsed_seconds": int(time.monotonic() - start_time),
|
||||||
|
}
|
||||||
|
# Only include full failure details in terminal emits (completed,
|
||||||
|
# cancelled, rate_limited) to avoid serializing the list on every
|
||||||
|
# per-model progress update.
|
||||||
|
if failures and status in ("completed", "cancelled", "rate_limited"):
|
||||||
|
payload["failures"] = failures
|
||||||
payload.update(extra)
|
payload.update(extra)
|
||||||
await progress_callback.on_progress(payload)
|
await progress_callback.on_progress(payload)
|
||||||
|
|
||||||
await emit("started")
|
await emit("started")
|
||||||
|
|
||||||
|
RATE_LIMIT_ABORT_THRESHOLD = 3
|
||||||
|
consecutive_rate_limits = 0
|
||||||
|
|
||||||
for model in to_process:
|
for model in to_process:
|
||||||
if self._service.scanner.is_cancelled():
|
if self._service.scanner.is_cancelled():
|
||||||
self._logger.info("Bulk metadata refresh cancelled by user")
|
self._logger.info("Bulk metadata refresh cancelled by user")
|
||||||
await emit("cancelled", processed=processed, success=success)
|
await emit("cancelled", processed=processed, success=success)
|
||||||
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models}
|
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
|
||||||
try:
|
try:
|
||||||
original_name = model.get("model_name")
|
original_name = model.get("model_name")
|
||||||
|
|
||||||
@@ -101,31 +124,76 @@ class BulkMetadataRefreshUseCase:
|
|||||||
model["hash_status"] = "completed"
|
model["hash_status"] = "completed"
|
||||||
else:
|
else:
|
||||||
self._logger.error(f"Failed to calculate hash for {file_path}")
|
self._logger.error(f"Failed to calculate hash for {file_path}")
|
||||||
|
failures.append({"name": model.get("model_name", file_path or "Unknown"), "error": "Failed to calculate hash"})
|
||||||
processed += 1
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
self._logger.warning(f"Scanner does not support lazy hash calculation for {file_path}")
|
self._logger.warning(f"Scanner does not support lazy hash calculation for {file_path}")
|
||||||
|
skipped_count += 1
|
||||||
processed += 1
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip models without valid hash
|
# Skip models without valid hash
|
||||||
if not model.get("sha256"):
|
if not model.get("sha256"):
|
||||||
self._logger.warning(f"Skipping model without hash: {file_path}")
|
self._logger.warning(f"Skipping model without hash: {file_path}")
|
||||||
|
skipped_count += 1
|
||||||
processed += 1
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
await MetadataManager.hydrate_model_data(model)
|
await MetadataManager.hydrate_model_data(model)
|
||||||
result, _ = await self._metadata_sync.fetch_and_update_model(
|
result, error_msg = await self._metadata_sync.fetch_and_update_model(
|
||||||
sha256=model["sha256"],
|
sha256=model["sha256"],
|
||||||
file_path=model["file_path"],
|
file_path=model["file_path"],
|
||||||
model_data=model,
|
model_data=model,
|
||||||
update_cache_func=self._service.scanner.update_single_model_cache,
|
update_cache_func=self._service.scanner.update_single_model_cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not result and error_msg and "Rate limited" in error_msg:
|
||||||
|
consecutive_rate_limits += 1
|
||||||
|
else:
|
||||||
|
consecutive_rate_limits = 0
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
current_name = model.get("model_name", file_path or "Unknown")
|
||||||
|
failures.append({"name": current_name, "error": error_msg or "Unknown error"})
|
||||||
|
self._logger.warning("Failed to fetch metadata for %s: %s", current_name, error_msg)
|
||||||
|
|
||||||
|
if consecutive_rate_limits >= RATE_LIMIT_ABORT_THRESHOLD:
|
||||||
|
# The current model was attempted and failed due to rate limiting;
|
||||||
|
# count it before aborting so the summary is consistent.
|
||||||
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
|
self._logger.warning(
|
||||||
|
"Bulk metadata refresh aborted: %d consecutive rate limits detected. "
|
||||||
|
"Processed %d/%d models.",
|
||||||
|
consecutive_rate_limits,
|
||||||
|
processed,
|
||||||
|
total_to_process,
|
||||||
|
)
|
||||||
|
await emit(
|
||||||
|
"rate_limited",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Rate limit detected; {total_to_process - processed} models skipped",
|
||||||
|
"processed": processed,
|
||||||
|
"updated": success,
|
||||||
|
"total": total_models,
|
||||||
|
"failures": failures,
|
||||||
|
"failure_count": len(failures),
|
||||||
|
"skipped_count": skipped_count,
|
||||||
|
"elapsed_seconds": int(time.monotonic() - start_time),
|
||||||
|
}
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
success += 1
|
success += 1
|
||||||
if original_name != model.get("model_name"):
|
if original_name != model.get("model_name"):
|
||||||
needs_resort = True
|
needs_resort = True
|
||||||
processed += 1
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
await emit(
|
await emit(
|
||||||
"processing",
|
"processing",
|
||||||
processed=processed,
|
processed=processed,
|
||||||
@@ -134,6 +202,9 @@ class BulkMetadataRefreshUseCase:
|
|||||||
)
|
)
|
||||||
except Exception as exc: # pragma: no cover - logging path
|
except Exception as exc: # pragma: no cover - logging path
|
||||||
processed += 1
|
processed += 1
|
||||||
|
handled_count += 1
|
||||||
|
current_name = model.get("model_name", model.get("file_path", "Unknown"))
|
||||||
|
failures.append({"name": current_name, "error": str(exc)})
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
"Error fetching CivitAI data for %s: %s",
|
"Error fetching CivitAI data for %s: %s",
|
||||||
model.get("file_path"),
|
model.get("file_path"),
|
||||||
@@ -150,7 +221,7 @@ class BulkMetadataRefreshUseCase:
|
|||||||
f"{success} of {processed} processed {self._service.model_type}s (total: {total_models})"
|
f"{success} of {processed} processed {self._service.model_type}s (total: {total_models})"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models}
|
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool:
|
def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool:
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ PREVIEW_EXTENSIONS = [
|
|||||||
".mp4",
|
".mp4",
|
||||||
".gif",
|
".gif",
|
||||||
".webm",
|
".webm",
|
||||||
|
".avif",
|
||||||
|
".jxl",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Card preview image width
|
# Card preview image width
|
||||||
@@ -41,7 +43,7 @@ EXAMPLE_IMAGE_WIDTH = 832
|
|||||||
|
|
||||||
# Supported media extensions for example downloads
|
# Supported media extensions for example downloads
|
||||||
SUPPORTED_MEDIA_EXTENSIONS = {
|
SUPPORTED_MEDIA_EXTENSIONS = {
|
||||||
"images": [".jpg", ".jpeg", ".png", ".webp", ".gif"],
|
"images": [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif", ".jxl"],
|
||||||
"videos": [".mp4", ".webm"],
|
"videos": [".mp4", ".webm"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ class ExampleImagesProcessor:
|
|||||||
return '.gif'
|
return '.gif'
|
||||||
elif content.startswith(b'RIFF') and b'WEBP' in content[:12]:
|
elif content.startswith(b'RIFF') and b'WEBP' in content[:12]:
|
||||||
return '.webp'
|
return '.webp'
|
||||||
|
elif len(content) >= 12 and content[4:8] == b'ftyp' and b'avif' in content[8:24]:
|
||||||
|
return '.avif'
|
||||||
|
elif content.startswith(b'\x00\x00\x00\x0cJXL \x0d\x0a\x87\x0a'):
|
||||||
|
return '.jxl'
|
||||||
elif content.startswith(b'\x00\x00\x00\x18ftypmp4') or content.startswith(b'\x00\x00\x00\x20ftypmp4'):
|
elif content.startswith(b'\x00\x00\x00\x18ftypmp4') or content.startswith(b'\x00\x00\x00\x20ftypmp4'):
|
||||||
return '.mp4'
|
return '.mp4'
|
||||||
elif content.startswith(b'\x1A\x45\xDF\xA3'):
|
elif content.startswith(b'\x1A\x45\xDF\xA3'):
|
||||||
@@ -75,6 +79,8 @@ class ExampleImagesProcessor:
|
|||||||
'image/png': '.png',
|
'image/png': '.png',
|
||||||
'image/gif': '.gif',
|
'image/gif': '.gif',
|
||||||
'image/webp': '.webp',
|
'image/webp': '.webp',
|
||||||
|
'image/avif': '.avif',
|
||||||
|
'image/jxl': '.jxl',
|
||||||
'video/mp4': '.mp4',
|
'video/mp4': '.mp4',
|
||||||
'video/webm': '.webm',
|
'video/webm': '.webm',
|
||||||
'video/quicktime': '.mov'
|
'video/quicktime': '.mov'
|
||||||
|
|||||||
@@ -1,17 +1,125 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import struct
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import piexif
|
import piexif
|
||||||
from PIL import Image, PngImagePlugin
|
from PIL import Image, PngImagePlugin
|
||||||
|
|
||||||
|
try:
|
||||||
|
import brotli
|
||||||
|
_BROTLI_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
brotli = None
|
||||||
|
_BROTLI_AVAILABLE = False
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class ExifUtils:
|
class ExifUtils:
|
||||||
"""Utility functions for working with EXIF data in images"""
|
"""Utility functions for working with EXIF data in images"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_isobmff_boxes(data: bytes, offset: int = 0) -> list[dict]:
|
||||||
|
boxes = []
|
||||||
|
while offset + 8 <= len(data):
|
||||||
|
size = struct.unpack('>I', data[offset:offset + 4])[0]
|
||||||
|
box_type = data[offset + 4:offset + 8]
|
||||||
|
if size == 0:
|
||||||
|
break
|
||||||
|
if size < 8 or offset + size > len(data):
|
||||||
|
break
|
||||||
|
box_data = data[offset + 8:offset + size]
|
||||||
|
boxes.append({'type': box_type, 'data': box_data, 'size': size})
|
||||||
|
offset += size
|
||||||
|
return boxes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_jxl_container(data: bytes) -> bool:
|
||||||
|
if len(data) < 32:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
struct.unpack('>I', data[:4])[0] == 12
|
||||||
|
and data[4:8] == b'JXL '
|
||||||
|
and data[8:12] == bytes([0x0d, 0x0a, 0x87, 0x0a])
|
||||||
|
and struct.unpack('>I', data[12:16])[0] >= 16
|
||||||
|
and data[16:20] == b'ftyp'
|
||||||
|
and data[20:24] == b'jxl '
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_avif_container(data: bytes) -> bool:
|
||||||
|
if len(data) < 16:
|
||||||
|
return False
|
||||||
|
for box in ExifUtils._parse_isobmff_boxes(data):
|
||||||
|
if box['type'] == b'ftyp' and b'avif' in box['data']:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Max decompressed size for brotli metadata (2 MB)
|
||||||
|
_BROTLI_MAX_DECOMPRESSED = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_isobmff_brotli(image_path: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
with open(image_path, 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if ExifUtils._is_jxl_container(data):
|
||||||
|
boxes = ExifUtils._parse_isobmff_boxes(data, offset=12)
|
||||||
|
elif ExifUtils._is_avif_container(data):
|
||||||
|
boxes = ExifUtils._parse_isobmff_boxes(data)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
brob = None
|
||||||
|
for box in boxes:
|
||||||
|
if box['type'] == b'brob':
|
||||||
|
brob = box
|
||||||
|
break
|
||||||
|
if brob is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload = brob['data']
|
||||||
|
if payload[:4] != b'comf':
|
||||||
|
return None
|
||||||
|
compressed = payload[4:]
|
||||||
|
|
||||||
|
if _BROTLI_AVAILABLE:
|
||||||
|
try:
|
||||||
|
decompressed = brotli.decompress(compressed)
|
||||||
|
if len(decompressed) > ExifUtils._BROTLI_MAX_DECOMPRESSED:
|
||||||
|
logger.warning(
|
||||||
|
"Brotli metadata too large (%d bytes, max %d), ignoring",
|
||||||
|
len(decompressed),
|
||||||
|
ExifUtils._BROTLI_MAX_DECOMPRESSED,
|
||||||
|
)
|
||||||
|
decompressed = None
|
||||||
|
except Exception:
|
||||||
|
decompressed = None
|
||||||
|
else:
|
||||||
|
decompressed = None
|
||||||
|
|
||||||
|
raw = decompressed if decompressed is not None else compressed
|
||||||
|
try:
|
||||||
|
meta = json.loads(raw.decode('utf-8'))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = {"parameters": None, "prompt": None, "workflow": None, "comment": None}
|
||||||
|
if isinstance(meta.get("prompt"), (dict, list)):
|
||||||
|
result["prompt"] = json.dumps(meta["prompt"])
|
||||||
|
elif isinstance(meta.get("prompt"), str):
|
||||||
|
result["prompt"] = meta["prompt"]
|
||||||
|
if isinstance(meta.get("workflow"), (dict, list)):
|
||||||
|
result["workflow"] = json.dumps(meta["workflow"])
|
||||||
|
elif isinstance(meta.get("workflow"), str):
|
||||||
|
result["workflow"] = meta["workflow"]
|
||||||
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _decode_user_comment(user_comment: Any) -> Optional[str]:
|
def _decode_user_comment(user_comment: Any) -> Optional[str]:
|
||||||
if user_comment is None:
|
if user_comment is None:
|
||||||
@@ -43,6 +151,12 @@ class ExifUtils:
|
|||||||
"comment": None,
|
"comment": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ext = os.path.splitext(image_path)[1].lower()
|
||||||
|
if ext in ('.avif', '.jxl'):
|
||||||
|
brotli_meta = ExifUtils._extract_isobmff_brotli(image_path)
|
||||||
|
if brotli_meta:
|
||||||
|
return brotli_meta
|
||||||
|
|
||||||
with Image.open(image_path) as img:
|
with Image.open(image_path) as img:
|
||||||
info = getattr(img, "info", {}) or {}
|
info = getattr(img, "info", {}) or {}
|
||||||
|
|
||||||
@@ -149,7 +263,6 @@ class ExifUtils:
|
|||||||
Optional[str]: Extracted metadata or None if not found
|
Optional[str]: Extracted metadata or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Skip for video files
|
|
||||||
if image_path:
|
if image_path:
|
||||||
ext = os.path.splitext(image_path)[1].lower()
|
ext = os.path.splitext(image_path)[1].lower()
|
||||||
if ext in ['.mp4', '.webm']:
|
if ext in ['.mp4', '.webm']:
|
||||||
@@ -177,10 +290,9 @@ class ExifUtils:
|
|||||||
str: Path to the updated image
|
str: Path to the updated image
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Skip for video files
|
|
||||||
if image_path:
|
if image_path:
|
||||||
ext = os.path.splitext(image_path)[1].lower()
|
ext = os.path.splitext(image_path)[1].lower()
|
||||||
if ext in ['.mp4', '.webm']:
|
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
|
||||||
return image_path
|
return image_path
|
||||||
|
|
||||||
metadata_fields = ExifUtils._load_structured_metadata(image_path)
|
metadata_fields = ExifUtils._load_structured_metadata(image_path)
|
||||||
@@ -212,10 +324,9 @@ class ExifUtils:
|
|||||||
def append_recipe_metadata(image_path, recipe_data) -> str:
|
def append_recipe_metadata(image_path, recipe_data) -> str:
|
||||||
"""Append recipe metadata to an image's EXIF data"""
|
"""Append recipe metadata to an image's EXIF data"""
|
||||||
try:
|
try:
|
||||||
# Skip for video files
|
|
||||||
if image_path:
|
if image_path:
|
||||||
ext = os.path.splitext(image_path)[1].lower()
|
ext = os.path.splitext(image_path)[1].lower()
|
||||||
if ext in ['.mp4', '.webm']:
|
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
|
||||||
return image_path
|
return image_path
|
||||||
|
|
||||||
# First, extract existing metadata
|
# First, extract existing metadata
|
||||||
@@ -327,10 +438,9 @@ class ExifUtils:
|
|||||||
Tuple of (optimized_image_data, extension)
|
Tuple of (optimized_image_data, extension)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Skip for video files early if it's a file path
|
|
||||||
if isinstance(image_data, str) and os.path.exists(image_data):
|
if isinstance(image_data, str) and os.path.exists(image_data):
|
||||||
ext = os.path.splitext(image_data)[1].lower()
|
ext = os.path.splitext(image_data)[1].lower()
|
||||||
if ext in ['.mp4', '.webm']:
|
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
|
||||||
try:
|
try:
|
||||||
with open(image_data, 'rb') as f:
|
with open(image_data, 'rb') as f:
|
||||||
return f.read(), ext
|
return f.read(), ext
|
||||||
|
|||||||
@@ -34,12 +34,21 @@ def _get_hash_chunk_size_bytes() -> int:
|
|||||||
|
|
||||||
|
|
||||||
async def calculate_sha256(file_path: str) -> str:
|
async def calculate_sha256(file_path: str) -> str:
|
||||||
"""Calculate SHA256 hash of a file (full file content)."""
|
"""Calculate SHA256 hash of a file (full file content).
|
||||||
|
|
||||||
|
Uses ``posix_fadvise`` with ``POSIX_FADV_DONTNEED`` to avoid polluting the OS page
|
||||||
|
cache — critical on WSL where cached file pages live inside the VM and are not
|
||||||
|
accounted for in guest ``used`` memory, causing VmmemWSL to balloon.
|
||||||
|
"""
|
||||||
sha256_hash = hashlib.sha256()
|
sha256_hash = hashlib.sha256()
|
||||||
chunk_size = _get_hash_chunk_size_bytes()
|
chunk_size = _get_hash_chunk_size_bytes()
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
|
fd = f.fileno()
|
||||||
for byte_block in iter(lambda: f.read(chunk_size), b""):
|
for byte_block in iter(lambda: f.read(chunk_size), b""):
|
||||||
sha256_hash.update(byte_block)
|
sha256_hash.update(byte_block)
|
||||||
|
# Evict pages after reading so the data doesn't linger in the kernel page
|
||||||
|
# cache — on WSL this otherwise appears as unreclaimable VmmemWSL growth.
|
||||||
|
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
|
||||||
return sha256_hash.hexdigest()
|
return sha256_hash.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.1.1"
|
version = "1.1.2"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ aiosqlite
|
|||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
platformdirs
|
platformdirs
|
||||||
pyyaml
|
pyyaml
|
||||||
|
# brotli — ISOBMFF (AVIF/JXL) metadata decompression
|
||||||
|
brotli>=1.2.0
|
||||||
|
|||||||
196
static/css/components/metadata-refresh-result.css
Normal file
196
static/css/components/metadata-refresh-result.css
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/* Metadata Refresh Result Modal — component styles only */
|
||||||
|
|
||||||
|
.metadata-refresh-result-modal {
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-summary-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: var(--space-3) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card > i {
|
||||||
|
font-size: 1.25em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-value {
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--lora-text);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-success {
|
||||||
|
border-left-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-success > i {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-failure {
|
||||||
|
border-left-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-failure > i {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-skipped {
|
||||||
|
border-left-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-skipped > i {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-total {
|
||||||
|
border-left-color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-total > i {
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-time {
|
||||||
|
border-left-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-time > i {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-failures-section {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-failures-section h4 {
|
||||||
|
margin: 0 0 var(--space-2) 0;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-error);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-failures-section h4 i {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table-wrapper {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border-bottom: 2px solid var(--lora-border);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
text-align: left;
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table td {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-table tr:hover td {
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-index {
|
||||||
|
width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-name {
|
||||||
|
max-width: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-success-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
border-left: 4px solid var(--color-success);
|
||||||
|
color: var(--lora-text);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-success-message i {
|
||||||
|
font-size: 1.2em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .failure-table th {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .failure-table td {
|
||||||
|
border-bottom-color: var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .failure-table tr:hover td {
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
@@ -8,69 +8,28 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: var(--transition-slow);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
z-index: var(--z-overlay);
|
z-index: var(--z-overlay);
|
||||||
box-shadow: var(--shadow-header);
|
box-shadow: var(--shadow-header);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
/* Default state: hidden off-screen */
|
/* Default: hidden off-screen — prevents flash before JS runs */
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-sidebar.hidden-by-setting {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Visible state */
|
|
||||||
.folder-sidebar.visible {
|
.folder-sidebar.visible {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Auto-hide states */
|
.folder-sidebar.hidden-by-setting {
|
||||||
.folder-sidebar.auto-hide {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-sidebar.auto-hide.hover-active {
|
|
||||||
transform: translateX(0);
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-sidebar.collapsed {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover detection area for auto-hide */
|
|
||||||
.sidebar-hover-area {
|
|
||||||
position: fixed;
|
|
||||||
top: 68px;
|
|
||||||
left: 0;
|
|
||||||
width: 20px;
|
|
||||||
height: calc(100vh - 88px);
|
|
||||||
z-index: calc(var(--z-overlay) - 1);
|
|
||||||
background: transparent;
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-hover-area.hidden-by-setting {
|
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-hover-area.disabled {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -151,65 +110,6 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Sidebar More Options Dropdown ===== */
|
|
||||||
.sidebar-more-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
right: 8px;
|
|
||||||
min-width: 190px;
|
|
||||||
background: var(--bg-color);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
z-index: calc(var(--z-overlay) + 20);
|
|
||||||
display: none;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-more-dropdown.open {
|
|
||||||
display: block;
|
|
||||||
animation: dropdownFadeIn 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dropdownFadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(-4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-dropdown-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85em;
|
|
||||||
color: var(--text-color);
|
|
||||||
transition: var(--transition-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-dropdown-item:hover {
|
|
||||||
background: var(--lora-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-dropdown-item i {
|
|
||||||
width: 16px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.9em;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-dropdown-item:hover i {
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-dropdown-item.disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Sidebar Hidden Indicator (left edge) ===== */
|
/* ===== Sidebar Hidden Indicator (left edge) ===== */
|
||||||
.sidebar-hidden-indicator {
|
.sidebar-hidden-indicator {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -630,7 +530,7 @@
|
|||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
/* Responsive Design — Mobile: overlay when shown */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.folder-sidebar {
|
.folder-sidebar {
|
||||||
top: 68px;
|
top: 68px;
|
||||||
@@ -641,12 +541,8 @@
|
|||||||
z-index: calc(var(--z-overlay) + 10);
|
z-index: calc(var(--z-overlay) + 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-sidebar.collapsed {
|
/* Mobile overlay when sidebar is shown */
|
||||||
transform: translateX(-100%);
|
.folder-sidebar.visible::before {
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile overlay */
|
|
||||||
.folder-sidebar:not(.collapsed)::before {
|
|
||||||
content: '';
|
content: '';
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
@import 'components/statistics.css'; /* Add statistics component */
|
@import 'components/statistics.css'; /* Add statistics component */
|
||||||
@import 'components/sidebar.css'; /* Add sidebar component */
|
@import 'components/sidebar.css'; /* Add sidebar component */
|
||||||
@import 'components/media-viewer.css';
|
@import 'components/media-viewer.css';
|
||||||
|
@import 'components/metadata-refresh-result.css';
|
||||||
|
|
||||||
.initialization-notice {
|
.initialization-notice {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -468,17 +468,21 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refreshModels(fullRebuild = false) {
|
async refreshModels(fullRebuild = false) {
|
||||||
|
const abortController = new AbortController();
|
||||||
try {
|
try {
|
||||||
state.loadingManager.show(
|
state.loadingManager.show(
|
||||||
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`,
|
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`,
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
state.loadingManager.showCancelButton(() => this.cancelTask());
|
state.loadingManager.showCancelButton(() => {
|
||||||
|
this.cancelTask();
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
const url = new URL(this.apiConfig.endpoints.scan, window.location.origin);
|
const url = new URL(this.apiConfig.endpoints.scan, window.location.origin);
|
||||||
url.searchParams.append('full_rebuild', fullRebuild);
|
url.searchParams.append('full_rebuild', fullRebuild);
|
||||||
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url, { signal: abortController.signal });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`);
|
throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`);
|
||||||
@@ -494,6 +498,10 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
|
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('Refresh failed:', error);
|
console.error('Refresh failed:', error);
|
||||||
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error');
|
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -547,6 +555,14 @@ export class BaseModelApiClient {
|
|||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
|
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
|
||||||
|
|
||||||
|
// Wait for WebSocket connection to establish
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
ws.onopen = resolve;
|
||||||
|
ws.onerror = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now that we're connected, set up the message/error handlers
|
||||||
|
// for the actual operation (separate from connection errors)
|
||||||
const operationComplete = new Promise((resolve, reject) => {
|
const operationComplete = new Promise((resolve, reject) => {
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
@@ -556,25 +572,39 @@ export class BaseModelApiClient {
|
|||||||
loading.setStatus('Starting metadata fetch...');
|
loading.setStatus('Starting metadata fetch...');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'processing':
|
case 'processing': {
|
||||||
const percent = ((data.processed / data.total) * 100).toFixed(1);
|
const handled = data.handled || data.processed;
|
||||||
|
const percent = ((handled / data.total) * 100).toFixed(1);
|
||||||
loading.setProgress(percent);
|
loading.setProgress(percent);
|
||||||
loading.setStatus(
|
let statusText = `Processing (${handled}/${data.total}) ${data.current_name || ''}`;
|
||||||
`Processing (${data.processed}/${data.total}) ${data.current_name}`
|
if (data.failure_count > 0) {
|
||||||
);
|
statusText += ` | ❌ ${data.failure_count} failed`;
|
||||||
|
}
|
||||||
|
if (data.skipped_count > 0) {
|
||||||
|
statusText += ` | ⏭️ ${data.skipped_count} skipped`;
|
||||||
|
}
|
||||||
|
loading.setStatus(statusText);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'completed':
|
case 'completed': {
|
||||||
loading.setProgress(100);
|
loading.setProgress(100);
|
||||||
loading.setStatus(
|
let summaryText = `Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s`;
|
||||||
`Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s`
|
if (data.failure_count > 0) {
|
||||||
);
|
summaryText += ` | ❌ ${data.failure_count} failed`;
|
||||||
|
}
|
||||||
|
if (data.skipped_count > 0) {
|
||||||
|
summaryText += ` | ⏭️ ${data.skipped_count} skipped`;
|
||||||
|
}
|
||||||
|
summaryText += ` (⏱ ${data.elapsed_seconds || '?'}s)`;
|
||||||
|
loading.setStatus(summaryText);
|
||||||
resolve(data);
|
resolve(data);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
loading.setStatus('Operation cancelled by user');
|
loading.setStatus('Operation cancelled by user');
|
||||||
resolve(data); // Consider it complete but marked as cancelled
|
resolve(data);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
@@ -588,12 +618,6 @@ export class BaseModelApiClient {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for WebSocket connection to establish
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
ws.onopen = resolve;
|
|
||||||
ws.onerror = reject;
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, {
|
const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -608,10 +632,10 @@ export class BaseModelApiClient {
|
|||||||
const finalData = await operationComplete;
|
const finalData = await operationComplete;
|
||||||
|
|
||||||
resetAndReload(false);
|
resetAndReload(false);
|
||||||
if (finalData && finalData.status === 'cancelled') {
|
|
||||||
showToast('toast.api.operationCancelledPartial', { success: finalData.success, total: finalData.total }, 'info');
|
// Show result summary with failure details
|
||||||
} else {
|
if (finalData) {
|
||||||
showToast('toast.api.metadataUpdateComplete', {}, 'success');
|
this._showMetadataRefreshResult(finalData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching metadata:', error);
|
console.error('Error fetching metadata:', error);
|
||||||
@@ -627,6 +651,210 @@ export class BaseModelApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_showMetadataRefreshResult(data) {
|
||||||
|
const { success, total } = data;
|
||||||
|
|
||||||
|
if (data.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelledPartial', { success, total }, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._showFailureDetailsModal(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
_showFailureDetailsModal(data) {
|
||||||
|
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
|
||||||
|
|
||||||
|
// Build failure list HTML
|
||||||
|
const failureRows = failures.map((f, i) =>
|
||||||
|
`<tr>
|
||||||
|
<td class="failure-index">${i + 1}</td>
|
||||||
|
<td class="failure-name" title="${this._escapeHtml(f.name)}">${this._escapeHtml(f.name)}</td>
|
||||||
|
<td class="failure-error">${this._escapeHtml(f.error || 'Unknown')}</td>
|
||||||
|
</tr>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const modalHtml = `
|
||||||
|
<div id="metadataRefreshResultModal" class="modal" style="display: block;">
|
||||||
|
<div class="modal-content metadata-refresh-result-modal">
|
||||||
|
<button class="close" data-action="close-modal">×</button>
|
||||||
|
|
||||||
|
<h2><i class="fas fa-sync-alt"></i> ${translate('modals.metadataFetchSummary.title', {}, 'Metadata Fetch Summary')}</h2>
|
||||||
|
|
||||||
|
<div class="refresh-summary-stats">
|
||||||
|
<div class="stat-card stat-card-success">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSuccess', {}, 'Success')}</span>
|
||||||
|
<span class="stat-card-value">${success}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-failure">
|
||||||
|
<i class="fas fa-times-circle"></i>
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statFailed', {}, 'Failed')}</span>
|
||||||
|
<span class="stat-card-value">${failure_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-skipped">
|
||||||
|
<i class="fas fa-forward"></i>
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSkipped', {}, 'Skipped')}</span>
|
||||||
|
<span class="stat-card-value">${skipped_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-total">
|
||||||
|
<i class="fas fa-database"></i>
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statTotal', {}, 'Total Scanned')}</span>
|
||||||
|
<span class="stat-card-value">${total || processed}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-time">
|
||||||
|
<i class="fas fa-clock"></i>
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statDuration', {}, 'Duration')}</span>
|
||||||
|
<span class="stat-card-value">${elapsed_seconds}s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${failure_count > 0 ? `
|
||||||
|
<div class="refresh-failures-section">
|
||||||
|
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.metadataFetchSummary.failedItems', { count: failure_count }, 'Failed Items (' + failure_count + ')')}</h4>
|
||||||
|
<div class="failure-table-wrapper">
|
||||||
|
<table class="failure-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>${translate('modals.metadataFetchSummary.columnModelName', {}, 'Model Name')}</th>
|
||||||
|
<th>${translate('modals.metadataFetchSummary.columnError', {}, 'Error')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${failureRows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="refresh-success-message">
|
||||||
|
<i class="fas fa-check-circle"></i> ${translate('modals.metadataFetchSummary.successMessage', { count: success, type: this.apiConfig.config.displayName }, 'All ' + success + ' ' + this.apiConfig.config.displayName + 's updated successfully!')}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="cancel-btn" data-action="close-modal">${translate('modals.metadataFetchSummary.close', {}, 'Close')}</button>
|
||||||
|
${failure_count > 0 ? `
|
||||||
|
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.metadataFetchSummary.copyReport', {}, 'Copy Report')}</button>
|
||||||
|
<button class="secondary-btn" data-action="download-csv"><i class="fas fa-download"></i> ${translate('modals.metadataFetchSummary.downloadCsv', {}, 'Download CSV')}</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const existing = document.getElementById('metadataRefreshResultModal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.innerHTML = modalHtml;
|
||||||
|
const modal = container.firstElementChild;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
const action = e.target.closest('[data-action]')?.dataset.action;
|
||||||
|
if (!action) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'close-modal':
|
||||||
|
modal.remove();
|
||||||
|
break;
|
||||||
|
case 'copy-report':
|
||||||
|
BaseModelApiClient._copyRefreshReport(e.target.closest('[data-action]'), data);
|
||||||
|
break;
|
||||||
|
case 'download-csv':
|
||||||
|
BaseModelApiClient._downloadRefreshReport(data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _copyRefreshReport(btn, data) {
|
||||||
|
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
|
||||||
|
const lines = [
|
||||||
|
'=== Metadata Refresh Report ===',
|
||||||
|
`Date: ${new Date().toLocaleString()}`,
|
||||||
|
`Duration: ${elapsed_seconds}s`,
|
||||||
|
`Total scanned: ${total || processed}`,
|
||||||
|
`Successfully updated: ${success}`,
|
||||||
|
`Failed: ${failure_count}`,
|
||||||
|
`Skipped: ${skipped_count}`,
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
if (failure_count > 0) {
|
||||||
|
lines.push('--- Failed Items ---');
|
||||||
|
failures.forEach((f, i) => {
|
||||||
|
lines.push(`${i + 1}. ${f.name || 'Unknown'} — ${f.error || 'Unknown error'}`);
|
||||||
|
});
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
lines.push('====================');
|
||||||
|
|
||||||
|
const text = lines.join('\n');
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
showToast('toast.api.copiedToClipboard', {}, 'success');
|
||||||
|
if (btn) {
|
||||||
|
const origHTML = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||||
|
setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// Fallback
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
showToast('toast.api.copiedToClipboard', {}, 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static _downloadRefreshReport(data) {
|
||||||
|
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
|
||||||
|
|
||||||
|
// CSV header
|
||||||
|
let csv = 'Model Name,Error\n';
|
||||||
|
failures.forEach(f => {
|
||||||
|
const name = (f.name || 'Unknown').replace(/"/g, '""');
|
||||||
|
const error = (f.error || 'Unknown').replace(/"/g, '""');
|
||||||
|
csv += `"${name}","${error}"\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add summary as trailing comments
|
||||||
|
csv += `\n# Summary: ${success} success, ${failure_count} failed, ${skipped_count} skipped, ${elapsed_seconds}s\n`;
|
||||||
|
csv += `# Total scanned: ${total || processed}\n`;
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `metadata-refresh-failures-${Date.now()}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
showToast('toast.api.downloadStarted', {}, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
async refreshBulkModelMetadata(filePaths) {
|
async refreshBulkModelMetadata(filePaths) {
|
||||||
if (!filePaths || filePaths.length === 0) {
|
if (!filePaths || filePaths.length === 0) {
|
||||||
throw new Error('No file paths provided');
|
throw new Error('No file paths provided');
|
||||||
@@ -728,13 +956,19 @@ export class BaseModelApiClient {
|
|||||||
throw new Error('No model IDs provided');
|
throw new Error('No model IDs provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
state.loadingManager.show('Checking for updates...', 0);
|
state.loadingManager.show('Checking for updates...', 0);
|
||||||
state.loadingManager.showCancelButton(() => this.cancelTask());
|
state.loadingManager.showCancelButton(() => {
|
||||||
|
this.cancelTask();
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
|
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: abortController.signal,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model_ids: modelIds,
|
model_ids: modelIds,
|
||||||
force
|
force
|
||||||
@@ -759,6 +993,10 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
console.error('Error refreshing updates for models:', error);
|
console.error('Error refreshing updates for models:', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -771,13 +1009,19 @@ export class BaseModelApiClient {
|
|||||||
throw new Error('No folder path provided');
|
throw new Error('No folder path provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
state.loadingManager.show('Checking for updates...', 0);
|
state.loadingManager.show('Checking for updates...', 0);
|
||||||
state.loadingManager.showCancelButton(() => this.cancelTask());
|
state.loadingManager.showCancelButton(() => {
|
||||||
|
this.cancelTask();
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
|
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: abortController.signal,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
folder_path: folderPath,
|
folder_path: folderPath,
|
||||||
force
|
force
|
||||||
@@ -802,6 +1046,10 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
console.error('Error refreshing updates for folder:', error);
|
console.error('Error refreshing updates for folder:', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1251,15 +1499,21 @@ export class BaseModelApiClient {
|
|||||||
throw new Error('No file paths provided');
|
throw new Error('No file paths provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`);
|
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`);
|
||||||
state.loadingManager.showCancelButton(() => this.cancelTask());
|
state.loadingManager.showCancelButton(() => {
|
||||||
|
this.cancelTask();
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
|
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
|
signal: abortController.signal,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
file_paths: filePaths
|
file_paths: filePaths
|
||||||
})
|
})
|
||||||
@@ -1282,6 +1536,10 @@ export class BaseModelApiClient {
|
|||||||
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
|
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
console.log(`Bulk delete cancelled by user for ${this.apiConfig.config.displayName.toLowerCase()}s`);
|
||||||
|
return { success: false, cancelled: true };
|
||||||
|
}
|
||||||
console.error(`Error during bulk delete of ${this.apiConfig.config.displayName.toLowerCase()}s:`, error);
|
console.error(`Error during bulk delete of ${this.apiConfig.config.displayName.toLowerCase()}s:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -17,12 +17,8 @@ export class SidebarManager {
|
|||||||
this.treeData = {};
|
this.treeData = {};
|
||||||
this.selectedPath = '';
|
this.selectedPath = '';
|
||||||
this.expandedNodes = new Set();
|
this.expandedNodes = new Set();
|
||||||
this.isVisible = true;
|
|
||||||
this.isPinned = false;
|
|
||||||
this.apiClient = null;
|
this.apiClient = null;
|
||||||
this.openDropdown = null;
|
this.openDropdown = null;
|
||||||
this.hoverTimeout = null;
|
|
||||||
this.isHovering = false;
|
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
this.displayMode = 'tree'; // 'tree' or 'list'
|
this.displayMode = 'tree'; // 'tree' or 'list'
|
||||||
this.foldersList = [];
|
this.foldersList = [];
|
||||||
@@ -35,9 +31,7 @@ export class SidebarManager {
|
|||||||
this.folderTreeElement = null;
|
this.folderTreeElement = null;
|
||||||
this.currentDropTarget = null;
|
this.currentDropTarget = null;
|
||||||
this.lastPageControls = null;
|
this.lastPageControls = null;
|
||||||
this.isDisabledBySetting = false;
|
|
||||||
this.isDisabledByPage = false;
|
this.isDisabledByPage = false;
|
||||||
this.isMoreDropdownOpen = false;
|
|
||||||
this.initializationPromise = null;
|
this.initializationPromise = null;
|
||||||
this.isCreatingFolder = false;
|
this.isCreatingFolder = false;
|
||||||
this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态
|
this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态
|
||||||
@@ -48,12 +42,7 @@ export class SidebarManager {
|
|||||||
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
|
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
|
||||||
this.handleDocumentClick = this.handleDocumentClick.bind(this);
|
this.handleDocumentClick = this.handleDocumentClick.bind(this);
|
||||||
this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this);
|
this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this);
|
||||||
this.handlePinToggle = this.handlePinToggle.bind(this);
|
|
||||||
this.handleCollapseAll = this.handleCollapseAll.bind(this);
|
this.handleCollapseAll = this.handleCollapseAll.bind(this);
|
||||||
this.handleMouseEnter = this.handleMouseEnter.bind(this);
|
|
||||||
this.handleMouseLeave = this.handleMouseLeave.bind(this);
|
|
||||||
this.handleHoverAreaEnter = this.handleHoverAreaEnter.bind(this);
|
|
||||||
this.handleHoverAreaLeave = this.handleHoverAreaLeave.bind(this);
|
|
||||||
this.updateContainerMargin = this.updateContainerMargin.bind(this);
|
this.updateContainerMargin = this.updateContainerMargin.bind(this);
|
||||||
this.handleDisplayModeToggle = this.handleDisplayModeToggle.bind(this);
|
this.handleDisplayModeToggle = this.handleDisplayModeToggle.bind(this);
|
||||||
this.handleFolderListClick = this.handleFolderListClick.bind(this);
|
this.handleFolderListClick = this.handleFolderListClick.bind(this);
|
||||||
@@ -70,9 +59,7 @@ export class SidebarManager {
|
|||||||
this.handleSidebarDrop = this.handleSidebarDrop.bind(this);
|
this.handleSidebarDrop = this.handleSidebarDrop.bind(this);
|
||||||
this.handleCreateFolderSubmit = this.handleCreateFolderSubmit.bind(this);
|
this.handleCreateFolderSubmit = this.handleCreateFolderSubmit.bind(this);
|
||||||
this.handleCreateFolderCancel = this.handleCreateFolderCancel.bind(this);
|
this.handleCreateFolderCancel = this.handleCreateFolderCancel.bind(this);
|
||||||
this.handleMoreToggle = this.handleMoreToggle.bind(this);
|
this.handleHideToggle = this.handleHideToggle.bind(this);
|
||||||
this.handleMoreDropdownItemClick = this.handleMoreDropdownItemClick.bind(this);
|
|
||||||
this.handleDocumentClickForMore = this.handleDocumentClickForMore.bind(this);
|
|
||||||
this.getPageDisplayName = this.getPageDisplayName.bind(this);
|
this.getPageDisplayName = this.getPageDisplayName.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,12 +68,6 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initialize(pageControls, options = {}) {
|
async initialize(pageControls, options = {}) {
|
||||||
const { forceInitialize = false } = options;
|
|
||||||
|
|
||||||
if (this.isDisabledBySetting && !forceInitialize) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up previous initialization if exists
|
// Clean up previous initialization if exists
|
||||||
if (this.isInitialized) {
|
if (this.isInitialized) {
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
@@ -99,25 +80,15 @@ export class SidebarManager {
|
|||||||
|| pageControls?.sidebarApiClient
|
|| pageControls?.sidebarApiClient
|
||||||
|| getModelApiClient();
|
|| getModelApiClient();
|
||||||
|
|
||||||
// Set initial sidebar state immediately (hidden by default)
|
|
||||||
this.setInitialSidebarState();
|
|
||||||
|
|
||||||
this.setupEventHandlers();
|
this.setupEventHandlers();
|
||||||
this.initializeDragAndDrop();
|
this.initializeDragAndDrop();
|
||||||
this.updateSidebarTitle();
|
this.updateSidebarTitle();
|
||||||
this.restoreSidebarState();
|
this.restoreSidebarState();
|
||||||
// Re-apply DOM visibility now that per-page state is known
|
// Apply DOM visibility based on per-page state
|
||||||
this.updateDomVisibility(!this.isDisabledBySetting);
|
this.updateDomVisibility();
|
||||||
await this.loadFolderTree();
|
await this.loadFolderTree();
|
||||||
if (this.isDisabledBySetting && !forceInitialize) {
|
|
||||||
this.cleanup();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.restoreSelectedFolder();
|
this.restoreSelectedFolder();
|
||||||
|
|
||||||
// Apply final state with animation after everything is loaded
|
|
||||||
this.applyFinalSidebarState();
|
|
||||||
|
|
||||||
// Update container margin based on initial sidebar state
|
// Update container margin based on initial sidebar state
|
||||||
this.updateContainerMargin();
|
this.updateContainerMargin();
|
||||||
|
|
||||||
@@ -128,12 +99,6 @@ export class SidebarManager {
|
|||||||
cleanup() {
|
cleanup() {
|
||||||
if (!this.isInitialized) return;
|
if (!this.isInitialized) return;
|
||||||
|
|
||||||
// Clear any pending timeouts
|
|
||||||
if (this.hoverTimeout) {
|
|
||||||
clearTimeout(this.hoverTimeout);
|
|
||||||
this.hoverTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up event handlers
|
// Clean up event handlers
|
||||||
this.removeEventHandlers();
|
this.removeEventHandlers();
|
||||||
|
|
||||||
@@ -151,11 +116,6 @@ export class SidebarManager {
|
|||||||
this.sidebarDragHandlersInitialized = false;
|
this.sidebarDragHandlersInitialized = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const moreDropdown = document.getElementById('sidebarMoreDropdown');
|
|
||||||
if (moreDropdown) {
|
|
||||||
moreDropdown.classList.remove('open');
|
|
||||||
}
|
|
||||||
this.isMoreDropdownOpen = false;
|
|
||||||
this.hideSidebarHiddenIndicator();
|
this.hideSidebarHiddenIndicator();
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
@@ -165,7 +125,6 @@ export class SidebarManager {
|
|||||||
this.selectedPath = '';
|
this.selectedPath = '';
|
||||||
this.expandedNodes = new Set();
|
this.expandedNodes = new Set();
|
||||||
this.openDropdown = null;
|
this.openDropdown = null;
|
||||||
this.isHovering = false;
|
|
||||||
this.isDisabledByPage = false;
|
this.isDisabledByPage = false;
|
||||||
this.apiClient = null;
|
this.apiClient = null;
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
@@ -185,19 +144,13 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeEventHandlers() {
|
removeEventHandlers() {
|
||||||
const pinToggleBtn = document.getElementById('sidebarPinToggle');
|
|
||||||
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
||||||
const folderTree = document.getElementById('sidebarFolderTree');
|
const folderTree = document.getElementById('sidebarFolderTree');
|
||||||
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
||||||
const sidebarHeader = document.getElementById('sidebarHeader');
|
const sidebarHeader = document.getElementById('sidebarHeader');
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
|
||||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
|
||||||
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
|
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
|
||||||
const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle');
|
const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle');
|
||||||
|
|
||||||
if (pinToggleBtn) {
|
|
||||||
pinToggleBtn.removeEventListener('click', this.handlePinToggle);
|
|
||||||
}
|
|
||||||
if (collapseAllBtn) {
|
if (collapseAllBtn) {
|
||||||
collapseAllBtn.removeEventListener('click', this.handleCollapseAll);
|
collapseAllBtn.removeEventListener('click', this.handleCollapseAll);
|
||||||
}
|
}
|
||||||
@@ -212,14 +165,6 @@ export class SidebarManager {
|
|||||||
if (sidebarHeader) {
|
if (sidebarHeader) {
|
||||||
sidebarHeader.removeEventListener('click', this.handleSidebarHeaderClick);
|
sidebarHeader.removeEventListener('click', this.handleSidebarHeaderClick);
|
||||||
}
|
}
|
||||||
if (sidebar) {
|
|
||||||
sidebar.removeEventListener('mouseenter', this.handleMouseEnter);
|
|
||||||
sidebar.removeEventListener('mouseleave', this.handleMouseLeave);
|
|
||||||
}
|
|
||||||
if (hoverArea) {
|
|
||||||
hoverArea.removeEventListener('mouseenter', this.handleHoverAreaEnter);
|
|
||||||
hoverArea.removeEventListener('mouseleave', this.handleHoverAreaLeave);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove document click handler
|
// Remove document click handler
|
||||||
document.removeEventListener('click', this.handleDocumentClick);
|
document.removeEventListener('click', this.handleDocumentClick);
|
||||||
@@ -234,17 +179,10 @@ export class SidebarManager {
|
|||||||
recursiveToggleBtn.removeEventListener('click', this.handleRecursiveToggle);
|
recursiveToggleBtn.removeEventListener('click', this.handleRecursiveToggle);
|
||||||
}
|
}
|
||||||
|
|
||||||
const moreToggle = document.getElementById('sidebarMoreToggle');
|
const hideToggle = document.getElementById('sidebarHideToggle');
|
||||||
if (moreToggle) {
|
if (hideToggle) {
|
||||||
moreToggle.removeEventListener('click', this.handleMoreToggle);
|
hideToggle.removeEventListener('click', this.handleHideToggle);
|
||||||
}
|
}
|
||||||
|
|
||||||
const moreDropdown = document.getElementById('sidebarMoreDropdown');
|
|
||||||
if (moreDropdown) {
|
|
||||||
moreDropdown.removeEventListener('click', this.handleMoreDropdownItemClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.removeEventListener('click', this.handleDocumentClickForMore);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeDragAndDrop() {
|
initializeDragAndDrop() {
|
||||||
@@ -919,60 +857,6 @@ export class SidebarManager {
|
|||||||
this.currentDropTarget = null;
|
this.currentDropTarget = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
|
||||||
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|
|
||||||
|| this.pageControls?.sidebarApiClient
|
|
||||||
|| getModelApiClient();
|
|
||||||
|
|
||||||
// Set initial sidebar state immediately (hidden by default)
|
|
||||||
this.setInitialSidebarState();
|
|
||||||
|
|
||||||
this.setupEventHandlers();
|
|
||||||
this.initializeDragAndDrop();
|
|
||||||
this.updateSidebarTitle();
|
|
||||||
this.restoreSidebarState();
|
|
||||||
await this.loadFolderTree();
|
|
||||||
this.restoreSelectedFolder();
|
|
||||||
|
|
||||||
// Apply final state with animation after everything is loaded
|
|
||||||
this.applyFinalSidebarState();
|
|
||||||
|
|
||||||
// Update container margin based on initial sidebar state
|
|
||||||
this.updateContainerMargin();
|
|
||||||
}
|
|
||||||
|
|
||||||
setInitialSidebarState() {
|
|
||||||
if (this.isDisabledBySetting) return;
|
|
||||||
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
|
||||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
|
||||||
|
|
||||||
if (!sidebar || !hoverArea) return;
|
|
||||||
|
|
||||||
// Get stored pin state
|
|
||||||
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true);
|
|
||||||
this.isPinned = isPinned;
|
|
||||||
|
|
||||||
// Sidebar starts hidden by default (CSS handles this)
|
|
||||||
// Just set up the hover area state
|
|
||||||
if (window.innerWidth <= 1024) {
|
|
||||||
hoverArea.classList.add('disabled');
|
|
||||||
} else if (this.isPinned) {
|
|
||||||
hoverArea.classList.add('disabled');
|
|
||||||
} else {
|
|
||||||
hoverArea.classList.remove('disabled');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyFinalSidebarState() {
|
|
||||||
if (this.isDisabledBySetting) return;
|
|
||||||
|
|
||||||
// Use requestAnimationFrame to ensure DOM is ready
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
this.updateAutoHideState();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSidebarTitle() {
|
updateSidebarTitle() {
|
||||||
const sidebarTitle = document.getElementById('sidebarTitle');
|
const sidebarTitle = document.getElementById('sidebarTitle');
|
||||||
if (sidebarTitle) {
|
if (sidebarTitle) {
|
||||||
@@ -987,12 +871,6 @@ export class SidebarManager {
|
|||||||
sidebarHeader.addEventListener('click', this.handleSidebarHeaderClick);
|
sidebarHeader.addEventListener('click', this.handleSidebarHeaderClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pin toggle button
|
|
||||||
const pinToggleBtn = document.getElementById('sidebarPinToggle');
|
|
||||||
if (pinToggleBtn) {
|
|
||||||
pinToggleBtn.addEventListener('click', this.handlePinToggle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collapse all button
|
// Collapse all button
|
||||||
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
||||||
if (collapseAllBtn) {
|
if (collapseAllBtn) {
|
||||||
@@ -1018,34 +896,18 @@ export class SidebarManager {
|
|||||||
sidebarBreadcrumbNav.addEventListener('click', this.handleBreadcrumbClick);
|
sidebarBreadcrumbNav.addEventListener('click', this.handleBreadcrumbClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hover detection for auto-hide
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
|
||||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
|
||||||
|
|
||||||
if (sidebar) {
|
|
||||||
sidebar.addEventListener('mouseenter', this.handleMouseEnter);
|
|
||||||
sidebar.addEventListener('mouseleave', this.handleMouseLeave);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hoverArea) {
|
|
||||||
hoverArea.addEventListener('mouseenter', this.handleHoverAreaEnter);
|
|
||||||
hoverArea.addEventListener('mouseleave', this.handleHoverAreaLeave);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close sidebar when clicking outside on mobile
|
// Close sidebar when clicking outside on mobile
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (window.innerWidth <= 1024 && this.isVisible) {
|
if (window.innerWidth <= 1024) {
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
|
if (sidebar && !sidebar.contains(e.target) && !this.isDisabledByPage) {
|
||||||
if (sidebar && !sidebar.contains(e.target)) {
|
sidebar.classList.remove('visible');
|
||||||
this.hideSidebar();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle window resize
|
// Handle window resize
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
this.updateAutoHideState();
|
|
||||||
this.updateContainerMargin();
|
this.updateContainerMargin();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1074,18 +936,11 @@ export class SidebarManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// More options dropdown
|
// Dedicated hide sidebar button
|
||||||
const moreToggle = document.getElementById('sidebarMoreToggle');
|
const hideToggle = document.getElementById('sidebarHideToggle');
|
||||||
if (moreToggle) {
|
if (hideToggle) {
|
||||||
moreToggle.addEventListener('click', this.handleMoreToggle);
|
hideToggle.addEventListener('click', this.handleHideToggle);
|
||||||
}
|
}
|
||||||
|
|
||||||
const moreDropdown = document.getElementById('sidebarMoreDropdown');
|
|
||||||
if (moreDropdown) {
|
|
||||||
moreDropdown.addEventListener('click', this.handleMoreDropdownItemClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', this.handleDocumentClickForMore);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDocumentClick(event) {
|
handleDocumentClick(event) {
|
||||||
@@ -1102,14 +957,9 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePinToggle(event) {
|
handleHideToggle(event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.isPinned = !this.isPinned;
|
this.toggleHideOnThisPage();
|
||||||
this.updateAutoHideState();
|
|
||||||
this.updatePinButton();
|
|
||||||
this.updateMoreDropdownLabels();
|
|
||||||
this.saveSidebarState();
|
|
||||||
this.updateContainerMargin();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCollapseAll(event) {
|
handleCollapseAll(event) {
|
||||||
@@ -1119,102 +969,13 @@ export class SidebarManager {
|
|||||||
this.saveExpandedState();
|
this.saveExpandedState();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseEnter() {
|
// ===== Sidebar visibility (per-page) and container margin =====
|
||||||
this.isHovering = true;
|
|
||||||
if (this.hoverTimeout) {
|
|
||||||
clearTimeout(this.hoverTimeout);
|
|
||||||
this.hoverTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.isPinned) {
|
|
||||||
this.showSidebar();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMouseLeave() {
|
|
||||||
this.isHovering = false;
|
|
||||||
if (!this.isPinned) {
|
|
||||||
this.hoverTimeout = setTimeout(() => {
|
|
||||||
if (!this.isHovering) {
|
|
||||||
this.hideSidebar();
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleHoverAreaEnter() {
|
|
||||||
if (!this.isPinned) {
|
|
||||||
this.showSidebar();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleHoverAreaLeave() {
|
|
||||||
// Let the sidebar's mouse leave handler deal with hiding
|
|
||||||
}
|
|
||||||
|
|
||||||
showSidebar() {
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
|
||||||
if (sidebar && !this.isPinned) {
|
|
||||||
sidebar.classList.add('hover-active');
|
|
||||||
this.isVisible = true;
|
|
||||||
this.updateContainerMargin();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hideSidebar() {
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
|
||||||
if (sidebar && !this.isPinned) {
|
|
||||||
sidebar.classList.remove('hover-active');
|
|
||||||
this.isVisible = false;
|
|
||||||
this.updateContainerMargin();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAutoHideState() {
|
|
||||||
if (this.isDisabledBySetting || this.isDisabledByPage) return;
|
|
||||||
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
|
||||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
|
||||||
|
|
||||||
if (!sidebar || !hoverArea) return;
|
|
||||||
|
|
||||||
if (window.innerWidth <= 1024) {
|
|
||||||
// Mobile: always use collapsed state
|
|
||||||
sidebar.classList.remove('auto-hide', 'hover-active', 'visible');
|
|
||||||
sidebar.classList.add('collapsed');
|
|
||||||
hoverArea.classList.add('disabled');
|
|
||||||
this.isVisible = false;
|
|
||||||
} else if (this.isPinned) {
|
|
||||||
// Desktop pinned: always visible
|
|
||||||
sidebar.classList.remove('auto-hide', 'collapsed', 'hover-active');
|
|
||||||
sidebar.classList.add('visible');
|
|
||||||
hoverArea.classList.add('disabled');
|
|
||||||
this.isVisible = true;
|
|
||||||
} else {
|
|
||||||
// Desktop auto-hide: use hover detection
|
|
||||||
sidebar.classList.remove('collapsed', 'visible');
|
|
||||||
sidebar.classList.add('auto-hide');
|
|
||||||
hoverArea.classList.remove('disabled');
|
|
||||||
|
|
||||||
if (this.isHovering) {
|
|
||||||
sidebar.classList.add('hover-active');
|
|
||||||
this.isVisible = true;
|
|
||||||
} else {
|
|
||||||
sidebar.classList.remove('hover-active');
|
|
||||||
this.isVisible = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update container margin when sidebar state changes
|
|
||||||
this.updateContainerMargin();
|
|
||||||
}
|
|
||||||
|
|
||||||
// New method to update container margin based on sidebar state
|
|
||||||
updateContainerMargin() {
|
updateContainerMargin() {
|
||||||
const container = document.querySelector('.container');
|
const container = document.querySelector('.container');
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
|
|
||||||
if (!container || !sidebar || this.isDisabledBySetting) return;
|
if (!container || !sidebar) return;
|
||||||
|
|
||||||
// Always reset margin first — needed when transitioning from visible to hidden
|
// Always reset margin first — needed when transitioning from visible to hidden
|
||||||
container.style.marginLeft = '';
|
container.style.marginLeft = '';
|
||||||
@@ -1222,194 +983,40 @@ export class SidebarManager {
|
|||||||
// When per-page disabled, skip adjustment but margin is already reset
|
// When per-page disabled, skip adjustment but margin is already reset
|
||||||
if (this.isDisabledByPage) return;
|
if (this.isDisabledByPage) return;
|
||||||
|
|
||||||
// Only adjust margin if sidebar is visible and pinned
|
// Sidebar is visible — adjust margin if we need room
|
||||||
if ((this.isPinned || this.isHovering) && this.isVisible) {
|
|
||||||
const sidebarWidth = sidebar.offsetWidth;
|
const sidebarWidth = sidebar.offsetWidth;
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const containerWidth = container.offsetWidth;
|
const containerWidth = container.offsetWidth;
|
||||||
|
|
||||||
// Check if there's enough space for both sidebar and container
|
|
||||||
// We need: sidebar width + container width + some padding < viewport width
|
|
||||||
if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
|
if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
|
||||||
// Not enough space, push container to the right
|
|
||||||
container.style.marginLeft = `${sidebarWidth + 10}px`;
|
container.style.marginLeft = `${sidebarWidth + 10}px`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
updateDomVisibility(enabled) {
|
updateDomVisibility() {
|
||||||
// Per-page disable adds on top of global setting
|
const isHidden = this.isDisabledByPage;
|
||||||
const isVisible = enabled && !this.isDisabledByPage;
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
|
||||||
|
|
||||||
if (sidebar) {
|
if (sidebar) {
|
||||||
sidebar.classList.toggle('hidden-by-setting', !isVisible);
|
sidebar.classList.toggle('visible', !isHidden);
|
||||||
sidebar.setAttribute('aria-hidden', (!isVisible).toString());
|
sidebar.classList.toggle('hidden-by-setting', isHidden);
|
||||||
|
sidebar.setAttribute('aria-hidden', isHidden.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hoverArea) {
|
// Show or hide the "sidebar hidden" edge indicator
|
||||||
hoverArea.classList.toggle('hidden-by-setting', !isVisible);
|
if (isHidden) {
|
||||||
if (!isVisible) {
|
|
||||||
hoverArea.classList.add('disabled');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show or hide the "sidebar hidden" notification
|
|
||||||
if (enabled && this.isDisabledByPage) {
|
|
||||||
this.showSidebarHiddenIndicator();
|
this.showSidebarHiddenIndicator();
|
||||||
} else {
|
} else {
|
||||||
this.hideSidebarHiddenIndicator();
|
this.hideSidebarHiddenIndicator();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async setSidebarEnabled(enabled) {
|
|
||||||
this.isDisabledBySetting = !enabled;
|
|
||||||
this.updateDomVisibility(enabled);
|
|
||||||
|
|
||||||
const shouldForceInitialization = !enabled && !this.isInitialized;
|
|
||||||
const needsInitialization = !this.isInitialized || shouldForceInitialization;
|
|
||||||
|
|
||||||
if (this.lastPageControls && needsInitialization) {
|
|
||||||
if (!this.initializationPromise) {
|
|
||||||
this.initializationPromise = this.initialize(this.lastPageControls, {
|
|
||||||
forceInitialize: shouldForceInitialization,
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Sidebar initialization failed:', error);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.initializationPromise = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.initializationPromise;
|
|
||||||
} else if (this.initializationPromise) {
|
|
||||||
await this.initializationPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!enabled) {
|
|
||||||
this.isHovering = false;
|
|
||||||
this.isVisible = false;
|
|
||||||
|
|
||||||
const container = document.querySelector('.container');
|
|
||||||
if (container) {
|
|
||||||
container.style.marginLeft = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isInitialized) {
|
|
||||||
this.updateBreadcrumbs();
|
|
||||||
this.updateSidebarHeader();
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isInitialized) {
|
|
||||||
this.updateAutoHideState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePinButton() {
|
|
||||||
const pinBtn = document.getElementById('sidebarPinToggle');
|
|
||||||
if (pinBtn) {
|
|
||||||
pinBtn.classList.toggle('active', this.isPinned);
|
|
||||||
pinBtn.title = this.isPinned
|
|
||||||
? translate('sidebar.unpinSidebar')
|
|
||||||
: translate('sidebar.pinSidebar');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== More Options Dropdown =====
|
|
||||||
|
|
||||||
handleMoreToggle(event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
const dropdown = document.getElementById('sidebarMoreDropdown');
|
|
||||||
if (!dropdown) return;
|
|
||||||
|
|
||||||
this.isMoreDropdownOpen = !dropdown.classList.contains('open');
|
|
||||||
dropdown.classList.toggle('open', this.isMoreDropdownOpen);
|
|
||||||
this.updateMoreDropdownLabels();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMoreDropdownItemClick(event) {
|
|
||||||
const item = event.target.closest('.sidebar-dropdown-item');
|
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
const action = item.dataset.action;
|
|
||||||
if (!action) return;
|
|
||||||
|
|
||||||
const dropdown = document.getElementById('sidebarMoreDropdown');
|
|
||||||
if (dropdown) {
|
|
||||||
dropdown.classList.remove('open');
|
|
||||||
this.isMoreDropdownOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'toggle-pin':
|
|
||||||
this.handlePinToggle(event);
|
|
||||||
break;
|
|
||||||
case 'toggle-hide':
|
|
||||||
this.toggleHideOnThisPage();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDocumentClickForMore(event) {
|
|
||||||
const dropdown = document.getElementById('sidebarMoreDropdown');
|
|
||||||
const toggle = document.getElementById('sidebarMoreToggle');
|
|
||||||
if (!dropdown || !toggle) return;
|
|
||||||
|
|
||||||
if (!dropdown.contains(event.target) && !toggle.contains(event.target)) {
|
|
||||||
dropdown.classList.remove('open');
|
|
||||||
this.isMoreDropdownOpen = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMoreDropdownLabels() {
|
|
||||||
const pinLabel = document.getElementById('sidebarMorePinLabel');
|
|
||||||
if (pinLabel) {
|
|
||||||
pinLabel.textContent = this.isPinned
|
|
||||||
? translate('sidebar.unpinSidebar')
|
|
||||||
: translate('sidebar.pinSidebar');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hideItem = document.querySelector('.sidebar-dropdown-item[data-action="toggle-hide"]');
|
|
||||||
if (hideItem) {
|
|
||||||
const hideIcon = hideItem.querySelector('i');
|
|
||||||
const hideLabel = hideItem.querySelector('span');
|
|
||||||
if (this.isDisabledByPage) {
|
|
||||||
hideLabel.textContent = translate('sidebar.showSidebar');
|
|
||||||
if (hideIcon) {
|
|
||||||
hideIcon.className = 'fas fa-eye';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
hideLabel.textContent = translate('sidebar.hideOnThisPage');
|
|
||||||
if (hideIcon) {
|
|
||||||
hideIcon.className = 'fas fa-eye-slash';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleHideOnThisPage() {
|
toggleHideOnThisPage() {
|
||||||
this.isDisabledByPage = !this.isDisabledByPage;
|
this.isDisabledByPage = !this.isDisabledByPage;
|
||||||
setStorageItem(`${this.pageType}_sidebarDisabled`, this.isDisabledByPage);
|
setStorageItem(`${this.pageType}_sidebarDisabled`, this.isDisabledByPage);
|
||||||
this.updateDomVisibility(!this.isDisabledBySetting);
|
this.updateDomVisibility();
|
||||||
this.updateAutoHideState();
|
|
||||||
this.updateContainerMargin();
|
this.updateContainerMargin();
|
||||||
this.updateMoreDropdownLabels();
|
|
||||||
|
|
||||||
if (!this.isDisabledByPage) {
|
|
||||||
this.hideSidebarHiddenIndicator();
|
|
||||||
} else {
|
|
||||||
showToast(
|
|
||||||
'sidebar.sidebarHiddenNotification',
|
|
||||||
{ page: this.getPageDisplayName() },
|
|
||||||
'info',
|
|
||||||
`Sidebar hidden on ${this.getPageDisplayName()} page`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getPageDisplayName() {
|
getPageDisplayName() {
|
||||||
@@ -1733,11 +1340,6 @@ export class SidebarManager {
|
|||||||
|
|
||||||
// Reload models with new filter
|
// Reload models with new filter
|
||||||
await this.pageControls.resetAndReload();
|
await this.pageControls.resetAndReload();
|
||||||
|
|
||||||
// Auto-hide sidebar on mobile after selection
|
|
||||||
if (window.innerWidth <= 1024) {
|
|
||||||
this.hideSidebar();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleFolderListClick(event) {
|
handleFolderListClick(event) {
|
||||||
@@ -2047,65 +1649,55 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSidebar() {
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
|
||||||
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
|
|
||||||
|
|
||||||
if (!sidebar) return;
|
|
||||||
|
|
||||||
this.isVisible = !this.isVisible;
|
|
||||||
|
|
||||||
if (this.isVisible) {
|
|
||||||
sidebar.classList.remove('collapsed');
|
|
||||||
sidebar.classList.add('visible');
|
|
||||||
} else {
|
|
||||||
sidebar.classList.remove('visible');
|
|
||||||
sidebar.classList.add('collapsed');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toggleBtn) {
|
|
||||||
toggleBtn.classList.toggle('active', this.isVisible);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.saveSidebarState();
|
|
||||||
}
|
|
||||||
|
|
||||||
closeSidebar() {
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
|
||||||
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
|
|
||||||
|
|
||||||
if (!sidebar) return;
|
|
||||||
|
|
||||||
this.isVisible = false;
|
|
||||||
sidebar.classList.remove('visible');
|
|
||||||
sidebar.classList.add('collapsed');
|
|
||||||
|
|
||||||
if (toggleBtn) {
|
|
||||||
toggleBtn.classList.remove('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.saveSidebarState();
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreSidebarState() {
|
restoreSidebarState() {
|
||||||
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true);
|
// Migration: old pin/unpin and global hide → per-page hide
|
||||||
|
this._migrateOldSettings();
|
||||||
|
|
||||||
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
|
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
|
||||||
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
|
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
|
||||||
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
|
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
|
||||||
this.isDisabledByPage = getStorageItem(`${this.pageType}_sidebarDisabled`, false);
|
this.isDisabledByPage = getStorageItem(`${this.pageType}_sidebarDisabled`, false);
|
||||||
|
|
||||||
this.isPinned = isPinned;
|
|
||||||
this.expandedNodes = new Set(expandedPaths);
|
this.expandedNodes = new Set(expandedPaths);
|
||||||
this.displayMode = displayMode;
|
this.displayMode = displayMode;
|
||||||
this.recursiveSearchEnabled = recursiveSearchEnabled;
|
this.recursiveSearchEnabled = recursiveSearchEnabled;
|
||||||
|
|
||||||
this.updatePinButton();
|
|
||||||
this.updateDisplayModeButton();
|
this.updateDisplayModeButton();
|
||||||
this.updateCollapseAllButton();
|
this.updateCollapseAllButton();
|
||||||
this.updateSearchRecursiveOption();
|
this.updateSearchRecursiveOption();
|
||||||
this.updateRecursiveToggleButton();
|
this.updateRecursiveToggleButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time migration: old pin/unpin and global show_folder_sidebar → per-page hide
|
||||||
|
* - sidebarPinned=false (was auto-hide) → sidebarDisabled=true for that page
|
||||||
|
* - show_folder_sidebar=false (global) → sidebarDisabled=true for ALL pages
|
||||||
|
*/
|
||||||
|
_migrateOldSettings() {
|
||||||
|
if (getStorageItem('_sidebar_migration_done')) return;
|
||||||
|
|
||||||
|
const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings'];
|
||||||
|
|
||||||
|
// 1. Migrate global hide setting to per-page
|
||||||
|
if (state?.global?.settings?.show_folder_sidebar === false) {
|
||||||
|
PAGES.forEach(p => setStorageItem(`${p}_sidebarDisabled`, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Migrate unpinned (auto-hide) to per-page hide
|
||||||
|
PAGES.forEach(p => {
|
||||||
|
const wasPinned = getStorageItem(`${p}_sidebarPinned`, true);
|
||||||
|
const alreadyDisabled = getStorageItem(`${p}_sidebarDisabled`, false);
|
||||||
|
if (wasPinned === false && !alreadyDisabled) {
|
||||||
|
// Was auto-hide → user didn't want sidebar taking space
|
||||||
|
setStorageItem(`${p}_sidebarDisabled`, true);
|
||||||
|
}
|
||||||
|
// Clean up old keys
|
||||||
|
localStorage.removeItem(`${p}_sidebarPinned`);
|
||||||
|
});
|
||||||
|
|
||||||
|
setStorageItem('_sidebar_migration_done', true);
|
||||||
|
}
|
||||||
|
|
||||||
restoreSelectedFolder() {
|
restoreSelectedFolder() {
|
||||||
const activeFolder = getStorageItem(`${this.pageType}_activeFolder`);
|
const activeFolder = getStorageItem(`${this.pageType}_activeFolder`);
|
||||||
if (activeFolder && typeof activeFolder === 'string') {
|
if (activeFolder && typeof activeFolder === 'string') {
|
||||||
@@ -2118,11 +1710,6 @@ export class SidebarManager {
|
|||||||
this.updateSidebarHeader();
|
this.updateSidebarHeader();
|
||||||
this.updateBreadcrumbs(); // Always update breadcrumbs
|
this.updateBreadcrumbs(); // Always update breadcrumbs
|
||||||
}
|
}
|
||||||
// Removed hidden class toggle since breadcrumbs are always visible now
|
|
||||||
}
|
|
||||||
|
|
||||||
saveSidebarState() {
|
|
||||||
setStorageItem(`${this.pageType}_sidebarPinned`, this.isPinned);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
saveExpandedState() {
|
saveExpandedState() {
|
||||||
@@ -2134,7 +1721,7 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refresh() {
|
async refresh() {
|
||||||
if (this.isDisabledBySetting || !this.isInitialized) {
|
if (!this.isInitialized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,8 +93,7 @@ export class PageControls {
|
|||||||
async initSidebarManager() {
|
async initSidebarManager() {
|
||||||
try {
|
try {
|
||||||
this.sidebarManager.setHostPageControls(this);
|
this.sidebarManager.setHostPageControls(this);
|
||||||
const shouldShowSidebar = state?.global?.settings?.show_folder_sidebar !== false;
|
await this.sidebarManager.initialize(this);
|
||||||
await this.sidebarManager.setSidebarEnabled(shouldShowSidebar);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize SidebarManager:', error);
|
console.error('Failed to initialize SidebarManager:', error);
|
||||||
}
|
}
|
||||||
@@ -664,13 +663,6 @@ export class PageControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.updateActionButtonStates();
|
this.updateActionButtonStates();
|
||||||
|
|
||||||
if (this.sidebarManager) {
|
|
||||||
const shouldShowSidebar = !isExcludedView && state?.global?.settings?.show_folder_sidebar !== false;
|
|
||||||
this.sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
|
|
||||||
console.error('Failed to update sidebar visibility:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspendInteractiveModes() {
|
suspendInteractiveModes() {
|
||||||
|
|||||||
@@ -355,9 +355,9 @@ function renderImportInterface(isEmpty) {
|
|||||||
<button class="select-files-btn" id="selectExampleFilesBtn">
|
<button class="select-files-btn" id="selectExampleFilesBtn">
|
||||||
<i class="fas fa-folder-open"></i> Select Files
|
<i class="fas fa-folder-open"></i> Select Files
|
||||||
</button>
|
</button>
|
||||||
<p class="import-formats">Supported formats: jpg, png, gif, webp, mp4, webm</p>
|
<p class="import-formats">Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm</p>
|
||||||
</div>
|
</div>
|
||||||
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;">
|
<input type="file" id="exampleFilesInput" multiple accept="image/*,image/avif,image/jxl,video/mp4,video/webm" style="display: none;">
|
||||||
<div class="import-progress-container" style="display: none;">
|
<div class="import-progress-container" style="display: none;">
|
||||||
<div class="import-progress">
|
<div class="import-progress">
|
||||||
<div class="progress-bar"></div>
|
<div class="progress-bar"></div>
|
||||||
@@ -473,7 +473,7 @@ export function initExampleImport(modelHash, container) {
|
|||||||
*/
|
*/
|
||||||
async function handleImportFiles(files, modelHash, importContainer) {
|
async function handleImportFiles(files, modelHash, importContainer) {
|
||||||
// Filter for supported file types
|
// Filter for supported file types
|
||||||
const supportedImages = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
const supportedImages = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.jxl'];
|
||||||
const supportedVideos = ['.mp4', '.webm'];
|
const supportedVideos = ['.mp4', '.webm'];
|
||||||
const supportedExtensions = [...supportedImages, ...supportedVideos];
|
const supportedExtensions = [...supportedImages, ...supportedVideos];
|
||||||
|
|
||||||
|
|||||||
@@ -611,7 +611,9 @@ export class BulkManager {
|
|||||||
|
|
||||||
const result = await apiClient.bulkDeleteModels(filePaths);
|
const result = await apiClient.bulkDeleteModels(filePaths);
|
||||||
|
|
||||||
if (result.success) {
|
if (result?.cancelled) {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
} else if (result.success) {
|
||||||
const currentConfig = this.getCurrentDisplayConfig();
|
const currentConfig = this.getCurrentDisplayConfig();
|
||||||
showToast('toast.models.deletedSuccessfully', {
|
showToast('toast.models.deletedSuccessfully', {
|
||||||
count: result.deleted_count,
|
count: result.deleted_count,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export class LoadingManager {
|
|||||||
if (this.onCancelCallback) {
|
if (this.onCancelCallback) {
|
||||||
this.onCancelCallback();
|
this.onCancelCallback();
|
||||||
this.cancelButton.disabled = true;
|
this.cancelButton.disabled = true;
|
||||||
this.cancelButton.textContent = translate('common.status.loading', {}, 'Loading...');
|
this.cancelButton.textContent = translate('common.status.cancelling', {}, 'Cancelling...');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { i18n } from '../i18n/index.js';
|
|||||||
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
|
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
|
||||||
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
|
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
|
||||||
import { bannerService } from './BannerService.js';
|
import { bannerService } from './BannerService.js';
|
||||||
import { sidebarManager } from '../components/SidebarManager.js';
|
|
||||||
|
|
||||||
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
|
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
|
||||||
|
|
||||||
@@ -884,12 +883,6 @@ export class SettingsManager {
|
|||||||
cardInfoDisplaySelect.value = state.global.settings.card_info_display || 'always';
|
cardInfoDisplaySelect.value = state.global.settings.card_info_display || 'always';
|
||||||
}
|
}
|
||||||
|
|
||||||
const showFolderSidebarCheckbox = document.getElementById('showFolderSidebar');
|
|
||||||
if (showFolderSidebarCheckbox) {
|
|
||||||
const showSidebarSetting = state.global.settings.show_folder_sidebar;
|
|
||||||
showFolderSidebarCheckbox.checked = showSidebarSetting !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set model card footer action
|
// Set model card footer action
|
||||||
const modelCardFooterActionSelect = document.getElementById('modelCardFooterAction');
|
const modelCardFooterActionSelect = document.getElementById('modelCardFooterAction');
|
||||||
if (modelCardFooterActionSelect) {
|
if (modelCardFooterActionSelect) {
|
||||||
@@ -2949,12 +2942,6 @@ export class SettingsManager {
|
|||||||
const showVersionOnCard = state.global.settings.show_version_on_card !== false;
|
const showVersionOnCard = state.global.settings.show_version_on_card !== false;
|
||||||
document.body.classList.toggle('hide-card-version', !showVersionOnCard);
|
document.body.classList.toggle('hide-card-version', !showVersionOnCard);
|
||||||
|
|
||||||
const shouldShowSidebar = state.global.settings.show_folder_sidebar !== false;
|
|
||||||
if (sidebarManager && typeof sidebarManager.setSidebarEnabled === 'function') {
|
|
||||||
sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
|
|
||||||
console.error('Failed to apply sidebar visibility setting:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,8 +95,7 @@ class RecipeManager {
|
|||||||
async _initSidebar() {
|
async _initSidebar() {
|
||||||
try {
|
try {
|
||||||
sidebarManager.setHostPageControls(this.pageControls);
|
sidebarManager.setHostPageControls(this.pageControls);
|
||||||
const shouldShowSidebar = state?.global?.settings?.show_folder_sidebar !== false;
|
await sidebarManager.initialize(this.pageControls);
|
||||||
await sidebarManager.setSidebarEnabled(shouldShowSidebar);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize recipe sidebar:', error);
|
console.error('Failed to initialize recipe sidebar:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
autoplay_on_hover: false,
|
autoplay_on_hover: false,
|
||||||
display_density: 'default',
|
display_density: 'default',
|
||||||
card_info_display: 'always',
|
card_info_display: 'always',
|
||||||
show_folder_sidebar: true,
|
|
||||||
model_name_display: 'model_name',
|
model_name_display: 'model_name',
|
||||||
lora_syntax_format: 'legacy',
|
lora_syntax_format: 'legacy',
|
||||||
model_card_footer_action: 'example_images',
|
model_card_footer_action: 'example_images',
|
||||||
|
|||||||
@@ -42,7 +42,12 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
|
|||||||
onStart?.({ displayName, loadingMessage });
|
onStart?.({ displayName, loadingMessage });
|
||||||
|
|
||||||
state.loadingManager?.showSimpleLoading?.(loadingMessage);
|
state.loadingManager?.showSimpleLoading?.(loadingMessage);
|
||||||
state.loadingManager?.showCancelButton?.(() => apiClient.cancelTask());
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
state.loadingManager?.showCancelButton?.(() => {
|
||||||
|
apiClient.cancelTask();
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
let status = 'success';
|
let status = 'success';
|
||||||
let records = [];
|
let records = [];
|
||||||
@@ -52,6 +57,7 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
|
|||||||
const response = await fetch(apiConfig.endpoints.refreshUpdates, {
|
const response = await fetch(apiConfig.endpoints.refreshUpdates, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: abortController.signal,
|
||||||
body: JSON.stringify({ force: false })
|
body: JSON.stringify({ force: false })
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,6 +87,11 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
|
|||||||
|
|
||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err?.name === 'AbortError') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
status = 'cancelled';
|
||||||
|
return { status: 'cancelled', displayName, records: [], error: null };
|
||||||
|
}
|
||||||
status = 'error';
|
status = 'error';
|
||||||
error = err instanceof Error ? err : new Error(String(err));
|
error = err instanceof Error ? err : new Error(String(err));
|
||||||
console.error('Error checking model updates:', error);
|
console.error('Error checking model updates:', error);
|
||||||
@@ -126,7 +137,12 @@ export async function performFolderUpdateCheck(folderPath, { onComplete } = {})
|
|||||||
);
|
);
|
||||||
|
|
||||||
state.loadingManager?.showSimpleLoading?.(loadingMessage);
|
state.loadingManager?.showSimpleLoading?.(loadingMessage);
|
||||||
state.loadingManager?.showCancelButton?.(() => apiClient.cancelTask());
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
state.loadingManager?.showCancelButton?.(() => {
|
||||||
|
apiClient.cancelTask();
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
let status = 'success';
|
let status = 'success';
|
||||||
let records = [];
|
let records = [];
|
||||||
@@ -136,6 +152,7 @@ export async function performFolderUpdateCheck(folderPath, { onComplete } = {})
|
|||||||
const response = await fetch(apiConfig.endpoints.refreshUpdates, {
|
const response = await fetch(apiConfig.endpoints.refreshUpdates, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: abortController.signal,
|
||||||
body: JSON.stringify({ folder_path: folderPath, force: false })
|
body: JSON.stringify({ folder_path: folderPath, force: false })
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,6 +182,11 @@ export async function performFolderUpdateCheck(folderPath, { onComplete } = {})
|
|||||||
|
|
||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err?.name === 'AbortError') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
status = 'cancelled';
|
||||||
|
return { status: 'cancelled', records: [], error: null };
|
||||||
|
}
|
||||||
status = 'error';
|
status = 'error';
|
||||||
error = err instanceof Error ? err : new Error(String(err));
|
error = err instanceof Error ? err : new Error(String(err));
|
||||||
console.error('Error checking folder model updates:', error);
|
console.error('Error checking folder model updates:', error);
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
<!-- Hover detection area -->
|
|
||||||
<div class="sidebar-hover-area" id="sidebarHoverArea"></div>
|
|
||||||
|
|
||||||
<!-- Folder Navigation Sidebar -->
|
<!-- Folder Navigation Sidebar -->
|
||||||
<div class="folder-sidebar" id="folderSidebar">
|
<div class="folder-sidebar" id="folderSidebar">
|
||||||
<div class="sidebar-header" id="sidebarHeader">
|
<div class="sidebar-header" id="sidebarHeader">
|
||||||
@@ -15,23 +12,9 @@
|
|||||||
<button class="sidebar-action-btn" id="sidebarCollapseAll" title="{{ t('sidebar.collapseAll') }}">
|
<button class="sidebar-action-btn" id="sidebarCollapseAll" title="{{ t('sidebar.collapseAll') }}">
|
||||||
<i class="fas fa-compress-alt"></i>
|
<i class="fas fa-compress-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="sidebar-action-btn" id="sidebarPinToggle" title="{{ t('sidebar.unpinSidebar') }}">
|
<button class="sidebar-action-btn" id="sidebarHideToggle" title="{{ t('sidebar.hideOnThisPage') }}">
|
||||||
<i class="fas fa-thumbtack"></i>
|
<i class="fas fa-chevron-left"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="sidebar-action-btn" id="sidebarMoreToggle" title="{{ t('sidebar.moreOptions') }}">
|
|
||||||
<i class="fas fa-ellipsis-v"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Dropdown menu for more options -->
|
|
||||||
<div class="sidebar-more-dropdown" id="sidebarMoreDropdown">
|
|
||||||
<div class="sidebar-dropdown-item" data-action="toggle-pin">
|
|
||||||
<i class="fas fa-thumbtack"></i>
|
|
||||||
<span id="sidebarMorePinLabel">{{ t('sidebar.pinSidebar') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-dropdown-item" data-action="toggle-hide">
|
|
||||||
<i class="fas fa-eye-slash"></i>
|
|
||||||
<span>{{ t('sidebar.hideOnThisPage') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
|
|||||||
@@ -480,24 +480,6 @@
|
|||||||
<div class="settings-subsection-header">
|
<div class="settings-subsection-header">
|
||||||
<h4>{{ t('settings.sections.layoutSettings') }}</h4>
|
<h4>{{ t('settings.sections.layoutSettings') }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-info">
|
|
||||||
<label for="showFolderSidebar">
|
|
||||||
{{ t('settings.layoutSettings.showFolderSidebar') }}
|
|
||||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.showFolderSidebarHelp') }}"></i>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="setting-control">
|
|
||||||
<label class="toggle-switch">
|
|
||||||
<input type="checkbox" id="showFolderSidebar"
|
|
||||||
onchange="settingsManager.saveToggleSetting('showFolderSidebar', 'show_folder_sidebar')">
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
|
|||||||
@@ -2190,6 +2190,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ force: false }),
|
body: JSON.stringify({ force: false }),
|
||||||
|
signal: expect.any(AbortSignal),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateResponse = await global.fetch.mock.results[1].value;
|
const updateResponse = await global.fetch.mock.results[1].value;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const downloadManagerMock = {
|
|||||||
|
|
||||||
const sidebarManagerMock = {
|
const sidebarManagerMock = {
|
||||||
setHostPageControls: vi.fn(),
|
setHostPageControls: vi.fn(),
|
||||||
setSidebarEnabled: vi.fn(async () => {
|
initialize: vi.fn(async () => {
|
||||||
sidebarManagerMock.isInitialized = true;
|
sidebarManagerMock.isInitialized = true;
|
||||||
}),
|
}),
|
||||||
refresh: vi.fn(async () => {}),
|
refresh: vi.fn(async () => {}),
|
||||||
@@ -75,9 +75,6 @@ beforeEach(() => {
|
|||||||
performModelUpdateCheckMock.mockResolvedValue({ status: 'success', displayName: 'LoRA', records: [] });
|
performModelUpdateCheckMock.mockResolvedValue({ status: 'success', displayName: 'LoRA', records: [] });
|
||||||
|
|
||||||
sidebarManagerMock.isInitialized = false;
|
sidebarManagerMock.isInitialized = false;
|
||||||
sidebarManagerMock.setSidebarEnabled.mockImplementation(async (enabled) => {
|
|
||||||
sidebarManagerMock.isInitialized = enabled;
|
|
||||||
});
|
|
||||||
|
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -72,12 +72,6 @@ vi.mock('../../../static/js/managers/BannerService.js', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
|
|
||||||
sidebarManager: {
|
|
||||||
setSidebarEnabled: vi.fn().mockResolvedValue(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
|
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
|
||||||
import { state } from '../../../static/js/state/index.js';
|
import { state } from '../../../static/js/state/index.js';
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
|
||||||
|
sidebarManager: {
|
||||||
|
setHostPageControls: vi.fn(),
|
||||||
|
initialize: vi.fn(async () => {}),
|
||||||
|
refresh: vi.fn(async () => {}),
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe('RecipeManager', () => {
|
describe('RecipeManager', () => {
|
||||||
let RecipeManager;
|
let RecipeManager;
|
||||||
let pageState;
|
let pageState;
|
||||||
|
|||||||
@@ -293,7 +293,8 @@ async def test_fetch_and_update_model_respects_deleted_without_archive():
|
|||||||
assert "metadata archive DB is not enabled" in error
|
assert "metadata archive DB is not enabled" in error
|
||||||
helpers.default_provider_factory.assert_not_awaited()
|
helpers.default_provider_factory.assert_not_awaited()
|
||||||
helpers.metadata_manager.hydrate_model_data.assert_not_awaited()
|
helpers.metadata_manager.hydrate_model_data.assert_not_awaited()
|
||||||
update_cache.assert_not_awaited()
|
# Now update_cache_func IS called to persist the not-found flags to SQLite
|
||||||
|
update_cache.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -441,7 +442,6 @@ async def test_fetch_and_update_model_returns_rate_limit_error(tmp_path):
|
|||||||
|
|
||||||
assert ok is False
|
assert ok is False
|
||||||
assert error is not None and "Rate limited" in error
|
assert error is not None and "Rate limited" in error
|
||||||
assert "7" in error
|
|
||||||
helpers.metadata_manager.save_metadata.assert_not_awaited()
|
helpers.metadata_manager.save_metadata.assert_not_awaited()
|
||||||
update_cache.assert_not_awaited()
|
update_cache.assert_not_awaited()
|
||||||
helpers.provider_selector.assert_not_awaited()
|
helpers.provider_selector.assert_not_awaited()
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ async def test_fallback_retries_same_provider_on_rate_limit(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fallback_respects_retry_limit(monkeypatch):
|
async def test_fallback_continues_to_next_provider_on_rate_limit(monkeypatch):
|
||||||
|
"""After exhausting retries on primary, fallback should continue to secondary."""
|
||||||
sleep_mock = AsyncMock()
|
sleep_mock = AsyncMock()
|
||||||
monkeypatch.setattr(provider_module.asyncio, "sleep", sleep_mock)
|
monkeypatch.setattr(provider_module.asyncio, "sleep", sleep_mock)
|
||||||
monkeypatch.setattr(provider_module.random, "uniform", lambda *_: 0.0)
|
monkeypatch.setattr(provider_module.random, "uniform", lambda *_: 0.0)
|
||||||
@@ -76,13 +77,13 @@ async def test_fallback_respects_retry_limit(monkeypatch):
|
|||||||
rate_limit_retry_limit=2,
|
rate_limit_retry_limit=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(RateLimitError) as exc_info:
|
# After Change A: no longer raises; falls through to secondary
|
||||||
await fallback.get_model_by_hash("abc")
|
result, error = await fallback.get_model_by_hash("abc")
|
||||||
|
|
||||||
assert exc_info.value.provider == "primary"
|
assert error is None
|
||||||
assert primary.calls == 2
|
assert result == {"id": "secondary"}
|
||||||
assert secondary.calls == 0
|
assert primary.calls == 2 # retry_limit exhausted on primary
|
||||||
sleep_mock.assert_awaited_once()
|
assert secondary.calls == 1 # secondary IS called now
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -117,3 +118,40 @@ async def test_rate_limit_retrying_provider_respects_limit(monkeypatch):
|
|||||||
assert exc_info.value.provider == "inner"
|
assert exc_info.value.provider == "inner"
|
||||||
assert inner.calls == 2
|
assert inner.calls == 2
|
||||||
sleep_mock.assert_awaited_once()
|
sleep_mock.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_helper_limits_retries_for_large_retry_after():
|
||||||
|
"""With retry_after >= 120s, _RateLimitRetryHelper should only attempt once (no retries)."""
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def failing():
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
raise RateLimitError("limited", retry_after=1500.0)
|
||||||
|
|
||||||
|
helper = provider_module._RateLimitRetryHelper(retry_limit=3)
|
||||||
|
with pytest.raises(RateLimitError):
|
||||||
|
await helper.run("test", failing)
|
||||||
|
assert calls == 1 # No retries for large retry_after
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_helper_retries_normally_for_small_retry_after(monkeypatch):
|
||||||
|
"""With retry_after < 120s, _RateLimitRetryHelper should retry normally (up to limit)."""
|
||||||
|
sleep_mock = AsyncMock()
|
||||||
|
monkeypatch.setattr(provider_module.asyncio, "sleep", sleep_mock)
|
||||||
|
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def succeeding():
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
raise RateLimitError("limited", retry_after=30.0)
|
||||||
|
return {"ok": True}, None
|
||||||
|
|
||||||
|
helper = provider_module._RateLimitRetryHelper(retry_limit=3)
|
||||||
|
result, _ = await helper.run("test", succeeding)
|
||||||
|
assert result == {"ok": True}
|
||||||
|
assert calls == 2 # Retried once (small retry_after)
|
||||||
|
|||||||
@@ -141,3 +141,150 @@ def test_update_image_metadata_preserves_png_workflow(tmp_path):
|
|||||||
img.info["parameters"]
|
img.info["parameters"]
|
||||||
== 'prompt text\nRecipe metadata: {"title":"recipe"}'
|
== 'prompt text\nRecipe metadata: {"title":"recipe"}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- ISOBMFF / brotli extraction tests ---
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import brotli
|
||||||
|
|
||||||
|
|
||||||
|
def _build_jxl_with_brob(payload_json: dict) -> bytes:
|
||||||
|
"""Build a minimal JXL container with a brob box containing brotli-compressed JSON."""
|
||||||
|
# ISOBMFF box 1: JXL signature box (size=12, type='JXL ', signature)
|
||||||
|
box1 = struct.pack(">I", 12) + b"JXL " + bytes([0x0d, 0x0a, 0x87, 0x0a])
|
||||||
|
# ISOBMFF box 2: ftyp (size=16, type='ftyp', major='jxl ', minor=0)
|
||||||
|
box2 = struct.pack(">I", 16) + b"ftyp" + b"jxl " + struct.pack(">I", 0)
|
||||||
|
# ISOBMFF box 3: brob — payload is b'comf' + brotli(json)
|
||||||
|
compressed = brotli.compress(json.dumps(payload_json).encode("utf-8"))
|
||||||
|
brob_payload = b"comf" + compressed
|
||||||
|
box3 = struct.pack(">I", 8 + len(brob_payload)) + b"brob" + brob_payload
|
||||||
|
return box1 + box2 + box3
|
||||||
|
|
||||||
|
|
||||||
|
def _build_avif_with_brob(payload_json: dict) -> bytes:
|
||||||
|
"""Build a minimal AVIF container with a brob box containing brotli-compressed JSON."""
|
||||||
|
compressed = brotli.compress(json.dumps(payload_json).encode("utf-8"))
|
||||||
|
brob_payload = b"comf" + compressed
|
||||||
|
ftyp_box = struct.pack(">I", 20) + b"ftyp" + b"avif" + struct.pack(">I", 0) + b"avif"
|
||||||
|
brob_box = struct.pack(">I", 8 + len(brob_payload)) + b"brob" + brob_payload
|
||||||
|
return ftyp_box + brob_box
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsobmffBrotliExtraction:
|
||||||
|
"""Tests for ISOBMFF brotli metadata extraction in ExifUtils."""
|
||||||
|
|
||||||
|
def test_extract_jxl_brotli_happy_path(self, tmp_path):
|
||||||
|
"""JXL container with valid brob box extracts prompt and workflow."""
|
||||||
|
payload = {"prompt": "a cute cat", "workflow": {"nodes": [{"id": 1}]}}
|
||||||
|
data = _build_jxl_with_brob(payload)
|
||||||
|
path = tmp_path / "test.jxl"
|
||||||
|
path.write_bytes(data)
|
||||||
|
|
||||||
|
result = ExifUtils._load_structured_metadata(str(path))
|
||||||
|
|
||||||
|
assert result["prompt"] == "a cute cat"
|
||||||
|
assert result["workflow"] == '{"nodes": [{"id": 1}]}'
|
||||||
|
assert result["parameters"] is None
|
||||||
|
assert result["comment"] is None
|
||||||
|
|
||||||
|
def test_extract_avif_brotli_happy_path(self, tmp_path):
|
||||||
|
"""AVIF container with valid brob box extracts prompt and workflow."""
|
||||||
|
payload = {"prompt": "landscape", "workflow": {"nodes": []}}
|
||||||
|
data = _build_avif_with_brob(payload)
|
||||||
|
path = tmp_path / "test.avif"
|
||||||
|
path.write_bytes(data)
|
||||||
|
|
||||||
|
result = ExifUtils._load_structured_metadata(str(path))
|
||||||
|
|
||||||
|
assert result["prompt"] == "landscape"
|
||||||
|
assert result["workflow"] == '{"nodes": []}'
|
||||||
|
|
||||||
|
def test_extract_no_brob_box_returns_none(self, tmp_path):
|
||||||
|
"""JXL container without a brob box returns None from _extract_isobmff_brotli."""
|
||||||
|
# Only JXL signature + ftyp, no brob
|
||||||
|
box1 = struct.pack(">I", 12) + b"JXL " + bytes([0x0d, 0x0a, 0x87, 0x0a])
|
||||||
|
box2 = struct.pack(">I", 16) + b"ftyp" + b"jxl " + struct.pack(">I", 0)
|
||||||
|
path = tmp_path / "test.jxl"
|
||||||
|
path.write_bytes(box1 + box2)
|
||||||
|
|
||||||
|
# The low-level extraction should return None (no brob box)
|
||||||
|
result = ExifUtils._extract_isobmff_brotli(str(path))
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_extract_corrupt_brob_returns_none(self, tmp_path):
|
||||||
|
"""Broken brob box payload gracefully returns None."""
|
||||||
|
box1 = struct.pack(">I", 12) + b"JXL " + bytes([0x0d, 0x0a, 0x87, 0x0a])
|
||||||
|
box2 = struct.pack(">I", 16) + b"ftyp" + b"jxl " + struct.pack(">I", 0)
|
||||||
|
# brob with garbage payload that doesn't start with b'comf'
|
||||||
|
garbage = b"\xff\xff\xff\xff" * 32
|
||||||
|
box3 = struct.pack(">I", 8 + len(garbage)) + b"brob" + garbage
|
||||||
|
path = tmp_path / "test.jxl"
|
||||||
|
path.write_bytes(box1 + box2 + box3)
|
||||||
|
|
||||||
|
result = ExifUtils._extract_isobmff_brotli(str(path))
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_extract_non_isobmff_file_falls_through(self, tmp_path):
|
||||||
|
"""A regular PNG file is not processed as ISOBMFF and returns PIL metadata."""
|
||||||
|
png_info = PngImagePlugin.PngInfo()
|
||||||
|
png_info.add_text("prompt", "from png")
|
||||||
|
path = tmp_path / "test.png"
|
||||||
|
Image.new("RGB", (4, 4), color="red").save(path, pnginfo=png_info)
|
||||||
|
|
||||||
|
result = ExifUtils._load_structured_metadata(str(path))
|
||||||
|
assert result["prompt"] == "from png"
|
||||||
|
|
||||||
|
def test_extract_skip_on_update_and_optimize(self, tmp_path):
|
||||||
|
"""AVIF/JXL files are skipped for write operations (update/append/optimize)."""
|
||||||
|
path = tmp_path / "test.avif"
|
||||||
|
path.write_bytes(b"fake avif data")
|
||||||
|
|
||||||
|
# update_image_metadata should return the path unchanged
|
||||||
|
result = ExifUtils.update_image_metadata(str(path), "some metadata")
|
||||||
|
assert result == str(path)
|
||||||
|
|
||||||
|
# append_recipe_metadata should also skip
|
||||||
|
result = ExifUtils.append_recipe_metadata(str(path), {"title": "test"})
|
||||||
|
assert result == str(path)
|
||||||
|
|
||||||
|
# optimize_image should passthrough for AVIF/JXL paths
|
||||||
|
result_data, ext = ExifUtils.optimize_image(str(path))
|
||||||
|
assert ext == ".avif"
|
||||||
|
assert result_data == b"fake avif data"
|
||||||
|
|
||||||
|
def test_extract_prompt_as_dict(self, tmp_path):
|
||||||
|
"""prompt field as dict is JSON-serialized."""
|
||||||
|
payload = {"prompt": {"text": "hello", "negative": "bad"}}
|
||||||
|
data = _build_jxl_with_brob(payload)
|
||||||
|
path = tmp_path / "test.jxl"
|
||||||
|
path.write_bytes(data)
|
||||||
|
|
||||||
|
result = ExifUtils._load_structured_metadata(str(path))
|
||||||
|
assert json.loads(result["prompt"]) == {"text": "hello", "negative": "bad"}
|
||||||
|
|
||||||
|
def test_extract_workflow_as_list(self, tmp_path):
|
||||||
|
"""workflow field as list is JSON-serialized."""
|
||||||
|
payload = {"workflow": [{"id": 1}, {"id": 2}]}
|
||||||
|
data = _build_avif_with_brob(payload)
|
||||||
|
path = tmp_path / "test.avif"
|
||||||
|
path.write_bytes(data)
|
||||||
|
|
||||||
|
result = ExifUtils._load_structured_metadata(str(path))
|
||||||
|
assert json.loads(result["workflow"]) == [{"id": 1}, {"id": 2}]
|
||||||
|
|
||||||
|
def test_over_decompressed_size_limit(self, tmp_path, monkeypatch):
|
||||||
|
"""Decompressed data exceeding _BROTLI_MAX_DECOMPRESSED is rejected."""
|
||||||
|
# Monkey-patch the limit to a small value to avoid large test data
|
||||||
|
monkeypatch.setattr(ExifUtils, "_BROTLI_MAX_DECOMPRESSED", 100)
|
||||||
|
|
||||||
|
large_content = "x" * 200
|
||||||
|
payload = {"prompt": large_content}
|
||||||
|
data = _build_jxl_with_brob(payload)
|
||||||
|
path = tmp_path / "test.jxl"
|
||||||
|
path.write_bytes(data)
|
||||||
|
|
||||||
|
# Direct extraction should return None because decompressed size exceeds limit
|
||||||
|
result = ExifUtils._extract_isobmff_brotli(str(path))
|
||||||
|
assert result is None
|
||||||
|
|||||||
135
web/comfyui/save_image_extra_output.js
Normal file
135
web/comfyui/save_image_extra_output.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { chainCallback, getAllGraphNodes, getWidgetByName } from "./utils.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date string using the given pattern (e.g. "yyyy-MM-dd").
|
||||||
|
* Supports: yyyy, yy, MM, M, dd, d, hh, h, mm, m, ss, s
|
||||||
|
*/
|
||||||
|
function formatDate(text, date) {
|
||||||
|
const pad = (n, len) => n.toString().padStart(len, "0");
|
||||||
|
// Order matters: longer patterns first to avoid partial substring matches.
|
||||||
|
// The original ComfyUI frontend uses the same ordered-alternation approach.
|
||||||
|
return text
|
||||||
|
.replace(/yyyy/g, () => date.getFullYear().toString())
|
||||||
|
.replace(/yy/g, () => pad(date.getFullYear() % 100, 2))
|
||||||
|
.replace(/MM/g, () => pad(date.getMonth() + 1, 2))
|
||||||
|
.replace(/M/g, () => (date.getMonth() + 1).toString())
|
||||||
|
.replace(/dd/g, () => pad(date.getDate(), 2))
|
||||||
|
.replace(/d/g, () => date.getDate().toString())
|
||||||
|
.replace(/hh/g, () => pad(date.getHours(), 2))
|
||||||
|
.replace(/h/g, () => date.getHours().toString())
|
||||||
|
.replace(/mm/g, () => pad(date.getMinutes(), 2))
|
||||||
|
.replace(/m/g, () => date.getMinutes().toString())
|
||||||
|
.replace(/ss/g, () => pad(date.getSeconds(), 2))
|
||||||
|
.replace(/s/g, () => date.getSeconds().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve %NodeTitle.WidgetName% placeholders in a string using the current graph.
|
||||||
|
*
|
||||||
|
* Patterns supported:
|
||||||
|
* %NodeTitle.WidgetName% – widget value from a node (by title or "Node name for S&R")
|
||||||
|
* %date:format% – current date/time formatted (e.g. %date:yyyy-MM-dd%)
|
||||||
|
* %width%, %height% – left as-is, handled by the backend
|
||||||
|
*
|
||||||
|
* All other %text% patterns are passed through unchanged (they may be handled by
|
||||||
|
* the backend's format_filename, e.g. %seed%, %model%, %pprompt%).
|
||||||
|
*/
|
||||||
|
function applyTextReplacements(value) {
|
||||||
|
if (!value || typeof value !== "string" || !value.includes("%")) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all nodes from the entire graph hierarchy (including subgraphs)
|
||||||
|
const allNodes = getAllGraphNodes(app.graph);
|
||||||
|
|
||||||
|
return value.replace(/%([^%]+)%/g, function (match, text) {
|
||||||
|
const split = text.split(".");
|
||||||
|
if (split.length !== 2) {
|
||||||
|
// Handle %date:format% patterns
|
||||||
|
if (split[0].startsWith("date:")) {
|
||||||
|
return formatDate(split[0].substring(5), new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
// %width% and %height% are left for the backend to handle
|
||||||
|
if (text !== "width" && text !== "height") {
|
||||||
|
console.warn(
|
||||||
|
"[Save Image (LoraManager)] Unknown placeholder: %" + text + "%"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try finding the node by its "Node name for S&R" property first
|
||||||
|
let nodes = allNodes
|
||||||
|
.filter((n) => n.node.properties?.["Node name for S&R"] === split[0])
|
||||||
|
.map((n) => n.node);
|
||||||
|
|
||||||
|
// Fall back to matching by node title
|
||||||
|
if (!nodes.length) {
|
||||||
|
nodes = allNodes
|
||||||
|
.filter((n) => n.node.title === split[0])
|
||||||
|
.map((n) => n.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodes.length) {
|
||||||
|
console.warn(
|
||||||
|
"[Save Image (LoraManager)] Node not found: " + split[0]
|
||||||
|
);
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length > 1) {
|
||||||
|
console.warn(
|
||||||
|
"[Save Image (LoraManager)] Multiple nodes matched '" +
|
||||||
|
split[0] +
|
||||||
|
"', using first match"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = nodes[0];
|
||||||
|
const widget = node.widgets?.find((w) => w.name === split[1]);
|
||||||
|
if (!widget) {
|
||||||
|
console.warn(
|
||||||
|
"[Save Image (LoraManager)] Widget '" +
|
||||||
|
split[1] +
|
||||||
|
"' not found on node " +
|
||||||
|
split[0]
|
||||||
|
);
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize the value: replace characters invalid for filenames
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
return ((widget.value ?? "") + "").replaceAll(
|
||||||
|
/[/?<>\\:*|"\x00-\x1F\x7F]/g,
|
||||||
|
"_"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "LoraManager.SaveImageExtraOutput",
|
||||||
|
|
||||||
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||||
|
if (nodeData.name !== "Save Image (LoraManager)") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||||
|
// Find the filename_prefix widget
|
||||||
|
const widget = getWidgetByName(this, "filename_prefix");
|
||||||
|
if (!widget) {
|
||||||
|
console.warn(
|
||||||
|
"[Save Image (LoraManager)] filename_prefix widget not found"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override serialization to resolve %NodeTitle.WidgetName% placeholders
|
||||||
|
widget.serializeValue = () => {
|
||||||
|
return applyTextReplacements(widget.value);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user