Compare commits

...

13 Commits

Author SHA1 Message Date
Will Miao
75298a402f chore(release): bump version to v1.1.3 2026-06-17 17:52:56 +08:00
Will Miao
92b5efd414 fix: guard posix_fadvise on non-Linux platforms to prevent AttributeError on Windows (#988) 2026-06-17 17:22:10 +08:00
Will Miao
33ee392b7b feat(settings): redesign Card Overlay Blur range slider to match settings UI style 2026-06-17 15:24:14 +08:00
Will Miao
5237f8b7dc chore: remove keyboard navigation UI elements and related code
- Delete static/css/components/keyboard-nav.css entirely
- Remove @import of keyboard-nav.css from style.css
- Remove keyboard-nav-hint divs from controls.html and recipes.html
- Clean up all keyboard.* translation keys from 10 locale files

The actual keyboard scrolling handlers (PageUp/PageDown in infiniteScroll.js
and VirtualScroller.js) are kept as they provide core scroll functionality.
2026-06-17 15:07:34 +08:00
Will Miao
5107313fd1 revert: restore &logo=github parameter to release-date badge
This reverts commit 95bbc669efb1aa0c23b94be6f0a5e7a188f1c019.

The real issue was shields.io GitHub API token pool exhaustion (intermittent),
not the &logo=github parameter. All 3 badges (Discord, Release, Release Date)
were affected at various times due to the same root cause: shields.io
temporarily unable to query GitHub API.
2026-06-17 11:24:40 +08:00
Will Miao
95bbc66919 fix: remove broken logo parameter from release-date badge URL 2026-06-17 11:21:26 +08:00
Will Miao
e268e59419 chore: stop tracking .docs/ and add to .gitignore
.docs/ is now excluded from git tracking so working/research notes
can live there without being committed.
2026-06-17 11:20:19 +08:00
willmiao
547e1f9498 docs: auto-update supporters list in README 2026-06-17 01:57:52 +00:00
Will Miao
bf32d8b6fd chore(release): bump version to v1.1.2 2026-06-17 09:57:37 +08:00
Will Miao
8299881024 refactor(sidebar): remove pin/unpin and global hide, use per-page hide only
- Remove pin/unpin and auto-hide hover mechanism (isPinned, isHovering,
  hoverTimeout, showSidebar/hideSidebar, updateAutoHideState, etc.)
- Remove global show_folder_sidebar setting (SettingsManager,
  PageControls, recipes, backend default)
- Simplify sidebar visibility to a single per-page toggle:
  · Dedicated chevron-left button in header to hide sidebar
  · Edge indicator (chevron-right) to restore when hidden
  · No dropdown, no hover area, no pin button
- Add _migrateOldSettings() to convert old sidebarPinned and
  show_folder_sidebar states to per-page sidebarDisabled
- Fix sidebar flicker on page load: CSS defaults to off-screen,
  JS explicitly sets .visible or .hidden-by-setting
- Remove obsolete CSS classes: auto-hide, hover-active, collapsed
- Remove i18n keys: pinSidebar, unpinSidebar, moreOptions
- Update test mocks for the new initialize() interface
2026-06-17 09:49:24 +08:00
Will Miao
da02268196 fix(css): add top margin to stat-cards container for consistent spacing 2026-06-17 08:24:03 +08:00
Will Miao
8c4b9a1e70 fix(metadata-sync): persist not-found flags to SQLite cache on deleted-provider path
When a model is already classified as civitai_deleted=True via
.metadata.json but re-enters the failure block through the
civarchive/sqlite provider path (not the default provider),
needs_save was never set to True because civitai_api_not_found
and sqlite_attempted were both False. The flags were never
persisted to SQLite, causing the model to be re-fetched on
every restart.

Also demoted duplicate INFO/ERROR logging in fetch_and_update_model
to DEBUG (the use case already logs at WARNING), and added
exc_info=True to the fetch_all_civitai error handler.
2026-06-17 08:22:24 +08:00
Will Miao
0906c484e9 fix: actually halt bulk operations on cancel — frontend AbortController + backend guards (#986) 2026-06-17 07:20:32 +08:00
43 changed files with 388 additions and 1196 deletions

View File

@@ -1,153 +0,0 @@
# Recipe Batch Import Feature Design
## Overview
Enable users to import multiple images as recipes in a single operation, rather than processing them individually. This feature addresses the need for efficient bulk recipe creation from existing image collections.
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend │
├─────────────────────────────────────────────────────────────────┤
│ BatchImportManager.js │
│ ├── InputCollector (收集URL列表/目录路径) │
│ ├── ConcurrencyController (自适应并发控制) │
│ ├── ProgressTracker (进度追踪) │
│ └── ResultAggregator (结果汇总) │
├─────────────────────────────────────────────────────────────────┤
│ batch_import_modal.html │
│ └── 批量导入UI组件 │
├─────────────────────────────────────────────────────────────────┤
│ batch_import_progress.css │
│ └── 进度显示样式 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Backend │
├─────────────────────────────────────────────────────────────────┤
│ py/routes/handlers/recipe_handlers.py │
│ ├── start_batch_import() - 启动批量导入 │
│ ├── get_batch_import_progress() - 查询进度 │
│ └── cancel_batch_import() - 取消导入 │
├─────────────────────────────────────────────────────────────────┤
│ py/services/batch_import_service.py │
│ ├── 自适应并发执行 │
│ ├── 结果汇总 │
│ └── WebSocket进度广播 │
└─────────────────────────────────────────────────────────────────┘
```
## API Endpoints
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/lm/recipes/batch-import/start` | POST | 启动批量导入,返回 operation_id |
| `/api/lm/recipes/batch-import/progress` | GET | 查询进度状态 |
| `/api/lm/recipes/batch-import/cancel` | POST | 取消导入 |
## Backend Implementation Details
### BatchImportService
Location: `py/services/batch_import_service.py`
Key classes:
- `BatchImportItem`: Dataclass for individual import item
- `BatchImportProgress`: Dataclass for tracking progress
- `BatchImportService`: Main service class
Features:
- Adaptive concurrency control (adjusts based on success/failure rate)
- WebSocket progress broadcasting
- Graceful error handling (individual failures don't stop the batch)
- Result aggregation
### WebSocket Message Format
```json
{
"type": "batch_import_progress",
"operation_id": "xxx",
"total": 50,
"completed": 23,
"success": 21,
"failed": 2,
"skipped": 0,
"current_item": "image_024.png",
"status": "running"
}
```
### Input Types
1. **URL List**: Array of URLs (http/https)
2. **Local Paths**: Array of local file paths
3. **Directory**: Path to directory with optional recursive flag
### Error Handling
- Invalid URLs/paths: Skip and record error
- Download failures: Record error, continue
- Metadata extraction failures: Mark as "no metadata"
- Duplicate detection: Option to skip duplicates
## Frontend Implementation Details (TODO)
### UI Components
1. **BatchImportModal**: Main modal with tabs for URLs/Directory input
2. **ProgressDisplay**: Real-time progress bar and status
3. **ResultsSummary**: Final results with success/failure breakdown
### Adaptive Concurrency Controller
```javascript
class AdaptiveConcurrencyController {
constructor(options = {}) {
this.minConcurrency = options.minConcurrency || 1;
this.maxConcurrency = options.maxConcurrency || 5;
this.currentConcurrency = options.initialConcurrency || 3;
}
adjustConcurrency(taskDuration, success) {
if (success && taskDuration < 1000 && this.currentConcurrency < this.maxConcurrency) {
this.currentConcurrency = Math.min(this.currentConcurrency + 1, this.maxConcurrency);
}
if (!success || taskDuration > 10000) {
this.currentConcurrency = Math.max(this.currentConcurrency - 1, this.minConcurrency);
}
return this.currentConcurrency;
}
}
```
## File Structure
```
Backend (implemented):
├── py/services/batch_import_service.py # 后端服务
├── py/routes/handlers/batch_import_handler.py # API处理器 (added to recipe_handlers.py)
├── tests/services/test_batch_import_service.py # 单元测试
└── tests/routes/test_batch_import_routes.py # API集成测试
Frontend (TODO):
├── static/js/managers/BatchImportManager.js # 主管理器
├── static/js/managers/batch/ # 子模块
│ ├── ConcurrencyController.js # 并发控制
│ ├── ProgressTracker.js # 进度追踪
│ └── ResultAggregator.js # 结果汇总
├── static/css/components/batch-import-modal.css # 样式
└── templates/components/batch_import_modal.html # Modal模板
```
## Implementation Status
- [x] Backend BatchImportService
- [x] Backend API handlers
- [x] WebSocket progress broadcasting
- [x] Unit tests
- [x] Integration tests
- [ ] Frontend BatchImportManager
- [ ] Frontend UI components
- [ ] E2E tests

3
.gitignore vendored
View File

@@ -28,3 +28,6 @@ vue-widgets/dist/
# Hypothesis test cache # Hypothesis test cache
.hypothesis/ .hypothesis/
# Working/research notes (not committed)
.docs/

File diff suppressed because one or more lines are too long

View File

@@ -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
} }

View File

@@ -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",
@@ -1426,15 +1424,6 @@
"duplicate": "Dieser Tag existiert bereits" "duplicate": "Dieser Tag existiert bereits"
} }
}, },
"keyboard": {
"navigation": "Tastatur-Navigation:",
"shortcuts": {
"pageUp": "Eine Seite nach oben scrollen",
"pageDown": "Eine Seite nach unten scrollen",
"home": "Zum Anfang springen",
"end": "Zum Ende springen"
}
},
"initialization": { "initialization": {
"title": "Initialisierung", "title": "Initialisierung",
"message": "Ihr Arbeitsbereich wird vorbereitet...", "message": "Ihr Arbeitsbereich wird vorbereitet...",

View File

@@ -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",
@@ -1426,15 +1424,6 @@
"duplicate": "This tag already exists" "duplicate": "This tag already exists"
} }
}, },
"keyboard": {
"navigation": "Keyboard Navigation:",
"shortcuts": {
"pageUp": "Scroll up one page",
"pageDown": "Scroll down one page",
"home": "Jump to top",
"end": "Jump to bottom"
}
},
"initialization": { "initialization": {
"title": "Initializing", "title": "Initializing",
"message": "Preparing your workspace...", "message": "Preparing your workspace...",

View File

@@ -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}",
@@ -1426,15 +1424,6 @@
"duplicate": "Esta etiqueta ya existe" "duplicate": "Esta etiqueta ya existe"
} }
}, },
"keyboard": {
"navigation": "Navegación por teclado:",
"shortcuts": {
"pageUp": "Desplazar hacia arriba una página",
"pageDown": "Desplazar hacia abajo una página",
"home": "Saltar al inicio",
"end": "Saltar al final"
}
},
"initialization": { "initialization": {
"title": "Inicializando", "title": "Inicializando",
"message": "Preparando tu espacio de trabajo...", "message": "Preparando tu espacio de trabajo...",

View File

@@ -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}",
@@ -1426,15 +1424,6 @@
"duplicate": "Ce tag existe déjà" "duplicate": "Ce tag existe déjà"
} }
}, },
"keyboard": {
"navigation": "Navigation au clavier :",
"shortcuts": {
"pageUp": "Défiler d'une page vers le haut",
"pageDown": "Défiler d'une page vers le bas",
"home": "Aller en haut",
"end": "Aller en bas"
}
},
"initialization": { "initialization": {
"title": "Initialisation", "title": "Initialisation",
"message": "Préparation de votre espace de travail...", "message": "Préparation de votre espace de travail...",

View File

@@ -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}",
@@ -1426,15 +1424,6 @@
"duplicate": "תגית זו כבר קיימת" "duplicate": "תגית זו כבר קיימת"
} }
}, },
"keyboard": {
"navigation": "ניווט במקלדת:",
"shortcuts": {
"pageUp": "גלול עמוד אחד למעלה",
"pageDown": "גלול עמוד אחד למטה",
"home": "קפוץ להתחלה",
"end": "קפוץ לסוף"
}
},
"initialization": { "initialization": {
"title": "מאתחל", "title": "מאתחל",
"message": "מכין את סביבת העבודה שלך...", "message": "מכין את סביבת העבודה שלך...",

View File

@@ -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}ページでサイドバーが非表示になっています",
@@ -1426,15 +1424,6 @@
"duplicate": "このタグは既に存在します" "duplicate": "このタグは既に存在します"
} }
}, },
"keyboard": {
"navigation": "キーボードナビゲーション:",
"shortcuts": {
"pageUp": "1ページ上にスクロール",
"pageDown": "1ページ下にスクロール",
"home": "トップにジャンプ",
"end": "ボトムにジャンプ"
}
},
"initialization": { "initialization": {
"title": "初期化中", "title": "初期化中",
"message": "ワークスペースを準備中...", "message": "ワークスペースを準備中...",

View File

@@ -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} 페이지에서 사이드바가 숨겨져 있습니다",
@@ -1426,15 +1424,6 @@
"duplicate": "이 태그는 이미 존재합니다" "duplicate": "이 태그는 이미 존재합니다"
} }
}, },
"keyboard": {
"navigation": "키보드 내비게이션:",
"shortcuts": {
"pageUp": "한 페이지 위로 스크롤",
"pageDown": "한 페이지 아래로 스크롤",
"home": "맨 위로 이동",
"end": "맨 아래로 이동"
}
},
"initialization": { "initialization": {
"title": "초기화 중", "title": "초기화 중",
"message": "작업공간을 준비하고 있습니다...", "message": "작업공간을 준비하고 있습니다...",

View File

@@ -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}",
@@ -1426,15 +1424,6 @@
"duplicate": "Этот тег уже существует" "duplicate": "Этот тег уже существует"
} }
}, },
"keyboard": {
"navigation": "Навигация с клавиатуры:",
"shortcuts": {
"pageUp": "Прокрутить на страницу вверх",
"pageDown": "Прокрутить на страницу вниз",
"home": "Перейти к началу",
"end": "Перейти к концу"
}
},
"initialization": { "initialization": {
"title": "Инициализация", "title": "Инициализация",
"message": "Подготовка вашего рабочего пространства...", "message": "Подготовка вашего рабочего пространства...",

View File

@@ -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}页面的文件夹侧边栏已隐藏",
@@ -1426,15 +1424,6 @@
"duplicate": "该标签已存在" "duplicate": "该标签已存在"
} }
}, },
"keyboard": {
"navigation": "键盘导航:",
"shortcuts": {
"pageUp": "向上一页滚动",
"pageDown": "向下一页滚动",
"home": "跳到顶部",
"end": "跳到底部"
}
},
"initialization": { "initialization": {
"title": "初始化", "title": "初始化",
"message": "正在准备你的工作空间...", "message": "正在准备你的工作空间...",

View File

@@ -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}頁面的資料夾側邊欄已隱藏",
@@ -1426,15 +1424,6 @@
"duplicate": "此標籤已存在" "duplicate": "此標籤已存在"
} }
}, },
"keyboard": {
"navigation": "鍵盤導覽:",
"shortcuts": {
"pageUp": "向上捲動一頁",
"pageDown": "向下捲動一頁",
"home": "跳至頂部",
"end": "跳至底部"
}
},
"initialization": { "initialization": {
"title": "初始化", "title": "初始化",
"message": "正在準備您的工作區...", "message": "正在準備您的工作區...",

View File

@@ -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)

View File

@@ -264,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()
@@ -272,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"
@@ -291,11 +300,9 @@ class MetadataSyncService:
f"Error fetching metadata: {resolved_error} " f"Error fetching metadata: {resolved_error} "
f"(file={os.path.basename(file_path)}, sha256={sha256})" f"(file={os.path.basename(file_path)}, sha256={sha256})"
) )
is_model_not_found = "Model not found" in resolved_error # Use case layer (BulkMetadataRefreshUseCase) logs failed models at WARNING level,
if is_expected_offline_error(resolved_error) or is_model_not_found: # so this level is demoted to DEBUG to avoid duplicate user-visible logging.
logger.info(error_msg) logger.debug(error_msg)
else:
logger.error(error_msg)
return False, error_msg return False, error_msg
model_data["from_civitai"] = True model_data["from_civitai"] = True

View File

@@ -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,14 +712,20 @@ 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()
await self._apply_scan_result(scan_result) if not self.is_cancelled():
await self._save_persistent_cache(scan_result) await self._apply_scan_result(scan_result)
await self._sync_download_history(scan_result.raw_data, source='scan') await self._save_persistent_cache(scan_result)
await self._sync_download_history(scan_result.raw_data, source='scan')
logger.info( logger.info(
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
@@ -1096,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)
@@ -1764,6 +1784,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

View File

@@ -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(),

View File

@@ -39,6 +39,9 @@ async def calculate_sha256(file_path: str) -> str:
Uses ``posix_fadvise`` with ``POSIX_FADV_DONTNEED`` to avoid polluting the OS page 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 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. accounted for in guest ``used`` memory, causing VmmemWSL to balloon.
On Windows/macOS where ``posix_fadvise`` is not available the hint is silently
skipped.
""" """
sha256_hash = hashlib.sha256() sha256_hash = hashlib.sha256()
chunk_size = _get_hash_chunk_size_bytes() chunk_size = _get_hash_chunk_size_bytes()
@@ -48,7 +51,9 @@ async def calculate_sha256(file_path: str) -> str:
sha256_hash.update(byte_block) sha256_hash.update(byte_block)
# Evict pages after reading so the data doesn't linger in the kernel page # Evict pages after reading so the data doesn't linger in the kernel page
# cache — on WSL this otherwise appears as unreclaimable VmmemWSL growth. # cache — on WSL this otherwise appears as unreclaimable VmmemWSL growth.
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED) # Guard against platforms (Windows, macOS) that lack posix_fadvise.
if hasattr(os, "posix_fadvise") and hasattr(os, "POSIX_FADV_DONTNEED"):
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
return sha256_hash.hexdigest() return sha256_hash.hexdigest()

View File

@@ -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.3"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -1,96 +0,0 @@
/* Keyboard navigation indicator and help */
.keyboard-nav-hint {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: help;
transition: var(--transition-base);
margin-left: 8px;
}
.keyboard-nav-hint:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
.keyboard-nav-hint i {
font-size: 14px;
}
/* Tooltip styling */
.tooltip {
position: relative;
}
.tooltip .tooltiptext {
visibility: hidden;
width: 240px;
background-color: var(--lora-surface);
color: var(--text-color);
text-align: center;
border-radius: var(--border-radius-xs);
padding: 8px;
position: absolute;
z-index: 9999; /* Ensure tooltip appears above cards */
right: 120%; /* Position tooltip to the left of the icon */
top: 50%; /* Vertically center */
transform: translateY(-15%); /* Vertically center */
opacity: 0;
transition: opacity 0.3s;
box-shadow: var(--shadow-lg);
border: 1px solid var(--lora-border);
font-size: 0.85em;
line-height: 1.4;
}
.tooltip .tooltiptext::after {
content: "";
position: absolute;
top: 50%; /* Vertically center arrow */
left: 100%; /* Arrow on the right side */
margin-top: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent transparent transparent var(--lora-border); /* Arrow points right */
}
.tooltip:hover .tooltiptext {
visibility: visible;
opacity: 1;
}
/* Keyboard shortcuts table */
.keyboard-shortcuts {
width: 100%;
border-collapse: collapse;
margin-top: 5px;
}
.keyboard-shortcuts td {
padding: 4px;
text-align: left;
}
.keyboard-shortcuts td:first-child {
font-weight: bold;
width: 40%;
}
.key {
display: inline-block;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 1px 5px;
font-size: 0.8em;
box-shadow: var(--shadow-xs);
}

View File

@@ -8,7 +8,7 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-2); gap: var(--space-2);
margin-bottom: var(--space-3); margin: var(--space-3) 0;
} }
.stat-card { .stat-card {

View File

@@ -823,54 +823,107 @@
} }
.range-control input[type="range"] { .range-control input[type="range"] {
--range-fill: 40%;
width: 120px; width: 120px;
height: 4px; height: 6px;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
background: var(--border-color); background: linear-gradient(
border-radius: 2px; to right,
var(--lora-accent) 0%,
var(--lora-accent) var(--range-fill),
var(--border-color) var(--range-fill),
var(--border-color) 100%
);
border-radius: var(--radius-full);
outline: none; outline: none;
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
transition: background 0.3s ease;
}
.range-control input[type="range"]:focus-visible {
outline: none;
} }
.range-control input[type="range"]::-webkit-slider-thumb { .range-control input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 16px; width: 18px;
height: 16px; height: 18px;
border-radius: 50%; border-radius: 50%;
background: var(--lora-accent); background: var(--lora-accent);
cursor: pointer; cursor: pointer;
border: 2px solid var(--lora-surface); border: 2px solid var(--lora-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); box-shadow: var(--shadow-md);
transition: transform 0.15s ease; transition: transform var(--transition-bounce), box-shadow 0.2s ease;
} }
.range-control input[type="range"]::-webkit-slider-thumb:hover { .range-control input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.15); transform: scale(1.2);
box-shadow: var(--shadow-md), 0 0 0 4px var(--color-accent-subtle);
}
.range-control input[type="range"]::-webkit-slider-thumb:active {
transform: scale(1.1);
box-shadow: var(--shadow-md), 0 0 0 6px var(--color-accent-subtle);
}
.range-control input[type="range"]:focus-visible::-webkit-slider-thumb {
box-shadow: var(--shadow-md), 0 0 0 3px var(--color-accent-subtle);
} }
.range-control input[type="range"]::-moz-range-thumb { .range-control input[type="range"]::-moz-range-thumb {
width: 16px; width: 18px;
height: 16px; height: 18px;
border-radius: 50%; border-radius: 50%;
background: var(--lora-accent); background: var(--lora-accent);
cursor: pointer; cursor: pointer;
border: 2px solid var(--lora-surface); border: 2px solid var(--lora-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); box-shadow: var(--shadow-md);
transition: transform var(--transition-bounce), box-shadow 0.2s ease;
}
.range-control input[type="range"]::-moz-range-thumb:hover {
transform: scale(1.2);
box-shadow: var(--shadow-md), 0 0 0 4px var(--color-accent-subtle);
}
.range-control input[type="range"]::-moz-range-thumb:active {
transform: scale(1.1);
box-shadow: var(--shadow-md), 0 0 0 6px var(--color-accent-subtle);
}
.range-control input[type="range"]::-moz-range-track {
height: 6px;
border-radius: var(--radius-full);
background: var(--border-color);
} }
.range-control .range-value { .range-control .range-value {
min-width: 36px; min-width: 36px;
text-align: center; text-align: center;
font-size: 0.9em; font-size: 0.85em;
font-weight: 600; font-weight: 700;
color: var(--text-color); color: var(--lora-accent);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
background: var(--surface-subtle);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
letter-spacing: 0.02em;
} }
[data-theme="dark"] .range-control input[type="range"] { [data-theme="dark"] .range-control input[type="range"] {
background: linear-gradient(
to right,
var(--lora-accent) 0%,
var(--lora-accent) var(--range-fill),
rgba(255, 255, 255, 0.15) var(--range-fill),
rgba(255, 255, 255, 0.15) 100%
);
}
[data-theme="dark"] .range-control input[type="range"]::-moz-range-track {
background: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.15);
} }

View File

@@ -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;
@@ -640,13 +540,9 @@
height: calc(100vh - 88px); height: calc(100vh - 88px);
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;
@@ -665,11 +561,11 @@
max-width: 280px; max-width: 280px;
left: 0px; left: 0px;
} }
.sidebar-breadcrumb-nav { .sidebar-breadcrumb-nav {
font-size: 0.8em; font-size: 0.8em;
} }
.sidebar-breadcrumb-item { .sidebar-breadcrumb-item {
padding: 3px 6px; padding: 3px 6px;
} }

View File

@@ -36,7 +36,7 @@
@import 'components/initialization.css'; @import 'components/initialization.css';
@import 'components/progress-panel.css'; @import 'components/progress-panel.css';
@import 'components/duplicates.css'; /* Add duplicates component */ @import 'components/duplicates.css'; /* Add duplicates component */
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
@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';

View File

@@ -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 {
@@ -948,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
@@ -979,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 {
@@ -991,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
@@ -1022,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 {
@@ -1471,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
}) })
@@ -1502,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 {

View File

@@ -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 if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
// We need: sidebar width + container width + some padding < viewport width container.style.marginLeft = `${sidebarWidth + 10}px`;
if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
// Not enough space, push container to the right
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;
} }

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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...');
} }
}; };

View File

@@ -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']);
@@ -806,12 +805,14 @@ export class SettingsManager {
// Set card blur amount slider // Set card blur amount slider
const cardBlurAmountInput = document.getElementById('cardBlurAmount'); const cardBlurAmountInput = document.getElementById('cardBlurAmount');
const cardBlurValue = state.global.settings.card_blur_amount ?? 8;
if (cardBlurAmountInput) { if (cardBlurAmountInput) {
cardBlurAmountInput.value = state.global.settings.card_blur_amount ?? 8; cardBlurAmountInput.value = cardBlurValue;
cardBlurAmountInput.style.setProperty('--range-fill', (cardBlurValue / 20 * 100) + '%');
} }
const cardBlurAmountValue = document.getElementById('cardBlurAmountValue'); const cardBlurAmountValue = document.getElementById('cardBlurAmountValue');
if (cardBlurAmountValue) { if (cardBlurAmountValue) {
cardBlurAmountValue.textContent = `${state.global.settings.card_blur_amount ?? 8}px`; cardBlurAmountValue.textContent = `${cardBlurValue}px`;
} }
const usePortableCheckbox = document.getElementById('usePortableSettings'); const usePortableCheckbox = document.getElementById('usePortableSettings');
@@ -884,12 +885,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) {
@@ -2077,6 +2072,9 @@ export class SettingsManager {
displayEl.textContent = `${value}px`; displayEl.textContent = `${value}px`;
} }
const max = parseInt(element.max, 10) || 20;
element.style.setProperty('--range-fill', (value / max * 100) + '%');
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success'); showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
} catch (error) { } catch (error) {
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
@@ -2949,12 +2947,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);
});
}
} }
} }

View File

@@ -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);
} }

View File

@@ -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',

View File

@@ -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);

View File

@@ -100,30 +100,6 @@
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span> <span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
</button> </button>
</div> </div>
<div class="keyboard-nav-hint tooltip">
<i class="fas fa-keyboard"></i>
<span class="tooltiptext">
<span>{{ t('keyboard.navigation') }}</span>
<table class="keyboard-shortcuts">
<tr>
<td><span class="key">Page Up</span></td>
<td>{{ t('keyboard.shortcuts.pageUp') }}</td>
</tr>
<tr>
<td><span class="key">Page Down</span></td>
<td>{{ t('keyboard.shortcuts.pageDown') }}</td>
</tr>
<tr>
<td><span class="key">Home</span></td>
<td>{{ t('keyboard.shortcuts.home') }}</td>
</tr>
<tr>
<td><span class="key">End</span></td>
<td>{{ t('keyboard.shortcuts.end') }}</td>
</tr>
</table>
</span>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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">

View File

@@ -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">
@@ -567,7 +549,7 @@
</div> </div>
<div class="setting-control range-control"> <div class="setting-control range-control">
<input type="range" id="cardBlurAmount" min="0" max="20" value="8" step="1" <input type="range" id="cardBlurAmount" min="0" max="20" value="8" step="1"
oninput="document.getElementById('cardBlurAmountValue').textContent = this.value + 'px'" oninput="var pct = (this.value / 20) * 100; this.style.setProperty('--range-fill', pct + '%'); document.getElementById('cardBlurAmountValue').textContent = this.value + 'px'"
onchange="settingsManager.saveRangeSetting('cardBlurAmount', 'cardBlurAmountValue', 'card_blur_amount')"> onchange="settingsManager.saveRangeSetting('cardBlurAmount', 'cardBlurAmountValue', 'card_blur_amount')">
<span id="cardBlurAmountValue" class="range-value">8px</span> <span id="cardBlurAmountValue" class="range-value">8px</span>
</div> </div>

View File

@@ -137,30 +137,6 @@
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span> <span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
</button> </button>
</div> </div>
<div class="keyboard-nav-hint tooltip">
<i class="fas fa-keyboard"></i>
<span class="tooltiptext">
<span>{{ t('keyboard.navigation') }}</span>
<table class="keyboard-shortcuts">
<tr>
<td><span class="key">Page Up</span></td>
<td>{{ t('keyboard.shortcuts.pageUp') }}</td>
</tr>
<tr>
<td><span class="key">Page Down</span></td>
<td>{{ t('keyboard.shortcuts.pageDown') }}</td>
</tr>
<tr>
<td><span class="key">Home</span></td>
<td>{{ t('keyboard.shortcuts.home') }}</td>
</tr>
<tr>
<td><span class="key">End</span></td>
<td>{{ t('keyboard.shortcuts.end') }}</td>
</tr>
</table>
</span>
</div>
</div> </div>
</div> </div>

View File

@@ -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;

View File

@@ -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,

View File

@@ -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';

View File

@@ -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;

View File

@@ -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