Compare commits

...

68 Commits

Author SHA1 Message Date
Will Miao 2b361f4f5d feat(ui): add group-by-model toggle to global context menu
Adds a 'Group by Model' toggle entry to the right-click global context
menu for quick access, complementing the existing setting in
Settings → Layout Settings. The menu item shows a checkmark indicator
reflecting the current state and immediately reloads the view on toggle.

Also fixes he.json translation that was mojibake (garbled characters).

Includes:
- Context menu HTML item with check-indicator
- JS toggle logic via settingsManager
- i18n for all 10 locales
- Hebrew translation fix
2026-06-22 11:31:15 +08:00
Will Miao 7438072f8c feat(save-image): add %batch_num% support in batch loop 2026-06-22 09:11:38 +08:00
Will Miao 26c54fd358 fix(versions): scope VLM custom filter per-page to prevent cross-page leak
Store the originating page type alongside VLM data in sessionStorage;
validate it on every page load before applying the filter or showing
the indicator. Stale data is auto-cleaned on mismatch.

This prevents the 'View all local versions' custom filter from leaking
into the checkpoints (or embeddings) page, which caused an empty grid.
2026-06-21 12:02:06 +08:00
Will Miao 7cb6b04c63 chore: remove duplicate _truncateText from LorasControls/CheckpointsControls, add backend test for civitai_model_id filter 2026-06-21 11:19:54 +08:00
Will Miao fc29cde82a feat(versions): add View all local versions button to model versions tab
Clicking the button closes the modal, writes filter params to sessionStorage,
and reloads the page to show all local versions of the model as individual
cards (bypassing group-by-model dedup). The filter respects the update flag
strategy and the versions-filter-toggle state (same-base vs all versions).

Supporting changes:
- sessionStorage keys vlm_model_id / vlm_model_name / vlm_base_model
- BaseModelApiClient._addModelSpecificParams adds civitai_model_id param
- LoraApiClient calls super._addModelSpecificParams for VLM detection
- LorasControls / CheckpointsControls clearCustomFilter checks VLM first
- PageControls.checkVlmFilter shows customFilterIndicator with label
- Backend parses civitai_model_id, filters before group_by_model dedup
2026-06-21 11:13:53 +08:00
Will Miao 559ca946dc feat(models): add group-by-model option to collapse multiple versions into one card
Adds a 'Group by Model' toggle in Layout Settings. When enabled, only the
latest version (highest civitai.id) of each Civitai model is shown as a
single card — older versions sharing the same modelId are hidden.

Backend dedup runs in BaseModelService.get_paginated_data() before
filtering/pagination, ensuring correct paginated results. The setting
is persisted via the existing settings pipeline and passed as a query
parameter to the listing endpoint.

Includes:
- Backend: dedup logic, route param parsing, settings default
- Frontend: API param, SettingsManager wiring, toggle UI
- i18n: translations for all 10 locales
- Tests: unit test covering dedup on/off and standalone items
2026-06-21 08:48:42 +08:00
Will Miao 2b8e7c7504 fix(tests): update recipes page tests for unified controls template
- Inject #customFilterIndicator DOM in beforeEach (raw template
  renderer doesn't process Jinja2 {% include %} tags)
- Fix selector from #customFilterText to .customFilterText
2026-06-20 06:55:47 +08:00
Will Miao 6816d75933 refactor(recipes): unify controls and breadcrumb UI with model pages
- Replace inline controls+breadcrumb in recipes.html with shared includes
- Add page_id conditionals in controls.html to adapt buttons per page type
- Unify customFilterText selector to class-based in recipes.js
- Add [data-action="find-duplicates"] event listener for unified button
- Fix i18n keys to use recipes-specific translations on recipes page
2026-06-19 22:41:50 +08:00
willmiao b58abbad7c docs: auto-update supporters list in README 2026-06-19 10:31:18 +00:00
Will Miao 999814ca87 chore(release): bump version to v1.1.4 2026-06-19 18:31:03 +08:00
Will Miao 3c2760a803 fix(stats): sort Base Model Distribution X-axis labels alphabetically (#796) 2026-06-19 17:29:33 +08:00
Will Miao 0edbd7bcca fix(metadata): add LoraTextLoaderLM extractor so SaveImageLM records its loras (#801) 2026-06-19 17:13:48 +08:00
Will Miao 21e89fa7de fix(tags): normalize tag case on save and make filtering case-insensitive (#727)
- save_metadata_updates now trims/lowercases/dedupes tags on write
- ModelFilterSet tag matching is now case-insensitive (both include/exclude)
- Removed redundant .lower() calls in tag_update_service.py
2026-06-19 16:42:09 +08:00
Will Miao 968d6d1d1f feat(tags): unify recipe modal tag UI with model modal
- Replace recipe modal's custom tag display/edit with shared
  renderCompactTags/setupTagEditMode from ModelTags and utils
- Remove 300+ lines of duplicated tag display and editing code
- Parameterize setupTagEditMode with saveHandler/onSaved/showSuggestions
  options for recipe-specific save flow (updateRecipeMetadata + dirty state)
- Scope all DOM queries in ModelTags.js via options.container / this.closest
  to prevent cross-modal element conflicts
- Fix edit button alignment (justify-content: flex-start)
- Fix tag tooltip selector scoping in setupTagTooltip
- Add width: 100% to #recipeTagsContainer for edit container full width
2026-06-19 16:31:27 +08:00
Will Miao cf0fd0e0ad feat(i18n): internationalize dynamic insights content with key/params architecture (#489) 2026-06-19 13:49:03 +08:00
Will Miao 16e5dcf7b2 feat(i18n): internationalize statistics page strings across all locales 2026-06-19 13:37:01 +08:00
Will Miao ab6bb25d46 fix(example-images): skip hidden files in path validation, show offending items on failure (#807) 2026-06-19 11:54:55 +08:00
Will Miao 07f49559be fix(virtual-scroll): avoid full reload on move-to-folder, scroll to top on filter/page reset
- MoveManager/SidebarManager: replace resetAndReload with in-place
  VirtualScroller update after move operations (remove non-visible,
  update visible items' file_path). Preserves scroll position and
  avoids empty grid.
- VirtualScroller: add removeMultipleItemsByFilePath for efficient
  batch removal with Array.isArray guard.
- baseModelApi: scroll to top on loadMoreWithVirtualScroll(true),
  covering filter/sort/search/folder/views changes.
- SidebarManager selectFolder: scroll now handled centrally.
2026-06-19 09:18:49 +08:00
Will Miao b24b1a7e57 feat(settings): hide API key from frontend, use status+edit instead of password field
Backend changes:
- Add civitai_api_key to _NO_SYNC_KEYS, return only boolean civitai_api_key_set
- Clean up known template placeholder on load to prevent false positive

Frontend changes:
- Replace type=password with type=text + CSS masking (-webkit-text-security)
- Replace pre-filled input with status display (Configured/Not configured)
- Add inline edit view with Save/Cancel buttons
- Re-add eye toggle via CSS class toggle (not type switching)
- Use CSS transitions for smooth status/edit view switching

This prevents Chromium/Vivaldi password manager from triggering
'save password' prompts when opening the settings modal.
2026-06-19 08:05:04 +08:00
Will Miao faf64f8986 fix(css): migrate duplicates component to canonical color tokens
Replace undefined --lora-accent-l/c/h and --lora-warning-l/c/h with
canonical --color-accent-l/c/h and --color-warning-l/c/h from the
design token system. Fix 5 border-color declarations missing oklch()
wrapper, fix var() space syntax error in .group-toggle-btn:hover,
and replace hardcoded green with --color-success token.
2026-06-18 22:41:46 +08:00
Will Miao a617487a43 fix(ui): lift theme popover out of header stacking context to appear above modals 2026-06-18 22:19:36 +08:00
Will Miao 3012a7aef3 fix(settings): prevent Firefox save-password prompt from API key input
- Remove server-side value='...' from password field in settings modal template
  so the API key is never baked into the DOM at page load time
- Populate the input dynamically via loadSettingsToUI() when modal opens
- Clear both API key and proxy password fields on modal close to prevent
  Firefox from detecting pre-filled password fields on page navigation
2026-06-18 21:57:03 +08:00
Will Miao 499e19de34 fix(modals): tone down batch summary modal styling - remove icons, flatten gradients, lock to design tokens
- Metadata Fetch Summary: remove per-card icons, demote total/duration cards
  to neutral border, drop title icon, fix table header border width
- Batch Import Summary: replace 3em centered hero with inline left-aligned
  layout, flatten progress bar gradient, simplify circular badges to plain
  colored icons, unify border widths to 4px and token namespace to --color-
- Lock all off-scale em typography to --text-{xs,lg} design tokens
2026-06-18 21:56:58 +08:00
Will Miao 9161762ca9 fix(sidebar): align hidden indicator height (48px) and icon size with sidebar header 2026-06-18 21:14:35 +08:00
Will Miao 9bbd26efe6 feat(license-icons): add second set of license icons matching current CivitAI design
- Add 5 new Tabler SVG icons (currency-dollar, brush, user, git-merge, license)
- Implement Set 2 rendering in ModelModal.js (standalone UI) with green/red
  permission indicators and preview_tooltip.js (ComfyUI widget)
- Add use_new_license_icons setting (default: true) with toggle in settings UI
- ComfyUI tooltip reads setting directly from preview-url API response to
  eliminate race conditions and respect standalone settings changes
- Remove the now-unused separate ComfyUI setting loramanager.license_icon_style
- Add CSS for both standalone (lora-modal.css) and widget (lm_styles.css)
- i18n: translate licenseIcons keys into all 10 supported languages
- Fix test to use classic style explicitly for continued coverage
2026-06-18 21:07:44 +08:00
Will Miao 258b2622d5 fix(sidebar): align restore indicator with sidebar header and add first-use breathing animation (#990) 2026-06-18 19:22:38 +08:00
Will Miao 80ec9085dd fix(theme): replace Gruvbox with Midnight, fix accent/info hue collisions and hardcoded colors
- Replace Gruvbox preset with Midnight (deep blue-purple, violet accent)
- Fix accent/info hue collisions in Nord, Monokai, Dracula, Solarized
- Fix Solarized error/warning collision (error-h 25->5) and WCAG contrast
- Make --color-skip-refresh-* follow --color-warning-h dynamically
- Replace hardcoded rgba(24,144,255) in onboarding.css with --color-accent
- Replace hardcoded #00B87A in import modals with --color-success
2026-06-18 18:57:53 +08:00
Will Miao c5c7373e10 feat(theme): add 5 preset color themes (Nord/Gruvbox/Monokai/Dracula/Solarized) with popover selector
Implements Approach C (dual-attribute: data-theme + data-theme-preset),
keeping all 106 existing [data-theme="dark"] overrides unchanged.

- Colors: 5 professionally designed oklch palettes in tokens/colors.css
- UI: popover theme selector with mode (Light/Dark/Auto) + preset grid
- JS: cycleTheme(), setPreset(), localStorage persistence
- Locale: 12 new translation keys across 10 languages
- Polish: solid accent swatches matching flat token-driven aesthetic
2026-06-18 09:53:40 +08:00
Will Miao b7721866e5 fix(stats): implement Model Types chart in Collection tab with correct type distribution 2026-06-18 06:48:46 +08:00
Will Miao 8314b9bedb feat(downloads): add /downloads/queue/status endpoint and integrate queue lifecycle
- New GET /api/lm/downloads/queue/status handler for non-terminal status
  transitions (queued -> downloading, downloading -> paused, etc.)
- Queue lifecycle auto-integration in DownloadManager._download_with_semaphore:
  downloading -> SQLite update_status('downloading') on semaphore acquire
  completed -> complete_download('completed') on success
  canceled -> complete_download('canceled') on CancelledError
  failed -> complete_download('failed') on Exception
- All queue operations wrapped in try/except to never break the download flow
2026-06-17 23:04:30 +08:00
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
Will Miao 4199c30fec fix(metadata-sync): downgrade "Model not found" to INFO and replace model_name with file+sha256 in log 2026-06-17 00:06:43 +08:00
Will Miao 4a8084cdbc feat(save-image): support %NodeTitle.WidgetName% placeholders and fix %seed% None fallback (#314) 2026-06-16 23:48:44 +08:00
Will Miao 6263e6848c fix: move posix_fadvise(DONTNEED) after read loop so it actually evicts pages (#985) 2026-06-16 23:12:02 +08:00
Will Miao 58c266ad07 fix(scanner): respect lazy hash for checkpoints, add posix_fadvise, cancel on shutdown (#985) 2026-06-16 23:00:23 +08:00
Will Miao 2939813e1a feat(metadata-fetch): add result summary modal with i18n, fix contrast and counting bugs (#38) 2026-06-16 22:38:50 +08:00
Will Miao a9e5ee7e79 fix: follow-up nits for AVIF/JXL brotli support
- Fix JXL container ftyp size check (==20 → >=16) to accept
  wider range of valid JXL files
- Add brotli decompression size limit (2 MB) to prevent OOM
- Add trailing newline to requirements.txt
- Add unit tests for new ISOBMFF/brotli extraction paths:
  JXL/AVIF happy paths, missing brob, corrupt payload,
  non-ISOBMFF fallthrough, write-skip on AVIF/JXL,
  JSON dict/list fields, and oversized decompression
2026-06-16 16:27:56 +08:00
Will Miao a17b0e9901 Merge pull request #982 from koloved/main
Add AVIF and JXL image support with brotli metadata decompression
2026-06-16 16:24:30 +08:00
s.ivanov 8f23d966bf Update requirements.txt 2026-06-16 07:27:32 +02:00
Will Miao 7a76fc72d0 fix(rate-limit): continue to next provider on CivArchive 429 to prevent bulk refresh from freezing (#983)
When CivArchive returns HTTP 429 with a large retry_after, the bulk
metadata refresh would block for hours because:

1. FallbackMetadataProvider raised RateLimitError instead of continuing
   to the next provider (e.g., SQLite archive was never reached).

2. _RateLimitRetryHelper retried long-rate-limit 429s 3 times — all
   futile since the hourly cap hasn't reset.

3. The batch loop had no awareness of persistent rate-limiting,
   causing 192+ models to each hammer the same rate-limited endpoint.

Changes:
- FallbackMetadataProvider: all 6 methods now continue to next provider
  on RateLimitError instead of raising (model_metadata_provider.py)
- fetch_and_update_model: deleted-model path also continues on
  RateLimitError so sqlite provider gets a chance (metadata_sync_service.py)
- _RateLimitRetryHelper: when retry_after >= 120s, only 1 attempt is
  made — retries are futile for hour-scale rate limits
- BulkMetadataRefreshUseCase: tracks consecutive rate-limit failures
  and aborts early after 3 (bulk_metadata_refresh_use_case.py)

Tests: updated test_fallback_respects_retry_limit for new continue
behavior; added tests for large/small retry_after thresholds.
2026-06-16 13:08:34 +08:00
Will Miao 518a4dd5ee chore: add reasonix.toml and .codegraph/ to .gitignore 2026-06-16 13:05:11 +08:00
s.ivanov 2b6d4e5d8b Add AVIF and JXL image support with brotli metadata decompression 2026-06-15 09:28:49 +02:00
Will Miao 1f4edbeb9d chore(release): bump version to v1.1.1 2026-06-14 23:49:44 +08:00
Will Miao a256558a0e fix(downloads): delete history entries on retry and add dedup for bug #980
- retry_from_history() and retry_all_failed() now DELETE the original
  history entry after re-queuing it. Previously the old entry stayed
  in history causing exponential growth on repeated retry→cancel→retry
  cycles.
- Add deduplicate() called once on singleton creation to clean up
  existing duplicate queue/history entries left by the bug:
  1. In-status dedup (keep highest id per model+version+status)
  2. Cross-status dedup (prefer completed > failed > canceled)
  3. Queue dedup (keep highest rowid per model+version)
  4. Orphan queue cleanup (source='retry' entries obsoleted by
     terminal history entries)
2026-06-14 22:52:44 +08:00
Will Miao 818b9113f0 fix(preview): add Cache-Control header to FileResponse for browser caching (#975)
Chrome does not cache 206 Partial Content responses for <video> elements
without an explicit Cache-Control header. When VirtualScroller recycles
cards and creates new <video> elements with the same URL, Chrome
re-downloads the full video (several MB each) instead of using the cache.

Verified via Chrome DevTools: same .mp4 URL appears 2-3 times in network
trace as separate requests with no cache hit, each returning 206. With
Cache-Control: max-age=86400, the browser will reuse the cached response
for 24 hours across scroll cycles.

Video preview files are ~3.5MB while image previews are ~50-100KB (due
to WebP optimization), making caching especially impactful for videos.
2026-06-14 17:36:59 +08:00
Will Miao 6a4fd020dc fix(api): return JSON error responses for all /api/* routes — prevent JSON.parse crashes on 404/500 2026-06-14 13:13:01 +08:00
Will Miao 7a23040452 fix(save-image): sanitize invalid filename chars from %pprompt%, %nprompt%, %model% patterns (#978) 2026-06-14 09:33:12 +08:00
Will Miao 138024aefe fix(preview): revert to FileResponse as default for all platforms (#975)
The previous commit (a19ddc14) restored Linux sendfile but kept the
manual streaming path for Windows via sys.platform guard. A Windows
user reports performance is still worse than v1.0.5.

Switch back to web.FileResponse for all files on all platforms as the
default. The IOCP crash is an edge case (fast scrolling through many
video previews) that affects few users, while the Python chunked I/O
performance penalty affects everyone.

_stream_file() is kept as an unused fallback for a future compat
setting toggle.
2026-06-13 21:43:44 +08:00
Will Miao a19ddc14f6 perf(preview): restore Linux sendfile, add cache headers, increase chunk size (#975)
- Restrict manual video streaming to Windows only (sys.platform == 'win32');
  Linux/macOS now uses kernel sendfile (zero-copy DMA) via aiohttp FileResponse
- Add Cache-Control: public, max-age=86400 to streaming responses so browsers
  cache video previews across scroll cycles
- Increase chunk size from 256KB to 1MB to reduce async iteration overhead on
  Windows where streaming is still required
2026-06-13 20:06:58 +08:00
Will Miao 7001ced694 fix(rate-limit): respect server retry_after instead of capping at 30s 2026-06-13 18:01:13 +08:00
pixelpaws a5c861646c Merge pull request #974 from itkitteh/fix/socks-proxy-support
fix: support SOCKS proxies for outbound requests
2026-06-13 14:15:02 +08:00
Artem Yakimenko 3e0bb73793 fix: support SOCKS proxies for outbound requests
The proxy settings allow selecting a SOCKS proxy type, but the SOCKS
URL was passed to aiohttp's per-request `proxy=` argument, which only
supports http(s) proxies. With a SOCKS proxy this opens a plain TCP
connection to the proxy port and sends an HTTP request; the SOCKS
server replies with its handshake bytes (e.g. b"\x05\xff") and aiohttp
fails with "Bad status line ... Expected HTTP/, RTSP/ or ICE/".

Route SOCKS proxy types through an aiohttp-socks ProxyConnector on the
session instead, leaving the `proxy=` kwarg for http(s) proxies only.
trust_env now keys off whether an app-level proxy is active. Adds
aiohttp-socks to requirements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:05:15 +10:00
Will Miao ac51f6a2f6 feat(settings): add adjustable card overlay blur setting (#973) 2026-06-13 09:43:49 +08:00
Will Miao bef222c77d perf(recipe): precompute image_id_map for O(1) CivitAI image existence checks
Build a civitai_image_id → recipe_id mapping once during cache
initialization instead of scanning all recipes on every
check_image_exists and import_from_url call.

- RecipeCache gains an image_id_map field populated by
  _build_image_id_map() during cache init
- check_image_exists and import_from_url duplicate detection
  now use the precomputed map (O(k) / O(1) vs O(n))
- Map is persisted in SQLite cache_metadata for fast startup
- Incrementally updated on add/remove/bulk_remove paths
- Fix: conn.close() before cache_metadata query (dead connection)
2026-06-13 08:32:03 +08:00
Will Miao 7cd6a53447 fix(downloads): accept optional completed_at in complete_download to preserve original timestamps 2026-06-13 07:06:59 +08:00
willmiao 6850b35770 docs: auto-update supporters list in README 2026-06-12 15:38:33 +00:00
121 changed files with 6477 additions and 2388 deletions
-153
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
+6 -1
View File
@@ -12,12 +12,14 @@ coverage/
.coverage .coverage
model_cache/ model_cache/
# agent # agent / dev tooling
.opencode/ .opencode/
.claude/ .claude/
.sisyphus/ .sisyphus/
.codex .codex
.omo .omo
reasonix.toml
.codegraph/
# Vue widgets development cache (but keep build output) # Vue widgets development cache (but keep build output)
vue-widgets/node_modules/ vue-widgets/node_modules/
@@ -26,3 +28,6 @@ vue-widgets/dist/
# Hypothesis test cache # Hypothesis test cache
.hypothesis/ .hypothesis/
# Working/research notes (not committed)
.docs/
+33 -3
View File
File diff suppressed because one or more lines are too long
+124 -107
View File
@@ -6,20 +6,22 @@
"Scott R" "Scott R"
], ],
"allSupporters": [ "allSupporters": [
"megakirbs",
"Brennok", "Brennok",
"Insomnia Art Designs", "Insomnia Art Designs",
"2018cfh", "2018cfh",
"megakirbs",
"Arlecchino Shion", "Arlecchino Shion",
"Charles Blakemore",
"Rob Williams", "Rob Williams",
"W+K+White", "W+K+White",
"$MetaSamsara",
"wackop", "wackop",
"Phil", "Phil",
"Carl G.", "Carl G.",
"Charles Blakemore",
"stone9k", "stone9k",
"Rosenthal",
"itismyelement", "itismyelement",
"$MetaSamsara", "Mozzel",
"Gingko Biloba", "Gingko Biloba",
"Kiba", "Kiba",
"onesecondinosaur", "onesecondinosaur",
@@ -27,21 +29,28 @@
"DM", "DM",
"Sen314", "Sen314",
"Estragon", "Estragon",
"Rosenthal",
"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,20 @@
"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", "bh",
"carozzz", "carozzz",
"Marlon Daniels",
"James Dooley", "James Dooley",
"zenbound", "zenbound",
"Buzzard", "Buzzard",
"jmack", "Aaron Bleuer",
"Adam Shaw", "Adam Shaw",
"Mark Corneglio", "Mark Corneglio",
"SarcasticHashtag", "SarcasticHashtag",
@@ -84,137 +96,129 @@
"Wolffen", "Wolffen",
"James Todd", "James Todd",
"Wicked Choices by ASLPro3D", "Wicked Choices by ASLPro3D",
"FinalyFree",
"Weasyl",
"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", "nwalker94",
"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",
"Sangheili460",
"MagnaInsomnia",
"Akira_HentAI",
"lmsupporter", "lmsupporter",
"andrew.tappan", "andrew.tappan",
"N/A",
"Greenmoustache",
"zounic", "zounic",
"wfpearl", "wfpearl",
"Eldithor",
"Jack B Nimble", "Jack B Nimble",
"Melville Parrish",
"Lustre",
"JaxMax", "JaxMax",
"contrite831", "contrite831",
"bh", "Jwk0205",
"Marlon Daniels",
"Starkselle", "Starkselle",
"Aaron Bleuer", "Olive",
"LacesOut!", "LacesOut!",
"greebles", "greebles",
"Some Guy Named Barry", "Some Guy Named Barry",
"M Postkasse", "M Postkasse",
"Gooohokrbe", "Gooohokrbe",
"wamekukyouzin",
"OldBones", "OldBones",
"Jacob Hoehler", "Jacob Hoehler",
"FinalyFree", "Dogmaster",
"Matt Wenzel", "Matt Wenzel",
"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", "Mouthlessman",
"otaku fra", "otaku fra",
"jean jahren", "jean jahren",
"MiraiKuriyamaSy", "MiraiKuriyamaSy",
"Ran C", "Ran C",
"ViperC", "ViperC",
"Penfore", "Penfore",
"Sangheili460",
"MagnaInsomnia",
"Karl P.", "Karl P.",
"Akira_HentAI",
"Gordon Cole", "Gordon Cole",
"Adam Taylor", "Adam Taylor",
"AbstractAss", "AbstractAss",
"Weird_With_A_Beard", "Weird_With_A_Beard",
"N/A",
"The Spawn", "The Spawn",
"graysock", "graysock",
"Pozadine1", "Pozadine1",
"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",
"Neco28",
"Cosmosis", "Cosmosis",
"David Ortega",
"AELOX", "AELOX",
"Dankin", "Dankin",
"Nicfit23", "Nicfit23",
"FloPro4Sho", "FloPro4Sho",
"Cristian Vazquez", "Cristian Vazquez",
"wamekukyouzin",
"drum matthieu", "drum matthieu",
"Dogmaster",
"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",
"Kevin John Duck", "Kevin John Duck",
"Dustin Chen", "Dustin Chen",
"Blackfish95", "Blackfish95",
"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 +232,19 @@
"Ranzitho", "Ranzitho",
"Gus", "Gus",
"MJG", "MJG",
"David LaVallee",
"linnfrey", "linnfrey",
"ae",
"Tr4shP4nda",
"IamAyam", "IamAyam",
"skaterb949", "skaterb949",
"Brian M",
"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",
@@ -243,14 +253,12 @@
"Tee Gee", "Tee Gee",
"Geolog", "Geolog",
"tarek helmi", "tarek helmi",
"Neco28",
"Eris3D", "Eris3D",
"Max Marklund", "Max Marklund",
"David Ortega",
"Pronredn", "Pronredn",
"Jamie Ogletree",
"a _", "a _",
"Jeff", "Jeff",
"Bruce",
"lh qwe", "lh qwe",
"James Coleman", "James Coleman",
"conner", "conner",
@@ -260,17 +268,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",
"Tr4shP4nda",
"Gamalonia", "Gamalonia",
"WRL_SPR", "WRL_SPR",
"capn", "capn",
@@ -286,18 +291,22 @@
"Hailshem", "Hailshem",
"kudari", "kudari",
"Naomi Hale Danchi", "Naomi Hale Danchi",
"ken",
"epicgamer0020690", "epicgamer0020690",
"Joshua Porrata",
"SuBu",
"RedPIXel",
"Richard", "Richard",
"奚明 刘", "奚明 刘",
"Andrew", "Andrew",
"Brian M",
"Robert Wegemund", "Robert Wegemund",
"sanborondon", "Littlehuggy",
"준희 김", "준희 김",
"Taylor Funk", "Brian Buie",
"Thought2Form", "Thought2Form",
"Kevin Picco", "Kevin Picco",
"Sadlip", "Sadlip",
"Joey Callahan",
"Tomohiro Baba", "Tomohiro Baba",
"m", "m",
"Noora", "Noora",
@@ -305,9 +314,13 @@
"Joshua Gray", "Joshua Gray",
"Mattssn", "Mattssn",
"Mikko Hemilä", "Mikko Hemilä",
"Jamie Ogletree", "Jacob McDaniel",
"Temikus",
"Artokun",
"Michael Taylor", "Michael Taylor",
"Derek Baker",
"Martial", "Martial",
"Michael Anthony Scott",
"Emil Andersson", "Emil Andersson",
"Ouro Boros", "Ouro Boros",
"Atilla Berke Pekduyar", "Atilla Berke Pekduyar",
@@ -318,9 +331,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",
@@ -328,12 +342,8 @@
"momokai", "momokai",
"starbugx", "starbugx",
"dc7431", "dc7431",
"ken",
"Crocket", "Crocket",
"Joshua Porrata",
"keemun", "keemun",
"SuBu",
"RedPIXel",
"Wind", "Wind",
"Nexus", "Nexus",
"Ramneek“Guy”Ashok", "Ramneek“Guy”Ashok",
@@ -349,20 +359,26 @@
"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",
"Pitpe11",
"TheD1rtyD03",
"gzmzmvp", "gzmzmvp",
"Littlehuggy",
"Gregory Kozhemiak", "Gregory Kozhemiak",
"Draven T", "Draven T",
"mrjuan", "mrjuan",
"Brian Buie",
"Eric Whitney", "Eric Whitney",
"Joey Callahan",
"Aquatic Coffee", "Aquatic Coffee",
"Ivan Tadic", "Ivan Tadic",
"Mike Simone", "Mike Simone",
@@ -373,26 +389,20 @@
"Theerat Jiramate", "Theerat Jiramate",
"Focuschannel", "Focuschannel",
"Noah", "Noah",
"Jacob McDaniel",
"X", "X",
"Sloan Steddy", "Sloan Steddy",
"Temikus",
"Artokun",
"hexxish", "hexxish",
"Derek Baker",
"Anthony Faxlandez", "Anthony Faxlandez",
"battu", "battu",
"Michael Anthony Scott",
"Nathan", "Nathan",
"NICHOLAS BAXLEY", "NICHOLAS BAXLEY",
"Pat Hen", "Pat Hen",
"Xeeosat", "Xeeosat",
"Saya",
"Ed Wang", "Ed Wang",
"Jordan Shaw", "Jordan Shaw",
"g unit", "g unit",
"Srdb", "Srdb",
"四糸凜音",
"Nihongasuki",
"JC", "JC",
"Prompt Pirate", "Prompt Pirate",
"uwutismxd", "uwutismxd",
@@ -400,19 +410,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",
"TheD1rtyD03",
"moonpetal", "moonpetal",
"SomeDude", "SomeDude",
"g9p0o", "g9p0o",
@@ -423,6 +424,8 @@
"SpringBootisTrash", "SpringBootisTrash",
"carsten", "carsten",
"ikok", "ikok",
"quantenmecha",
"Jason+Nash",
"DarkRoast", "DarkRoast",
"letzte", "letzte",
"Nasty+Hobbit", "Nasty+Hobbit",
@@ -437,12 +440,16 @@
"Wolfe7D1", "Wolfe7D1",
"blikkies", "blikkies",
"Chris", "Chris",
"Time Valentine",
"elleshar666", "elleshar666",
"Shock Shockor", "Shock Shockor",
"ACTUALLY_the_Real_Willem_Dafoe", "ACTUALLY_the_Real_Willem_Dafoe",
"Михал Михалыч",
"Matt",
"Goldwaters", "Goldwaters",
"Kauffy", "Kauffy",
"Zude", "Zude",
"SPJ",
"Kyler", "Kyler",
"Edward Kennedy", "Edward Kennedy",
"Justin Blaylock", "Justin Blaylock",
@@ -456,13 +463,13 @@
"Billy Gladky", "Billy Gladky",
"Michael Scott", "Michael Scott",
"Probis", "Probis",
"Solixer",
"Wes Sims", "Wes Sims",
"ItsGeneralButtNaked", "ItsGeneralButtNaked",
"Donor4115", "Donor4115",
"Distortik", "Distortik",
"Filippo Ferrari", "Filippo Ferrari",
"Youguang", "Youguang",
"Saya",
"andrewzpong", "andrewzpong",
"BossGame", "BossGame",
"lrdchs", "lrdchs",
@@ -474,6 +481,10 @@
"Whitepinetrader", "Whitepinetrader",
"POPPIN", "POPPIN",
"nanana", "nanana",
"D",
"Dark_Pest",
"Alex",
"Karru",
"ChaChanoKo", "ChaChanoKo",
"ghoulars", "ghoulars",
"null", "null",
@@ -489,8 +500,6 @@
"Doug+Rintoul", "Doug+Rintoul",
"Noor", "Noor",
"Yorunai", "Yorunai",
"quantenmecha",
"Jason+Nash",
"BillyBoy84", "BillyBoy84",
"Buecyb99", "Buecyb99",
"Welkor", "Welkor",
@@ -499,19 +508,22 @@
"JBsuede", "JBsuede",
"moranqianlong", "moranqianlong",
"Kalli Core", "Kalli Core",
"Time Valentine",
"Christian Schäfer", "Christian Schäfer",
"りん あめ", "りん あめ",
"Михал Михалыч", "Joaquin Hierrezuelo",
"Matt", "Locrospiel",
"Frogmilk", "Frogmilk",
"SPJ", "Sean voets",
"Kor",
"Joseph Hanson",
"John Rednoulf",
"Kyron Mahan", "Kyron Mahan",
"Bryan Rutkowski", "Bryan Rutkowski",
"TBitz33", "TBitz33",
"Anonym dkjglfleeoeldldldlkf", "Anonym dkjglfleeoeldldldlkf",
"Ezokewn", "Ezokewn",
"SendingRavens", "SendingRavens",
"Steven",
"JackJohnnyJim", "JackJohnnyJim",
"TenaciousD", "TenaciousD",
"Dmitry Ryzhov", "Dmitry Ryzhov",
@@ -521,7 +533,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 +564,13 @@
"Scott", "Scott",
"Muratoraccio", "Muratoraccio",
"D", "D",
"Mobius2020",
"ExLightSaber",
"YaboiRay",
"nickname",
"Sildoren",
"Darv",
"Seon+Song",
"2turbo", "2turbo",
"Somebody", "Somebody",
"Balut+Omelette", "Balut+Omelette",
@@ -574,11 +592,7 @@
"Inkognito", "Inkognito",
"G", "G",
"Tan+Huynh", "Tan+Huynh",
"D",
"Dark_Pest",
"Alex",
"Jacky+Ho", "Jacky+Ho",
"Karru",
"generic404", "generic404",
"abattoirblues", "abattoirblues",
"zounik", "zounik",
@@ -593,30 +607,28 @@
"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",
"Locrospiel",
"Sean voets",
"Owen Gwosdz", "Owen Gwosdz",
"Jarrid Lee", "Jarrid Lee",
"Poophead27 Blyat", "Poophead27 Blyat",
"Kor",
"Joseph Hanson",
"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",
"Terminuz",
"ivistorm", "ivistorm",
"max blo", "max blo",
"Sauv", "Sauv",
"Steven",
"CptNeo", "CptNeo",
"Borte",
"Maso", "Maso",
"Ted Cart", "Ted Cart",
"Sage Himeros", "Sage Himeros",
@@ -627,6 +639,7 @@
"Tigon", "Tigon",
"BastardSama", "BastardSama",
"mercur", "mercur",
"SkibidiRizzler",
"Tania Nayelli Fernandez", "Tania Nayelli Fernandez",
"Draconach", "Draconach",
"Yavizu3d", "Yavizu3d",
@@ -634,7 +647,9 @@
"Teriak47", "Teriak47",
"Just me", "Just me",
"Raf Stahelin", "Raf Stahelin",
"Nacho Ferrando",
"Вячеслав Маринин", "Вячеслав Маринин",
"Marcos Tortosa Carmona",
"Dkommander22", "Dkommander22",
"Cola Matthew", "Cola Matthew",
"OniNoKen", "OniNoKen",
@@ -679,6 +694,13 @@
"SelfishMedic", "SelfishMedic",
"adderleighn", "adderleighn",
"EnragedAntelope", "EnragedAntelope",
"shw",
"Celestial+Kitten",
"bakeliteboy",
"TequiTequi",
"Homero+Banda",
"Nick",
"Jim",
"Monix", "Monix",
"Trolinka", "Trolinka",
"IshouI;_;", "IshouI;_;",
@@ -703,13 +725,7 @@
"PoorStudent", "PoorStudent",
"lucites", "lucites",
"Alex+Zaw", "Alex+Zaw",
"Mobius2020",
"ExLightSaber",
"YaboiRay",
"Drizzly", "Drizzly",
"Sildoren",
"Darvidous",
"Seon+Song",
"Nebuleux", "Nebuleux",
"Join+Chun", "Join+Chun",
"GDS+DEV", "GDS+DEV",
@@ -734,6 +750,7 @@
"Nico", "Nico",
"Maximilian Krischan", "Maximilian Krischan",
"Banana Joe", "Banana Joe",
"proto merp",
"_ G3n", "_ G3n",
"Donovan Jenkins", "Donovan Jenkins",
"Hans Meier", "Hans Meier",
@@ -752,10 +769,10 @@
"Seraphy", "Seraphy",
"雨の心 落", "雨の心 落",
"AllTimeNoobie", "AllTimeNoobie",
"Leslie Andrew Ridings",
"jumpd", "jumpd",
"John C", "John C",
"Rim", "Rim",
"yfx507",
"Room Light", "Room Light",
"Jairus Knudsen", "Jairus Knudsen",
"Xan Dionysus", "Xan Dionysus",
@@ -766,32 +783,33 @@
"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",
"Kurt", "Kurt",
"Matt M.", "Matt M.",
"Ivan Imes", "Ivan Imes",
"J M", "J M",
"Slacks",
"Bouya shaka", "Bouya shaka",
"john Greene",
"Faburizu", "Faburizu",
"Jack Lawfield", "Jack Lawfield",
"jimyjomson", "jimyjomson",
"Borte",
"JaeHyun Jang", "JaeHyun Jang",
"Homero Banda",
"Chase Kwon", "Chase Kwon",
"Bob Ling",
"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 +822,6 @@
"hannibal", "hannibal",
"Jo+Example", "Jo+Example",
"BrentBertram", "BrentBertram",
"inusanorthcape",
"eumelzocker", "eumelzocker",
"dxjaymz", "dxjaymz",
"L C", "L C",
@@ -812,5 +829,5 @@
"Somebody", "Somebody",
"CK" "CK"
], ],
"totalCount": 809 "totalCount": 826
} }
+125 -17
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",
@@ -182,6 +183,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "Ausgeschlossene Modelle verwalten" "label": "Ausgeschlossene Modelle verwalten"
},
"groupByModel": {
"label": "Nach Modell gruppieren"
} }
}, },
"header": { "header": {
@@ -250,7 +254,18 @@
"toggle": "Theme wechseln", "toggle": "Theme wechseln",
"switchToLight": "Zu hellem Theme wechseln", "switchToLight": "Zu hellem Theme wechseln",
"switchToDark": "Zu dunklem Theme wechseln", "switchToDark": "Zu dunklem Theme wechseln",
"switchToAuto": "Zu automatischem Theme wechseln" "switchToAuto": "Zu automatischem Theme wechseln",
"presets": "Theme-Voreinstellungen",
"default": "Standard",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "Modus",
"light": "Hell",
"dark": "Dunkel",
"auto": "Auto"
}, },
"actions": { "actions": {
"checkUpdates": "Updates prüfen", "checkUpdates": "Updates prüfen",
@@ -262,6 +277,9 @@
"civitaiApiKey": "Civitai API Key", "civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein", "civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet", "civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
"civitaiApiKeyConfigured": "Konfiguriert",
"civitaiApiKeyNotConfigured": "Nicht konfiguriert",
"civitaiApiKeySet": "Einrichten",
"civitaiHost": { "civitaiHost": {
"label": "Civitai-Host", "label": "Civitai-Host",
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.", "help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
@@ -302,6 +320,7 @@
"downloads": "Downloads", "downloads": "Downloads",
"videoSettings": "Video-Einstellungen", "videoSettings": "Video-Einstellungen",
"layoutSettings": "Layout-Einstellungen", "layoutSettings": "Layout-Einstellungen",
"licenseIcons": "Lizenzsymbole",
"misc": "Verschiedenes", "misc": "Verschiedenes",
"backup": "Backups", "backup": "Backups",
"folderSettings": "Standard-Roots", "folderSettings": "Standard-Roots",
@@ -414,6 +433,8 @@
"help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe." "help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "Nach Modell gruppieren",
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes Civitai-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
"displayDensity": "Anzeige-Dichte", "displayDensity": "Anzeige-Dichte",
"displayDensityOptions": { "displayDensityOptions": {
"default": "Standard", "default": "Standard",
@@ -448,7 +469,9 @@
"modelName": "Modellname", "modelName": "Modellname",
"fileName": "Dateiname" "fileName": "Dateiname"
}, },
"modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll" "modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll",
"cardBlurAmount": "Karten-Overlay-Unschärfe",
"cardBlurAmountHelp": "Passen Sie die Unschärfeintensität der Kopf- und Fußzeilen-Overlays auf Modell- und Rezeptkarten an (0 = keine Unschärfe, 20 = maximale Unschärfe)."
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "Aktive Bibliothek", "activeLibrary": "Aktive Bibliothek",
@@ -580,6 +603,10 @@
"label": "Früher Zugriff Updates ausblenden", "label": "Früher Zugriff Updates ausblenden",
"help": "Nur Early-Access-Updates" "help": "Nur Early-Access-Updates"
}, },
"licenseIcons": {
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
"useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design."
},
"misc": { "misc": {
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen", "includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen", "includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen",
@@ -953,10 +980,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",
@@ -997,6 +1021,18 @@
"storage": "Speicher", "storage": "Speicher",
"insights": "Erkenntnisse" "insights": "Erkenntnisse"
}, },
"metrics": {
"totalModels": "Modelle gesamt",
"totalStorage": "Speicher gesamt",
"totalGenerations": "Generationen gesamt",
"usageRate": "Nutzungsrate",
"loras": "LoRAs",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"uniqueTags": "Einzigartige Tags",
"unusedModels": "Ungenutzte Modelle",
"avgUsesPerModel": "Ø Nutzungen/Modell"
},
"usage": { "usage": {
"mostUsedLoras": "Meistgenutzte LoRAs", "mostUsedLoras": "Meistgenutzte LoRAs",
"mostUsedCheckpoints": "Meistgenutzte Checkpoints", "mostUsedCheckpoints": "Meistgenutzte Checkpoints",
@@ -1014,13 +1050,77 @@
}, },
"insights": { "insights": {
"smartInsights": "Intelligente Erkenntnisse", "smartInsights": "Intelligente Erkenntnisse",
"recommendations": "Empfehlungen" "recommendations": "Empfehlungen",
"noInsights": "Keine Erkenntnisse verfügbar",
"unusedLoras": {
"high": {
"title": "Hohe Anzahl ungenutzter LoRAs",
"description": "{percent}% Ihrer LoRAs ({count}/{total}) wurden noch nie verwendet.",
"suggestion": "Erwägen Sie, ungenutzte Modelle zu organisieren oder zu archivieren, um Speicherplatz freizugeben."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Ungenutzte Checkpoints erkannt",
"description": "{percent}% Ihrer Checkpoints ({count}/{total}) wurden noch nie verwendet.",
"suggestion": "Überprüfen Sie nicht mehr benötigte Checkpoints und erwägen Sie deren Entfernung."
}
},
"unusedEmbeddings": {
"high": {
"title": "Hohe Anzahl ungenutzter Embeddings",
"description": "{percent}% Ihrer Embeddings ({count}/{total}) wurden noch nie verwendet.",
"suggestion": "Organisieren oder archivieren Sie ungenutzte Embeddings, um Ihre Sammlung zu optimieren."
}
},
"collection": {
"large": {
"title": "Große Sammlung erkannt",
"description": "Ihre Modellsammlung verwendet {size} Speicher.",
"suggestion": "Erwägen Sie externe Speicher- oder Cloud-Lösungen für eine bessere Organisation."
}
},
"activity": {
"active": {
"title": "Aktiver Benutzer",
"description": "Sie haben {count} Generationen abgeschlossen!",
"suggestion": "Entdecken und erstellen Sie weiterhin großartige Inhalte mit Ihren Modellen."
}
}
}, },
"charts": { "charts": {
"collectionOverview": "Sammlungsübersicht", "collectionOverview": "Sammlungsübersicht",
"baseModelDistribution": "Basis-Modell-Verteilung", "baseModelDistribution": "Basis-Modell-Verteilung",
"usageTrends": "Nutzungstrends (Letzte 30 Tage)", "usageTrends": "Nutzungstrends (Letzte 30 Tage)",
"usageDistribution": "Nutzungsverteilung" "usageDistribution": "Nutzungsverteilung",
"date": "Datum",
"usageCount": "Nutzungsanzahl",
"fileSizeBytes": "Dateigröße (Bytes)",
"models": "Modelle",
"loraUsage": "LoRA-Nutzung",
"checkpointUsage": "Checkpoint-Nutzung",
"embeddingUsage": "Embedding-Nutzung"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusionsmodell",
"embedding": "Embeddings"
},
"placeholders": {
"loading": "Lädt...",
"noModels": "Keine Modelle gefunden",
"errorLoading": "Fehler beim Laden der Daten",
"noStorageData": "Keine Speicherdaten verfügbar",
"rootFolder": "Root",
"chartLibraryMissing": "Diagramm benötigt Chart.js-Bibliothek"
},
"tooltips": {
"tagCount": "{tag}: {count} Modelle",
"chartUsage": "{name}: {size}, {count} Nutzungen",
"chartPercentage": "{label}: {value} ({pct}%)"
} }
}, },
"modals": { "modals": {
@@ -1396,6 +1496,21 @@
"versionDeleted": "Version gelöscht" "versionDeleted": "Version gelöscht"
} }
} }
},
"metadataFetchSummary": {
"title": "Metadaten abrufen — Zusammenfassung",
"statSuccess": "Erfolgreich",
"statFailed": "Fehlgeschlagen",
"statSkipped": "Übersprungen",
"statTotal": "Gesamt geprüft",
"statDuration": "Dauer",
"successMessage": "Alle {count} {type}s erfolgreich aktualisiert!",
"failedItems": "Fehlgeschlagene Elemente ({count})",
"close": "Schließen",
"copyReport": "Bericht kopieren",
"downloadCsv": "CSV herunterladen",
"columnModelName": "Modellname",
"columnError": "Fehler"
} }
}, },
"modelTags": { "modelTags": {
@@ -1409,15 +1524,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...",
@@ -1955,7 +2061,9 @@
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben", "bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!", "exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}", "exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
"moveFailed": "Failed to move item: {message}" "moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "In die Zwischenablage kopiert",
"downloadStarted": "Download gestartet"
} }
}, },
"doctor": { "doctor": {
+126 -18
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",
@@ -182,6 +183,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "Manage Excluded Models" "label": "Manage Excluded Models"
},
"groupByModel": {
"label": "Group by Model"
} }
}, },
"header": { "header": {
@@ -250,7 +254,18 @@
"toggle": "Toggle theme", "toggle": "Toggle theme",
"switchToLight": "Switch to light theme", "switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark theme", "switchToDark": "Switch to dark theme",
"switchToAuto": "Switch to auto theme" "switchToAuto": "Switch to auto theme",
"presets": "Theme Presets",
"default": "Default",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "Mode",
"light": "Light",
"dark": "Dark",
"auto": "Auto"
}, },
"actions": { "actions": {
"checkUpdates": "Check Updates", "checkUpdates": "Check Updates",
@@ -262,6 +277,9 @@
"civitaiApiKey": "Civitai API Key", "civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Enter your Civitai API key", "civitaiApiKeyPlaceholder": "Enter your Civitai API key",
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai", "civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
"civitaiApiKeyConfigured": "Configured",
"civitaiApiKeyNotConfigured": "Not configured",
"civitaiApiKeySet": "Set up",
"civitaiHost": { "civitaiHost": {
"label": "Civitai host", "label": "Civitai host",
"help": "Choose which Civitai site opens when using View on Civitai links.", "help": "Choose which Civitai site opens when using View on Civitai links.",
@@ -302,6 +320,7 @@
"downloads": "Downloads", "downloads": "Downloads",
"videoSettings": "Video Settings", "videoSettings": "Video Settings",
"layoutSettings": "Layout Settings", "layoutSettings": "Layout Settings",
"licenseIcons": "License Icons",
"misc": "Miscellaneous", "misc": "Miscellaneous",
"backup": "Backups", "backup": "Backups",
"folderSettings": "Default Roots", "folderSettings": "Default Roots",
@@ -414,6 +433,8 @@
"help": "When enabled, versions downloaded before will be skipped." "help": "When enabled, versions downloaded before will be skipped."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "Group by Model",
"groupByModelHelp": "When enabled, only the latest version of each Civitai model is shown as a single card. Older versions are hidden.",
"displayDensity": "Display Density", "displayDensity": "Display Density",
"displayDensityOptions": { "displayDensityOptions": {
"default": "Default", "default": "Default",
@@ -448,7 +469,9 @@
"modelName": "Model Name", "modelName": "Model Name",
"fileName": "File Name" "fileName": "File Name"
}, },
"modelNameDisplayHelp": "Choose what to display in the model card footer" "modelNameDisplayHelp": "Choose what to display in the model card footer",
"cardBlurAmount": "Card Overlay Blur",
"cardBlurAmountHelp": "Adjust the blur intensity of the header and footer overlays on model and recipe cards (0 = no blur, 20 = maximum blur)."
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "Active Library", "activeLibrary": "Active Library",
@@ -580,6 +603,10 @@
"label": "Hide Early Access Updates", "label": "Hide Early Access Updates",
"help": "When enabled, models with only early access updates will not show 'Update available' badge" "help": "When enabled, models with only early access updates will not show 'Update available' badge"
}, },
"licenseIcons": {
"useNewStyle": "Use updated license icons",
"useNewStyleHelp": "Display license permissions with colored indicators (new style) or restriction-only icons (classic style). Mirroring the current CivitAI design."
},
"misc": { "misc": {
"includeTriggerWords": "Include Trigger Words in LoRA Syntax", "includeTriggerWords": "Include Trigger Words in LoRA Syntax",
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard", "includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard",
@@ -953,10 +980,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",
@@ -997,6 +1021,18 @@
"storage": "Storage", "storage": "Storage",
"insights": "Insights" "insights": "Insights"
}, },
"metrics": {
"totalModels": "Total Models",
"totalStorage": "Total Storage",
"totalGenerations": "Total Generations",
"usageRate": "Usage Rate",
"loras": "LoRAs",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"uniqueTags": "Unique Tags",
"unusedModels": "Unused Models",
"avgUsesPerModel": "Avg. Uses/Model"
},
"usage": { "usage": {
"mostUsedLoras": "Most Used LoRAs", "mostUsedLoras": "Most Used LoRAs",
"mostUsedCheckpoints": "Most Used Checkpoints", "mostUsedCheckpoints": "Most Used Checkpoints",
@@ -1014,13 +1050,77 @@
}, },
"insights": { "insights": {
"smartInsights": "Smart Insights", "smartInsights": "Smart Insights",
"recommendations": "Recommendations" "recommendations": "Recommendations",
"noInsights": "No insights available",
"unusedLoras": {
"high": {
"title": "High Number of Unused LoRAs",
"description": "{percent}% of your LoRAs ({count}/{total}) have never been used.",
"suggestion": "Consider organizing or archiving unused models to free up storage space."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Unused Checkpoints Detected",
"description": "{percent}% of your checkpoints ({count}/{total}) have never been used.",
"suggestion": "Review and consider removing checkpoints you no longer need."
}
},
"unusedEmbeddings": {
"high": {
"title": "High Number of Unused Embeddings",
"description": "{percent}% of your embeddings ({count}/{total}) have never been used.",
"suggestion": "Consider organizing or archiving unused embeddings to optimize your collection."
}
},
"collection": {
"large": {
"title": "Large Collection Detected",
"description": "Your model collection is using {size} of storage.",
"suggestion": "Consider using external storage or cloud solutions for better organization."
}
},
"activity": {
"active": {
"title": "Active User",
"description": "You've completed {count} generations so far!",
"suggestion": "Keep exploring and creating amazing content with your models."
}
}
}, },
"charts": { "charts": {
"collectionOverview": "Collection Overview", "collectionOverview": "Collection Overview",
"baseModelDistribution": "Base Model Distribution", "baseModelDistribution": "Base Model Distribution",
"usageTrends": "Usage Trends (Last 30 Days)", "usageTrends": "Usage Trends (Last 30 Days)",
"usageDistribution": "Usage Distribution" "usageDistribution": "Usage Distribution",
"date": "Date",
"usageCount": "Usage Count",
"fileSizeBytes": "File Size (bytes)",
"models": "Models",
"loraUsage": "LoRA Usage",
"checkpointUsage": "Checkpoint Usage",
"embeddingUsage": "Embedding Usage"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model",
"embedding": "Embeddings"
},
"placeholders": {
"loading": "Loading...",
"noModels": "No models found",
"errorLoading": "Error loading data",
"noStorageData": "No storage data available",
"rootFolder": "Root",
"chartLibraryMissing": "Chart requires Chart.js library"
},
"tooltips": {
"tagCount": "{tag}: {count} models",
"chartUsage": "{name}: {size}, {count} uses",
"chartPercentage": "{label}: {value} ({pct}%)"
} }
}, },
"modals": { "modals": {
@@ -1368,7 +1468,7 @@
"resumeModelUpdates": "Resume updates for this model", "resumeModelUpdates": "Resume updates for this model",
"ignoreModelUpdates": "Ignore updates for this model", "ignoreModelUpdates": "Ignore updates for this model",
"viewLocalVersions": "View all local versions", "viewLocalVersions": "View all local versions",
"viewLocalTooltip": "Coming soon" "viewLocalTooltip": "Show all local versions of this model on the main page"
}, },
"filters": { "filters": {
"label": "Base filter", "label": "Base filter",
@@ -1396,6 +1496,21 @@
"versionDeleted": "Version deleted" "versionDeleted": "Version deleted"
} }
} }
},
"metadataFetchSummary": {
"title": "Metadata Fetch Summary",
"statSuccess": "Success",
"statFailed": "Failed",
"statSkipped": "Skipped",
"statTotal": "Total Scanned",
"statDuration": "Duration",
"successMessage": "All {count} {type}s updated successfully!",
"failedItems": "Failed Items ({count})",
"close": "Close",
"copyReport": "Copy Report",
"downloadCsv": "Download CSV",
"columnModelName": "Model Name",
"columnError": "Error"
} }
}, },
"modelTags": { "modelTags": {
@@ -1409,15 +1524,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...",
@@ -1955,7 +2061,9 @@
"bulkMoveSuccess": "Successfully moved {successCount} {type}s", "bulkMoveSuccess": "Successfully moved {successCount} {type}s",
"exampleImagesDownloadSuccess": "Successfully downloaded example images!", "exampleImagesDownloadSuccess": "Successfully downloaded example images!",
"exampleImagesDownloadFailed": "Failed to download example images: {message}", "exampleImagesDownloadFailed": "Failed to download example images: {message}",
"moveFailed": "Failed to move item: {message}" "moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "Copied to clipboard",
"downloadStarted": "Download started"
} }
}, },
"doctor": { "doctor": {
+125 -17
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",
@@ -182,6 +183,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "Gestionar modelos excluidos" "label": "Gestionar modelos excluidos"
},
"groupByModel": {
"label": "Agrupar por modelo"
} }
}, },
"header": { "header": {
@@ -250,7 +254,18 @@
"toggle": "Cambiar tema", "toggle": "Cambiar tema",
"switchToLight": "Cambiar a tema claro", "switchToLight": "Cambiar a tema claro",
"switchToDark": "Cambiar a tema oscuro", "switchToDark": "Cambiar a tema oscuro",
"switchToAuto": "Cambiar a tema automático" "switchToAuto": "Cambiar a tema automático",
"presets": "Preajustes de tema",
"default": "Predeterminado",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "Modo",
"light": "Claro",
"dark": "Oscuro",
"auto": "Auto"
}, },
"actions": { "actions": {
"checkUpdates": "Comprobar actualizaciones", "checkUpdates": "Comprobar actualizaciones",
@@ -262,6 +277,9 @@
"civitaiApiKey": "Clave API de Civitai", "civitaiApiKey": "Clave API de Civitai",
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai", "civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai", "civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
"civitaiApiKeyConfigured": "Configurado",
"civitaiApiKeyNotConfigured": "No configurado",
"civitaiApiKeySet": "Configurar",
"civitaiHost": { "civitaiHost": {
"label": "Host de Civitai", "label": "Host de Civitai",
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".", "help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
@@ -302,6 +320,7 @@
"downloads": "Descargas", "downloads": "Descargas",
"videoSettings": "Configuración de video", "videoSettings": "Configuración de video",
"layoutSettings": "Configuración de diseño", "layoutSettings": "Configuración de diseño",
"licenseIcons": "Iconos de licencia",
"misc": "Varios", "misc": "Varios",
"backup": "Copias de seguridad", "backup": "Copias de seguridad",
"folderSettings": "Raíces predeterminadas", "folderSettings": "Raíces predeterminadas",
@@ -414,6 +433,8 @@
"help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga." "help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "Agrupar por modelo",
"groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de Civitai como una tarjeta única. Las versiones anteriores están ocultas.",
"displayDensity": "Densidad de visualización", "displayDensity": "Densidad de visualización",
"displayDensityOptions": { "displayDensityOptions": {
"default": "Predeterminado", "default": "Predeterminado",
@@ -448,7 +469,9 @@
"modelName": "Nombre del modelo", "modelName": "Nombre del modelo",
"fileName": "Nombre del archivo" "fileName": "Nombre del archivo"
}, },
"modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo" "modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo",
"cardBlurAmount": "Desenfoque de superposición de tarjetas",
"cardBlurAmountHelp": "Ajuste la intensidad de desenfoque de las superposiciones del encabezado y pie de página en las tarjetas de modelos y recetas (0 = sin desenfoque, 20 = desenfoque máximo)."
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "Biblioteca activa", "activeLibrary": "Biblioteca activa",
@@ -580,6 +603,10 @@
"label": "Ocultar actualizaciones de acceso temprano", "label": "Ocultar actualizaciones de acceso temprano",
"help": "Solo actualizaciones de acceso temprano" "help": "Solo actualizaciones de acceso temprano"
}, },
"licenseIcons": {
"useNewStyle": "Usar iconos de licencia actualizados",
"useNewStyleHelp": "Mostrar permisos de licencia con indicadores de color (nuevo estilo) o solo iconos de restricción (estilo clásico). Refleja el diseño actual de CivitAI."
},
"misc": { "misc": {
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA", "includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles", "includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles",
@@ -953,10 +980,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}",
@@ -997,6 +1021,18 @@
"storage": "Almacenamiento", "storage": "Almacenamiento",
"insights": "Perspectivas" "insights": "Perspectivas"
}, },
"metrics": {
"totalModels": "Total de modelos",
"totalStorage": "Almacenamiento total",
"totalGenerations": "Generaciones totales",
"usageRate": "Tasa de uso",
"loras": "LoRAs",
"checkpoints": "Puntos de control",
"embeddings": "Embeddings",
"uniqueTags": "Etiquetas únicas",
"unusedModels": "Modelos no usados",
"avgUsesPerModel": "Prom. usos/modelo"
},
"usage": { "usage": {
"mostUsedLoras": "LoRAs más utilizados", "mostUsedLoras": "LoRAs más utilizados",
"mostUsedCheckpoints": "Checkpoints más utilizados", "mostUsedCheckpoints": "Checkpoints más utilizados",
@@ -1014,13 +1050,77 @@
}, },
"insights": { "insights": {
"smartInsights": "Perspectivas inteligentes", "smartInsights": "Perspectivas inteligentes",
"recommendations": "Recomendaciones" "recommendations": "Recomendaciones",
"noInsights": "No hay información disponible",
"unusedLoras": {
"high": {
"title": "Alta cantidad de LoRAs no utilizadas",
"description": "El {percent}% de tus LoRAs ({count}/{total}) nunca se han utilizado.",
"suggestion": "Considera organizar o archivar modelos no utilizados para liberar espacio."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Puntos de control no utilizados detectados",
"description": "El {percent}% de tus puntos de control ({count}/{total}) nunca se han utilizado.",
"suggestion": "Revisa y considera eliminar los puntos de control que ya no necesites."
}
},
"unusedEmbeddings": {
"high": {
"title": "Alta cantidad de Embeddings no utilizados",
"description": "El {percent}% de tus embeddings ({count}/{total}) nunca se han utilizado.",
"suggestion": "Considera organizar o archivar embeddings no utilizados para optimizar tu colección."
}
},
"collection": {
"large": {
"title": "Colección grande detectada",
"description": "Tu colección de modelos está usando {size} de almacenamiento.",
"suggestion": "Considera usar almacenamiento externo o soluciones en la nube para una mejor organización."
}
},
"activity": {
"active": {
"title": "Usuario activo",
"description": "¡Has completado {count} generaciones hasta ahora!",
"suggestion": "Sigue explorando y creando contenido increíble con tus modelos."
}
}
}, },
"charts": { "charts": {
"collectionOverview": "Resumen de colección", "collectionOverview": "Resumen de colección",
"baseModelDistribution": "Distribución de modelo base", "baseModelDistribution": "Distribución de modelo base",
"usageTrends": "Tendencias de uso (Últimos 30 días)", "usageTrends": "Tendencias de uso (Últimos 30 días)",
"usageDistribution": "Distribución de uso" "usageDistribution": "Distribución de uso",
"date": "Fecha",
"usageCount": "Conteo de uso",
"fileSizeBytes": "Tamaño del archivo (bytes)",
"models": "Modelos",
"loraUsage": "Uso de LoRA",
"checkpointUsage": "Uso de Checkpoint",
"embeddingUsage": "Uso de Embedding"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Punto de control",
"diffusion_model": "Modelo de difusión",
"embedding": "Embeddings"
},
"placeholders": {
"loading": "Cargando...",
"noModels": "No se encontraron modelos",
"errorLoading": "Error al cargar datos",
"noStorageData": "No hay datos de almacenamiento disponibles",
"rootFolder": "Raíz",
"chartLibraryMissing": "El gráfico requiere la librería Chart.js"
},
"tooltips": {
"tagCount": "{tag}: {count} modelos",
"chartUsage": "{name}: {size}, {count} usos",
"chartPercentage": "{label}: {value} ({pct}%)"
} }
}, },
"modals": { "modals": {
@@ -1396,6 +1496,21 @@
"versionDeleted": "Versión eliminada" "versionDeleted": "Versión eliminada"
} }
} }
},
"metadataFetchSummary": {
"title": "Resumen de obtención de metadatos",
"statSuccess": "Éxito",
"statFailed": "Fallido",
"statSkipped": "Omitido",
"statTotal": "Total escaneado",
"statDuration": "Duración",
"successMessage": "¡Todos los {count} {type}s actualizados correctamente!",
"failedItems": "Elementos fallidos ({count})",
"close": "Cerrar",
"copyReport": "Copiar informe",
"downloadCsv": "Descargar CSV",
"columnModelName": "Nombre del modelo",
"columnError": "Error"
} }
}, },
"modelTags": { "modelTags": {
@@ -1409,15 +1524,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...",
@@ -1955,7 +2061,9 @@
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s", "bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!", "exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}", "exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
"moveFailed": "Failed to move item: {message}" "moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "Copiado al portapapeles",
"downloadStarted": "Descarga iniciada"
} }
}, },
"doctor": { "doctor": {
+125 -17
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",
@@ -182,6 +183,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "Gérer les modèles exclus" "label": "Gérer les modèles exclus"
},
"groupByModel": {
"label": "Grouper par modèle"
} }
}, },
"header": { "header": {
@@ -250,7 +254,18 @@
"toggle": "Basculer le thème", "toggle": "Basculer le thème",
"switchToLight": "Passer au thème clair", "switchToLight": "Passer au thème clair",
"switchToDark": "Passer au thème sombre", "switchToDark": "Passer au thème sombre",
"switchToAuto": "Passer au thème automatique" "switchToAuto": "Passer au thème automatique",
"presets": "Préréglages de thème",
"default": "Par défaut",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "Mode",
"light": "Clair",
"dark": "Sombre",
"auto": "Auto"
}, },
"actions": { "actions": {
"checkUpdates": "Vérifier les mises à jour", "checkUpdates": "Vérifier les mises à jour",
@@ -262,6 +277,9 @@
"civitaiApiKey": "Clé API Civitai", "civitaiApiKey": "Clé API Civitai",
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai", "civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai", "civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
"civitaiApiKeyConfigured": "Configuré",
"civitaiApiKeyNotConfigured": "Non configuré",
"civitaiApiKeySet": "Configurer",
"civitaiHost": { "civitaiHost": {
"label": "Hôte Civitai", "label": "Hôte Civitai",
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».", "help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
@@ -302,6 +320,7 @@
"downloads": "Téléchargements", "downloads": "Téléchargements",
"videoSettings": "Paramètres vidéo", "videoSettings": "Paramètres vidéo",
"layoutSettings": "Paramètres d'affichage", "layoutSettings": "Paramètres d'affichage",
"licenseIcons": "Icônes de licence",
"misc": "Divers", "misc": "Divers",
"backup": "Sauvegardes", "backup": "Sauvegardes",
"folderSettings": "Racines par défaut", "folderSettings": "Racines par défaut",
@@ -414,6 +433,8 @@
"help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement." "help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "Grouper par modèle",
"groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle Civitai s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.",
"displayDensity": "Densité d'affichage", "displayDensity": "Densité d'affichage",
"displayDensityOptions": { "displayDensityOptions": {
"default": "Par défaut", "default": "Par défaut",
@@ -448,7 +469,9 @@
"modelName": "Nom du modèle", "modelName": "Nom du modèle",
"fileName": "Nom du fichier" "fileName": "Nom du fichier"
}, },
"modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle" "modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle",
"cardBlurAmount": "Flou de superposition des cartes",
"cardBlurAmountHelp": "Ajustez l'intensité du flou des superpositions d'en-tête et de pied de page sur les cartes de modèles et de recettes (0 = aucun flou, 20 = flou maximal)."
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "Bibliothèque active", "activeLibrary": "Bibliothèque active",
@@ -580,6 +603,10 @@
"label": "Masquer les mises à jour en accès anticipé", "label": "Masquer les mises à jour en accès anticipé",
"help": "Seulement les mises à jour en accès anticipé" "help": "Seulement les mises à jour en accès anticipé"
}, },
"licenseIcons": {
"useNewStyle": "Utiliser les icônes de licence mises à jour",
"useNewStyleHelp": "Afficher les permissions de licence avec des indicateurs colorés (nouveau style) ou des icônes de restriction uniquement (style classique). Reprend le design actuel de CivitAI."
},
"misc": { "misc": {
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA", "includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers", "includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers",
@@ -953,10 +980,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}",
@@ -997,6 +1021,18 @@
"storage": "Stockage", "storage": "Stockage",
"insights": "Aperçus" "insights": "Aperçus"
}, },
"metrics": {
"totalModels": "Total des modèles",
"totalStorage": "Stockage total",
"totalGenerations": "Générations totales",
"usageRate": "Taux d'utilisation",
"loras": "LoRAs",
"checkpoints": "Points de contrôle",
"embeddings": "Embeddings",
"uniqueTags": "Tags uniques",
"unusedModels": "Modèles inutilisés",
"avgUsesPerModel": "Moy. utilisations/modèle"
},
"usage": { "usage": {
"mostUsedLoras": "LoRAs les plus utilisés", "mostUsedLoras": "LoRAs les plus utilisés",
"mostUsedCheckpoints": "Checkpoints les plus utilisés", "mostUsedCheckpoints": "Checkpoints les plus utilisés",
@@ -1014,13 +1050,77 @@
}, },
"insights": { "insights": {
"smartInsights": "Aperçus intelligents", "smartInsights": "Aperçus intelligents",
"recommendations": "Recommandations" "recommendations": "Recommandations",
"noInsights": "Aucun aperçu disponible",
"unusedLoras": {
"high": {
"title": "Nombre élevé de LoRAs inutilisées",
"description": "{percent}% de vos LoRAs ({count}/{total}) n'ont jamais été utilisées.",
"suggestion": "Envisagez d'organiser ou d'archiver les modèles inutilisés pour libérer de l'espace."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Points de contrôle inutilisés détectés",
"description": "{percent}% de vos points de contrôle ({count}/{total}) n'ont jamais été utilisés.",
"suggestion": "Examinez et envisagez de supprimer les points de contrôle dont vous n'avez plus besoin."
}
},
"unusedEmbeddings": {
"high": {
"title": "Nombre élevé d'Embeddings inutilisées",
"description": "{percent}% de vos embeddings ({count}/{total}) n'ont jamais été utilisées.",
"suggestion": "Envisagez d'organiser ou d'archiver les embeddings inutilisées pour optimiser votre collection."
}
},
"collection": {
"large": {
"title": "Grande collection détectée",
"description": "Votre collection de modèles utilise {size} de stockage.",
"suggestion": "Envisagez d'utiliser un stockage externe ou des solutions cloud pour une meilleure organisation."
}
},
"activity": {
"active": {
"title": "Utilisateur actif",
"description": "Vous avez effectué {count} générations jusqu'à présent !",
"suggestion": "Continuez à explorer et à créer du contenu formidable avec vos modèles."
}
}
}, },
"charts": { "charts": {
"collectionOverview": "Aperçu de la collection", "collectionOverview": "Aperçu de la collection",
"baseModelDistribution": "Distribution des modèles de base", "baseModelDistribution": "Distribution des modèles de base",
"usageTrends": "Tendances d'utilisation (30 derniers jours)", "usageTrends": "Tendances d'utilisation (30 derniers jours)",
"usageDistribution": "Distribution de l'utilisation" "usageDistribution": "Distribution de l'utilisation",
"date": "Date",
"usageCount": "Nombre d'utilisations",
"fileSizeBytes": "Taille du fichier (octets)",
"models": "Modèles",
"loraUsage": "Utilisation LoRA",
"checkpointUsage": "Utilisation Checkpoint",
"embeddingUsage": "Utilisation Embedding"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Point de contrôle",
"diffusion_model": "Modèle de diffusion",
"embedding": "Embeddings"
},
"placeholders": {
"loading": "Chargement...",
"noModels": "Aucun modèle trouvé",
"errorLoading": "Erreur de chargement des données",
"noStorageData": "Aucune donnée de stockage disponible",
"rootFolder": "Racine",
"chartLibraryMissing": "Le graphique nécessite la bibliothèque Chart.js"
},
"tooltips": {
"tagCount": "{tag}: {count} modèles",
"chartUsage": "{name}: {size}, {count} utilisations",
"chartPercentage": "{label}: {value} ({pct}%)"
} }
}, },
"modals": { "modals": {
@@ -1396,6 +1496,21 @@
"versionDeleted": "Version supprimée" "versionDeleted": "Version supprimée"
} }
} }
},
"metadataFetchSummary": {
"title": "Récapitulatif de la récupération des métadonnées",
"statSuccess": "Réussi",
"statFailed": "Échoué",
"statSkipped": "Ignoré",
"statTotal": "Total scanné",
"statDuration": "Durée",
"successMessage": "Tous les {count} {type}s mis à jour avec succès !",
"failedItems": "Éléments échoués ({count})",
"close": "Fermer",
"copyReport": "Copier le rapport",
"downloadCsv": "Télécharger CSV",
"columnModelName": "Nom du modèle",
"columnError": "Erreur"
} }
}, },
"modelTags": { "modelTags": {
@@ -1409,15 +1524,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...",
@@ -1955,7 +2061,9 @@
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès", "bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !", "exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}", "exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
"moveFailed": "Failed to move item: {message}" "moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "Copié dans le presse-papiers",
"downloadStarted": "Téléchargement démarré"
} }
}, },
"doctor": { "doctor": {
+125 -17
View File
@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "טוען...", "loading": "טוען...",
"cancelling": "מבטל...",
"unknown": "לא ידוע", "unknown": "לא ידוע",
"date": "תאריך", "date": "תאריך",
"version": "גרסה", "version": "גרסה",
@@ -182,6 +183,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "ניהול מודלים מוחרגים" "label": "ניהול מודלים מוחרגים"
},
"groupByModel": {
"label": "קיבוץ לפי דגם"
} }
}, },
"header": { "header": {
@@ -250,7 +254,18 @@
"toggle": "החלף ערכת נושא", "toggle": "החלף ערכת נושא",
"switchToLight": "עבור לערכת נושא בהירה", "switchToLight": "עבור לערכת נושא בהירה",
"switchToDark": "עבור לערכת נושא כהה", "switchToDark": "עבור לערכת נושא כהה",
"switchToAuto": "עבור לערכת נושא אוטומטית" "switchToAuto": "עבור לערכת נושא אוטומטית",
"presets": "ערכות נושא מוגדרות",
"default": "ברירת מחדל",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "מצב",
"light": "בהיר",
"dark": "כהה",
"auto": "אוטומטי"
}, },
"actions": { "actions": {
"checkUpdates": "בדוק עדכונים", "checkUpdates": "בדוק עדכונים",
@@ -262,6 +277,9 @@
"civitaiApiKey": "מפתח API של Civitai", "civitaiApiKey": "מפתח API של Civitai",
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai", "civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai", "civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
"civitaiApiKeyConfigured": "מוגדר",
"civitaiApiKeyNotConfigured": "לא מוגדר",
"civitaiApiKeySet": "הגדר",
"civitaiHost": { "civitaiHost": {
"label": "מארח Civitai", "label": "מארח Civitai",
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".", "help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
@@ -302,6 +320,7 @@
"downloads": "הורדות", "downloads": "הורדות",
"videoSettings": "הגדרות וידאו", "videoSettings": "הגדרות וידאו",
"layoutSettings": "הגדרות פריסה", "layoutSettings": "הגדרות פריסה",
"licenseIcons": "סמלי רישיון",
"misc": "שונות", "misc": "שונות",
"backup": "גיבויים", "backup": "גיבויים",
"folderSettings": "תיקיות ברירת מחדל", "folderSettings": "תיקיות ברירת מחדל",
@@ -414,6 +433,8 @@
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה." "help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "קיבוץ לפי דגם",
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
"displayDensity": "צפיפות תצוגה", "displayDensity": "צפיפות תצוגה",
"displayDensityOptions": { "displayDensityOptions": {
"default": "ברירת מחדל", "default": "ברירת מחדל",
@@ -448,7 +469,9 @@
"modelName": "שם מודל", "modelName": "שם מודל",
"fileName": "שם קובץ" "fileName": "שם קובץ"
}, },
"modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל" "modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל",
"cardBlurAmount": "עוצמת טשטוש שכבת-על בכרטיס",
"cardBlurAmountHelp": "כוונן את עוצמת הטשטוש של שכבת-העל בכותרת ובכותרות תחתונה בכרטיסי מודל ומתכונים (0 = ללא טשטוש, 20 = טשטוש מקסימלי)."
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "ספרייה פעילה", "activeLibrary": "ספרייה פעילה",
@@ -580,6 +603,10 @@
"label": "הסתר עדכוני גישה מוקדמת", "label": "הסתר עדכוני גישה מוקדמת",
"help": "רק עדכוני גישה מוקדמת" "help": "רק עדכוני גישה מוקדמת"
}, },
"licenseIcons": {
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
},
"misc": { "misc": {
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA", "includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח", "includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח",
@@ -953,10 +980,7 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "שורש", "modelRoot": "שורש",
"moreOptions": "אפשרויות נוספות",
"collapseAll": "כווץ את כל התיקיות", "collapseAll": "כווץ את כל התיקיות",
"pinSidebar": "נעל סרגל צד",
"unpinSidebar": "שחרר סרגל צד",
"hideOnThisPage": "הסתר סרגל צד בדף זה", "hideOnThisPage": "הסתר סרגל צד בדף זה",
"showSidebar": "הצג סרגל צד", "showSidebar": "הצג סרגל צד",
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}", "sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
@@ -997,6 +1021,18 @@
"storage": "אחסון", "storage": "אחסון",
"insights": "תובנות" "insights": "תובנות"
}, },
"metrics": {
"totalModels": "סה\"כ דגמים",
"totalStorage": "סה\"כ אחסון",
"totalGenerations": "סה\"כ יצירות",
"usageRate": "שיעור שימוש",
"loras": "LoRA",
"checkpoints": "נקודות ביקורת",
"embeddings": "הטמעות",
"uniqueTags": "תגיות ייחודיות",
"unusedModels": "דגמים שאינם בשימוש",
"avgUsesPerModel": "ממוצע שימושים/דגם"
},
"usage": { "usage": {
"mostUsedLoras": "LoRAs הנפוצים ביותר", "mostUsedLoras": "LoRAs הנפוצים ביותר",
"mostUsedCheckpoints": "Checkpoints הנפוצים ביותר", "mostUsedCheckpoints": "Checkpoints הנפוצים ביותר",
@@ -1014,13 +1050,77 @@
}, },
"insights": { "insights": {
"smartInsights": "תובנות חכמות", "smartInsights": "תובנות חכמות",
"recommendations": "המלצות" "recommendations": "המלצות",
"noInsights": "אין תובנות זמינות",
"unusedLoras": {
"high": {
"title": "כמות גבוהה של LoRAs שאינן בשימוש",
"description": "{percent}% מה-LoRAs שלך ({count}/{total}) מעולם לא נעשה בהם שימוש.",
"suggestion": "שקול לארגן או לאחסן בארכיון מודלים שאינם בשימוש כדי לפנות שטח אחסון."
}
},
"unusedCheckpoints": {
"detected": {
"title": "התגלו נקודות ביקורת שאינן בשימוש",
"description": "{percent}% מנקודות הביקורת שלך ({count}/{total}) מעולם לא נעשה בהן שימוש.",
"suggestion": "בדוק ושקול להסיר נקודות ביקורת שאינך צריך עוד."
}
},
"unusedEmbeddings": {
"high": {
"title": "כמות גבוהה של Embeddings שאינם בשימוש",
"description": "{percent}% מה-Embeddings שלך ({count}/{total}) מעולם לא נעשה בהם שימוש.",
"suggestion": "שקול לארגן או לאחסן בארכיון Embeddings שאינם בשימוש כדי לייעל את האוסף."
}
},
"collection": {
"large": {
"title": "התגלה אוסף גדול",
"description": "אוסף המודלים שלך משתמש ב-{size} של אחסון.",
"suggestion": "שקול להשתמש באחסון חיצוני או בפתרונות ענן לארגון טוב יותר."
}
},
"activity": {
"active": {
"title": "משתמש פעיל",
"description": "השלמת {count} יצירות עד כה!",
"suggestion": "המשך לחקור וליצור תוכן מדהים עם המודלים שלך."
}
}
}, },
"charts": { "charts": {
"collectionOverview": "סקירת אוסף", "collectionOverview": "סקירת אוסף",
"baseModelDistribution": "התפלגות מודלי בסיס", "baseModelDistribution": "התפלגות מודלי בסיס",
"usageTrends": "מגמות שימוש (30 יום אחרונים)", "usageTrends": "מגמות שימוש (30 יום אחרונים)",
"usageDistribution": "התפלגות שימוש" "usageDistribution": "התפלגות שימוש",
"date": "תאריך",
"usageCount": "מספר שימושים",
"fileSizeBytes": "גודל קובץ (בתים)",
"models": "דגמים",
"loraUsage": "שימוש ב-LoRA",
"checkpointUsage": "שימוש ב-Checkpoint",
"embeddingUsage": "שימוש ב-Embedding"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "נקודת ביקורת",
"diffusion_model": "מודל דיפוזיה",
"embedding": "הטמעות"
},
"placeholders": {
"loading": "טוען...",
"noModels": "לא נמצאו דגמים",
"errorLoading": "שגיאה בטעינת נתונים",
"noStorageData": "אין נתוני אחסון זמינים",
"rootFolder": "שורש",
"chartLibraryMissing": "הגרף דורש את ספריית Chart.js"
},
"tooltips": {
"tagCount": "{tag}: {count} דגמים",
"chartUsage": "{name}: {size}, {count} שימושים",
"chartPercentage": "{label}: {value} ({pct}%)"
} }
}, },
"modals": { "modals": {
@@ -1396,6 +1496,21 @@
"versionDeleted": "הגרסה נמחקה" "versionDeleted": "הגרסה נמחקה"
} }
} }
},
"metadataFetchSummary": {
"title": "סיכום שליפת מטא-דאטה",
"statSuccess": "הצלחה",
"statFailed": "נכשל",
"statSkipped": "דולג",
"statTotal": "סה\"כ נסרק",
"statDuration": "משך",
"successMessage": "כל {count} {type}s עודכנו בהצלחה!",
"failedItems": "פריטים נכשלים ({count})",
"close": "סגור",
"copyReport": "העתק דוח",
"downloadCsv": "הורד CSV",
"columnModelName": "שם המודל",
"columnError": "שגיאה"
} }
}, },
"modelTags": { "modelTags": {
@@ -1409,15 +1524,6 @@
"duplicate": "תגית זו כבר קיימת" "duplicate": "תגית זו כבר קיימת"
} }
}, },
"keyboard": {
"navigation": "ניווט במקלדת:",
"shortcuts": {
"pageUp": "גלול עמוד אחד למעלה",
"pageDown": "גלול עמוד אחד למטה",
"home": "קפוץ להתחלה",
"end": "קפוץ לסוף"
}
},
"initialization": { "initialization": {
"title": "מאתחל", "title": "מאתחל",
"message": "מכין את סביבת העבודה שלך...", "message": "מכין את סביבת העבודה שלך...",
@@ -1955,7 +2061,9 @@
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s", "bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!", "exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}", "exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
"moveFailed": "Failed to move item: {message}" "moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "הועתק ללוח",
"downloadStarted": "ההורדה החלה"
} }
}, },
"doctor": { "doctor": {
+125 -17
View File
@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "読み込み中...", "loading": "読み込み中...",
"cancelling": "キャンセル中...",
"unknown": "不明", "unknown": "不明",
"date": "日付", "date": "日付",
"version": "バージョン", "version": "バージョン",
@@ -182,6 +183,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "除外モデルを管理" "label": "除外モデルを管理"
},
"groupByModel": {
"label": "モデルでグループ化"
} }
}, },
"header": { "header": {
@@ -250,7 +254,18 @@
"toggle": "テーマの切り替え", "toggle": "テーマの切り替え",
"switchToLight": "ライトテーマに切り替え", "switchToLight": "ライトテーマに切り替え",
"switchToDark": "ダークテーマに切り替え", "switchToDark": "ダークテーマに切り替え",
"switchToAuto": "自動テーマに切り替え" "switchToAuto": "自動テーマに切り替え",
"presets": "テーマプリセット",
"default": "デフォルト",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "モード",
"light": "ライト",
"dark": "ダーク",
"auto": "自動"
}, },
"actions": { "actions": {
"checkUpdates": "更新確認", "checkUpdates": "更新確認",
@@ -262,6 +277,9 @@
"civitaiApiKey": "Civitai APIキー", "civitaiApiKey": "Civitai APIキー",
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください", "civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます", "civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
"civitaiApiKeyConfigured": "設定済み",
"civitaiApiKeyNotConfigured": "未設定",
"civitaiApiKeySet": "設定",
"civitaiHost": { "civitaiHost": {
"label": "Civitai ホスト", "label": "Civitai ホスト",
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。", "help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
@@ -302,6 +320,7 @@
"downloads": "ダウンロード", "downloads": "ダウンロード",
"videoSettings": "動画設定", "videoSettings": "動画設定",
"layoutSettings": "レイアウト設定", "layoutSettings": "レイアウト設定",
"licenseIcons": "ライセンスアイコン",
"misc": "その他", "misc": "その他",
"backup": "バックアップ", "backup": "バックアップ",
"folderSettings": "デフォルトルート", "folderSettings": "デフォルトルート",
@@ -414,6 +433,8 @@
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。" "help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "モデルでグループ化",
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
"displayDensity": "表示密度", "displayDensity": "表示密度",
"displayDensityOptions": { "displayDensityOptions": {
"default": "デフォルト", "default": "デフォルト",
@@ -448,7 +469,9 @@
"modelName": "モデル名", "modelName": "モデル名",
"fileName": "ファイル名" "fileName": "ファイル名"
}, },
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択" "modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択",
"cardBlurAmount": "カードオーバーレイのぼかし",
"cardBlurAmountHelp": "モデルカードとレシピカードのヘッダー・フッターオーバーレイのぼかし強度を調整します(0 = ぼかしなし、20 = 最大ぼかし)。"
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "アクティブライブラリ", "activeLibrary": "アクティブライブラリ",
@@ -580,6 +603,10 @@
"label": "早期アクセス更新を非表示", "label": "早期アクセス更新を非表示",
"help": "早期アクセスのみの更新" "help": "早期アクセスのみの更新"
}, },
"licenseIcons": {
"useNewStyle": "更新されたライセンスアイコンを使用",
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。"
},
"misc": { "misc": {
"includeTriggerWords": "LoRA構文にトリガーワードを含める", "includeTriggerWords": "LoRA構文にトリガーワードを含める",
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます", "includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます",
@@ -953,10 +980,7 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "ルート", "modelRoot": "ルート",
"moreOptions": "その他のオプション",
"collapseAll": "すべてのフォルダを折りたたむ", "collapseAll": "すべてのフォルダを折りたたむ",
"pinSidebar": "サイドバーを固定",
"unpinSidebar": "サイドバーの固定を解除",
"hideOnThisPage": "このページでサイドバーを非表示", "hideOnThisPage": "このページでサイドバーを非表示",
"showSidebar": "サイドバーを表示", "showSidebar": "サイドバーを表示",
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています", "sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
@@ -997,6 +1021,18 @@
"storage": "ストレージ", "storage": "ストレージ",
"insights": "インサイト" "insights": "インサイト"
}, },
"metrics": {
"totalModels": "モデル総数",
"totalStorage": "ストレージ合計",
"totalGenerations": "生成回数合計",
"usageRate": "使用率",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "ユニークタグ",
"unusedModels": "未使用モデル",
"avgUsesPerModel": "平均使用回数/モデル"
},
"usage": { "usage": {
"mostUsedLoras": "最も使用されているLoRA", "mostUsedLoras": "最も使用されているLoRA",
"mostUsedCheckpoints": "最も使用されているCheckpoint", "mostUsedCheckpoints": "最も使用されているCheckpoint",
@@ -1014,13 +1050,77 @@
}, },
"insights": { "insights": {
"smartInsights": "スマートインサイト", "smartInsights": "スマートインサイト",
"recommendations": "推奨事項" "recommendations": "推奨事項",
"noInsights": "インサイトはありません",
"unusedLoras": {
"high": {
"title": "未使用のLoRAが多数あります",
"description": "LoRAの{percent}%{count}/{total})が一度も使用されていません。",
"suggestion": "未使用のモデルを整理またはアーカイブしてストレージを解放してください。"
}
},
"unusedCheckpoints": {
"detected": {
"title": "未使用のCheckpointを検出",
"description": "Checkpointの{percent}%{count}/{total})が一度も使用されていません。",
"suggestion": "不要なCheckpointを確認して削除を検討してください。"
}
},
"unusedEmbeddings": {
"high": {
"title": "未使用のEmbeddingが多数あります",
"description": "Embeddingの{percent}%{count}/{total})が一度も使用されていません。",
"suggestion": "未使用のEmbeddingを整理またはアーカイブしてコレクションを最適化してください。"
}
},
"collection": {
"large": {
"title": "大規模コレクションを検出",
"description": "モデルコレクションが{size}のストレージを使用しています。",
"suggestion": "外部ストレージやクラウドソリューションの使用を検討してください。"
}
},
"activity": {
"active": {
"title": "アクティブユーザー",
"description": "これまでに{count}回の生成を完了しました!",
"suggestion": "モデルを使って素晴らしいコンテンツを作り続けてください。"
}
}
}, },
"charts": { "charts": {
"collectionOverview": "コレクション概要", "collectionOverview": "コレクション概要",
"baseModelDistribution": "ベースモデル分布", "baseModelDistribution": "ベースモデル分布",
"usageTrends": "使用傾向(過去30日)", "usageTrends": "使用傾向(過去30日)",
"usageDistribution": "使用分布" "usageDistribution": "使用分布",
"date": "日付",
"usageCount": "使用回数",
"fileSizeBytes": "ファイルサイズ(バイト)",
"models": "モデル",
"loraUsage": "LoRA 使用量",
"checkpointUsage": "Checkpoint 使用量",
"embeddingUsage": "Embedding 使用量"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "拡散モデル",
"embedding": "Embedding"
},
"placeholders": {
"loading": "読み込み中...",
"noModels": "モデルが見つかりません",
"errorLoading": "データ読み込みエラー",
"noStorageData": "ストレージデータがありません",
"rootFolder": "ルート",
"chartLibraryMissing": "Chart.js ライブラリが必要です"
},
"tooltips": {
"tagCount": "{tag}: {count} モデル",
"chartUsage": "{name}: {size}, {count} 回使用",
"chartPercentage": "{label}: {value} ({pct}%)"
} }
}, },
"modals": { "modals": {
@@ -1396,6 +1496,21 @@
"versionDeleted": "バージョンを削除しました" "versionDeleted": "バージョンを削除しました"
} }
} }
},
"metadataFetchSummary": {
"title": "メタデータ取得サマリー",
"statSuccess": "成功",
"statFailed": "失敗",
"statSkipped": "スキップ",
"statTotal": "スキャン合計",
"statDuration": "所要時間",
"successMessage": "すべての{count}件の{type}を正常に更新しました",
"failedItems": "失敗したアイテム ({count})",
"close": "閉じる",
"copyReport": "レポートをコピー",
"downloadCsv": "CSVをダウンロード",
"columnModelName": "モデル名",
"columnError": "エラー"
} }
}, },
"modelTags": { "modelTags": {
@@ -1409,15 +1524,6 @@
"duplicate": "このタグは既に存在します" "duplicate": "このタグは既に存在します"
} }
}, },
"keyboard": {
"navigation": "キーボードナビゲーション:",
"shortcuts": {
"pageUp": "1ページ上にスクロール",
"pageDown": "1ページ下にスクロール",
"home": "トップにジャンプ",
"end": "ボトムにジャンプ"
}
},
"initialization": { "initialization": {
"title": "初期化中", "title": "初期化中",
"message": "ワークスペースを準備中...", "message": "ワークスペースを準備中...",
@@ -1955,7 +2061,9 @@
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました", "bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!", "exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}", "exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
"moveFailed": "Failed to move item: {message}" "moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "クリップボードにコピーしました",
"downloadStarted": "ダウンロードを開始しました"
} }
}, },
"doctor": { "doctor": {
+125 -17
View File
@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "로딩 중...", "loading": "로딩 중...",
"cancelling": "취소 중...",
"unknown": "알 수 없음", "unknown": "알 수 없음",
"date": "날짜", "date": "날짜",
"version": "버전", "version": "버전",
@@ -182,6 +183,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "제외된 모델 관리" "label": "제외된 모델 관리"
},
"groupByModel": {
"label": "모델별 그룹화"
} }
}, },
"header": { "header": {
@@ -250,7 +254,18 @@
"toggle": "테마 토글", "toggle": "테마 토글",
"switchToLight": "라이트 테마로 전환", "switchToLight": "라이트 테마로 전환",
"switchToDark": "다크 테마로 전환", "switchToDark": "다크 테마로 전환",
"switchToAuto": "자동 테마로 전환" "switchToAuto": "자동 테마로 전환",
"presets": "테마 프리셋",
"default": "기본",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "모드",
"light": "라이트",
"dark": "다크",
"auto": "자동"
}, },
"actions": { "actions": {
"checkUpdates": "업데이트 확인", "checkUpdates": "업데이트 확인",
@@ -262,6 +277,9 @@
"civitaiApiKey": "Civitai API 키", "civitaiApiKey": "Civitai API 키",
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요", "civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다", "civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
"civitaiApiKeyConfigured": "설정됨",
"civitaiApiKeyNotConfigured": "설정되지 않음",
"civitaiApiKeySet": "설정",
"civitaiHost": { "civitaiHost": {
"label": "Civitai 호스트", "label": "Civitai 호스트",
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.", "help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
@@ -302,6 +320,7 @@
"downloads": "다운로드", "downloads": "다운로드",
"videoSettings": "비디오 설정", "videoSettings": "비디오 설정",
"layoutSettings": "레이아웃 설정", "layoutSettings": "레이아웃 설정",
"licenseIcons": "라이선스 아이콘",
"misc": "기타", "misc": "기타",
"backup": "백업", "backup": "백업",
"folderSettings": "기본 루트", "folderSettings": "기본 루트",
@@ -414,6 +433,8 @@
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다." "help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "모델별 그룹화",
"groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
"displayDensity": "표시 밀도", "displayDensity": "표시 밀도",
"displayDensityOptions": { "displayDensityOptions": {
"default": "기본", "default": "기본",
@@ -448,7 +469,9 @@
"modelName": "모델명", "modelName": "모델명",
"fileName": "파일명" "fileName": "파일명"
}, },
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요" "modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요",
"cardBlurAmount": "카드 오버레이 흐림 강도",
"cardBlurAmountHelp": "모델 및 레시피 카드의 헤더와 푸터 오버레이 흐림 강도를 조정합니다 (0 = 흐림 없음, 20 = 최대 흐림)."
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "활성 라이브러리", "activeLibrary": "활성 라이브러리",
@@ -580,6 +603,10 @@
"label": "얼리 액세스 업데이트 숨기기", "label": "얼리 액세스 업데이트 숨기기",
"help": "얼리 액세스 업데이트만" "help": "얼리 액세스 업데이트만"
}, },
"licenseIcons": {
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
},
"misc": { "misc": {
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함", "includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다", "includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다",
@@ -953,10 +980,7 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "루트", "modelRoot": "루트",
"moreOptions": "더 많은 옵션",
"collapseAll": "모든 폴더 접기", "collapseAll": "모든 폴더 접기",
"pinSidebar": "사이드바 고정",
"unpinSidebar": "사이드바 고정 해제",
"hideOnThisPage": "이 페이지에서 사이드바 숨기기", "hideOnThisPage": "이 페이지에서 사이드바 숨기기",
"showSidebar": "사이드바 표시", "showSidebar": "사이드바 표시",
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다", "sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
@@ -997,6 +1021,18 @@
"storage": "저장소", "storage": "저장소",
"insights": "인사이트" "insights": "인사이트"
}, },
"metrics": {
"totalModels": "모델 총계",
"totalStorage": "총 저장 공간",
"totalGenerations": "총 생성 횟수",
"usageRate": "사용률",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "고유 태그",
"unusedModels": "미사용 모델",
"avgUsesPerModel": "모델당 평균 사용"
},
"usage": { "usage": {
"mostUsedLoras": "가장 많이 사용된 LoRA", "mostUsedLoras": "가장 많이 사용된 LoRA",
"mostUsedCheckpoints": "가장 많이 사용된 Checkpoint", "mostUsedCheckpoints": "가장 많이 사용된 Checkpoint",
@@ -1014,13 +1050,77 @@
}, },
"insights": { "insights": {
"smartInsights": "스마트 인사이트", "smartInsights": "스마트 인사이트",
"recommendations": "추천" "recommendations": "추천",
"noInsights": "인사이트 없음",
"unusedLoras": {
"high": {
"title": "사용하지 않은 LoRA가 많음",
"description": "LoRA의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
"suggestion": "사용하지 않는 모델을 정리하거나 보관하여 저장 공간을 확보하세요."
}
},
"unusedCheckpoints": {
"detected": {
"title": "사용하지 않은 Checkpoint 감지",
"description": "Checkpoint의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
"suggestion": "더 이상 필요하지 않은 Checkpoint를 검토하고 제거하세요."
}
},
"unusedEmbeddings": {
"high": {
"title": "사용하지 않은 Embedding이 많음",
"description": "Embedding의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
"suggestion": "사용하지 않는 Embedding을 정리하여 컬렉션을 최적화하세요."
}
},
"collection": {
"large": {
"title": "대규모 컬렉션 감지",
"description": "모델 컬렉션이 {size}의 저장 공간을 사용 중입니다.",
"suggestion": "더 나은 관리를 위해 외부 저장소나 클라우드 솔루션을 고려하세요."
}
},
"activity": {
"active": {
"title": "활성 사용자",
"description": "지금까지 {count}번의 생성을 완료했습니다!",
"suggestion": "모델로 계속해서 멋진 콘텐츠를 탐색하고 만들어보세요."
}
}
}, },
"charts": { "charts": {
"collectionOverview": "컬렉션 개요", "collectionOverview": "컬렉션 개요",
"baseModelDistribution": "베이스 모델 분포", "baseModelDistribution": "베이스 모델 분포",
"usageTrends": "사용량 트렌드 (최근 30일)", "usageTrends": "사용량 트렌드 (최근 30일)",
"usageDistribution": "사용량 분포" "usageDistribution": "사용량 분포",
"date": "날짜",
"usageCount": "사용 횟수",
"fileSizeBytes": "파일 크기(바이트)",
"models": "모델",
"loraUsage": "LoRA 사용량",
"checkpointUsage": "Checkpoint 사용량",
"embeddingUsage": "Embedding 사용량"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "확산 모델",
"embedding": "Embedding"
},
"placeholders": {
"loading": "로딩 중...",
"noModels": "모델을 찾을 수 없음",
"errorLoading": "데이터 로딩 오류",
"noStorageData": "저장 데이터 없음",
"rootFolder": "루트",
"chartLibraryMissing": "Chart.js 라이브러리가 필요합니다"
},
"tooltips": {
"tagCount": "{tag}: {count}개 모델",
"chartUsage": "{name}: {size}, {count}회 사용",
"chartPercentage": "{label}: {value}({pct}%)"
} }
}, },
"modals": { "modals": {
@@ -1396,6 +1496,21 @@
"versionDeleted": "버전이 삭제되었습니다" "versionDeleted": "버전이 삭제되었습니다"
} }
} }
},
"metadataFetchSummary": {
"title": "메타데이터 가져오기 요약",
"statSuccess": "성공",
"statFailed": "실패",
"statSkipped": "건너뜀",
"statTotal": "총 스캔",
"statDuration": "소요 시간",
"successMessage": "모든 {count}개 {type}이(가) 성공적으로 업데이트되었습니다",
"failedItems": "실패한 항목 ({count})",
"close": "닫기",
"copyReport": "보고서 복사",
"downloadCsv": "CSV 다운로드",
"columnModelName": "모델 이름",
"columnError": "오류"
} }
}, },
"modelTags": { "modelTags": {
@@ -1409,15 +1524,6 @@
"duplicate": "이 태그는 이미 존재합니다" "duplicate": "이 태그는 이미 존재합니다"
} }
}, },
"keyboard": {
"navigation": "키보드 내비게이션:",
"shortcuts": {
"pageUp": "한 페이지 위로 스크롤",
"pageDown": "한 페이지 아래로 스크롤",
"home": "맨 위로 이동",
"end": "맨 아래로 이동"
}
},
"initialization": { "initialization": {
"title": "초기화 중", "title": "초기화 중",
"message": "작업공간을 준비하고 있습니다...", "message": "작업공간을 준비하고 있습니다...",
@@ -1955,7 +2061,9 @@
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다", "bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!", "exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}", "exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
"moveFailed": "Failed to move item: {message}" "moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "클립보드에 복사됨",
"downloadStarted": "다운로드 시작됨"
} }
}, },
"doctor": { "doctor": {
+125 -17
View File
@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "Загрузка...", "loading": "Загрузка...",
"cancelling": "Отмена...",
"unknown": "Неизвестно", "unknown": "Неизвестно",
"date": "Дата", "date": "Дата",
"version": "Версия", "version": "Версия",
@@ -182,6 +183,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "Управление исключёнными моделями" "label": "Управление исключёнными моделями"
},
"groupByModel": {
"label": "Группировать по модели"
} }
}, },
"header": { "header": {
@@ -250,7 +254,18 @@
"toggle": "Переключить тему", "toggle": "Переключить тему",
"switchToLight": "Переключить на светлую тему", "switchToLight": "Переключить на светлую тему",
"switchToDark": "Переключить на тёмную тему", "switchToDark": "Переключить на тёмную тему",
"switchToAuto": "Переключить на автоматическую тему" "switchToAuto": "Переключить на автоматическую тему",
"presets": "Предустановки тем",
"default": "По умолчанию",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "Режим",
"light": "Светлый",
"dark": "Тёмный",
"auto": "Авто"
}, },
"actions": { "actions": {
"checkUpdates": "Проверить обновления", "checkUpdates": "Проверить обновления",
@@ -262,6 +277,9 @@
"civitaiApiKey": "Ключ API Civitai", "civitaiApiKey": "Ключ API Civitai",
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai", "civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai", "civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
"civitaiApiKeyConfigured": "Настроен",
"civitaiApiKeyNotConfigured": "Не настроен",
"civitaiApiKeySet": "Настроить",
"civitaiHost": { "civitaiHost": {
"label": "Хост Civitai", "label": "Хост Civitai",
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».", "help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
@@ -302,6 +320,7 @@
"downloads": "Загрузки", "downloads": "Загрузки",
"videoSettings": "Настройки видео", "videoSettings": "Настройки видео",
"layoutSettings": "Настройки макета", "layoutSettings": "Настройки макета",
"licenseIcons": "Значки лицензии",
"misc": "Разное", "misc": "Разное",
"backup": "Резервные копии", "backup": "Резервные копии",
"folderSettings": "Корневые папки", "folderSettings": "Корневые папки",
@@ -414,6 +433,8 @@
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки." "help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "Группировать по модели",
"groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.",
"displayDensity": "Плотность отображения", "displayDensity": "Плотность отображения",
"displayDensityOptions": { "displayDensityOptions": {
"default": "По умолчанию", "default": "По умолчанию",
@@ -448,7 +469,9 @@
"modelName": "Название модели", "modelName": "Название модели",
"fileName": "Имя файла" "fileName": "Имя файла"
}, },
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели" "modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели",
"cardBlurAmount": "Размытие наложения карточек",
"cardBlurAmountHelp": "Настройте интенсивность размытия наложений верхнего и нижнего колонтитулов на карточках моделей и рецептов (0 = без размытия, 20 = максимальное размытие)."
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "Активная библиотека", "activeLibrary": "Активная библиотека",
@@ -580,6 +603,10 @@
"label": "Скрыть обновления раннего доступа", "label": "Скрыть обновления раннего доступа",
"help": "Только обновления раннего доступа" "help": "Только обновления раннего доступа"
}, },
"licenseIcons": {
"useNewStyle": "Использовать обновлённые значки лицензии",
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
},
"misc": { "misc": {
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA", "includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена", "includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена",
@@ -953,10 +980,7 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "Корень", "modelRoot": "Корень",
"moreOptions": "Дополнительные параметры",
"collapseAll": "Свернуть все папки", "collapseAll": "Свернуть все папки",
"pinSidebar": "Закрепить боковую панель",
"unpinSidebar": "Открепить боковую панель",
"hideOnThisPage": "Скрыть боковую панель на этой странице", "hideOnThisPage": "Скрыть боковую панель на этой странице",
"showSidebar": "Показать боковую панель", "showSidebar": "Показать боковую панель",
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}", "sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
@@ -997,6 +1021,18 @@
"storage": "Хранение", "storage": "Хранение",
"insights": "Аналитика" "insights": "Аналитика"
}, },
"metrics": {
"totalModels": "Всего моделей",
"totalStorage": "Всего хранилища",
"totalGenerations": "Всего генераций",
"usageRate": "Коэффициент использования",
"loras": "LoRA",
"checkpoints": "Контрольные точки",
"embeddings": "Эмбеддинги",
"uniqueTags": "Уникальные теги",
"unusedModels": "Неиспользуемые модели",
"avgUsesPerModel": "Сред. использований/модель"
},
"usage": { "usage": {
"mostUsedLoras": "Наиболее используемые LoRAs", "mostUsedLoras": "Наиболее используемые LoRAs",
"mostUsedCheckpoints": "Наиболее используемые Checkpoints", "mostUsedCheckpoints": "Наиболее используемые Checkpoints",
@@ -1014,13 +1050,77 @@
}, },
"insights": { "insights": {
"smartInsights": "Умная аналитика", "smartInsights": "Умная аналитика",
"recommendations": "Рекомендации" "recommendations": "Рекомендации",
"noInsights": "Нет доступных данных",
"unusedLoras": {
"high": {
"title": "Большое количество неиспользуемых LoRA",
"description": "{percent}% ваших LoRA ({count}/{total}) никогда не использовались.",
"suggestion": "Рассмотрите возможность организации или архивирования неиспользуемых моделей для освобождения места."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Обнаружены неиспользуемые контрольные точки",
"description": "{percent}% ваших контрольных точек ({count}/{total}) никогда не использовались.",
"suggestion": "Проверьте и удалите ненужные контрольные точки."
}
},
"unusedEmbeddings": {
"high": {
"title": "Большое количество неиспользуемых эмбеддингов",
"description": "{percent}% ваших эмбеддингов ({count}/{total}) никогда не использовались.",
"suggestion": "Организуйте или архивируйте неиспользуемые эмбеддинги для оптимизации коллекции."
}
},
"collection": {
"large": {
"title": "Обнаружена большая коллекция",
"description": "Ваша коллекция моделей использует {size} хранилища.",
"suggestion": "Рассмотрите внешнее хранилище или облачные решения для лучшей организации."
}
},
"activity": {
"active": {
"title": "Активный пользователь",
"description": "Вы завершили {count} генераций!",
"suggestion": "Продолжайте исследовать и создавать удивительный контент с вашими моделями."
}
}
}, },
"charts": { "charts": {
"collectionOverview": "Обзор коллекции", "collectionOverview": "Обзор коллекции",
"baseModelDistribution": "Распределение базовых моделей", "baseModelDistribution": "Распределение базовых моделей",
"usageTrends": "Тенденции использования (за последние 30 дней)", "usageTrends": "Тенденции использования (за последние 30 дней)",
"usageDistribution": "Распределение использования" "usageDistribution": "Распределение использования",
"date": "Дата",
"usageCount": "Количество использований",
"fileSizeBytes": "Размер файла (байты)",
"models": "Модели",
"loraUsage": "Использование LoRA",
"checkpointUsage": "Использование Checkpoint",
"embeddingUsage": "Использование Embedding"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Контрольная точка",
"diffusion_model": "Диффузионная модель",
"embedding": "Эмбеддинги"
},
"placeholders": {
"loading": "Загрузка...",
"noModels": "Модели не найдены",
"errorLoading": "Ошибка загрузки данных",
"noStorageData": "Нет данных о хранилище",
"rootFolder": "Корень",
"chartLibraryMissing": "Для графика требуется библиотека Chart.js"
},
"tooltips": {
"tagCount": "{tag}: {count} моделей",
"chartUsage": "{name}: {size}, {count} использований",
"chartPercentage": "{label}: {value} ({pct}%)"
} }
}, },
"modals": { "modals": {
@@ -1396,6 +1496,21 @@
"versionDeleted": "Версия удалена" "versionDeleted": "Версия удалена"
} }
} }
},
"metadataFetchSummary": {
"title": "Сводка получения метаданных",
"statSuccess": "Успешно",
"statFailed": "Ошибка",
"statSkipped": "Пропущено",
"statTotal": "Всего проверено",
"statDuration": "Длительность",
"successMessage": "Все {count} {type}s успешно обновлены",
"failedItems": "Ошибочные элементы ({count})",
"close": "Закрыть",
"copyReport": "Копировать отчет",
"downloadCsv": "Скачать CSV",
"columnModelName": "Имя модели",
"columnError": "Ошибка"
} }
}, },
"modelTags": { "modelTags": {
@@ -1409,15 +1524,6 @@
"duplicate": "Этот тег уже существует" "duplicate": "Этот тег уже существует"
} }
}, },
"keyboard": {
"navigation": "Навигация с клавиатуры:",
"shortcuts": {
"pageUp": "Прокрутить на страницу вверх",
"pageDown": "Прокрутить на страницу вниз",
"home": "Перейти к началу",
"end": "Перейти к концу"
}
},
"initialization": { "initialization": {
"title": "Инициализация", "title": "Инициализация",
"message": "Подготовка вашего рабочего пространства...", "message": "Подготовка вашего рабочего пространства...",
@@ -1955,7 +2061,9 @@
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s", "bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!", "exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}", "exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
"moveFailed": "Failed to move item: {message}" "moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "Скопировано в буфер обмена",
"downloadStarted": "Загрузка начата"
} }
}, },
"doctor": { "doctor": {
+125 -17
View File
@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "加载中...", "loading": "加载中...",
"cancelling": "取消中...",
"unknown": "未知", "unknown": "未知",
"date": "日期", "date": "日期",
"version": "版本", "version": "版本",
@@ -182,6 +183,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "管理已排除的模型" "label": "管理已排除的模型"
},
"groupByModel": {
"label": "按模型分组"
} }
}, },
"header": { "header": {
@@ -250,7 +254,18 @@
"toggle": "切换主题", "toggle": "切换主题",
"switchToLight": "切换到浅色主题", "switchToLight": "切换到浅色主题",
"switchToDark": "切换到深色主题", "switchToDark": "切换到深色主题",
"switchToAuto": "切换到自动主题" "switchToAuto": "切换到自动主题",
"presets": "主题预设",
"default": "默认",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "模式",
"light": "浅色",
"dark": "深色",
"auto": "自动"
}, },
"actions": { "actions": {
"checkUpdates": "检查更新", "checkUpdates": "检查更新",
@@ -262,6 +277,9 @@
"civitaiApiKey": "Civitai API 密钥", "civitaiApiKey": "Civitai API 密钥",
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥", "civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证", "civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
"civitaiApiKeyConfigured": "已配置",
"civitaiApiKeyNotConfigured": "未配置",
"civitaiApiKeySet": "设置",
"civitaiHost": { "civitaiHost": {
"label": "Civitai 站点", "label": "Civitai 站点",
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。", "help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
@@ -302,6 +320,7 @@
"downloads": "下载", "downloads": "下载",
"videoSettings": "视频设置", "videoSettings": "视频设置",
"layoutSettings": "布局设置", "layoutSettings": "布局设置",
"licenseIcons": "许可协议图标",
"misc": "其他", "misc": "其他",
"backup": "备份", "backup": "备份",
"folderSettings": "默认根目录", "folderSettings": "默认根目录",
@@ -414,6 +433,8 @@
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。" "help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "按模型分组",
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
"displayDensity": "显示密度", "displayDensity": "显示密度",
"displayDensityOptions": { "displayDensityOptions": {
"default": "默认", "default": "默认",
@@ -448,7 +469,9 @@
"modelName": "模型名称", "modelName": "模型名称",
"fileName": "文件名" "fileName": "文件名"
}, },
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容" "modelNameDisplayHelp": "选择在模型卡片底部显示的内容",
"cardBlurAmount": "卡片叠加模糊强度",
"cardBlurAmountHelp": "调整模型和配方卡片上页眉和页脚叠加层的模糊强度(0 = 无模糊,20 = 最大模糊)。"
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "活动库", "activeLibrary": "活动库",
@@ -580,6 +603,10 @@
"label": "隐藏抢先体验更新", "label": "隐藏抢先体验更新",
"help": "抢先体验更新" "help": "抢先体验更新"
}, },
"licenseIcons": {
"useNewStyle": "使用新版许可协议图标",
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
},
"misc": { "misc": {
"includeTriggerWords": "复制 LoRA 语法时包含触发词", "includeTriggerWords": "复制 LoRA 语法时包含触发词",
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词", "includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词",
@@ -953,10 +980,7 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "根目录", "modelRoot": "根目录",
"moreOptions": "更多选项",
"collapseAll": "折叠所有文件夹", "collapseAll": "折叠所有文件夹",
"pinSidebar": "固定侧边栏",
"unpinSidebar": "取消固定侧边栏",
"hideOnThisPage": "隐藏此页面侧边栏", "hideOnThisPage": "隐藏此页面侧边栏",
"showSidebar": "显示侧边栏", "showSidebar": "显示侧边栏",
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏", "sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
@@ -997,6 +1021,18 @@
"storage": "存储", "storage": "存储",
"insights": "洞察" "insights": "洞察"
}, },
"metrics": {
"totalModels": "模型总数",
"totalStorage": "总存储空间",
"totalGenerations": "总生成次数",
"usageRate": "使用率",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "唯一标签",
"unusedModels": "未使用模型",
"avgUsesPerModel": "平均使用次数/模型"
},
"usage": { "usage": {
"mostUsedLoras": "最常用 LoRA", "mostUsedLoras": "最常用 LoRA",
"mostUsedCheckpoints": "最常用 Checkpoint", "mostUsedCheckpoints": "最常用 Checkpoint",
@@ -1014,13 +1050,77 @@
}, },
"insights": { "insights": {
"smartInsights": "智能洞察", "smartInsights": "智能洞察",
"recommendations": "推荐" "recommendations": "推荐",
"noInsights": "暂无可用洞察",
"unusedLoras": {
"high": {
"title": "大量未使用的 LoRA",
"description": "你的 LoRA 中有 {percent}%{count}/{total})从未被使用过。",
"suggestion": "考虑整理或归档未使用的模型以释放存储空间。"
}
},
"unusedCheckpoints": {
"detected": {
"title": "检测到未使用的 Checkpoint",
"description": "你的 Checkpoint 中有 {percent}%{count}/{total})从未被使用过。",
"suggestion": "审查并考虑删除不再需要的 Checkpoint。"
}
},
"unusedEmbeddings": {
"high": {
"title": "大量未使用的 Embedding",
"description": "你的 Embedding 中有 {percent}%{count}/{total})从未被使用过。",
"suggestion": "考虑整理或归档未使用的 Embedding 以优化你的收藏。"
}
},
"collection": {
"large": {
"title": "检测到大型收藏",
"description": "你的模型收藏正在使用 {size} 的存储空间。",
"suggestion": "考虑使用外部存储或云解决方案以获得更好的组织。"
}
},
"activity": {
"active": {
"title": "活跃用户",
"description": "你已经完成了 {count} 次生成!",
"suggestion": "继续探索并用你的模型创作精彩内容。"
}
}
}, },
"charts": { "charts": {
"collectionOverview": "收藏概览", "collectionOverview": "收藏概览",
"baseModelDistribution": "基础模型分布", "baseModelDistribution": "基础模型分布",
"usageTrends": "使用趋势(最近30天)", "usageTrends": "使用趋势(最近30天)",
"usageDistribution": "使用分布" "usageDistribution": "使用分布",
"date": "日期",
"usageCount": "使用次数",
"fileSizeBytes": "文件大小(字节)",
"models": "模型",
"loraUsage": "LoRA 使用量",
"checkpointUsage": "Checkpoint 使用量",
"embeddingUsage": "Embedding 使用量"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "扩散模型",
"embedding": "Embedding"
},
"placeholders": {
"loading": "加载中...",
"noModels": "未找到模型",
"errorLoading": "数据加载失败",
"noStorageData": "暂无存储数据",
"rootFolder": "根目录",
"chartLibraryMissing": "需要 Chart.js 库来显示图表"
},
"tooltips": {
"tagCount": "{tag}{count} 个模型",
"chartUsage": "{name}{size}{count} 次使用",
"chartPercentage": "{label}{value}{pct}%"
} }
}, },
"modals": { "modals": {
@@ -1396,6 +1496,21 @@
"versionDeleted": "版本已删除" "versionDeleted": "版本已删除"
} }
} }
},
"metadataFetchSummary": {
"title": "元数据获取摘要",
"statSuccess": "成功",
"statFailed": "失败",
"statSkipped": "已跳过",
"statTotal": "总计扫描",
"statDuration": "耗时",
"successMessage": "全部 {count} 个 {type} 更新成功!",
"failedItems": "失败项目 ({count})",
"close": "关闭",
"copyReport": "复制报告",
"downloadCsv": "下载 CSV",
"columnModelName": "模型名称",
"columnError": "错误"
} }
}, },
"modelTags": { "modelTags": {
@@ -1409,15 +1524,6 @@
"duplicate": "该标签已存在" "duplicate": "该标签已存在"
} }
}, },
"keyboard": {
"navigation": "键盘导航:",
"shortcuts": {
"pageUp": "向上一页滚动",
"pageDown": "向下一页滚动",
"home": "跳到顶部",
"end": "跳到底部"
}
},
"initialization": { "initialization": {
"title": "初始化", "title": "初始化",
"message": "正在准备你的工作空间...", "message": "正在准备你的工作空间...",
@@ -1955,7 +2061,9 @@
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}", "bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
"exampleImagesDownloadSuccess": "示例图片下载成功!", "exampleImagesDownloadSuccess": "示例图片下载成功!",
"exampleImagesDownloadFailed": "示例图片下载失败:{message}", "exampleImagesDownloadFailed": "示例图片下载失败:{message}",
"moveFailed": "Failed to move item: {message}" "moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "已复制到剪贴板",
"downloadStarted": "下载已开始"
} }
}, },
"doctor": { "doctor": {
+125 -17
View File
@@ -22,6 +22,7 @@
}, },
"status": { "status": {
"loading": "載入中...", "loading": "載入中...",
"cancelling": "取消中...",
"unknown": "未知", "unknown": "未知",
"date": "日期", "date": "日期",
"version": "版本", "version": "版本",
@@ -182,6 +183,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "管理已排除的模型" "label": "管理已排除的模型"
},
"groupByModel": {
"label": "按模型分組"
} }
}, },
"header": { "header": {
@@ -250,7 +254,18 @@
"toggle": "切換主題", "toggle": "切換主題",
"switchToLight": "切換至淺色主題", "switchToLight": "切換至淺色主題",
"switchToDark": "切換至深色主題", "switchToDark": "切換至深色主題",
"switchToAuto": "自動主題" "switchToAuto": "自動主題",
"presets": "主題預設",
"default": "預設",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "模式",
"light": "淺色",
"dark": "深色",
"auto": "自動"
}, },
"actions": { "actions": {
"checkUpdates": "檢查更新", "checkUpdates": "檢查更新",
@@ -262,6 +277,9 @@
"civitaiApiKey": "Civitai API 金鑰", "civitaiApiKey": "Civitai API 金鑰",
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰", "civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證", "civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
"civitaiApiKeyConfigured": "已設定",
"civitaiApiKeyNotConfigured": "未設定",
"civitaiApiKeySet": "設定",
"civitaiHost": { "civitaiHost": {
"label": "Civitai 站點", "label": "Civitai 站點",
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。", "help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
@@ -302,6 +320,7 @@
"downloads": "下載", "downloads": "下載",
"videoSettings": "影片設定", "videoSettings": "影片設定",
"layoutSettings": "版面設定", "layoutSettings": "版面設定",
"licenseIcons": "許可協議圖標",
"misc": "其他", "misc": "其他",
"backup": "備份", "backup": "備份",
"folderSettings": "預設根目錄", "folderSettings": "預設根目錄",
@@ -414,6 +433,8 @@
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。" "help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "按模型分組",
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
"displayDensity": "顯示密度", "displayDensity": "顯示密度",
"displayDensityOptions": { "displayDensityOptions": {
"default": "預設", "default": "預設",
@@ -448,7 +469,9 @@
"modelName": "模型名稱", "modelName": "模型名稱",
"fileName": "檔案名稱" "fileName": "檔案名稱"
}, },
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容" "modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容",
"cardBlurAmount": "卡片疊加模糊強度",
"cardBlurAmountHelp": "調整模型和配方卡片上頁首和頁尾疊加層的模糊強度(0 = 無模糊,20 = 最大模糊)。"
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "使用中的資料庫", "activeLibrary": "使用中的資料庫",
@@ -580,6 +603,10 @@
"label": "隱藏搶先體驗更新", "label": "隱藏搶先體驗更新",
"help": "搶先體驗更新" "help": "搶先體驗更新"
}, },
"licenseIcons": {
"useNewStyle": "使用新版許可協議圖標",
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
},
"misc": { "misc": {
"includeTriggerWords": "在 LoRA 語法中包含觸發詞", "includeTriggerWords": "在 LoRA 語法中包含觸發詞",
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞", "includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞",
@@ -953,10 +980,7 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "根目錄", "modelRoot": "根目錄",
"moreOptions": "更多選項",
"collapseAll": "全部摺疊資料夾", "collapseAll": "全部摺疊資料夾",
"pinSidebar": "固定側邊欄",
"unpinSidebar": "取消固定側邊欄",
"hideOnThisPage": "隱藏此頁面側邊欄", "hideOnThisPage": "隱藏此頁面側邊欄",
"showSidebar": "顯示側邊欄", "showSidebar": "顯示側邊欄",
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏", "sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
@@ -997,6 +1021,18 @@
"storage": "儲存空間", "storage": "儲存空間",
"insights": "洞察" "insights": "洞察"
}, },
"metrics": {
"totalModels": "模型總數",
"totalStorage": "總儲存空間",
"totalGenerations": "總生成次數",
"usageRate": "使用率",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "唯一標籤",
"unusedModels": "未使用模型",
"avgUsesPerModel": "平均使用次數/模型"
},
"usage": { "usage": {
"mostUsedLoras": "最常用的 LoRA", "mostUsedLoras": "最常用的 LoRA",
"mostUsedCheckpoints": "最常用的 Checkpoint", "mostUsedCheckpoints": "最常用的 Checkpoint",
@@ -1014,13 +1050,77 @@
}, },
"insights": { "insights": {
"smartInsights": "智慧洞察", "smartInsights": "智慧洞察",
"recommendations": "推薦" "recommendations": "推薦",
"noInsights": "暫無可用洞察",
"unusedLoras": {
"high": {
"title": "大量未使用的 LoRA",
"description": "你的 LoRA 中有 {percent}%{count}/{total})從未被使用過。",
"suggestion": "考慮整理或封存未使用的模型以釋放儲存空間。"
}
},
"unusedCheckpoints": {
"detected": {
"title": "檢測到未使用的 Checkpoint",
"description": "你的 Checkpoint 中有 {percent}%{count}/{total})從未被使用過。",
"suggestion": "審查並考慮刪除不再需要的 Checkpoint。"
}
},
"unusedEmbeddings": {
"high": {
"title": "大量未使用的 Embedding",
"description": "你的 Embedding 中有 {percent}%{count}/{total})從未被使用過。",
"suggestion": "考慮整理或封存未使用的 Embedding 以優化你的收藏。"
}
},
"collection": {
"large": {
"title": "檢測到大型收藏",
"description": "你的模型收藏正在使用 {size} 的儲存空間。",
"suggestion": "考慮使用外部儲存或雲端解決方案以獲得更好的組織。"
}
},
"activity": {
"active": {
"title": "活躍用戶",
"description": "你已經完成了 {count} 次生成!",
"suggestion": "繼續探索並用你的模型創作精彩內容。"
}
}
}, },
"charts": { "charts": {
"collectionOverview": "收藏總覽", "collectionOverview": "收藏總覽",
"baseModelDistribution": "基礎模型分布", "baseModelDistribution": "基礎模型分布",
"usageTrends": "使用趨勢(最近 30 天)", "usageTrends": "使用趨勢(最近 30 天)",
"usageDistribution": "使用分布" "usageDistribution": "使用分布",
"date": "日期",
"usageCount": "使用次數",
"fileSizeBytes": "檔案大小(位元組)",
"models": "模型",
"loraUsage": "LoRA 使用量",
"checkpointUsage": "Checkpoint 使用量",
"embeddingUsage": "Embedding 使用量"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "擴散模型",
"embedding": "Embedding"
},
"placeholders": {
"loading": "載入中...",
"noModels": "找不到模型",
"errorLoading": "資料載入失敗",
"noStorageData": "暫無儲存資料",
"rootFolder": "根目錄",
"chartLibraryMissing": "需要 Chart.js 函式庫來顯示圖表"
},
"tooltips": {
"tagCount": "{tag}{count} 個模型",
"chartUsage": "{name}{size}{count} 次使用",
"chartPercentage": "{label}{value}{pct}%"
} }
}, },
"modals": { "modals": {
@@ -1396,6 +1496,21 @@
"versionDeleted": "已刪除此版本" "versionDeleted": "已刪除此版本"
} }
} }
},
"metadataFetchSummary": {
"title": "元資料獲取摘要",
"statSuccess": "成功",
"statFailed": "失敗",
"statSkipped": "已跳過",
"statTotal": "總計掃描",
"statDuration": "耗時",
"successMessage": "全部 {count} 個 {type} 更新成功!",
"failedItems": "失敗項目 ({count})",
"close": "關閉",
"copyReport": "複製報告",
"downloadCsv": "下載 CSV",
"columnModelName": "模型名稱",
"columnError": "錯誤"
} }
}, },
"modelTags": { "modelTags": {
@@ -1409,15 +1524,6 @@
"duplicate": "此標籤已存在" "duplicate": "此標籤已存在"
} }
}, },
"keyboard": {
"navigation": "鍵盤導覽:",
"shortcuts": {
"pageUp": "向上捲動一頁",
"pageDown": "向下捲動一頁",
"home": "跳至頂部",
"end": "跳至底部"
}
},
"initialization": { "initialization": {
"title": "初始化", "title": "初始化",
"message": "正在準備您的工作區...", "message": "正在準備您的工作區...",
@@ -1955,7 +2061,9 @@
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}", "bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
"exampleImagesDownloadSuccess": "範例圖片下載成功!", "exampleImagesDownloadSuccess": "範例圖片下載成功!",
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}", "exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
"moveFailed": "Failed to move item: {message}" "moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "已複製到剪貼簿",
"downloadStarted": "下載已開始"
} }
}, },
"doctor": { "doctor": {
+15
View File
@@ -33,6 +33,7 @@ from .utils.example_images_migration import ExampleImagesMigration
from .services.websocket_manager import ws_manager from .services.websocket_manager import ws_manager
from .services.example_images_cleanup_service import ExampleImagesCleanupService from .services.example_images_cleanup_service import ExampleImagesCleanupService
from .middleware.csp_middleware import relax_csp_for_remote_media from .middleware.csp_middleware import relax_csp_for_remote_media
from .middleware.error_middleware import api_json_error
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -76,6 +77,11 @@ class LoraManager:
"""Initialize and register all routes using the new refactored architecture""" """Initialize and register all routes using the new refactored architecture"""
app = PromptServer.instance.app app = PromptServer.instance.app
# Register JSON error middleware for /api/* routes as the outermost
# middleware so it catches errors from all other middlewares.
if api_json_error not in app.middlewares:
app.middlewares.insert(0, api_json_error)
if relax_csp_for_remote_media not in app.middlewares: if relax_csp_for_remote_media not in app.middlewares:
# Ensure CSP relaxer executes after ComfyUI's block_external_middleware so it can # Ensure CSP relaxer executes after ComfyUI's block_external_middleware so it can
# see and extend the restrictive header instead of being overwritten by it. # see and extend the restrictive header instead of being overwritten by it.
@@ -430,5 +436,14 @@ class LoraManager:
try: try:
logger.info("LoRA Manager: Cleaning up services") logger.info("LoRA Manager: Cleaning up services")
# Cancel any in-flight scanner initialization tasks so thread-pool
# workers (e.g. _initialize_cache_sync) can break out of their loops
# when the server shuts down (e.g. Ctrl+C on WSL).
for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"):
scanner = ServiceRegistry.get_service_sync(name)
if scanner is not None and hasattr(scanner, "cancel_task"):
scanner.cancel_task()
logger.debug("LoRA Manager: Cancelled %s", name)
except Exception as e: except Exception as e:
logger.error(f"Error during cleanup: {e}", exc_info=True) logger.error(f"Error during cleanup: {e}", exc_info=True)
+50
View File
@@ -901,6 +901,55 @@ class LoraLoaderManagerExtractor(NodeMetadataExtractor):
"node_id": node_id "node_id": node_id
} }
class LoraTextLoaderManagerExtractor(NodeMetadataExtractor):
"""Extract LoRA metadata from LoraTextLoaderLM (LoRA Text Loader).
The node accepts a `lora_syntax` STRING containing <lora:name:strength> tags
(same format as the ComfyUI prompt), plus an optional `lora_stack`.
This extractor parses the syntax string using the same regex as the node.
"""
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
active_loras = []
# Process lora_stack if available (optional input)
if "lora_stack" in inputs:
lora_stack = inputs.get("lora_stack", [])
for item in lora_stack:
# lora_stack entries are (path, model_strength, clip_strength) tuples
if isinstance(item, (list, tuple)) and len(item) >= 2:
lora_path = item[0]
model_strength = item[1]
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
active_loras.append({
"name": lora_name,
"strength": round(float(model_strength), 2)
})
# Process lora_syntax string input
if "lora_syntax" in inputs:
lora_syntax = inputs.get("lora_syntax", "")
if lora_syntax and isinstance(lora_syntax, str):
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
matches = re.findall(pattern, lora_syntax, re.IGNORECASE)
for match in matches:
lora_name = match[0]
model_strength = float(match[1])
active_loras.append({
"name": lora_name,
"strength": round(model_strength, 2)
})
if active_loras:
metadata[LORAS][node_id] = {
"lora_list": active_loras,
"node_id": node_id
}
class FluxGuidanceExtractor(NodeMetadataExtractor): class FluxGuidanceExtractor(NodeMetadataExtractor):
@staticmethod @staticmethod
def extract(node_id, inputs, outputs, metadata): def extract(node_id, inputs, outputs, metadata):
@@ -1146,6 +1195,7 @@ NODE_EXTRACTORS = {
"UNETLoaderLM": UNETLoaderExtractor, # LoRA Manager "UNETLoaderLM": UNETLoaderExtractor, # LoRA Manager
"LoraLoader": LoraLoaderExtractor, "LoraLoader": LoraLoaderExtractor,
"LoraLoaderLM": LoraLoaderManagerExtractor, "LoraLoaderLM": LoraLoaderManagerExtractor,
"LoraTextLoaderLM": LoraTextLoaderManagerExtractor,
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor, "RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
"TensorRTLoader": TensorRTLoaderExtractor, "TensorRTLoader": TensorRTLoaderExtractor,
# Conditioning # Conditioning
+2
View File
@@ -16,6 +16,8 @@ IMG_EXTENSIONS = (
".tif", ".tif",
".tiff", ".tiff",
".webp", ".webp",
".avif",
".jxl",
".mp4" ".mp4"
) )
+71
View File
@@ -0,0 +1,71 @@
"""JSON error middleware for API routes.
Ensures all responses to /api/* requests return valid JSON that the
browser-extension frontend can JSON.parse() without crashing, even when
the route does not exist (404) or the handler raises an exception (500).
Extension consumers call response.json() unconditionally — an HTML error
page causes ``SyntaxError: unexpected end of data`` that leaks into the
popup UI as a toast notification.
"""
from __future__ import annotations
import logging
from typing import Awaitable, Callable
from aiohttp import web
logger = logging.getLogger(__name__)
@web.middleware
async def api_json_error(
request: web.Request,
handler: Callable[[web.Request], Awaitable[web.Response]],
) -> web.Response:
"""Return JSON ``{"success": false, "error": "..."}`` for API errors.
Only intercepts paths starting with ``/api/`` — all other routes
(frontend pages, static files, WebSocket upgrades) pass through
unchanged.
"""
if not request.path.startswith("/api/"):
return await handler(request)
try:
response = await handler(request)
return response
except web.HTTPException as exc:
# Let redirects (301, 302, 307, 308) propagate — they are not errors.
if exc.status < 400:
raise
logger.warning(
"API %s %s returned HTTP %d: %s",
request.method,
request.path,
exc.status,
exc.reason,
)
return web.json_response(
{"success": False, "error": f"{exc.status}: {exc.reason}"},
status=exc.status,
)
except Exception as exc:
logger.error(
"API %s %s raised unhandled exception: %s",
request.method,
request.path,
exc,
exc_info=True,
)
return web.json_response(
{
"success": False,
"error": f"500: Internal Server Error ({type(exc).__name__})",
},
status=500,
)
+11 -3
View File
@@ -11,7 +11,7 @@ from ..metadata_collector.metadata_processor import MetadataProcessor
from ..metadata_collector import get_metadata from ..metadata_collector import get_metadata
from ..utils.constants import CARD_PREVIEW_WIDTH from ..utils.constants import CARD_PREVIEW_WIDTH
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
from ..utils.utils import calculate_recipe_fingerprint from ..utils.utils import calculate_recipe_fingerprint, sanitize_folder_name
from PIL import Image, PngImagePlugin from PIL import Image, PngImagePlugin
import piexif import piexif
import logging import logging
@@ -298,7 +298,12 @@ class SaveImageLM:
key = parts[0] key = parts[0]
if key == "seed" and "seed" in metadata_dict: if key == "seed" and "seed" in metadata_dict:
filename = filename.replace(segment, str(metadata_dict.get("seed", ""))) seed_value = metadata_dict.get("seed")
if seed_value is not None:
filename = filename.replace(segment, str(seed_value))
else:
# Fallback if seed was not captured by metadata collector
filename = filename.replace(segment, "0")
elif key == "width" and "size" in metadata_dict: elif key == "width" and "size" in metadata_dict:
size = metadata_dict.get("size", "x") size = metadata_dict.get("size", "x")
w = size.split("x")[0] if isinstance(size, str) else size[0] w = size.split("x")[0] if isinstance(size, str) else size[0]
@@ -309,12 +314,14 @@ class SaveImageLM:
filename = filename.replace(segment, str(h)) filename = filename.replace(segment, str(h))
elif key == "pprompt" and "prompt" in metadata_dict: elif key == "pprompt" and "prompt" in metadata_dict:
prompt = metadata_dict.get("prompt", "").replace("\n", " ") prompt = metadata_dict.get("prompt", "").replace("\n", " ")
prompt = sanitize_folder_name(prompt)
if len(parts) >= 2: if len(parts) >= 2:
length = int(parts[1]) length = int(parts[1])
prompt = prompt[:length] prompt = prompt[:length]
filename = filename.replace(segment, prompt.strip()) filename = filename.replace(segment, prompt.strip())
elif key == "nprompt" and "negative_prompt" in metadata_dict: elif key == "nprompt" and "negative_prompt" in metadata_dict:
prompt = metadata_dict.get("negative_prompt", "").replace("\n", " ") prompt = metadata_dict.get("negative_prompt", "").replace("\n", " ")
prompt = sanitize_folder_name(prompt)
if len(parts) >= 2: if len(parts) >= 2:
length = int(parts[1]) length = int(parts[1])
prompt = prompt[:length] prompt = prompt[:length]
@@ -328,6 +335,7 @@ class SaveImageLM:
model = "model_unavailable" model = "model_unavailable"
else: else:
model = os.path.splitext(os.path.basename(model_value))[0] model = os.path.splitext(os.path.basename(model_value))[0]
model = sanitize_folder_name(model)
if len(parts) >= 2: if len(parts) >= 2:
length = int(parts[1]) length = int(parts[1])
model = model[:length] model = model[:length]
@@ -600,7 +608,7 @@ class SaveImageLM:
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
# Generate filename with counter if needed # Generate filename with counter if needed
base_filename = filename base_filename = filename.replace("%batch_num%", str(i))
if add_counter_to_filename: if add_counter_to_filename:
# Use counter + i to ensure unique filenames for all images in batch # Use counter + i to ensure unique filenames for all images in batch
current_counter = counter + i current_counter = counter + i
+20 -1
View File
@@ -49,7 +49,10 @@ from ...utils.constants import (
VALID_LORA_TYPES, VALID_LORA_TYPES,
) )
from ...utils.civitai_utils import rewrite_preview_url from ...utils.civitai_utils import rewrite_preview_url
from ...utils.example_images_paths import is_valid_example_images_root from ...utils.example_images_paths import (
find_non_compliant_items_in_example_images_root,
is_valid_example_images_root,
)
from ...utils.lora_metadata import extract_trained_words from ...utils.lora_metadata import extract_trained_words
from ...utils.session_logging import get_standalone_session_log_snapshot from ...utils.session_logging import get_standalone_session_log_snapshot
from ...utils.usage_stats import UsageStats from ...utils.usage_stats import UsageStats
@@ -1328,6 +1331,9 @@ class SettingsHandler:
"folder_paths", "folder_paths",
"libraries", "libraries",
"active_library", "active_library",
# Sensitive — never expose the actual value to the frontend;
# frontend receives a boolean instead (civitai_api_key_set).
"civitai_api_key",
} }
) )
@@ -1382,6 +1388,9 @@ class SettingsHandler:
value = self._settings.get(key) value = self._settings.get(key)
if value is not None: if value is not None:
response_data[key] = value response_data[key] = value
# Sensitive fields: only expose a boolean indicating whether set
raw_key = self._settings.get("civitai_api_key")
response_data["civitai_api_key_set"] = bool(raw_key)
settings_file = getattr(self._settings, "settings_file", None) settings_file = getattr(self._settings, "settings_file", None)
if settings_file: if settings_file:
response_data["settings_file"] = settings_file response_data["settings_file"] = settings_file
@@ -1492,6 +1501,16 @@ class SettingsHandler:
if not os.path.isdir(folder_path): if not os.path.isdir(folder_path):
return "Please set a dedicated folder for example images." return "Please set a dedicated folder for example images."
if not self._is_dedicated_example_images_folder(folder_path): if not self._is_dedicated_example_images_folder(folder_path):
offending = find_non_compliant_items_in_example_images_root(folder_path)
if offending:
items_str = ", ".join(repr(item) for item in offending[:5])
if len(offending) > 5:
items_str += f" … and {len(offending) - 5} more"
return (
f"The folder contains items that are not valid example image "
f"folders: {items_str}. Please use a dedicated, empty folder "
f"for example images to prevent accidental data loss."
)
return "Please set a dedicated folder for example images." return "Please set a dedicated folder for example images."
return None return None
+65 -1
View File
@@ -233,6 +233,8 @@ class ModelListingHandler:
start_time = time.perf_counter() start_time = time.perf_counter()
try: try:
params = self._parse_common_params(request) params = self._parse_common_params(request)
# group_by_model is meaningless for excluded view; strip it
params.pop("group_by_model", None)
result = await self._service.get_excluded_paginated_data(**params) result = await self._service.get_excluded_paginated_data(**params)
format_start = time.perf_counter() format_start = time.perf_counter()
@@ -366,6 +368,19 @@ class ModelListingHandler:
request.query.get("name_pattern_use_regex", "false").lower() == "true" request.query.get("name_pattern_use_regex", "false").lower() == "true"
) )
# Group-by-model flag: deduplicate versions sharing the same civitai modelId
group_by_model = (
request.query.get("group_by_model", "false").lower() == "true"
)
# View-local-versions filter: show all local versions of a specific model
civitai_model_id = request.query.get("civitai_model_id")
if civitai_model_id is not None:
try:
civitai_model_id = int(civitai_model_id)
except (TypeError, ValueError):
civitai_model_id = None
return { return {
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
@@ -389,6 +404,8 @@ class ModelListingHandler:
"name_pattern_include": name_pattern_include, "name_pattern_include": name_pattern_include,
"name_pattern_exclude": name_pattern_exclude, "name_pattern_exclude": name_pattern_exclude,
"name_pattern_use_regex": name_pattern_use_regex, "name_pattern_use_regex": name_pattern_use_regex,
"group_by_model": group_by_model,
"civitai_model_id": civitai_model_id,
**self._parse_specific_params(request), **self._parse_specific_params(request),
} }
@@ -1272,6 +1289,14 @@ class ModelQueryHandler:
license_flags = (model_data or {}).get("license_flags") license_flags = (model_data or {}).get("license_flags")
if license_flags is not None: if license_flags is not None:
response_payload["license_flags"] = int(license_flags) response_payload["license_flags"] = int(license_flags)
# Include the user's license icon style preference so the
# ComfyUI tooltip can pick the right set without a separate
# API call.
try:
settings = get_settings_manager()
response_payload["use_new_license_icons"] = settings.get("use_new_license_icons", True)
except Exception:
pass
return web.json_response(response_payload) return web.json_response(response_payload)
return web.json_response( return web.json_response(
{ {
@@ -1785,6 +1810,8 @@ class ModelDownloadHandler:
bytes_downloaded = 0 bytes_downloaded = 0
total_bytes_raw = request.query.get("total_bytes") total_bytes_raw = request.query.get("total_bytes")
total_bytes = int(total_bytes_raw) if total_bytes_raw else None total_bytes = int(total_bytes_raw) if total_bytes_raw else None
completed_at_raw = request.query.get("completed_at")
completed_at = float(completed_at_raw) if completed_at_raw else None
service = await DownloadQueueService.get_instance() service = await DownloadQueueService.get_instance()
item = await service.complete_download( item = await service.complete_download(
@@ -1794,6 +1821,7 @@ class ModelDownloadHandler:
file_path=file_path, file_path=file_path,
bytes_downloaded=bytes_downloaded, bytes_downloaded=bytes_downloaded,
total_bytes=total_bytes, total_bytes=total_bytes,
completed_at=completed_at,
) )
if item is None: if item is None:
return web.json_response( return web.json_response(
@@ -1817,6 +1845,39 @@ class ModelDownloadHandler:
) )
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def update_download_queue_status(self, request: web.Request) -> web.Response:
"""Update the status of a queue item (non-terminal transitions).
Supported transitions include ``queued downloading``,
``downloading paused``, ``paused downloading``, etc.
Terminal transitions (``completed``, ``failed``, ``canceled``)
should use ``complete_download_in_queue`` instead.
"""
try:
download_id = request.query.get("download_id")
status = request.query.get("status")
if not download_id or not status:
return web.json_response(
{
"success": False,
"error": "download_id and status are required",
},
status=400,
)
service = await DownloadQueueService.get_instance()
updated = await service.update_status(download_id, status)
if not updated:
return web.json_response(
{"success": False, "error": "Download not found in queue"},
status=404,
)
return web.json_response({"success": True})
except Exception as exc:
self._logger.error(
"Error updating download queue status: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelCivitaiHandler: class ModelCivitaiHandler:
"""CivitAI integration endpoints.""" """CivitAI integration endpoints."""
@@ -1858,7 +1919,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)
@@ -2859,6 +2922,7 @@ class ModelHandlerSet:
"retry_all_failed_downloads": self.download.retry_all_failed_downloads, "retry_all_failed_downloads": self.download.retry_all_failed_downloads,
"complete_download_in_queue": self.download.complete_download_in_queue, "complete_download_in_queue": self.download.complete_download_in_queue,
"get_download_stats": self.download.get_download_stats, "get_download_stats": self.download.get_download_stats,
"update_download_queue_status": self.download.update_download_queue_status,
"get_civitai_versions": self.civitai.get_civitai_versions, "get_civitai_versions": self.civitai.get_civitai_versions,
"get_civitai_model_by_version": self.civitai.get_civitai_model_by_version, "get_civitai_model_by_version": self.civitai.get_civitai_model_by_version,
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash, "get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,
+18 -11
View File
@@ -13,7 +13,7 @@ from ...config import config as global_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_CHUNK_SIZE = 256 * 1024 # 256 KB _CHUNK_SIZE = 1024 * 1024 # 1 MB — balance between streaming iteration overhead and per-chunk memory
# Video file extensions that bypass native sendfile on Windows # Video file extensions that bypass native sendfile on Windows
# to avoid IOCP/ProactorEventLoop crashes during client disconnect. # to avoid IOCP/ProactorEventLoop crashes during client disconnect.
@@ -55,16 +55,19 @@ class PreviewHandler:
logger.debug("Preview file not found at %s", str(resolved)) logger.debug("Preview file not found at %s", str(resolved))
raise web.HTTPNotFound(text="Preview file not found") raise web.HTTPNotFound(text="Preview file not found")
# Video files: stream manually to avoid Windows native sendfile crash. # aiohttp's FileResponse handles range requests, content headers, and
# aiohttp's FileResponse uses _sendfile_native on Windows (IOCP-based), # uses kernel sendfile (zero-copy DMA) on Linux/macOS. On Windows it
# which breaks when the client disconnects mid-transfer — this happens # uses IOCP-based _sendfile_native which can crash when the client
# constantly when users scroll through a gallery of animated previews. # disconnects mid-transfer during fast scrolling. The _stream_file()
suffix = resolved.suffix.lower() # fallback is kept for a future compat toggle.
if suffix in _VIDEO_EXTENSIONS: #
return await self._stream_file(request, resolved) # Set explicit Cache-Control so the browser can cache video (and image)
# previews across VirtualScroller recycling cycles. Without this,
# aiohttp's FileResponse handles range requests and content headers for us. # Chrome does not cache 206 Partial Content responses for <video>
return web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE) # elements, causing the same video to be re-downloaded on every scroll.
resp = web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
resp.headers["Cache-Control"] = "public, max-age=86400"
return resp
async def _stream_file( async def _stream_file(
self, request: web.Request, path: Path self, request: web.Request, path: Path
@@ -83,6 +86,10 @@ class PreviewHandler:
resp.content_type = content_type resp.content_type = content_type
resp.content_length = file_size resp.content_length = file_size
# Allow browser caching: video previews rarely change during a session.
# The frontend already appends ?t={version} to bust cache on update.
resp.headers["Cache-Control"] = "public, max-age=86400"
await resp.prepare(request) await resp.prepare(request)
try: try:
+16 -21
View File
@@ -1597,15 +1597,8 @@ class RecipeManagementHandler:
cache = await recipe_scanner.get_cached_data() cache = await recipe_scanner.get_cached_data()
# Build lookup: image_id -> recipe_id from stored source_path # Use precomputed image_id_map (built once at cache init)
image_to_recipe = {} image_to_recipe = getattr(cache, "image_id_map", {})
for recipe in getattr(cache, "raw_data", []):
source = recipe.get("source_path")
if not source:
continue
image_id = extract_civitai_image_id(source)
if image_id and image_id not in image_to_recipe:
image_to_recipe[image_id] = recipe.get("id")
results = {} results = {}
for img_id in requested_ids: for img_id in requested_ids:
@@ -1641,20 +1634,22 @@ class RecipeManagementHandler:
"Could not extract Civitai image ID from URL" "Could not extract Civitai image ID from URL"
) )
# Check for duplicate (fast, before acquiring semaphore), unless force
if not force: if not force:
cache = await recipe_scanner.get_cached_data() cache = await recipe_scanner.get_cached_data()
for recipe in getattr(cache, "raw_data", []): image_to_recipe = getattr(cache, "image_id_map", {})
source = recipe.get("source_path") existing_recipe_id = image_to_recipe.get(image_id)
if source: if existing_recipe_id:
existing_id = extract_civitai_image_id(source) recipe_name = ""
if existing_id == image_id: for recipe in getattr(cache, "raw_data", []):
return web.json_response({ if str(recipe.get("id", "")) == existing_recipe_id:
"success": True, recipe_name = recipe.get("title", "") or ""
"recipe_id": recipe.get("id"), break
"name": recipe.get("title", ""), return web.json_response({
"already_exists": True, "success": True,
}) "recipe_id": existing_recipe_id,
"name": recipe_name,
"already_exists": True,
})
async with self._import_semaphore: async with self._import_semaphore:
return await self._do_import_from_url(image_url, recipe_scanner) return await self._do_import_from_url(image_url, recipe_scanner)
+3
View File
@@ -138,6 +138,9 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"GET", "/api/lm/downloads/queue/complete", "complete_download_in_queue" "GET", "/api/lm/downloads/queue/complete", "complete_download_in_queue"
), ),
RouteDefinition(
"GET", "/api/lm/downloads/queue/status", "update_download_queue_status"
),
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"), RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
RouteDefinition("GET", "/{prefix}", "handle_models_page"), RouteDefinition("GET", "/{prefix}", "handle_models_page"),
) )
+45 -16
View File
@@ -11,6 +11,8 @@ from ..config import config
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from ..services.server_i18n import server_i18n from ..services.server_i18n import server_i18n
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..services.model_query import normalize_sub_type, resolve_sub_type
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES
from ..utils.usage_stats import UsageStats from ..utils.usage_stats import UsageStats
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -140,6 +142,21 @@ class StatsRoutes:
# Get usage statistics # Get usage statistics
usage_data = await self.usage_stats.get_stats() usage_data = await self.usage_stats.get_stats()
# CivitAI model type distribution across all model types
# Use the same logic as the filter panel: normalize_sub_type(resolve_sub_type(entry))
# with sub-type validation per model type
model_types_counter: Counter[str] = Counter()
for entry in lora_cache.raw_data:
ntype = normalize_sub_type(resolve_sub_type(entry))
if ntype and ntype in VALID_LORA_SUB_TYPES:
model_types_counter[ntype] += 1
for entry in checkpoint_cache.raw_data:
ntype = normalize_sub_type(resolve_sub_type(entry))
if ntype and ntype in VALID_CHECKPOINT_SUB_TYPES:
model_types_counter[ntype] += 1
# Embeddings: always count as "embedding" regardless of CivitAI sub-type
model_types_counter['embedding'] = len(embedding_cache.raw_data)
return web.json_response({ return web.json_response({
'success': True, 'success': True,
'data': { 'data': {
@@ -154,7 +171,8 @@ class StatsRoutes:
'total_generations': usage_data.get('total_executions', 0), 'total_generations': usage_data.get('total_executions', 0),
'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})), 'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})),
'unused_checkpoints': self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {})), 'unused_checkpoints': self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {})),
'unused_embeddings': self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {})) 'unused_embeddings': self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {})),
'model_types_distribution': dict(model_types_counter.most_common())
} }
}) })
@@ -459,9 +477,12 @@ class StatsRoutes:
if unused_lora_percent > 50: if unused_lora_percent > 50:
insights.append({ insights.append({
'type': 'warning', 'type': 'warning',
'title': 'High Number of Unused LoRAs', 'key': 'insights.unusedLoras.high',
'description': f'{unused_lora_percent:.1f}% of your LoRAs ({unused_loras}/{total_loras}) have never been used.', 'params': {
'suggestion': 'Consider organizing or archiving unused models to free up storage space.' 'percent': f'{unused_lora_percent:.1f}',
'count': str(unused_loras),
'total': str(total_loras)
}
}) })
if total_checkpoints > 0: if total_checkpoints > 0:
@@ -469,9 +490,12 @@ class StatsRoutes:
if unused_checkpoint_percent > 30: if unused_checkpoint_percent > 30:
insights.append({ insights.append({
'type': 'warning', 'type': 'warning',
'title': 'Unused Checkpoints Detected', 'key': 'insights.unusedCheckpoints.detected',
'description': f'{unused_checkpoint_percent:.1f}% of your checkpoints ({unused_checkpoints}/{total_checkpoints}) have never been used.', 'params': {
'suggestion': 'Review and consider removing checkpoints you no longer need.' 'percent': f'{unused_checkpoint_percent:.1f}',
'count': str(unused_checkpoints),
'total': str(total_checkpoints)
}
}) })
if total_embeddings > 0: if total_embeddings > 0:
@@ -479,9 +503,12 @@ class StatsRoutes:
if unused_embedding_percent > 50: if unused_embedding_percent > 50:
insights.append({ insights.append({
'type': 'warning', 'type': 'warning',
'title': 'High Number of Unused Embeddings', 'key': 'insights.unusedEmbeddings.high',
'description': f'{unused_embedding_percent:.1f}% of your embeddings ({unused_embeddings}/{total_embeddings}) have never been used.', 'params': {
'suggestion': 'Consider organizing or archiving unused embeddings to optimize your collection.' 'percent': f'{unused_embedding_percent:.1f}',
'count': str(unused_embeddings),
'total': str(total_embeddings)
}
}) })
# Storage insights # Storage insights
@@ -492,18 +519,20 @@ class StatsRoutes:
if total_size > 100 * 1024 * 1024 * 1024: # 100GB if total_size > 100 * 1024 * 1024 * 1024: # 100GB
insights.append({ insights.append({
'type': 'info', 'type': 'info',
'title': 'Large Collection Detected', 'key': 'insights.collection.large',
'description': f'Your model collection is using {self._format_size(total_size)} of storage.', 'params': {
'suggestion': 'Consider using external storage or cloud solutions for better organization.' 'size': self._format_size(total_size)
}
}) })
# Recent activity insight # Recent activity insight
if usage_data.get('total_executions', 0) > 100: if usage_data.get('total_executions', 0) > 100:
insights.append({ insights.append({
'type': 'success', 'type': 'success',
'title': 'Active User', 'key': 'insights.activity.active',
'description': f'You\'ve completed {usage_data["total_executions"]} generations so far!', 'params': {
'suggestion': 'Keep exploring and creating amazing content with your models.' 'count': str(usage_data['total_executions'])
}
}) })
return web.json_response({ return web.json_response({
+26 -1
View File
@@ -104,6 +104,30 @@ class BaseModelService(ABC):
fetch_duration = time.perf_counter() - t0 fetch_duration = time.perf_counter() - t0
initial_count = len(sorted_data) initial_count = len(sorted_data)
# Optionally filter by civitai model ID (shows all local versions of a specific model)
civitai_model_id = kwargs.get("civitai_model_id")
if civitai_model_id is not None:
sorted_data = [
item for item in sorted_data
if self._extract_model_id(item) == civitai_model_id
]
# Optionally group by civitai modelId, showing only the latest version per model
dedup_lost = 0
if kwargs.get("group_by_model") and civitai_model_id is None:
dedup_map = {} # modelId -> (item, version_id)
standalone = []
for item in sorted_data:
mid = self._extract_model_id(item)
if mid is None:
standalone.append(item)
continue
vid = self._extract_version_id(item) or 0
if mid not in dedup_map or vid > dedup_map[mid][1]:
dedup_map[mid] = (item, vid)
dedup_lost = len(sorted_data) - (len(dedup_map) + len(standalone))
sorted_data = [entry[0] for entry in dedup_map.values()] + standalone
t1 = time.perf_counter() t1 = time.perf_counter()
if hash_filters: if hash_filters:
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters) filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
@@ -172,7 +196,7 @@ class BaseModelService(ABC):
overall_duration = time.perf_counter() - overall_start overall_duration = time.perf_counter() - overall_start
logger.debug( logger.debug(
"%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). " "%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). "
"Counts: initial=%d, post_filter=%d, final=%d", "Counts: initial=%d, dedup=%d, post_filter=%d, final=%d",
self.__class__.__name__, self.__class__.__name__,
overall_duration, overall_duration,
fetch_duration, fetch_duration,
@@ -181,6 +205,7 @@ class BaseModelService(ABC):
pagination_duration, pagination_duration,
annotate_duration, annotate_duration,
initial_count, initial_count,
dedup_lost,
post_filter_count, post_filter_count,
final_count, final_count,
) )
+55
View File
@@ -29,6 +29,7 @@ from .metadata_service import get_default_metadata_provider, get_metadata_provid
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
from .aria2_downloader import Aria2Error, get_aria2_downloader from .aria2_downloader import Aria2Error, get_aria2_downloader
from .aria2_transfer_state import Aria2TransferStateStore from .aria2_transfer_state import Aria2TransferStateStore
from .download_queue_service import DownloadQueueService
# Download to temporary file first # Download to temporary file first
import tempfile import tempfile
@@ -360,6 +361,15 @@ class DownloadManager:
if self._active_downloads[task_id].get("transfer_backend") == "aria2": if self._active_downloads[task_id].get("transfer_backend") == "aria2":
await self._persist_aria2_state(task_id) await self._persist_aria2_state(task_id)
# Update SQLite queue status to 'downloading'
try:
queue_service = await DownloadQueueService.get_instance()
await queue_service.update_status(task_id, "downloading")
except Exception:
logger.warning(
"Failed to update queue status for %s", task_id, exc_info=True
)
# Use original download implementation # Use original download implementation
try: try:
# Check for cancellation before starting # Check for cancellation before starting
@@ -396,6 +406,22 @@ class DownloadManager:
if self._active_downloads[task_id].get("transfer_backend") == "aria2": if self._active_downloads[task_id].get("transfer_backend") == "aria2":
await self._persist_aria2_state(task_id) await self._persist_aria2_state(task_id)
# Move queue item to history on completion
try:
queue_service = await DownloadQueueService.get_instance()
await queue_service.complete_download(
download_id=task_id,
status=result.get("status", "completed") if result.get("success") else "failed",
error=result.get("error") if not result.get("success") else None,
file_path=result.get("file_path"),
bytes_downloaded=self._active_downloads.get(task_id, {}).get("bytes_downloaded", 0),
total_bytes=self._active_downloads.get(task_id, {}).get("total_bytes"),
)
except Exception:
logger.warning(
"Failed to complete queue item for %s", task_id, exc_info=True
)
return result return result
except asyncio.CancelledError: except asyncio.CancelledError:
# Handle cancellation # Handle cancellation
@@ -404,6 +430,19 @@ class DownloadManager:
self._active_downloads[task_id]["bytes_per_second"] = 0.0 self._active_downloads[task_id]["bytes_per_second"] = 0.0
if self._active_downloads[task_id].get("transfer_backend") == "aria2": if self._active_downloads[task_id].get("transfer_backend") == "aria2":
await self._persist_aria2_state(task_id) await self._persist_aria2_state(task_id)
# Move queue item to history as canceled
try:
queue_service = await DownloadQueueService.get_instance()
await queue_service.complete_download(
download_id=task_id,
status="canceled",
)
except Exception:
logger.warning(
"Failed to cancel queue item for %s", task_id, exc_info=True
)
logger.info(f"Download cancelled for task {task_id}") logger.info(f"Download cancelled for task {task_id}")
raise raise
except Exception as e: except Exception as e:
@@ -417,6 +456,22 @@ class DownloadManager:
self._active_downloads[task_id]["bytes_per_second"] = 0.0 self._active_downloads[task_id]["bytes_per_second"] = 0.0
if self._active_downloads[task_id].get("transfer_backend") == "aria2": if self._active_downloads[task_id].get("transfer_backend") == "aria2":
await self._persist_aria2_state(task_id) await self._persist_aria2_state(task_id)
# Move queue item to history as failed
try:
queue_service = await DownloadQueueService.get_instance()
await queue_service.complete_download(
download_id=task_id,
status="failed",
error=str(e),
bytes_downloaded=self._active_downloads.get(task_id, {}).get("bytes_downloaded", 0),
total_bytes=self._active_downloads.get(task_id, {}).get("total_bytes"),
)
except Exception:
logger.warning(
"Failed to complete queue item for %s", task_id, exc_info=True
)
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
finally: finally:
# Schedule cleanup of download record after delay # Schedule cleanup of download record after delay
+143 -2
View File
@@ -82,6 +82,7 @@ class DownloadQueueService:
async with cls._class_lock: async with cls._class_lock:
if cls._instance is None: if cls._instance is None:
cls._instance = cls() cls._instance = cls()
await cls._instance.deduplicate()
return cls._instance return cls._instance
def __init__(self, db_path: Optional[str] = None) -> None: def __init__(self, db_path: Optional[str] = None) -> None:
@@ -349,6 +350,7 @@ class DownloadQueueService:
file_path: Optional[str] = None, file_path: Optional[str] = None,
bytes_downloaded: int = 0, bytes_downloaded: int = 0,
total_bytes: Optional[int] = None, total_bytes: Optional[int] = None,
completed_at: Optional[float] = None,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
"""Atomically move a download from the queue into the history table. """Atomically move a download from the queue into the history table.
@@ -356,6 +358,9 @@ class DownloadQueueService:
queue, and inserts a corresponding history entry with the given queue, and inserts a corresponding history entry with the given
terminal status (``completed``, ``failed``, or ``canceled``). terminal status (``completed``, ``failed``, or ``canceled``).
When *completed_at* is provided it is used as the completion
timestamp; otherwise ``time.time()`` is used.
Returns the original queue record (before deletion) on success, Returns the original queue record (before deletion) on success,
or ``None`` if the download was not found in the queue. or ``None`` if the download was not found in the queue.
""" """
@@ -368,7 +373,7 @@ class DownloadQueueService:
if row is None: if row is None:
return None return None
now = time.time() now = completed_at if completed_at is not None else time.time()
conn.execute( conn.execute(
"DELETE FROM download_queue WHERE download_id = ?", "DELETE FROM download_queue WHERE download_id = ?",
(download_id,), (download_id,),
@@ -604,7 +609,9 @@ class DownloadQueueService:
Looks up the history record by its primary key. If the status is Looks up the history record by its primary key. If the status is
``failed`` or ``canceled`` a new queue entry is created with the ``failed`` or ``canceled`` a new queue entry is created with the
same model metadata and a fresh download id. same model metadata and a fresh download id, and the original
history entry is **deleted** to prevent exponential growth when
the retried item is later canceled or fails again and re-retried.
""" """
async with self._lock: async with self._lock:
conn = self._get_conn() conn = self._get_conn()
@@ -641,6 +648,10 @@ class DownloadQueueService:
now, now,
), ),
) )
conn.execute(
"DELETE FROM download_history WHERE id = ?",
(item_id,),
)
conn.commit() conn.commit()
queued = conn.execute( queued = conn.execute(
"SELECT * FROM download_queue WHERE download_id = ?", "SELECT * FROM download_queue WHERE download_id = ?",
@@ -652,6 +663,9 @@ class DownloadQueueService:
async def retry_all_failed(self) -> int: async def retry_all_failed(self) -> int:
"""Re-queue all failed and canceled downloads from history. """Re-queue all failed and canceled downloads from history.
Each history entry is **deleted** after being re-queued so that
repeated retry-all calls do not cause exponential growth.
Returns the number of items that were re-queued. Returns the number of items that were re-queued.
""" """
async with self._lock: async with self._lock:
@@ -687,6 +701,10 @@ class DownloadQueueService:
now, now,
), ),
) )
conn.execute(
"DELETE FROM download_history WHERE id = ?",
(row["id"],),
)
count += 1 count += 1
conn.commit() conn.commit()
@@ -728,3 +746,126 @@ class DownloadQueueService:
"failed": history_stats.get("failed", 0), "failed": history_stats.get("failed", 0),
"canceled": history_stats.get("canceled", 0), "canceled": history_stats.get("canceled", 0),
} }
# ------------------------------------------------------------------
# Deduplication (one-time cleanup for bug #980)
# ------------------------------------------------------------------
async def deduplicate(self) -> dict[str, int]:
"""Remove duplicate entries caused by the retry-amplification bug.
The bug (issue #980) caused the same download to appear N times in
both the queue and history tables when ``retry_all_failed`` was
called repeatedly without deleting the original history rows.
This method is called **once** when the singleton is first created.
It is idempotent after the first run there will be no duplicates
to remove, so subsequent calls are a no-op.
Returns a dict with the count of removed rows per table.
"""
result: dict[str, int] = {
"removed_history": 0,
"removed_queue": 0,
"removed_orphan_queue": 0,
}
async with self._lock:
conn = self._get_conn()
# 1. History: for each (model_id, model_version_id, status) triplet
# keep only the row with the highest id (most recently inserted).
conn.execute("""
DELETE FROM download_history
WHERE id NOT IN (
SELECT MAX(id)
FROM download_history
GROUP BY model_id, model_version_id, status
)
""")
result["removed_history"] = conn.execute(
"SELECT changes()"
).fetchone()[0]
# 2. Cross-status dedup: for each (model_id, model_version_id),
# keep only the entry with the highest-priority terminal status.
# Priority: completed (3) > failed (2) > canceled (1).
# This prevents the same model version from having both a
# 'failed' and a 'canceled' entry (or a 'completed' alongside
# either) after the bug-created duplicates are removed.
conn.execute("""
DELETE FROM download_history
WHERE id NOT IN (
SELECT dh.id
FROM download_history dh
INNER JOIN (
SELECT model_id, model_version_id,
MAX(CASE status
WHEN 'completed' THEN 3
WHEN 'failed' THEN 2
WHEN 'canceled' THEN 1
ELSE 0
END) AS best_prio
FROM download_history
GROUP BY model_id, model_version_id
) best
ON dh.model_id = best.model_id
AND dh.model_version_id = best.model_version_id
AND CASE dh.status
WHEN 'completed' THEN 3
WHEN 'failed' THEN 2
WHEN 'canceled' THEN 1
ELSE 0
END = best.best_prio
GROUP BY dh.model_id, dh.model_version_id
HAVING dh.id = MAX(dh.id)
)
""")
result["removed_history"] += conn.execute(
"SELECT changes()"
).fetchone()[0]
# 3. Queue: for each (model_id, model_version_id) keep only the
# row with the latest added_at (most recently enqueued).
conn.execute("""
DELETE FROM download_queue
WHERE rowid NOT IN (
SELECT MAX(rowid)
FROM download_queue
WHERE status IN ('queued', 'downloading', 'paused', 'waiting')
GROUP BY model_id, model_version_id
)
AND status IN ('queued', 'downloading', 'paused', 'waiting')
""")
result["removed_queue"] = conn.execute(
"SELECT changes()"
).fetchone()[0]
# 4. Remove orphaned queue entries — items that were re-queued
# (source='retry') but whose model version already has a
# terminal history entry. These are artifacts of the buggy
# retry cycle that were never cleaned up.
conn.execute("""
DELETE FROM download_queue
WHERE source = 'retry'
AND (model_id, model_version_id) IN (
SELECT model_id, model_version_id
FROM download_history
WHERE status IN ('failed', 'canceled')
)
AND status IN ('queued', 'waiting')
""")
result["removed_orphan_queue"] = conn.execute(
"SELECT changes()"
).fetchone()[0]
conn.commit()
logger.info(
"Deduplicate: removed %s history rows, %s queue rows, "
"%s orphaned queue rows",
result["removed_history"],
result["removed_queue"],
result["removed_orphan_queue"],
)
return result
+35 -7
View File
@@ -256,7 +256,9 @@ class Downloader:
self._session = None self._session = None
# Check for app-level proxy settings # Check for app-level proxy settings
proxy_url = None proxy_url = None # http(s) proxy, passed via the per-request `proxy=` kwarg
socks_proxy_url = None # SOCKS proxy, handled via aiohttp-socks connector
app_proxy_active = False
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
if settings_manager.get("proxy_enabled", False): if settings_manager.get("proxy_enabled", False):
proxy_host = settings_manager.get("proxy_host", "").strip() proxy_host = settings_manager.get("proxy_host", "").strip()
@@ -268,9 +270,19 @@ class Downloader:
if proxy_host and proxy_port: if proxy_host and proxy_port:
# Build proxy URL # Build proxy URL
if proxy_username and proxy_password: if proxy_username and proxy_password:
proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}" full_proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
else: else:
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}" full_proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
app_proxy_active = True
# aiohttp cannot tunnel SOCKS via the per-request `proxy=` kwarg
# (it would send HTTP to the SOCKS port and fail parsing the
# SOCKS handshake reply). SOCKS must be handled by an
# aiohttp-socks ProxyConnector instead.
if proxy_type.startswith("socks"):
socks_proxy_url = full_proxy_url
else:
proxy_url = full_proxy_url
logger.debug( logger.debug(
f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}" f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}"
@@ -294,13 +306,27 @@ class Downloader:
logger.debug("SSL: certifi unavailable; using system default CA bundle") logger.debug("SSL: certifi unavailable; using system default CA bundle")
# Optimize TCP connection parameters # Optimize TCP connection parameters
connector = aiohttp.TCPConnector( connector_kwargs = dict(
ssl=ssl_context, ssl=ssl_context,
limit=8, # Concurrent connections limit=8, # Concurrent connections
ttl_dns_cache=300, # DNS cache timeout ttl_dns_cache=300, # DNS cache timeout
force_close=False, # Keep connections for reuse force_close=False, # Keep connections for reuse
enable_cleanup_closed=True, enable_cleanup_closed=True,
) )
if socks_proxy_url:
# Route all traffic through the SOCKS proxy via aiohttp-socks. The
# connector tunnels every connection, so no per-request `proxy=` is
# used (and must not be — see self._proxy_url below).
try:
from aiohttp_socks import ProxyConnector
except ImportError as e: # pragma: no cover
raise RuntimeError(
"A SOCKS proxy is configured but the 'aiohttp-socks' package "
"is not installed. Install it with: pip install aiohttp-socks"
) from e
connector = ProxyConnector.from_url(socks_proxy_url, **connector_kwargs)
else:
connector = aiohttp.TCPConnector(**connector_kwargs)
# Configure timeout parameters # Configure timeout parameters
timeout = aiohttp.ClientTimeout( timeout = aiohttp.ClientTimeout(
@@ -311,12 +337,14 @@ class Downloader:
self._session = aiohttp.ClientSession( self._session = aiohttp.ClientSession(
connector=connector, connector=connector,
trust_env=proxy_url # Only fall back to system/env proxy when no app-level proxy is active
is None, # Only use system proxy if no app-level proxy is set trust_env=not app_proxy_active,
timeout=timeout, timeout=timeout,
) )
# Store proxy URL for use in requests # Store proxy URL for per-request use. Stays None for SOCKS because the
# ProxyConnector already tunnels everything; passing proxy= for SOCKS
# would re-trigger the original aiohttp parse error.
self._proxy_url = proxy_url self._proxy_url = proxy_url
self._session_created_at = datetime.now() self._session_created_at = datetime.now()
+35 -8
View File
@@ -216,13 +216,19 @@ class MetadataSyncService:
provider_used: Optional[str] = None provider_used: Optional[str] = None
last_error: Optional[str] = None last_error: Optional[str] = None
civitai_api_not_found = False civitai_api_not_found = False
any_rate_limited = False
for provider_name, provider in provider_attempts: for provider_name, provider in provider_attempts:
try: try:
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256) civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
except RateLimitError as exc: except RateLimitError as exc:
exc.provider = exc.provider or (provider_name or provider.__class__.__name__) logger.warning(
raise "Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
provider_name or provider.__class__.__name__,
exc.retry_after or 0,
)
any_rate_limited = True
continue
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Provider %s failed for hash %s: %s", provider_name, sha256, exc) logger.error("Provider %s failed for hash %s: %s", provider_name, sha256, exc)
civitai_metadata_candidate, error = None, str(exc) civitai_metadata_candidate, error = None, str(exc)
@@ -258,6 +264,14 @@ class MetadataSyncService:
model_data["last_checked_at"] = datetime.now().timestamp() model_data["last_checked_at"] = datetime.now().timestamp()
needs_save = True needs_save = True
# When the model was already classified as "not on CivitAI" via
# .metadata.json (civitai_deleted=True) but the SQLite cache is
# stale (because the pre-fix code never persisted these flags),
# ensure the flags are written to the scanner cache + SQLite.
if not needs_save and model_data.get("civitai_deleted") is True:
model_data["last_checked_at"] = datetime.now().timestamp()
needs_save = True
# Save metadata if any state was updated # Save metadata if any state was updated
if needs_save: if needs_save:
data_to_save = model_data.copy() data_to_save = model_data.copy()
@@ -266,6 +280,7 @@ class MetadataSyncService:
if "last_checked_at" not in data_to_save: if "last_checked_at" not in data_to_save:
data_to_save["last_checked_at"] = datetime.now().timestamp() data_to_save["last_checked_at"] = datetime.now().timestamp()
await self._metadata_manager.save_metadata(file_path, data_to_save) await self._metadata_manager.save_metadata(file_path, data_to_save)
await update_cache_func(file_path, file_path, data_to_save)
default_error = ( default_error = (
"CivitAI model is deleted and metadata archive DB is not enabled" "CivitAI model is deleted and metadata archive DB is not enabled"
@@ -276,17 +291,18 @@ class MetadataSyncService:
) )
resolved_error = last_error or default_error resolved_error = last_error or default_error
if any_rate_limited and "Rate limited" not in resolved_error:
resolved_error = "Rate limited"
if is_expected_offline_error(resolved_error): if is_expected_offline_error(resolved_error):
resolved_error = OFFLINE_FRIENDLY_MESSAGE resolved_error = OFFLINE_FRIENDLY_MESSAGE
error_msg = ( error_msg = (
f"Error fetching metadata: {resolved_error} " f"Error fetching metadata: {resolved_error} "
f"(model_name={model_data.get('model_name', '')})" f"(file={os.path.basename(file_path)}, sha256={sha256})"
) )
if is_expected_offline_error(resolved_error): # Use case layer (BulkMetadataRefreshUseCase) logs failed models at WARNING level,
logger.info(error_msg) # so this level is demoted to DEBUG to avoid duplicate user-visible logging.
else: logger.debug(error_msg)
logger.error(error_msg)
return False, error_msg return False, error_msg
model_data["from_civitai"] = True model_data["from_civitai"] = True
@@ -411,7 +427,18 @@ class MetadataSyncService:
metadata = await metadata_loader(metadata_path) metadata = await metadata_loader(metadata_path)
for key, value in updates.items(): for key, value in updates.items():
if isinstance(value, dict) and isinstance(metadata.get(key), dict): if key == "tags" and isinstance(value, list):
# Normalize tags: trim, lowercase, deduplicate
normalized = []
seen = set()
for tag in value:
if isinstance(tag, str):
t = tag.strip().lower()
if t and t not in seen:
normalized.append(t)
seen.add(t)
metadata[key] = normalized
elif isinstance(value, dict) and isinstance(metadata.get(key), dict):
metadata[key].update(value) metadata[key].update(value)
else: else:
metadata[key] = value metadata[key] = value
+49 -22
View File
@@ -65,7 +65,14 @@ class _RateLimitRetryHelper:
return await func(*args, **kwargs) return await func(*args, **kwargs)
except RateLimitError as exc: except RateLimitError as exc:
attempt += 1 attempt += 1
if attempt >= self._retry_limit:
# Determine effective retry limit based on rate-limit magnitude
effective_retry_limit = self._retry_limit # default: 3
if exc.retry_after is not None and exc.retry_after >= 120.0:
# Long rate-limit window (>=2 min) — retries are futile
effective_retry_limit = 1 # total 1 attempt = 0 retries
if attempt >= effective_retry_limit:
exc.provider = exc.provider or label exc.provider = exc.provider or label
raise raise
@@ -81,7 +88,11 @@ class _RateLimitRetryHelper:
def _calculate_delay(self, retry_after: Optional[float], attempt: int) -> float: def _calculate_delay(self, retry_after: Optional[float], attempt: int) -> float:
if retry_after is not None: if retry_after is not None:
return min(self._max_delay, max(0.0, retry_after)) # Cap at 1800s (30 min) as a safety ceiling. The old 30s cap was
# too low — CivArchive can return retry_after ~1500s, causing all
# retries to fail. A generous ceiling protects against pathological
# server values while still respecting the server's guidance.
return min(1800.0, max(0.0, retry_after))
base_delay = self._base_delay * (2 ** max(0, attempt - 1)) base_delay = self._base_delay * (2 ** max(0, attempt - 1))
jitter_span = base_delay * self._jitter_ratio jitter_span = base_delay * self._jitter_ratio
@@ -474,8 +485,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result: if result:
return result, error return result, error
except RateLimitError as exc: except RateLimitError as exc:
exc.provider = exc.provider or label logger.warning(
raise exc "Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
exc.retry_after or 0,
)
continue
except Exception as e: except Exception as e:
logger.debug("Provider %s failed for get_model_by_hash: %s", label, e) logger.debug("Provider %s failed for get_model_by_hash: %s", label, e)
continue continue
@@ -493,16 +508,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result: if result:
return result return result
except RateLimitError as exc: except RateLimitError as exc:
if not_found_confirmed: logger.warning(
logger.debug( "Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
"Suppressing rate limit from %s for model %s: " label,
"already confirmed as not found by another provider", exc.retry_after or 0,
label, )
model_id, continue
)
return None
exc.provider = exc.provider or label
raise exc
except ResourceNotFoundError: except ResourceNotFoundError:
not_found_confirmed = True not_found_confirmed = True
logger.debug( logger.debug(
@@ -528,8 +539,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result: if result:
return result return result
except RateLimitError as exc: except RateLimitError as exc:
exc.provider = exc.provider or label logger.warning(
raise exc "Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
exc.retry_after or 0,
)
continue
except Exception as e: except Exception as e:
logger.debug("Provider %s failed for get_model_version: %s", label, e) logger.debug("Provider %s failed for get_model_version: %s", label, e)
continue continue
@@ -546,8 +561,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result: if result:
return result, error return result, error
except RateLimitError as exc: except RateLimitError as exc:
exc.provider = exc.provider or label logger.warning(
raise exc "Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
exc.retry_after or 0,
)
continue
except Exception as e: except Exception as e:
logger.debug("Provider %s failed for get_model_version_info: %s", label, e) logger.debug("Provider %s failed for get_model_version_info: %s", label, e)
continue continue
@@ -568,8 +587,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
except NotImplementedError: except NotImplementedError:
continue continue
except RateLimitError as exc: except RateLimitError as exc:
exc.provider = exc.provider or label logger.warning(
raise exc "Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
exc.retry_after or 0,
)
continue
except Exception as e: except Exception as e:
logger.debug( logger.debug(
"Provider %s failed for get_model_versions_by_hashes: %s", "Provider %s failed for get_model_versions_by_hashes: %s",
@@ -590,8 +613,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result is not None: if result is not None:
return result return result
except RateLimitError as exc: except RateLimitError as exc:
exc.provider = exc.provider or label logger.warning(
raise exc "Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
exc.retry_after or 0,
)
continue
except Exception as e: except Exception as e:
logger.debug("Provider %s failed for get_user_models: %s", label, e) logger.debug("Provider %s failed for get_user_models: %s", label, e)
continue continue
+17 -9
View File
@@ -294,12 +294,14 @@ class ModelFilterSet:
for tag, state in tag_filters.items(): for tag, state in tag_filters.items():
if not tag: if not tag:
continue continue
# Normalize to lowercase for case-insensitive matching
normalized = tag.strip().lower()
if state == "exclude": if state == "exclude":
exclude_tags.add(tag) exclude_tags.add(normalized)
else: else:
include_tags.add(tag) include_tags.add(normalized)
else: else:
include_tags = {tag for tag in tag_filters if tag} include_tags = {tag.strip().lower() for tag in tag_filters if tag}
if include_tags: if include_tags:
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any" tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
@@ -318,13 +320,17 @@ class ModelFilterSet:
return True return True
# Otherwise, check if all non-special tags match # Otherwise, check if all non-special tags match
if non_special_tags: if non_special_tags:
return all(tag in (item_tags or []) for tag in non_special_tags) # Case-insensitive: normalize item tags too
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
return all(tag in normalized_item_tags for tag in non_special_tags)
return True return True
# Normal case: all tags must match # Normal case: all tags must match (case-insensitive)
return all(tag in (item_tags or []) for tag in non_special_tags) normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
return all(tag in normalized_item_tags for tag in non_special_tags)
else: else:
# OR logic (default): item must have ANY include tag # OR logic (default): item must have ANY include tag (case-insensitive)
return any(tag in include_tags for tag in (item_tags or [])) normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
return bool(normalized_item_tags & include_tags)
items = [item for item in items if matches_include(item.get("tags"))] items = [item for item in items if matches_include(item.get("tags"))]
@@ -333,7 +339,9 @@ class ModelFilterSet:
def matches_exclude(item_tags): def matches_exclude(item_tags):
if not item_tags and "__no_tags__" in exclude_tags: if not item_tags and "__no_tags__" in exclude_tags:
return True return True
return any(tag in exclude_tags for tag in (item_tags or [])) # Case-insensitive: normalize item tags
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
return bool(normalized_item_tags & exclude_tags)
items = [ items = [
item for item in items if not matches_exclude(item.get("tags")) item for item in items if not matches_exclude(item.get("tags"))
+39 -9
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
@@ -1067,8 +1080,11 @@ class ModelScanner:
model_data = self._build_cache_entry(metadata, folder=normalized_folder) model_data = self._build_cache_entry(metadata, folder=normalized_folder)
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes) # Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes).
if not model_data.get('sha256') and file_path: # Respect hash_status='pending' (set by CheckpointScanner for large models) to defer
# hash calculation until on-demand — avoids reading entire checkpoint files at startup.
hash_status = model_data.get('hash_status', '')
if not model_data.get('sha256') and hash_status != 'pending' and file_path:
try: try:
logger.info(f"Computing SHA256 hash for {file_path} (was empty from metadata)") logger.info(f"Computing SHA256 hash for {file_path} (was empty from metadata)")
sha256 = await calculate_sha256(file_path) sha256 = await calculate_sha256(file_path)
@@ -1093,6 +1109,13 @@ class ModelScanner:
if scan_result is None: if scan_result is None:
return return
if self.is_cancelled():
logger.info(
f"{self.model_type.capitalize()} Scanner: Skipping _apply_scan_result "
"after cancellation"
)
return
self._hash_index = scan_result.hash_index self._hash_index = scan_result.hash_index
self._tags_count = dict(scan_result.tags_count) self._tags_count = dict(scan_result.tags_count)
self._excluded_models = list(scan_result.excluded_models) self._excluded_models = list(scan_result.excluded_models)
@@ -1762,6 +1785,13 @@ class ModelScanner:
if not file_paths or self._cache is None: if not file_paths or self._cache is None:
return False return False
if self.is_cancelled():
logger.info(
f"{self.model_type.capitalize()} Scanner: Skipping cache update "
"after cancelled bulk delete"
)
return False
try: try:
# Get all models that need to be removed from cache # Get all models that need to be removed from cache
models_to_remove = [item for item in self._cache.raw_data if item['file_path'] in file_paths] models_to_remove = [item for item in self._cache.raw_data if item['file_path'] in file_paths]
+58 -3
View File
@@ -12,7 +12,7 @@ import logging
import os import os
import sqlite3 import sqlite3
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple from typing import Dict, List, Optional, Set, Tuple
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
@@ -26,6 +26,8 @@ class PersistedRecipeData:
raw_data: List[Dict] raw_data: List[Dict]
file_stats: Dict[str, Tuple[float, int]] # json_path -> (mtime, size) file_stats: Dict[str, Tuple[float, int]] # json_path -> (mtime, size)
image_id_map: Dict[str, str] = field(default_factory=dict)
"""Precomputed mapping of civitai image_id → recipe_id."""
class PersistentRecipeCache: class PersistentRecipeCache:
@@ -116,6 +118,20 @@ class PersistentRecipeCache:
if not rows: if not rows:
return None return None
# Restore precomputed image_id_map if available
image_id_map: Dict[str, str] = {}
try:
meta_row = conn.execute(
"SELECT value FROM cache_metadata WHERE key = ?",
("image_id_map",),
).fetchone()
if meta_row:
parsed = json.loads(meta_row["value"])
if isinstance(parsed, dict):
image_id_map = parsed
except Exception:
pass # missing or corrupt — rebuilt on next cache refresh
finally: finally:
conn.close() conn.close()
except FileNotFoundError: except FileNotFoundError:
@@ -138,14 +154,24 @@ class PersistentRecipeCache:
row["file_size"] or 0, row["file_size"] or 0,
) )
return PersistedRecipeData(raw_data=raw_data, file_stats=file_stats) return PersistedRecipeData(
raw_data=raw_data,
file_stats=file_stats,
image_id_map=image_id_map,
)
def save_cache(self, recipes: List[Dict], json_paths: Optional[Dict[str, str]] = None) -> None: def save_cache(
self,
recipes: List[Dict],
json_paths: Optional[Dict[str, str]] = None,
image_id_map: Optional[Dict[str, str]] = None,
) -> None:
"""Save all recipes to SQLite cache. """Save all recipes to SQLite cache.
Args: Args:
recipes: List of recipe dictionaries to persist. recipes: List of recipe dictionaries to persist.
json_paths: Optional mapping of recipe_id -> json_path for file stats. json_paths: Optional mapping of recipe_id -> json_path for file stats.
image_id_map: Optional precomputed civitai image_id recipe_id mapping.
""" """
if not self.is_enabled(): if not self.is_enabled():
return return
@@ -186,6 +212,12 @@ class PersistentRecipeCache:
recipe_rows, recipe_rows,
) )
# Persist image_id_map for O(1) lookups on cache load
conn.execute(
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
("image_id_map", json.dumps(image_id_map or {})),
)
conn.commit() conn.commit()
logger.debug("Persisted %d recipes to cache", len(recipe_rows)) logger.debug("Persisted %d recipes to cache", len(recipe_rows))
finally: finally:
@@ -273,6 +305,29 @@ class PersistentRecipeCache:
except Exception as exc: except Exception as exc:
logger.debug("Failed to remove recipe %s from cache: %s", recipe_id, exc) logger.debug("Failed to remove recipe %s from cache: %s", recipe_id, exc)
def save_image_id_map(self, image_id_map: Dict[str, str]) -> None:
"""Persist the image_id_map to cache_metadata without rewriting the full cache.
This is called after ``add_recipe`` / ``remove_recipe`` mutations so
the persistent copy does not go stale between full ``save_cache`` calls.
"""
if not self.is_enabled() or not self._schema_initialized:
return
try:
with self._db_lock:
conn = self._connect()
try:
conn.execute(
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
("image_id_map", json.dumps(image_id_map)),
)
conn.commit()
finally:
conn.close()
except Exception as exc:
logger.debug("Failed to persist image_id_map: %s", exc)
def get_indexed_recipe_ids(self) -> Set[str]: def get_indexed_recipe_ids(self) -> Set[str]:
"""Return all recipe IDs in the cache. """Return all recipe IDs in the cache.
+10 -1
View File
@@ -1,6 +1,6 @@
import asyncio import asyncio
from typing import Iterable, List, Dict, Optional from typing import Iterable, List, Dict, Optional
from dataclasses import dataclass from dataclasses import dataclass, field
from operator import itemgetter from operator import itemgetter
from natsort import natsorted from natsort import natsorted
@@ -14,6 +14,15 @@ class RecipeCache:
sorted_by_date: List[Dict] sorted_by_date: List[Dict]
folders: List[str] | None = None folders: List[str] | None = None
folder_tree: Dict | None = None folder_tree: Dict | None = None
image_id_map: Dict[str, str] = field(default_factory=dict)
"""Mapping of civitai image_id → recipe_id, precomputed at cache build time.
Built once during cache initialization (O(n)) so that
``check_image_exists`` and ``import_from_url`` duplicate checks
can look up image_id in O(1) instead of scanning all recipes.
Recipes imported from local files have no valid civitai image_id
and are naturally excluded from this map.
"""
def __post_init__(self): def __post_init__(self):
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
+69 -4
View File
@@ -20,6 +20,7 @@ from .metadata_service import get_default_metadata_provider
from .checkpoint_scanner import CheckpointScanner from .checkpoint_scanner import CheckpointScanner
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from .recipes.errors import RecipeNotFoundError from .recipes.errors import RecipeNotFoundError
from ..utils.civitai_utils import extract_civitai_image_id
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match
from natsort import natsorted from natsort import natsorted
import sys import sys
@@ -532,7 +533,21 @@ class RecipeScanner:
self._sort_cache_sync() self._sort_cache_sync()
# Backfill source_path from JSON files if missing (schema migration) # Backfill source_path from JSON files if missing (schema migration)
if self._backfill_source_path_if_needed(recipes, json_paths): if self._backfill_source_path_if_needed(recipes, json_paths):
self._persistent_cache.save_cache(recipes, json_paths) self._cache.image_id_map = self._build_image_id_map()
self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map
)
else:
# Use persisted map, or rebuild if empty (e.g. first startup
# after deploying the image_id_map feature).
if persisted.image_id_map:
self._cache.image_id_map = dict(persisted.image_id_map)
else:
self._cache.image_id_map = self._build_image_id_map()
if self._cache.image_id_map:
self._persistent_cache.save_image_id_map(
self._cache.image_id_map
)
return self._cache return self._cache
else: else:
# Partial update: some files changed # Partial update: some files changed
@@ -545,8 +560,11 @@ class RecipeScanner:
self._sort_cache_sync() self._sort_cache_sync()
# Backfill source_path from JSON files if missing (schema migration) # Backfill source_path from JSON files if missing (schema migration)
self._backfill_source_path_if_needed(recipes, json_paths) self._backfill_source_path_if_needed(recipes, json_paths)
self._cache.image_id_map = self._build_image_id_map()
# Persist updated cache # Persist updated cache
self._persistent_cache.save_cache(recipes, json_paths) self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map
)
return self._cache return self._cache
# Fall back to full directory scan # Fall back to full directory scan
@@ -558,9 +576,12 @@ class RecipeScanner:
self._cache.raw_data = recipes self._cache.raw_data = recipes
self._update_folder_metadata(self._cache) self._update_folder_metadata(self._cache)
self._sort_cache_sync() self._sort_cache_sync()
self._cache.image_id_map = self._build_image_id_map()
# Persist for next startup # Persist for next startup
self._persistent_cache.save_cache(recipes, json_paths) self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map
)
return self._cache return self._cache
except Exception as e: except Exception as e:
@@ -832,6 +853,28 @@ class RecipeScanner:
except Exception as e: except Exception as e:
logger.error(f"Error sorting recipe cache: {e}") logger.error(f"Error sorting recipe cache: {e}")
def _build_image_id_map(self) -> Dict[str, str]:
"""Build civitai image_id → recipe_id mapping from cached recipes.
Only recipes with a valid CivitAI image URL source_path produce an
entry. Recipes imported from local files are naturally excluded.
"""
mapping: Dict[str, str] = {}
if not self._cache:
return mapping
for recipe in getattr(self._cache, "raw_data", []):
if not isinstance(recipe, dict):
continue
source = recipe.get("source_path")
if not source:
continue
image_id = extract_civitai_image_id(source)
if image_id and image_id not in mapping:
recipe_id = recipe.get("id")
if recipe_id is not None:
mapping[image_id] = str(recipe_id)
return mapping
async def _wait_for_lora_scanner(self) -> None: async def _wait_for_lora_scanner(self) -> None:
"""Ensure the LoRA scanner has initialized before recipe enrichment.""" """Ensure the LoRA scanner has initialized before recipe enrichment."""
@@ -1296,11 +1339,20 @@ class RecipeScanner:
# Update FTS index # Update FTS index
self._update_fts_index_for_recipe(recipe_data, "add") self._update_fts_index_for_recipe(recipe_data, "add")
source = recipe_data.get("source_path")
if source:
image_id = extract_civitai_image_id(source)
if image_id:
recipe_id_value = recipe_data.get("id")
if recipe_id_value is not None:
cache.image_id_map[image_id] = str(recipe_id_value)
# Persist to SQLite cache # Persist to SQLite cache
if self._persistent_cache: if self._persistent_cache:
recipe_id = str(recipe_data.get("id", "")) recipe_id = str(recipe_data.get("id", ""))
json_path = self._json_path_map.get(recipe_id, "") json_path = self._json_path_map.get(recipe_id, "")
self._persistent_cache.update_recipe(recipe_data, json_path) self._persistent_cache.update_recipe(recipe_data, json_path)
self._persistent_cache.save_image_id_map(cache.image_id_map)
async def remove_recipe(self, recipe_id: str) -> bool: async def remove_recipe(self, recipe_id: str) -> bool:
"""Remove a recipe from the cache by ID.""" """Remove a recipe from the cache by ID."""
@@ -1319,9 +1371,15 @@ class RecipeScanner:
# Update FTS index # Update FTS index
self._update_fts_index_for_recipe(recipe_id, "remove") self._update_fts_index_for_recipe(recipe_id, "remove")
# Remove any image_id entry pointing to this recipe
stale = [k for k, v in cache.image_id_map.items() if v == recipe_id]
for k in stale:
del cache.image_id_map[k]
# Remove from SQLite cache # Remove from SQLite cache
if self._persistent_cache: if self._persistent_cache:
self._persistent_cache.remove_recipe(recipe_id) self._persistent_cache.remove_recipe(recipe_id)
self._persistent_cache.save_image_id_map(cache.image_id_map)
self._json_path_map.pop(recipe_id, None) self._json_path_map.pop(recipe_id, None)
return True return True
@@ -1332,14 +1390,21 @@ class RecipeScanner:
cache = await self.get_cached_data() cache = await self.get_cached_data()
removed = await cache.bulk_remove(recipe_ids, resort=False) removed = await cache.bulk_remove(recipe_ids, resort=False)
if removed: if removed:
removed_ids = {str(r.get("id", "")) for r in removed}
stale = [k for k, v in cache.image_id_map.items() if v in removed_ids]
for k in stale:
del cache.image_id_map[k]
self._schedule_resort() self._schedule_resort()
# Update FTS index and persistent cache for each removed recipe
for recipe in removed: for recipe in removed:
recipe_id = str(recipe.get("id", "")) recipe_id = str(recipe.get("id", ""))
self._update_fts_index_for_recipe(recipe_id, "remove") self._update_fts_index_for_recipe(recipe_id, "remove")
if self._persistent_cache: if self._persistent_cache:
self._persistent_cache.remove_recipe(recipe_id) self._persistent_cache.remove_recipe(recipe_id)
self._json_path_map.pop(recipe_id, None) self._json_path_map.pop(recipe_id, None)
if self._persistent_cache:
self._persistent_cache.save_image_id_map(cache.image_id_map)
return len(removed) return len(removed)
async def scan_all_recipes(self) -> List[Dict]: async def scan_all_recipes(self) -> List[Dict]:
+11 -1
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(),
@@ -106,6 +105,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"download_skip_base_models": [], "download_skip_base_models": [],
"backup_auto_enabled": True, "backup_auto_enabled": True,
"backup_retention_count": 5, "backup_retention_count": 5,
"use_new_license_icons": True,
"group_by_model": False,
} }
@@ -134,6 +135,9 @@ class SettingsManager:
self._template_path = ( self._template_path = (
Path(__file__).resolve().parents[2] / "settings.json.example" Path(__file__).resolve().parents[2] / "settings.json.example"
) )
# Known placeholder value in settings.json.example; any file containing
# this value should be treated as "not configured".
self._TEMPLATE_PLACEHOLDER_API_KEY = "your_civitai_api_key_here"
self.settings = self._load_settings() self.settings = self._load_settings()
self._migrate_setting_keys() self._migrate_setting_keys()
self._ensure_default_settings() self._ensure_default_settings()
@@ -165,6 +169,12 @@ class SettingsManager:
self._original_disk_payload = copy.deepcopy(data) self._original_disk_payload = copy.deepcopy(data)
if self._matches_template_payload(data): if self._matches_template_payload(data):
self._preserve_disk_template = True self._preserve_disk_template = True
# Clean up the template placeholder so it is not treated
# as a real key (affects both the frontend boolean and
# the downloader's Authorization header).
placeholder = self._TEMPLATE_PLACEHOLDER_API_KEY
if data.get("civitai_api_key") == placeholder:
data["civitai_api_key"] = ""
return data return data
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
logger.error("Failed to parse settings.json: %s", exc) logger.error("Failed to parse settings.json: %s", exc)
+2 -2
View File
@@ -36,9 +36,9 @@ class TagUpdateService:
if isinstance(tag, str) and tag.strip(): if isinstance(tag, str) and tag.strip():
# Convert all tags to lowercase to avoid case sensitivity issues on Windows # Convert all tags to lowercase to avoid case sensitivity issues on Windows
normalized = tag.strip().lower() normalized = tag.strip().lower()
if normalized.lower() not in existing_lower: if normalized not in existing_lower:
existing_tags.append(normalized) existing_tags.append(normalized)
existing_lower.append(normalized.lower()) existing_lower.append(normalized)
tags_added.append(normalized) tags_added.append(normalized)
metadata["tags"] = existing_tags metadata["tags"] = existing_tags
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import time
from typing import Any, Dict, List, Optional, Protocol, Sequence from typing import Any, Dict, List, Optional, Protocol, Sequence
from ..metadata_sync_service import MetadataSyncService from ..metadata_sync_service import MetadataSyncService
@@ -62,26 +63,48 @@ class BulkMetadataRefreshUseCase:
] ]
total_to_process = len(to_process) total_to_process = len(to_process)
initial_skipped = total_models - total_to_process # models excluded from fetch queue
processed = 0 processed = 0
success = 0 success = 0
skipped_count = initial_skipped
handled_count = initial_skipped
needs_resort = False needs_resort = False
start_time = time.monotonic()
failures: List[Dict[str, str]] = []
self._service.scanner.reset_cancellation() self._service.scanner.reset_cancellation()
async def emit(status: str, **extra: Any) -> None: async def emit(status: str, **extra: Any) -> None:
if progress_callback is None: if progress_callback is None:
return return
payload = {"status": status, "total": total_to_process, "processed": processed, "success": success} payload = {
"status": status,
"total": total_models,
"processed": processed,
"success": success,
"failure_count": len(failures),
"skipped_count": skipped_count,
"handled": handled_count,
"elapsed_seconds": int(time.monotonic() - start_time),
}
# Only include full failure details in terminal emits (completed,
# cancelled, rate_limited) to avoid serializing the list on every
# per-model progress update.
if failures and status in ("completed", "cancelled", "rate_limited"):
payload["failures"] = failures
payload.update(extra) payload.update(extra)
await progress_callback.on_progress(payload) await progress_callback.on_progress(payload)
await emit("started") await emit("started")
RATE_LIMIT_ABORT_THRESHOLD = 3
consecutive_rate_limits = 0
for model in to_process: for model in to_process:
if self._service.scanner.is_cancelled(): if self._service.scanner.is_cancelled():
self._logger.info("Bulk metadata refresh cancelled by user") self._logger.info("Bulk metadata refresh cancelled by user")
await emit("cancelled", processed=processed, success=success) await emit("cancelled", processed=processed, success=success)
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models} return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
try: try:
original_name = model.get("model_name") original_name = model.get("model_name")
@@ -101,31 +124,76 @@ class BulkMetadataRefreshUseCase:
model["hash_status"] = "completed" model["hash_status"] = "completed"
else: else:
self._logger.error(f"Failed to calculate hash for {file_path}") self._logger.error(f"Failed to calculate hash for {file_path}")
failures.append({"name": model.get("model_name", file_path or "Unknown"), "error": "Failed to calculate hash"})
processed += 1 processed += 1
handled_count += 1
continue continue
else: else:
self._logger.warning(f"Scanner does not support lazy hash calculation for {file_path}") self._logger.warning(f"Scanner does not support lazy hash calculation for {file_path}")
skipped_count += 1
processed += 1 processed += 1
handled_count += 1
continue continue
# Skip models without valid hash # Skip models without valid hash
if not model.get("sha256"): if not model.get("sha256"):
self._logger.warning(f"Skipping model without hash: {file_path}") self._logger.warning(f"Skipping model without hash: {file_path}")
skipped_count += 1
processed += 1 processed += 1
handled_count += 1
continue continue
await MetadataManager.hydrate_model_data(model) await MetadataManager.hydrate_model_data(model)
result, _ = await self._metadata_sync.fetch_and_update_model( result, error_msg = await self._metadata_sync.fetch_and_update_model(
sha256=model["sha256"], sha256=model["sha256"],
file_path=model["file_path"], file_path=model["file_path"],
model_data=model, model_data=model,
update_cache_func=self._service.scanner.update_single_model_cache, update_cache_func=self._service.scanner.update_single_model_cache,
) )
if not result and error_msg and "Rate limited" in error_msg:
consecutive_rate_limits += 1
else:
consecutive_rate_limits = 0
if not result:
current_name = model.get("model_name", file_path or "Unknown")
failures.append({"name": current_name, "error": error_msg or "Unknown error"})
self._logger.warning("Failed to fetch metadata for %s: %s", current_name, error_msg)
if consecutive_rate_limits >= RATE_LIMIT_ABORT_THRESHOLD:
# The current model was attempted and failed due to rate limiting;
# count it before aborting so the summary is consistent.
processed += 1
handled_count += 1
self._logger.warning(
"Bulk metadata refresh aborted: %d consecutive rate limits detected. "
"Processed %d/%d models.",
consecutive_rate_limits,
processed,
total_to_process,
)
await emit(
"rate_limited",
)
return {
"success": False,
"message": f"Rate limit detected; {total_to_process - processed} models skipped",
"processed": processed,
"updated": success,
"total": total_models,
"failures": failures,
"failure_count": len(failures),
"skipped_count": skipped_count,
"elapsed_seconds": int(time.monotonic() - start_time),
}
if result: if result:
success += 1 success += 1
if original_name != model.get("model_name"): if original_name != model.get("model_name"):
needs_resort = True needs_resort = True
processed += 1 processed += 1
handled_count += 1
await emit( await emit(
"processing", "processing",
processed=processed, processed=processed,
@@ -134,6 +202,9 @@ class BulkMetadataRefreshUseCase:
) )
except Exception as exc: # pragma: no cover - logging path except Exception as exc: # pragma: no cover - logging path
processed += 1 processed += 1
handled_count += 1
current_name = model.get("model_name", model.get("file_path", "Unknown"))
failures.append({"name": current_name, "error": str(exc)})
self._logger.error( self._logger.error(
"Error fetching CivitAI data for %s: %s", "Error fetching CivitAI data for %s: %s",
model.get("file_path"), model.get("file_path"),
@@ -150,7 +221,7 @@ class BulkMetadataRefreshUseCase:
f"{success} of {processed} processed {self._service.model_type}s (total: {total_models})" f"{success} of {processed} processed {self._service.model_type}s (total: {total_models})"
) )
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models} return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
@staticmethod @staticmethod
def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool: def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool:
+3 -1
View File
@@ -31,6 +31,8 @@ PREVIEW_EXTENSIONS = [
".mp4", ".mp4",
".gif", ".gif",
".webm", ".webm",
".avif",
".jxl",
] ]
# Card preview image width # Card preview image width
@@ -41,7 +43,7 @@ EXAMPLE_IMAGE_WIDTH = 832
# Supported media extensions for example downloads # Supported media extensions for example downloads
SUPPORTED_MEDIA_EXTENSIONS = { SUPPORTED_MEDIA_EXTENSIONS = {
"images": [".jpg", ".jpeg", ".png", ".webp", ".gif"], "images": [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif", ".jxl"],
"videos": [".mp4", ".webm"], "videos": [".mp4", ".webm"],
} }
+70
View File
@@ -12,6 +12,18 @@ from ..services.settings_manager import get_settings_manager
_HEX_PATTERN = re.compile(r"[a-fA-F0-9]{64}") _HEX_PATTERN = re.compile(r"[a-fA-F0-9]{64}")
# Filesystem/metadata files that are never created by the example images system
# and are safe to ignore during validation. The cleanup service only operates on
# directories, so these files pose no data-loss risk.
_SAFE_FILENAMES: frozenset[str] = frozenset({
".DS_Store", # macOS folder metadata
"Thumbs.db", # Windows thumbnail cache
"desktop.ini", # Windows folder customization
".localized", # macOS folder name localization
".gitkeep", # Placeholder to keep empty dirs in git
".gitignore", # Git ignore rules
})
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -180,6 +192,22 @@ def is_hash_folder(name: str) -> bool:
return bool(_HEX_PATTERN.fullmatch(name or "")) return bool(_HEX_PATTERN.fullmatch(name or ""))
def _is_safe_ignorable_entry(item: str, item_path: str) -> bool:
"""Return True if *item* is a harmless system/hidden file we can skip.
These files are never created by the example images system and are safe to
ignore because the cleanup/delete operations only act on **directories**,
never on individual files (other than ``.download_progress.json``).
"""
if item in _SAFE_FILENAMES:
return True
# Hide Unix hidden files (dotfiles) that are regular files,
# since the cleanup system never deletes or moves files.
if item.startswith(".") and os.path.isfile(item_path):
return True
return False
def is_valid_example_images_root(folder_path: str) -> bool: def is_valid_example_images_root(folder_path: str) -> bool:
"""Check whether a folder looks like a dedicated example images root.""" """Check whether a folder looks like a dedicated example images root."""
@@ -190,9 +218,16 @@ def is_valid_example_images_root(folder_path: str) -> bool:
for item in items: for item in items:
item_path = os.path.join(folder_path, item) item_path = os.path.join(folder_path, item)
# .download_progress.json is an expected metadata file — check before
# the generic dotfile rule so it stays explicitly documented.
if item == ".download_progress.json" and os.path.isfile(item_path): if item == ".download_progress.json" and os.path.isfile(item_path):
continue continue
# Skip harmless system/hidden files — cleanup only touches directories
if _is_safe_ignorable_entry(item, item_path):
continue
if os.path.isdir(item_path): if os.path.isdir(item_path):
if is_hash_folder(item): if is_hash_folder(item):
continue continue
@@ -211,6 +246,41 @@ def is_valid_example_images_root(folder_path: str) -> bool:
return True return True
def find_non_compliant_items_in_example_images_root(folder_path: str) -> list[str]:
"""Return the names of items that prevent *folder_path* from being a valid
example images root, or an empty list if the folder is valid.
This mirrors ``is_valid_example_images_root`` but **returns** the offending
names instead of a boolean, so callers can produce actionable error messages.
"""
try:
items = os.listdir(folder_path)
except OSError as exc:
return [f"<cannot list directory: {exc}>"]
offending: list[str] = []
for item in items:
item_path = os.path.join(folder_path, item)
# Same skip rules as is_valid_example_images_root
if item == ".download_progress.json" and os.path.isfile(item_path):
continue
if _is_safe_ignorable_entry(item, item_path):
continue
if os.path.isdir(item_path):
if is_hash_folder(item):
continue
if item == "_deleted":
continue
if _library_folder_has_only_hash_dirs(item_path):
continue
offending.append(item)
return offending
def _library_folder_has_only_hash_dirs(path: str) -> bool: def _library_folder_has_only_hash_dirs(path: str) -> bool:
"""Return True when a library subfolder only contains hash folders or metadata files.""" """Return True when a library subfolder only contains hash folders or metadata files."""
+6
View File
@@ -62,6 +62,10 @@ class ExampleImagesProcessor:
return '.gif' return '.gif'
elif content.startswith(b'RIFF') and b'WEBP' in content[:12]: elif content.startswith(b'RIFF') and b'WEBP' in content[:12]:
return '.webp' return '.webp'
elif len(content) >= 12 and content[4:8] == b'ftyp' and b'avif' in content[8:24]:
return '.avif'
elif content.startswith(b'\x00\x00\x00\x0cJXL \x0d\x0a\x87\x0a'):
return '.jxl'
elif content.startswith(b'\x00\x00\x00\x18ftypmp4') or content.startswith(b'\x00\x00\x00\x20ftypmp4'): elif content.startswith(b'\x00\x00\x00\x18ftypmp4') or content.startswith(b'\x00\x00\x00\x20ftypmp4'):
return '.mp4' return '.mp4'
elif content.startswith(b'\x1A\x45\xDF\xA3'): elif content.startswith(b'\x1A\x45\xDF\xA3'):
@@ -75,6 +79,8 @@ class ExampleImagesProcessor:
'image/png': '.png', 'image/png': '.png',
'image/gif': '.gif', 'image/gif': '.gif',
'image/webp': '.webp', 'image/webp': '.webp',
'image/avif': '.avif',
'image/jxl': '.jxl',
'video/mp4': '.mp4', 'video/mp4': '.mp4',
'video/webm': '.webm', 'video/webm': '.webm',
'video/quicktime': '.mov' 'video/quicktime': '.mov'
+117 -7
View File
@@ -1,17 +1,125 @@
import json import json
import logging import logging
import os import os
import struct
from io import BytesIO from io import BytesIO
from typing import Any, Optional from typing import Any, Optional
import piexif import piexif
from PIL import Image, PngImagePlugin from PIL import Image, PngImagePlugin
try:
import brotli
_BROTLI_AVAILABLE = True
except ImportError:
brotli = None
_BROTLI_AVAILABLE = False
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ExifUtils: class ExifUtils:
"""Utility functions for working with EXIF data in images""" """Utility functions for working with EXIF data in images"""
@staticmethod
def _parse_isobmff_boxes(data: bytes, offset: int = 0) -> list[dict]:
boxes = []
while offset + 8 <= len(data):
size = struct.unpack('>I', data[offset:offset + 4])[0]
box_type = data[offset + 4:offset + 8]
if size == 0:
break
if size < 8 or offset + size > len(data):
break
box_data = data[offset + 8:offset + size]
boxes.append({'type': box_type, 'data': box_data, 'size': size})
offset += size
return boxes
@staticmethod
def _is_jxl_container(data: bytes) -> bool:
if len(data) < 32:
return False
return (
struct.unpack('>I', data[:4])[0] == 12
and data[4:8] == b'JXL '
and data[8:12] == bytes([0x0d, 0x0a, 0x87, 0x0a])
and struct.unpack('>I', data[12:16])[0] >= 16
and data[16:20] == b'ftyp'
and data[20:24] == b'jxl '
)
@staticmethod
def _is_avif_container(data: bytes) -> bool:
if len(data) < 16:
return False
for box in ExifUtils._parse_isobmff_boxes(data):
if box['type'] == b'ftyp' and b'avif' in box['data']:
return True
return False
# Max decompressed size for brotli metadata (2 MB)
_BROTLI_MAX_DECOMPRESSED = 2 * 1024 * 1024
@staticmethod
def _extract_isobmff_brotli(image_path: str) -> Optional[dict]:
try:
with open(image_path, 'rb') as f:
data = f.read()
except Exception:
return None
if ExifUtils._is_jxl_container(data):
boxes = ExifUtils._parse_isobmff_boxes(data, offset=12)
elif ExifUtils._is_avif_container(data):
boxes = ExifUtils._parse_isobmff_boxes(data)
else:
return None
brob = None
for box in boxes:
if box['type'] == b'brob':
brob = box
break
if brob is None:
return None
payload = brob['data']
if payload[:4] != b'comf':
return None
compressed = payload[4:]
if _BROTLI_AVAILABLE:
try:
decompressed = brotli.decompress(compressed)
if len(decompressed) > ExifUtils._BROTLI_MAX_DECOMPRESSED:
logger.warning(
"Brotli metadata too large (%d bytes, max %d), ignoring",
len(decompressed),
ExifUtils._BROTLI_MAX_DECOMPRESSED,
)
decompressed = None
except Exception:
decompressed = None
else:
decompressed = None
raw = decompressed if decompressed is not None else compressed
try:
meta = json.loads(raw.decode('utf-8'))
except Exception:
return None
result = {"parameters": None, "prompt": None, "workflow": None, "comment": None}
if isinstance(meta.get("prompt"), (dict, list)):
result["prompt"] = json.dumps(meta["prompt"])
elif isinstance(meta.get("prompt"), str):
result["prompt"] = meta["prompt"]
if isinstance(meta.get("workflow"), (dict, list)):
result["workflow"] = json.dumps(meta["workflow"])
elif isinstance(meta.get("workflow"), str):
result["workflow"] = meta["workflow"]
return result
@staticmethod @staticmethod
def _decode_user_comment(user_comment: Any) -> Optional[str]: def _decode_user_comment(user_comment: Any) -> Optional[str]:
if user_comment is None: if user_comment is None:
@@ -43,6 +151,12 @@ class ExifUtils:
"comment": None, "comment": None,
} }
ext = os.path.splitext(image_path)[1].lower()
if ext in ('.avif', '.jxl'):
brotli_meta = ExifUtils._extract_isobmff_brotli(image_path)
if brotli_meta:
return brotli_meta
with Image.open(image_path) as img: with Image.open(image_path) as img:
info = getattr(img, "info", {}) or {} info = getattr(img, "info", {}) or {}
@@ -149,7 +263,6 @@ class ExifUtils:
Optional[str]: Extracted metadata or None if not found Optional[str]: Extracted metadata or None if not found
""" """
try: try:
# Skip for video files
if image_path: if image_path:
ext = os.path.splitext(image_path)[1].lower() ext = os.path.splitext(image_path)[1].lower()
if ext in ['.mp4', '.webm']: if ext in ['.mp4', '.webm']:
@@ -177,10 +290,9 @@ class ExifUtils:
str: Path to the updated image str: Path to the updated image
""" """
try: try:
# Skip for video files
if image_path: if image_path:
ext = os.path.splitext(image_path)[1].lower() ext = os.path.splitext(image_path)[1].lower()
if ext in ['.mp4', '.webm']: if ext in ['.mp4', '.webm', '.avif', '.jxl']:
return image_path return image_path
metadata_fields = ExifUtils._load_structured_metadata(image_path) metadata_fields = ExifUtils._load_structured_metadata(image_path)
@@ -212,10 +324,9 @@ class ExifUtils:
def append_recipe_metadata(image_path, recipe_data) -> str: def append_recipe_metadata(image_path, recipe_data) -> str:
"""Append recipe metadata to an image's EXIF data""" """Append recipe metadata to an image's EXIF data"""
try: try:
# Skip for video files
if image_path: if image_path:
ext = os.path.splitext(image_path)[1].lower() ext = os.path.splitext(image_path)[1].lower()
if ext in ['.mp4', '.webm']: if ext in ['.mp4', '.webm', '.avif', '.jxl']:
return image_path return image_path
# First, extract existing metadata # First, extract existing metadata
@@ -327,10 +438,9 @@ class ExifUtils:
Tuple of (optimized_image_data, extension) Tuple of (optimized_image_data, extension)
""" """
try: try:
# Skip for video files early if it's a file path
if isinstance(image_data, str) and os.path.exists(image_data): if isinstance(image_data, str) and os.path.exists(image_data):
ext = os.path.splitext(image_data)[1].lower() ext = os.path.splitext(image_data)[1].lower()
if ext in ['.mp4', '.webm']: if ext in ['.mp4', '.webm', '.avif', '.jxl']:
try: try:
with open(image_data, 'rb') as f: with open(image_data, 'rb') as f:
return f.read(), ext return f.read(), ext
+15 -1
View File
@@ -34,12 +34,26 @@ def _get_hash_chunk_size_bytes() -> int:
async def calculate_sha256(file_path: str) -> str: async def calculate_sha256(file_path: str) -> str:
"""Calculate SHA256 hash of a file (full file content).""" """Calculate SHA256 hash of a file (full file content).
Uses ``posix_fadvise`` with ``POSIX_FADV_DONTNEED`` to avoid polluting the OS page
cache critical on WSL where cached file pages live inside the VM and are not
accounted for in guest ``used`` memory, causing VmmemWSL to balloon.
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()
with open(file_path, "rb") as f: with open(file_path, "rb") as f:
fd = f.fileno()
for byte_block in iter(lambda: f.read(chunk_size), b""): for byte_block in iter(lambda: f.read(chunk_size), b""):
sha256_hash.update(byte_block) sha256_hash.update(byte_block)
# Evict pages after reading so the data doesn't linger in the kernel page
# cache — on WSL this otherwise appears as unreclaimable VmmemWSL growth.
# 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()
+1 -1
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.0" version = "1.1.4"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",
+3
View File
@@ -1,4 +1,5 @@
aiohttp aiohttp
aiohttp-socks
jinja2 jinja2
safetensors safetensors
piexif piexif
@@ -12,3 +13,5 @@ aiosqlite
beautifulsoup4 beautifulsoup4
platformdirs platformdirs
pyyaml pyyaml
# brotli — ISOBMFF (AVIF/JXL) metadata decompression
brotli>=1.2.0
+2 -1
View File
@@ -2,6 +2,7 @@ import os
import sys import sys
import json import json
from py.middleware.cache_middleware import cache_control from py.middleware.cache_middleware import cache_control
from py.middleware.error_middleware import api_json_error
from py.utils.settings_paths import ensure_settings_file from py.utils.settings_paths import ensure_settings_file
# Set environment variable to indicate standalone mode # Set environment variable to indicate standalone mode
@@ -157,7 +158,7 @@ class StandaloneServer:
def __init__(self): def __init__(self):
self.app = web.Application( self.app = web.Application(
logger=logger, logger=logger,
middlewares=[cache_control], middlewares=[api_json_error, cache_control],
client_max_size=256 * 1024 * 1024, client_max_size=256 * 1024 * 1024,
handler_args={ handler_args={
"max_field_size": HEADER_SIZE_LIMIT, "max_field_size": HEADER_SIZE_LIMIT,
+47 -53
View File
@@ -349,8 +349,8 @@
} }
.progress-percentage { .progress-percentage {
font-size: 1.2em; font-size: var(--text-lg);
font-weight: 600; font-weight: var(--weight-semibold);
color: var(--lora-accent); color: var(--lora-accent);
} }
@@ -365,9 +365,9 @@
.progress-bar { .progress-bar {
height: 100%; height: 100%;
background: linear-gradient(90deg, var(--lora-accent), oklch(from var(--lora-accent) calc(l + 0.1) c h)); background: var(--lora-accent);
border-radius: 4px; border-radius: var(--border-radius-xs);
transition: width 0.3s ease; transition: width var(--transition-base);
} }
/* Progress Stats */ /* Progress Stats */
@@ -389,27 +389,26 @@
} }
.stat-item.success { .stat-item.success {
border-left: 3px solid #00B87A; border-left: 4px solid var(--color-success);
} }
.stat-item.failed { .stat-item.failed {
border-left: 3px solid var(--lora-error); border-left: 4px solid var(--color-error);
} }
.stat-item.skipped { .stat-item.skipped {
border-left: 3px solid var(--lora-warning); border-left: 4px solid var(--color-warning);
} }
.stat-label { .stat-label {
font-size: 0.8em; font-size: var(--text-xs);
color: var(--text-color); color: var(--text-secondary);
opacity: 0.7;
margin-bottom: 4px; margin-bottom: 4px;
} }
.stat-value { .stat-value {
font-size: 1.4em; font-size: var(--text-lg);
font-weight: 600; font-weight: var(--weight-semibold);
color: var(--text-color); color: var(--text-color);
} }
@@ -425,8 +424,7 @@
} }
.current-item-label { .current-item-label {
color: var(--text-color); color: var(--text-secondary);
opacity: 0.7;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -449,27 +447,29 @@
} }
.results-header { .results-header {
text-align: center; display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
} }
.results-icon { .results-icon {
font-size: 3em; font-size: var(--text-xl);
color: #00B87A; color: var(--color-success);
margin-bottom: var(--space-1); flex-shrink: 0;
} }
.results-icon.warning { .results-icon.warning {
color: var(--lora-warning); color: var(--color-warning);
} }
.results-icon.error { .results-icon.error {
color: var(--lora-error); color: var(--color-error);
} }
.results-title { .results-title {
font-size: 1.3em; font-size: var(--text-lg);
font-weight: 600; font-weight: var(--weight-semibold);
color: var(--text-color); color: var(--text-color);
} }
@@ -493,27 +493,26 @@
} }
.result-card.success { .result-card.success {
border-left: 3px solid #00B87A; border-left: 4px solid var(--color-success);
} }
.result-card.failed { .result-card.failed {
border-left: 3px solid var(--lora-error); border-left: 4px solid var(--color-error);
} }
.result-card.skipped { .result-card.skipped {
border-left: 3px solid var(--lora-warning); border-left: 4px solid var(--color-warning);
} }
.result-label { .result-label {
font-size: 0.8em; font-size: var(--text-xs);
color: var(--text-color); color: var(--text-secondary);
opacity: 0.7;
margin-bottom: 4px; margin-bottom: 4px;
} }
.result-value { .result-value {
font-size: 1.4em; font-size: var(--text-lg);
font-weight: 600; font-weight: var(--weight-semibold);
color: var(--text-color); color: var(--text-color);
} }
@@ -527,13 +526,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: var(--space-2);
padding: 10px; padding: var(--space-2);
cursor: pointer; cursor: pointer;
color: var(--lora-accent); color: var(--lora-accent);
font-weight: 500; font-weight: var(--weight-medium);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
transition: background 0.2s; transition: background var(--transition-base);
} }
.details-toggle:hover { .details-toggle:hover {
@@ -541,7 +540,7 @@
} }
.details-toggle i { .details-toggle i {
transition: transform 0.2s; transition: transform var(--transition-base);
} }
.details-toggle.expanded i { .details-toggle.expanded i {
@@ -561,10 +560,10 @@
.result-item { .result-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: var(--space-2);
padding: 10px 12px; padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
font-size: 0.9em; font-size: var(--text-sm);
} }
.result-item:last-child { .result-item:last-child {
@@ -572,28 +571,23 @@
} }
.result-item-status { .result-item-status {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 0.8em; font-size: var(--text-sm);
flex-shrink: 0;
} }
.result-item-status.success { .result-item-status.success {
background: oklch(from #00B87A l c h / 0.2); color: var(--color-success);
color: #00B87A;
} }
.result-item-status.failed { .result-item-status.failed {
background: oklch(from var(--lora-error) l c h / 0.2); color: var(--color-error);
color: var(--lora-error);
} }
.result-item-status.skipped { .result-item-status.skipped {
background: oklch(from var(--lora-warning) l c h / 0.2); color: var(--color-warning);
color: var(--lora-warning);
} }
.result-item-info { .result-item-info {
@@ -610,8 +604,8 @@
} }
.result-item-error { .result-item-error {
font-size: 0.8em; font-size: var(--text-xs);
color: var(--lora-error); color: var(--color-error);
margin-top: 2px; margin-top: 2px;
} }
@@ -661,11 +655,11 @@
/* Completed State */ /* Completed State */
.batch-progress-container.completed .progress-bar { .batch-progress-container.completed .progress-bar {
background: #00B87A; background: var(--color-success);
} }
.batch-progress-container.completed .status-icon { .batch-progress-container.completed .status-icon {
color: #00B87A; color: var(--color-success);
} }
.batch-progress-container.completed .status-icon i { .batch-progress-container.completed .status-icon i {
+2 -2
View File
@@ -278,7 +278,7 @@
left: 0; left: 0;
right: 0; right: 0;
background: linear-gradient(transparent 15%, oklch(0% 0 0 / 0.75)); background: linear-gradient(transparent 15%, oklch(0% 0 0 / 0.75));
backdrop-filter: blur(8px); backdrop-filter: blur(var(--card-blur-amount, 8px));
color: white; color: white;
padding: var(--space-1); padding: var(--space-1);
display: flex; display: flex;
@@ -294,7 +294,7 @@
left: 0; left: 0;
right: 0; right: 0;
background: linear-gradient(oklch(0% 0 0 / 0.75), transparent 85%); background: linear-gradient(oklch(0% 0 0 / 0.75), transparent 85%);
backdrop-filter: blur(8px); backdrop-filter: blur(var(--card-blur-amount, 8px));
color: white; color: white;
padding: var(--space-1); padding: var(--space-1);
display: flex; display: flex;
+27 -27
View File
@@ -5,10 +5,10 @@
position: sticky; /* Keep the sticky position */ position: sticky; /* Keep the sticky position */
top: var(--space-1); top: var(--space-1);
width: 100%; width: 100%;
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); /* Use accent color with low opacity */ background-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.1); /* Use accent color with low opacity */
color: var(--text-color); color: var(--text-color);
border-top: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3); /* Add top border with accent color */ border-top: 1px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.3); /* Add top border with accent color */
border-bottom: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4); /* Make bottom border stronger */ border-bottom: 1px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.4); /* Make bottom border stronger */
z-index: var(--z-overlay); z-index: var(--z-overlay);
padding: 12px 0; padding: 12px 0;
box-shadow: var(--shadow-lg); /* Stronger shadow */ box-shadow: var(--shadow-lg); /* Stronger shadow */
@@ -41,7 +41,7 @@
.duplicates-banner i.fa-exclamation-triangle { .duplicates-banner i.fa-exclamation-triangle {
font-size: 18px; font-size: 18px;
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
} }
.duplicates-banner .banner-actions { .duplicates-banner .banner-actions {
@@ -70,7 +70,7 @@
.duplicates-banner button.btn-exit-mode:hover { .duplicates-banner button.btn-exit-mode:hover {
background-color: var(--bg-color); background-color: var(--bg-color);
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h); border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -92,7 +92,7 @@
} }
.duplicates-banner button:hover { .duplicates-banner button:hover {
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h); border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
background: var(--bg-color); background: var(--bg-color);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
@@ -117,7 +117,7 @@
/* Duplicate groups */ /* Duplicate groups */
.duplicate-group { .duplicate-group {
position: relative; position: relative;
border: 2px solid oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); border: 2px solid oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
padding: 16px; padding: 16px;
margin-bottom: 24px; margin-bottom: 24px;
@@ -152,7 +152,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-left: 4px solid oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); /* Add accent border on the left */ border-left: 4px solid oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h)); /* Add accent border on the left */
} }
.duplicate-group-header span:last-child { .duplicate-group-header span:last-child {
@@ -180,7 +180,7 @@
} }
.duplicate-group-header button:hover { .duplicate-group-header button:hover {
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h); border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
background: var(--bg-color); background: var(--bg-color);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
@@ -235,7 +235,7 @@
} }
.group-toggle-btn:hover { .group-toggle-btn:hover {
border-color: var(--lora-accent-l) var(--lora-accent-c) var (--lora-accent-h); border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
@@ -247,16 +247,16 @@
} }
.model-card.duplicate:hover { .model-card.duplicate:hover {
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h); border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
} }
.model-card.duplicate.latest { .model-card.duplicate.latest {
border-style: solid; border-style: solid;
border-color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); border-color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
} }
.model-card.duplicate-selected { .model-card.duplicate-selected {
border: 2px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h)); border: 2px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
@@ -276,7 +276,7 @@
position: absolute; position: absolute;
top: 10px; top: 10px;
left: 10px; left: 10px;
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h)); background: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
color: white; color: white;
font-size: 12px; font-size: 12px;
padding: 2px 6px; padding: 2px 6px;
@@ -328,7 +328,7 @@
margin-top: 8px; margin-top: 8px;
padding-top: 8px; padding-top: 8px;
border-top: 1px dashed var(--border-color); border-top: 1px dashed var(--border-color);
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
font-weight: bold; font-weight: bold;
word-break: break-all; /* Ensure long hashes wrap properly */ word-break: break-all; /* Ensure long hashes wrap properly */
} }
@@ -351,12 +351,12 @@
} }
.verification-badge.verified { .verification-badge.verified {
background-color: oklch(70% 0.2 140); /* Green for verified */ background-color: var(--color-success); /* Green for verified */
color: white; color: white;
} }
.verification-badge.mismatch { .verification-badge.mismatch {
background-color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); background-color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
color: white; color: white;
} }
@@ -366,7 +366,7 @@
/* Hash Mismatch Styling */ /* Hash Mismatch Styling */
.model-card.duplicate.hash-mismatch { .model-card.duplicate.hash-mismatch {
border: 2px dashed oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); border: 2px dashed oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
opacity: 0.85; opacity: 0.85;
position: relative; position: relative;
} }
@@ -380,8 +380,8 @@
bottom: 0; bottom: 0;
background: repeating-linear-gradient( background: repeating-linear-gradient(
45deg, 45deg,
oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h) / 0.05), oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h) / 0.05),
oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h) / 0.05) 10px, oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h) / 0.05) 10px,
transparent 10px, transparent 10px,
transparent 20px transparent 20px
); );
@@ -398,7 +398,7 @@
position: absolute; position: absolute;
top: 10px; top: 10px;
left: 10px; /* Changed from right:10px to left:10px */ left: 10px; /* Changed from right:10px to left:10px */
background: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); background: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
color: white; color: white;
font-size: 12px; font-size: 12px;
padding: 3px 8px; padding: 3px 8px;
@@ -417,7 +417,7 @@
margin-top: 8px; margin-top: 8px;
padding-top: 8px; padding-top: 8px;
border-top: 1px dashed var(--border-color); border-top: 1px dashed var(--border-color);
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
font-weight: bold; font-weight: bold;
} }
@@ -437,7 +437,7 @@
.btn-verify-hashes:hover { .btn-verify-hashes:hover {
background: var(--bg-color); background: var(--bg-color);
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h)); border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -498,7 +498,7 @@
.help-icon:hover { .help-icon:hover {
opacity: 1; opacity: 1;
color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h)); color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
} }
/* Help tooltip */ /* Help tooltip */
@@ -573,7 +573,7 @@
/* In dark mode, add additional distinction */ /* In dark mode, add additional distinction */
html[data-theme="dark"] .duplicates-banner { html[data-theme="dark"] .duplicates-banner {
box-shadow: var(--shadow-dark-lg); /* Stronger shadow in dark mode */ box-shadow: var(--shadow-dark-lg); /* Stronger shadow in dark mode */
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15); /* Slightly stronger background in dark mode */ background-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.15); /* Slightly stronger background in dark mode */
} }
html[data-theme="dark"] .duplicate-group { html[data-theme="dark"] .duplicate-group {
@@ -598,11 +598,11 @@ html[data-theme="dark"] .help-tooltip {
background: var(--lora-accent); background: var(--lora-accent);
color: white; color: white;
border-color: var(--lora-accent); border-color: var(--lora-accent);
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25); box-shadow: 0 0 0 2px oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.25);
position: relative; position: relative;
z-index: 5; z-index: 5;
} }
#findDuplicatesBtn.active:hover { #findDuplicatesBtn.active:hover {
background: oklch(calc(var(--lora-accent-l) - 5%) var(--lora-accent-c) var(--lora-accent-h)); background: oklch(calc(var(--color-accent-l) - 5%) var(--color-accent-c) var(--color-accent-h));
} }
+195 -6
View File
@@ -283,7 +283,6 @@
.theme-toggle { .theme-toggle {
position: relative; position: relative;
/* Ensure relative positioning for the container */
} }
.theme-toggle .light-icon, .theme-toggle .light-icon,
@@ -293,17 +292,14 @@
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
/* Center perfectly */
opacity: 0; opacity: 0;
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
} }
/* Default state shows dark icon */
.theme-toggle .dark-icon { .theme-toggle .dark-icon {
opacity: 1; opacity: 1;
} }
/* Light theme shows light icon */
.theme-toggle.theme-light .light-icon { .theme-toggle.theme-light .light-icon {
opacity: 1; opacity: 1;
} }
@@ -313,7 +309,6 @@
opacity: 0; opacity: 0;
} }
/* Dark theme shows dark icon */
.theme-toggle.theme-dark .dark-icon { .theme-toggle.theme-dark .dark-icon {
opacity: 1; opacity: 1;
} }
@@ -323,7 +318,6 @@
opacity: 0; opacity: 0;
} }
/* Auto theme shows auto icon */
.theme-toggle.theme-auto .auto-icon { .theme-toggle.theme-auto .auto-icon {
opacity: 1; opacity: 1;
} }
@@ -333,6 +327,201 @@
opacity: 0; opacity: 0;
} }
.theme-popover {
display: none;
position: fixed;
background: var(--surface-base, #ffffff);
border: 1px solid var(--border-base, #e0e0e0);
border-radius: var(--radius-md, 8px);
box-shadow: var(--shadow-xl, 0 4px 16px rgba(0, 0, 0, 0.15));
padding: 12px;
min-width: 220px;
z-index: calc(var(--z-overlay) + 1);
animation: theme-popover-in 0.15s ease-out;
}
.theme-popover.active {
display: block;
}
@keyframes theme-popover-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.theme-popover-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.theme-popover-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary, #6c757d);
}
.theme-popover-divider {
height: 1px;
background: var(--border-base, #e0e0e0);
margin: 10px 0;
}
.theme-popover-modes {
display: flex;
gap: 6px;
}
.theme-mode-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 4px;
border: 1px solid var(--border-base, #e0e0e0);
border-radius: var(--radius-sm, 6px);
background: var(--surface-elevated, #ffffff);
color: var(--text-primary, #333333);
cursor: pointer;
font-size: 0.75rem;
transition: background-color var(--transition-base, 200ms ease),
border-color var(--transition-base, 200ms ease),
color var(--transition-base, 200ms ease);
}
.theme-mode-btn i {
font-size: 0.9rem;
}
.theme-mode-btn:hover {
background: var(--surface-hover, oklch(95% 0.02 256));
border-color: var(--color-accent, oklch(68% 0.28 256));
}
.theme-mode-btn.active {
background: var(--color-accent-subtle, oklch(68% 0.28 256 / 0.12));
border-color: var(--color-accent, oklch(68% 0.28 256));
color: var(--color-accent, oklch(68% 0.28 256));
}
.theme-popover-presets {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.theme-preset-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 4px;
border: 1px solid var(--border-base, #e0e0e0);
border-radius: var(--radius-sm, 6px);
background: var(--surface-elevated, #ffffff);
color: var(--text-primary, #333333);
cursor: pointer;
font-size: 0.7rem;
transition: background-color var(--transition-base, 200ms ease),
border-color var(--transition-base, 200ms ease),
color var(--transition-base, 200ms ease);
}
.theme-preset-btn:hover {
background: var(--surface-hover, oklch(95% 0.02 256));
border-color: var(--color-accent, oklch(68% 0.28 256));
}
.theme-preset-btn.active {
background: var(--color-accent-subtle, oklch(68% 0.28 256 / 0.12));
border-color: var(--color-accent, oklch(68% 0.28 256));
color: var(--color-accent, oklch(68% 0.28 256));
}
.preset-swatch {
display: inline-block;
width: 22px;
height: 22px;
border-radius: var(--radius-xs, 4px);
border: 1px solid var(--border-subtle, oklch(72% 0.03 256 / 0.45));
flex-shrink: 0;
transition: transform var(--transition-base, 200ms ease),
box-shadow var(--transition-base, 200ms ease);
}
/* Solid accent colors each swatch shows the theme's accent color directly.
This matches the app's flat, token-driven design language instead of using
decorative gradients that clash with the matte aesthetic. */
.preset-swatch-default {
background: oklch(68% 0.28 256);
}
.preset-swatch-nord {
background: oklch(62% 0.18 213);
}
.preset-swatch-midnight {
background: oklch(52% 0.15 300);
}
.preset-swatch-monokai {
background: oklch(72% 0.24 190);
}
.preset-swatch-dracula {
background: oklch(68% 0.24 265);
}
.preset-swatch-solarized {
background: oklch(55% 0.18 175);
}
.theme-preset-btn.active .preset-swatch {
box-shadow: 0 0 0 2px var(--color-accent, oklch(68% 0.28 256));
}
.theme-preset-btn:hover .preset-swatch {
transform: scale(1.08);
}
/* Dark mode: use each preset's dark-mode accent lightness for visibility.
These match the --color-accent-l values from [data-theme="dark"][data-theme-preset="..."]
in tokens/colors.css so the swatch accurately previews what the theme looks like. */
[data-theme="dark"] .preset-swatch-default {
background: oklch(68% 0.28 256);
}
[data-theme="dark"] .preset-swatch-nord {
background: oklch(68% 0.18 213);
}
[data-theme="dark"] .preset-swatch-midnight {
background: oklch(68% 0.14 300);
}
[data-theme="dark"] .preset-swatch-monokai {
background: oklch(72% 0.24 190);
}
[data-theme="dark"] .preset-swatch-dracula {
background: oklch(72% 0.24 265);
}
[data-theme="dark"] .preset-swatch-solarized {
background: oklch(60% 0.18 175);
}
/* Badge styling */ /* Badge styling */
.update-badge { .update-badge {
position: absolute; position: absolute;
+4 -4
View File
@@ -211,7 +211,7 @@
.lora-item.is-early-access { .lora-item.is-early-access {
background: rgba(0, 184, 122, 0.05); background: rgba(0, 184, 122, 0.05);
border-left: 4px solid #00B87A; border-left: 4px solid var(--color-success);
} }
.lora-item.missing-locally { .lora-item.missing-locally {
@@ -310,7 +310,7 @@
.missing-lora-item.is-early-access { .missing-lora-item.is-early-access {
background: rgba(0, 184, 122, 0.05); background: rgba(0, 184, 122, 0.05);
border-left: 3px solid #00B87A; border-left: 3px solid var(--color-success);
padding-left: 10px; padding-left: 10px;
} }
@@ -630,7 +630,7 @@
gap: 12px; gap: 12px;
padding: 12px 16px; padding: 12px 16px;
background: rgba(0, 184, 122, 0.1); background: rgba(0, 184, 122, 0.1);
border: 1px solid #00B87A; border: 1px solid var(--color-success);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
color: var(--text-color); color: var(--text-color);
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
@@ -646,7 +646,7 @@
/* Specific styling for the early access warning container in import modal */ /* Specific styling for the early access warning container in import modal */
.early-access-warning .warning-icon { .early-access-warning .warning-icon {
color: #00B87A; color: var(--color-success);
font-size: 1.2em; font-size: 1.2em;
} }
-96
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);
}
@@ -72,6 +72,10 @@
margin-left: auto; margin-left: auto;
} }
.modal-header-actions .license-permissions {
margin-left: auto;
}
.license-restrictions { .license-restrictions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -95,6 +99,41 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Set 2 — New style permission indicators */
.license-permissions {
display: flex;
gap: 4px;
align-items: center;
}
.license-icon-new {
width: 22px;
height: 22px;
display: inline-block;
border-radius: 4px;
background-color: var(--text-muted);
-webkit-mask: var(--license-icon-image) center/contain no-repeat;
mask: var(--license-icon-image) center/contain no-repeat;
transition: background-color 0.2s ease, transform 0.2s ease;
cursor: default;
outline: 2px solid transparent;
outline-offset: 1px;
}
.license-icon-new.allowed {
background-color: var(--color-success, #40c057);
outline-color: color-mix(in oklch, var(--color-success, #40c057) 30%, transparent);
}
.license-icon-new.denied {
background-color: var(--color-error, #fa5252);
outline-color: color-mix(in oklch, var(--color-error, #fa5252) 30%, transparent);
}
.license-icon-new:hover {
transform: translateY(-1px);
}
/* Info Grid */ /* Info Grid */
.info-grid { .info-grid {
display: grid; display: grid;
+8 -1
View File
@@ -17,6 +17,8 @@
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 6px; gap: 6px;
align-items: center; align-items: center;
min-width: 0;
overflow: hidden;
} }
.model-tag-compact { .model-tag-compact {
@@ -28,6 +30,9 @@
font-size: 0.75em; font-size: 0.75em;
color: var(--text-color); color: var(--text-color);
white-space: nowrap; white-space: nowrap;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
} }
/* Style for empty tags placeholder */ /* Style for empty tags placeholder */
@@ -118,8 +123,9 @@
/* Model Tags Edit Mode */ /* Model Tags Edit Mode */
.model-tags-header { .model-tags-header {
display: flex; display: flex;
justify-content: space-between; justify-content: flex-start;
align-items: center; align-items: center;
overflow: hidden;
} }
.edit-tags-btn { .edit-tags-btn {
@@ -132,6 +138,7 @@
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
transition: var(--transition-base); transition: var(--transition-base);
margin-left: var(--space-1); margin-left: var(--space-1);
flex-shrink: 0;
} }
.edit-tags-btn.visible, .edit-tags-btn.visible,
@@ -0,0 +1,171 @@
/* Metadata Refresh Result Modal — component styles only */
.metadata-refresh-result-modal {
max-width: 700px;
}
.refresh-summary-stats {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: var(--space-3) 0;
}
.stat-card {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-sm);
background: var(--surface-subtle);
border-left: 4px solid transparent;
font-size: var(--text-sm);
flex: 1;
min-width: 130px;
}
.stat-card-body {
display: flex;
flex-direction: column;
min-width: 0;
}
.stat-card-label {
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: var(--leading-tight);
}
.stat-card-value {
font-weight: var(--weight-bold);
font-size: var(--text-lg);
color: var(--lora-text);
line-height: var(--leading-tight);
}
.stat-card-success {
border-left-color: var(--color-success);
}
.stat-card-failure {
border-left-color: var(--color-error);
}
.stat-card-skipped {
border-left-color: var(--color-warning);
}
.stat-card-total {
border-left-color: var(--lora-border);
}
.stat-card-time {
border-left-color: var(--lora-border);
}
.refresh-failures-section {
margin-bottom: var(--space-3);
}
.refresh-failures-section h4 {
margin: 0 0 var(--space-2) 0;
font-size: var(--text-base);
color: var(--color-error);
display: flex;
align-items: center;
gap: var(--space-1);
}
.refresh-failures-section h4 i {
font-size: 0.9em;
}
.failure-table-wrapper {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
}
.failure-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.failure-table th {
position: sticky;
top: 0;
background: var(--lora-surface);
border-bottom: 1px solid var(--lora-border);
padding: var(--space-1) var(--space-2);
text-align: left;
font-weight: var(--weight-semibold);
color: var(--text-secondary);
z-index: 1;
}
.failure-table td {
padding: var(--space-1) var(--space-2);
border-bottom: 1px solid var(--lora-border);
vertical-align: top;
}
.failure-table tr:last-child td {
border-bottom: none;
}
.failure-table tr:hover td {
background: var(--surface-subtle);
}
.failure-index {
width: 30px;
text-align: center;
color: var(--text-secondary);
}
.failure-name {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.failure-error {
color: var(--color-error);
font-size: var(--text-xs);
}
.refresh-success-message {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3);
margin-bottom: var(--space-3);
background: var(--surface-subtle);
border-left: 4px solid var(--color-success);
color: var(--lora-text);
border-radius: var(--border-radius-sm);
font-weight: var(--weight-medium);
}
.refresh-success-message i {
font-size: 1.2em;
flex-shrink: 0;
color: var(--color-success);
}
[data-theme="dark"] .failure-table th {
background: var(--lora-surface);
}
[data-theme="dark"] .failure-table td {
border-bottom-color: var(--lora-border);
}
[data-theme="dark"] .failure-table tr:hover td {
background: var(--surface-subtle);
}
+214 -2
View File
@@ -335,7 +335,12 @@
} }
} }
/* API key input specific styles */ /* API key input — CSS masking (prevents Chrome password manager triggers) */
.api-key-masked {
-webkit-text-security: disc;
}
/* API key input specific styles (shared with proxy password) */
.api-key-input { .api-key-input {
width: 100%; /* Take full width of parent */ width: 100%; /* Take full width of parent */
position: relative; position: relative;
@@ -345,7 +350,7 @@
.api-key-input input { .api-key-input input {
width: 100%; width: 100%;
padding: 6px 40px 6px 10px; /* Add left padding */ padding: 6px 40px 6px 10px; /* Right padding for eye button */
height: 32px; height: 32px;
box-sizing: border-box; box-sizing: border-box;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
@@ -353,6 +358,13 @@
background-color: var(--lora-surface); background-color: var(--lora-surface);
color: var(--text-color); color: var(--text-color);
font-size: 0.95em; font-size: 0.95em;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.api-key-input input:focus {
border-color: var(--lora-accent);
outline: none;
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
} }
.api-key-input .toggle-visibility { .api-key-input .toggle-visibility {
@@ -364,12 +376,98 @@
opacity: 0.6; opacity: 0.6;
cursor: pointer; cursor: pointer;
padding: 4px 8px; padding: 4px 8px;
transition: opacity 0.2s ease;
} }
.api-key-input .toggle-visibility:hover { .api-key-input .toggle-visibility:hover {
opacity: 1; opacity: 1;
} }
/* API key item — stack status/edit views vertically for smooth cross-fade */
.api-key-item .setting-control {
flex-direction: column;
align-items: flex-end;
}
/* API key status display (shown when not editing) */
.api-key-status {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
justify-content: flex-end;
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
max-height: 80px;
overflow: hidden;
}
.api-key-status.is-hidden {
opacity: 0;
max-height: 0;
transform: translateY(-4px);
pointer-events: none;
margin: 0;
}
.api-key-status-text {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.95em;
white-space: nowrap;
transition: color 0.2s ease;
}
/* Status color modifiers — replace inline styles */
.api-key-status--configured .fa-check-circle {
color: var(--lora-success);
}
.api-key-status--unconfigured .fa-times-circle {
color: var(--lora-error);
}
/* Utility classes for status icon colors (used by JS) */
.text-success {
color: var(--lora-success);
}
.text-error {
color: var(--lora-error);
}
/* API key inline edit container — flex row with input + buttons */
.api-key-edit {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
justify-content: flex-end;
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
max-height: 80px;
overflow: hidden;
}
.api-key-edit.is-hidden {
opacity: 0;
max-height: 0;
transform: translateY(-4px);
pointer-events: none;
margin: 0;
}
.api-key-edit .api-key-input {
flex: 1;
min-width: 0;
}
.api-key-edit .primary-btn,
.api-key-edit .secondary-btn {
height: 32px;
flex-shrink: 0;
white-space: nowrap;
}
/* Text input wrapper styles for consistent input styling */ /* Text input wrapper styles for consistent input styling */
.text-input-wrapper { .text-input-wrapper {
width: 100%; width: 100%;
@@ -813,6 +911,120 @@
outline: none; outline: none;
} }
/* Range Slider Control */
.range-control {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
justify-content: flex-end;
}
.range-control input[type="range"] {
--range-fill: 40%;
width: 120px;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: linear-gradient(
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;
cursor: pointer;
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 {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--lora-accent);
cursor: pointer;
border: 2px solid var(--lora-surface);
box-shadow: var(--shadow-md);
transition: transform var(--transition-bounce), box-shadow 0.2s ease;
}
.range-control input[type="range"]::-webkit-slider-thumb:hover {
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 {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--lora-accent);
cursor: pointer;
border: 2px solid var(--lora-surface);
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 {
min-width: 36px;
text-align: center;
font-size: 0.85em;
font-weight: 700;
color: var(--lora-accent);
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"] {
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);
}
/* Toggle Switch */ /* Toggle Switch */
.toggle-switch { .toggle-switch {
position: relative; position: relative;
+5 -117
View File
@@ -9,6 +9,10 @@
position: relative; position: relative;
} }
#recipeTagsContainer {
width: 100%;
}
.recipe-modal-header h2 { .recipe-modal-header h2 {
margin: 0 0 var(--space-1); margin: 0 0 var(--space-1);
padding: var(--space-1); padding: var(--space-1);
@@ -95,127 +99,11 @@
min-width: 0; min-width: 0;
} }
.content-editor.tags-editor input {
font-size: 0.9em;
}
/* Remove obsolete button styles */ /* Remove obsolete button styles */
.editor-actions { .editor-actions {
display: none; display: none;
} }
/* Special styling for tags content */
.tags-content {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 8px;
}
.tags-display {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
flex: 1;
min-width: 0;
overflow: hidden;
}
.no-tags {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.6;
font-style: italic;
}
/* Recipe Tags styles */
.recipe-tags-container {
position: relative;
margin-top: 0;
margin-bottom: 10px;
}
.recipe-tags-compact {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
}
.recipe-tag-compact {
background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs);
padding: 2px 8px;
font-size: 0.75em;
color: var(--text-color);
white-space: nowrap;
}
[data-theme="dark"] .recipe-tag-compact {
background: var(--surface-subtle);
border: 1px solid var(--lora-border);
}
.recipe-tag-more {
background: var(--lora-accent);
color: var(--lora-text);
border-radius: var(--border-radius-xs);
padding: 2px 8px;
font-size: 0.75em;
cursor: pointer;
white-space: nowrap;
font-weight: 500;
}
.recipe-tags-tooltip {
position: absolute;
top: calc(100% + 8px);
left: 0;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
box-shadow: var(--shadow-dropdown);
padding: 10px 14px;
max-width: 400px;
z-index: 10;
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition: var(--transition-base);
pointer-events: none;
}
.recipe-tags-tooltip.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
.tooltip-content {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 200px;
overflow-y: auto;
}
.tooltip-tag {
background: var(--surface-hover);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs);
padding: 3px 8px;
font-size: 0.75em;
color: var(--text-color);
}
[data-theme="dark"] .tooltip-tag {
background: var(--surface-hover);
border: 1px solid var(--lora-border);
}
#recipeModal .modal-content { #recipeModal .modal-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1153,7 +1041,7 @@
max-height: 2.4em; max-height: 2.4em;
} }
.recipe-tags-container { #recipeTagsContainer {
margin-bottom: 6px; margin-bottom: 6px;
} }
+23 -113
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,74 +110,14 @@
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;
left: 0; left: 0;
top: 50%; top: 68px; /* Align with sidebar header */
transform: translateY(-50%);
z-index: var(--z-overlay); z-index: var(--z-overlay);
width: 14px; width: 14px;
height: 44px; height: 48px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -235,7 +134,7 @@
} }
.sidebar-hidden-indicator i { .sidebar-hidden-indicator i {
font-size: 9px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
transition: color 0.15s ease; transition: color 0.15s ease;
} }
@@ -244,6 +143,21 @@
color: white; color: white;
} }
/* Subtle breathing animation for first-time discovery */
@keyframes sidebarBreathing {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.65; }
}
.sidebar-hidden-indicator.breathing {
animation: sidebarBreathing 2.5s ease-in-out infinite;
animation-delay: 0.5s;
}
.sidebar-hidden-indicator.breathing:hover {
animation: none;
}
.sidebar-hidden-indicator-tooltip { .sidebar-hidden-indicator-tooltip {
position: absolute; position: absolute;
left: 100%; left: 100%;
@@ -630,7 +544,7 @@
opacity: 0.3; opacity: 0.3;
} }
/* Responsive Design */ /* Responsive Design — Mobile: overlay when shown */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.folder-sidebar { .folder-sidebar {
top: 68px; top: 68px;
@@ -641,12 +555,8 @@
z-index: calc(var(--z-overlay) + 10); z-index: calc(var(--z-overlay) + 10);
} }
.folder-sidebar.collapsed { /* Mobile overlay when sidebar is shown */
transform: translateX(-100%); .folder-sidebar.visible::before {
}
/* Mobile overlay */
.folder-sidebar:not(.collapsed)::before {
content: ''; content: '';
position: fixed; position: fixed;
top: 0; top: 0;
+6 -6
View File
@@ -27,8 +27,8 @@
transition: var(--transition-slow); transition: var(--transition-slow);
/* Add glow effect */ /* Add glow effect */
box-shadow: box-shadow:
0 0 0 2px rgba(24, 144, 255, 0.3), 0 0 0 2px color-mix(in oklch, var(--color-accent) 30%, transparent),
0 0 20px rgba(24, 144, 255, 0.2), 0 0 20px color-mix(in oklch, var(--color-accent) 20%, transparent),
inset 0 0 0 1px rgba(255, 255, 255, 0.1); inset 0 0 0 1px rgba(255, 255, 255, 0.1);
} }
@@ -221,14 +221,14 @@
@keyframes onboarding-pulse { @keyframes onboarding-pulse {
0%, 100% { 0%, 100% {
box-shadow: box-shadow:
0 0 0 2px rgba(24, 144, 255, 0.4), 0 0 0 2px color-mix(in oklch, var(--color-accent) 40%, transparent),
0 0 20px rgba(24, 144, 255, 0.3), 0 0 20px color-mix(in oklch, var(--color-accent) 30%, transparent),
inset 0 0 0 1px rgba(255, 255, 255, 0.1); inset 0 0 0 1px rgba(255, 255, 255, 0.1);
} }
50% { 50% {
box-shadow: box-shadow:
0 0 0 4px rgba(24, 144, 255, 0.6), 0 0 0 4px color-mix(in oklch, var(--color-accent) 60%, transparent),
0 0 30px rgba(24, 144, 255, 0.4), 0 0 30px color-mix(in oklch, var(--color-accent) 40%, transparent),
inset 0 0 0 1px rgba(255, 255, 255, 0.2); inset 0 0 0 1px rgba(255, 255, 255, 0.2);
} }
} }
+2 -1
View File
@@ -36,10 +36,11 @@
@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';
@import 'components/metadata-refresh-result.css';
.initialization-notice { .initialization-notice {
display: flex; display: flex;
+357 -9
View File
@@ -37,13 +37,13 @@
--color-error-border: color-mix(in oklch, var(--color-error) 50%, transparent); --color-error-border: color-mix(in oklch, var(--color-error) 50%, transparent);
--color-info: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h)); --color-info: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h));
--color-info-bg: oklch(72% 0.2 220); --color-info-bg: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h));
--color-info-text: oklch(28% 0.03 220); --color-info-text: oklch(28% 0.03 var(--color-info-h));
--color-info-glow: oklch(72% 0.2 220 / 0.28); --color-info-glow: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h) / 0.28);
--color-skip-refresh-bg: oklch(82% 0.12 45); --color-skip-refresh-bg: oklch(82% 0.12 var(--color-warning-h));
--color-skip-refresh-text: oklch(35% 0.02 45); --color-skip-refresh-text: oklch(35% 0.02 var(--color-warning-h));
--color-skip-refresh-glow: oklch(82% 0.12 45 / 0.15); --color-skip-refresh-glow: oklch(82% 0.12 var(--color-warning-h) / 0.15);
} }
:root { :root {
@@ -106,12 +106,360 @@
--status-info-bg: oklch(50% 0.10 190 / 0.25); --status-info-bg: oklch(50% 0.10 190 / 0.25);
--status-info-border: oklch(55% 0.12 195 / 0.3); --status-info-border: oklch(55% 0.12 195 / 0.3);
--color-info-bg: oklch(62% 0.18 220); --color-info-bg: oklch(62% 0.18 var(--color-info-h));
--color-info-text: oklch(98% 0.02 240); --color-info-text: oklch(98% 0.02 var(--color-info-h));
--color-info-glow: oklch(62% 0.18 220 / 0.4); --color-info-glow: oklch(62% 0.18 var(--color-info-h) / 0.4);
--color-error-bg: color-mix(in oklch, var(--color-error) 15%, transparent); --color-error-bg: color-mix(in oklch, var(--color-error) 15%, transparent);
--color-error-border: color-mix(in oklch, var(--color-error) 40%, transparent); --color-error-border: color-mix(in oklch, var(--color-error) 40%, transparent);
--favorite-color: #ffc107; --favorite-color: #ffc107;
} }
/* ── Preset: Nord ──────────────────────────────────────────── */
[data-theme-preset="nord"] {
--color-accent-h: 213;
--color-accent-c: 0.18;
--color-accent-l: 62%;
--color-warning-h: 35;
--color-warning-c: 0.18;
--color-success-h: 130;
--color-error-l: 62%;
--color-error-c: 0.22;
--color-error-h: 5;
--color-info-h: 195;
--color-info-c: 0.18;
--bg-base: oklch(96% 0.01 240);
--bg-elevated: oklch(98% 0.008 240 / 0.95);
--bg-hover: oklch(93% 0.02 240);
--bg-disabled: oklch(92% 0.01 240);
--text-primary: oklch(22% 0.03 260);
--text-secondary: oklch(48% 0.03 260);
--text-inverse: oklch(97% 0.01 240);
--surface-base: oklch(97% 0.01 240);
--surface-elevated: oklch(98% 0.008 240 / 0.95);
--surface-hover: oklch(93% 0.02 240);
--surface-subtle: oklch(0% 0 0 / 0.03);
--border-base: oklch(82% 0.03 240);
--border-subtle: oklch(82% 0.03 240 / 0.45);
--favorite-color: oklch(72% 0.14 85);
--favorite-glow: oklch(72% 0.14 85 / 0.5);
}
[data-theme="dark"][data-theme-preset="nord"] {
--color-accent-h: 213;
--color-accent-c: 0.18;
--color-accent-l: 68%;
--color-warning-h: 35;
--color-warning-c: 0.18;
--color-success-h: 130;
--color-error-l: 65%;
--color-error-c: 0.22;
--color-error-h: 5;
--color-info-h: 195;
--color-info-c: 0.18;
--bg-base: oklch(20% 0.03 260);
--bg-elevated: oklch(24% 0.03 260 / 0.98);
--bg-hover: oklch(30% 0.03 260);
--bg-disabled: oklch(30% 0.02 260);
--text-primary: oklch(87% 0.02 240);
--text-secondary: oklch(68% 0.02 240);
--text-inverse: oklch(20% 0.03 260);
--surface-base: oklch(26% 0.03 260);
--surface-elevated: oklch(24% 0.03 260 / 0.98);
--surface-hover: oklch(30% 0.03 260);
--surface-subtle: oklch(100% 0 0 / 0.03);
--border-base: oklch(38% 0.03 260);
--border-subtle: oklch(87% 0.02 240 / 0.15);
--favorite-color: oklch(78% 0.15 85);
--favorite-glow: oklch(78% 0.15 85 / 0.5);
}
/* ── Preset: Midnight ───────────────────────────────────────── */
[data-theme-preset="midnight"] {
--color-accent-h: 300;
--color-accent-c: 0.15;
--color-accent-l: 52%;
--color-warning-h: 50;
--color-warning-c: 0.18;
--color-success-h: 135;
--color-error-h: 5;
--color-error-l: 62%;
--color-error-c: 0.22;
--color-info-h: 195;
--color-info-c: 0.12;
--bg-base: oklch(96% 0.01 255);
--bg-elevated: oklch(98% 0.008 255 / 0.95);
--bg-hover: oklch(93% 0.02 255);
--bg-disabled: oklch(92% 0.01 255);
--text-primary: oklch(22% 0.03 260);
--text-secondary: oklch(48% 0.03 260);
--text-inverse: oklch(97% 0.01 255);
--surface-base: oklch(97% 0.01 255);
--surface-elevated: oklch(98% 0.008 255 / 0.95);
--surface-hover: oklch(93% 0.02 255);
--surface-subtle: oklch(0% 0 0 / 0.03);
--border-base: oklch(80% 0.03 255);
--border-subtle: oklch(80% 0.03 255 / 0.45);
--favorite-color: oklch(72% 0.16 85);
--favorite-glow: oklch(72% 0.16 85 / 0.5);
}
[data-theme="dark"][data-theme-preset="midnight"] {
--color-accent-h: 300;
--color-accent-c: 0.14;
--color-accent-l: 68%;
--color-warning-h: 50;
--color-warning-c: 0.18;
--color-success-h: 135;
--color-error-h: 5;
--color-error-l: 65%;
--color-error-c: 0.22;
--color-info-h: 195;
--color-info-c: 0.12;
--bg-base: oklch(18% 0.03 260);
--bg-elevated: oklch(22% 0.03 260 / 0.98);
--bg-hover: oklch(28% 0.03 260);
--bg-disabled: oklch(28% 0.02 260);
--text-primary: oklch(88% 0.02 255);
--text-secondary: oklch(68% 0.02 255);
--text-inverse: oklch(18% 0.03 260);
--surface-base: oklch(24% 0.03 260);
--surface-elevated: oklch(22% 0.03 260 / 0.98);
--surface-hover: oklch(28% 0.03 260);
--surface-subtle: oklch(100% 0 0 / 0.03);
--border-base: oklch(36% 0.03 260);
--border-subtle: oklch(88% 0.02 255 / 0.15);
--favorite-color: oklch(78% 0.16 85);
--favorite-glow: oklch(78% 0.16 85 / 0.5);
}
/* ── Preset: Monokai ───────────────────────────────────────── */
[data-theme-preset="monokai"] {
--color-accent-h: 190;
--color-accent-c: 0.24;
--color-accent-l: 72%;
--color-warning-h: 50;
--color-warning-c: 0.22;
--color-success-h: 140;
--color-error-l: 60%;
--color-error-c: 0.22;
--color-error-h: 340;
--color-info-h: 250;
--bg-base: oklch(96% 0.01 80);
--bg-elevated: oklch(98% 0.005 80 / 0.95);
--bg-hover: oklch(93% 0.015 80);
--bg-disabled: oklch(92% 0.01 80);
--text-primary: oklch(20% 0.02 100);
--text-secondary: oklch(45% 0.02 100);
--text-inverse: oklch(97% 0.01 80);
--surface-base: oklch(97% 0.008 80);
--surface-elevated: oklch(98% 0.005 80 / 0.95);
--surface-hover: oklch(93% 0.015 80);
--surface-subtle: oklch(0% 0 0 / 0.03);
--border-base: oklch(80% 0.02 80);
--border-subtle: oklch(80% 0.02 80 / 0.45);
--favorite-color: oklch(72% 0.16 85);
--favorite-glow: oklch(72% 0.16 85 / 0.5);
}
[data-theme="dark"][data-theme-preset="monokai"] {
--color-accent-h: 190;
--color-accent-c: 0.24;
--color-accent-l: 72%;
--color-warning-h: 50;
--color-warning-c: 0.22;
--color-success-h: 140;
--color-error-l: 65%;
--color-error-c: 0.22;
--color-error-h: 340;
--color-info-h: 250;
--bg-base: oklch(18% 0.02 100);
--bg-elevated: oklch(22% 0.02 100 / 0.98);
--bg-hover: oklch(28% 0.025 100);
--bg-disabled: oklch(28% 0.015 100);
--text-primary: oklch(90% 0.02 80);
--text-secondary: oklch(70% 0.02 80);
--text-inverse: oklch(18% 0.02 100);
--surface-base: oklch(24% 0.02 100);
--surface-elevated: oklch(22% 0.02 100 / 0.98);
--surface-hover: oklch(28% 0.025 100);
--surface-subtle: oklch(100% 0 0 / 0.03);
--border-base: oklch(36% 0.02 100);
--border-subtle: oklch(90% 0.02 80 / 0.15);
--favorite-color: oklch(78% 0.16 85);
--favorite-glow: oklch(78% 0.16 85 / 0.5);
}
/* ── Preset: Dracula ───────────────────────────────────────── */
[data-theme-preset="dracula"] {
--color-accent-h: 265;
--color-accent-c: 0.24;
--color-accent-l: 68%;
--color-warning-h: 45;
--color-warning-c: 0.22;
--color-success-h: 135;
--color-error-l: 62%;
--color-error-c: 0.22;
--color-error-h: 350;
--color-info-h: 195;
--bg-base: oklch(96% 0.01 290);
--bg-elevated: oklch(98% 0.008 290 / 0.95);
--bg-hover: oklch(93% 0.02 290);
--bg-disabled: oklch(92% 0.01 290);
--text-primary: oklch(22% 0.04 290);
--text-secondary: oklch(48% 0.04 290);
--text-inverse: oklch(97% 0.01 290);
--surface-base: oklch(97% 0.01 290);
--surface-elevated: oklch(98% 0.008 290 / 0.95);
--surface-hover: oklch(93% 0.02 290);
--surface-subtle: oklch(0% 0 0 / 0.03);
--border-base: oklch(80% 0.04 290);
--border-subtle: oklch(80% 0.04 290 / 0.45);
--favorite-color: oklch(72% 0.16 85);
--favorite-glow: oklch(72% 0.16 85 / 0.5);
}
[data-theme="dark"][data-theme-preset="dracula"] {
--color-accent-h: 265;
--color-accent-c: 0.24;
--color-accent-l: 72%;
--color-warning-h: 45;
--color-warning-c: 0.22;
--color-success-h: 135;
--color-error-l: 65%;
--color-error-c: 0.22;
--color-error-h: 350;
--color-info-h: 195;
--bg-base: oklch(18% 0.04 290);
--bg-elevated: oklch(22% 0.04 290 / 0.98);
--bg-hover: oklch(28% 0.04 290);
--bg-disabled: oklch(28% 0.03 290);
--text-primary: oklch(90% 0.02 290);
--text-secondary: oklch(70% 0.03 290);
--text-inverse: oklch(18% 0.04 290);
--surface-base: oklch(24% 0.04 290);
--surface-elevated: oklch(22% 0.04 290 / 0.98);
--surface-hover: oklch(28% 0.04 290);
--surface-subtle: oklch(100% 0 0 / 0.03);
--border-base: oklch(36% 0.04 290);
--border-subtle: oklch(90% 0.02 290 / 0.15);
--favorite-color: oklch(78% 0.16 85);
--favorite-glow: oklch(78% 0.16 85 / 0.5);
}
/* ── Preset: Solarized ─────────────────────────────────────── */
[data-theme-preset="solarized"] {
--color-accent-h: 175;
--color-accent-c: 0.18;
--color-accent-l: 55%;
--color-warning-h: 45;
--color-warning-c: 0.20;
--color-success-h: 68;
--color-error-l: 62%;
--color-error-c: 0.22;
--color-error-h: 5;
--color-info-h: 220;
--color-info-c: 0.16;
--color-info-l: 68%;
--bg-base: oklch(95% 0.03 85);
--bg-elevated: oklch(97% 0.025 85 / 0.95);
--bg-hover: oklch(91% 0.035 85);
--bg-disabled: oklch(90% 0.025 85);
--text-primary: oklch(30% 0.06 200);
--text-secondary: oklch(50% 0.04 200);
--text-inverse: oklch(95% 0.03 85);
--surface-base: oklch(96% 0.025 85);
--surface-elevated: oklch(97% 0.025 85 / 0.95);
--surface-hover: oklch(91% 0.035 85);
--surface-subtle: oklch(0% 0 0 / 0.03);
--border-base: oklch(78% 0.04 85);
--border-subtle: oklch(78% 0.04 85 / 0.45);
--favorite-color: oklch(68% 0.16 75);
--favorite-glow: oklch(68% 0.16 75 / 0.5);
}
[data-theme="dark"][data-theme-preset="solarized"] {
--color-accent-h: 175;
--color-accent-c: 0.18;
--color-accent-l: 60%;
--color-warning-h: 45;
--color-warning-c: 0.20;
--color-success-h: 68;
--color-error-l: 65%;
--color-error-c: 0.22;
--color-error-h: 5;
--color-info-h: 220;
--color-info-c: 0.16;
--color-info-l: 68%;
--bg-base: oklch(18% 0.05 200);
--bg-elevated: oklch(22% 0.05 200 / 0.98);
--bg-hover: oklch(28% 0.05 200);
--bg-disabled: oklch(28% 0.04 200);
--text-primary: oklch(72% 0.03 85);
--text-secondary: oklch(62% 0.03 85);
--text-inverse: oklch(18% 0.05 200);
--surface-base: oklch(24% 0.05 200);
--surface-elevated: oklch(22% 0.05 200 / 0.98);
--surface-hover: oklch(28% 0.05 200);
--surface-subtle: oklch(100% 0 0 / 0.03);
--border-base: oklch(36% 0.04 200);
--border-subtle: oklch(72% 0.03 85 / 0.15);
--favorite-color: oklch(72% 0.16 75);
--favorite-glow: oklch(72% 0.16 75 / 0.5);
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-brush"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 21v-4a4 4 0 1 1 4 4h-4" /><path d="M21 3a16 16 0 0 0 -12.8 10.2" /><path d="M21 3a16 16 0 0 1 -10.2 12.8" /><path d="M10.6 9a9 9 0 0 1 4.4 4.4" /></svg>

After

Width:  |  Height:  |  Size: 460 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-currency-dollar"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M16.7 8a3 3 0 0 0 -2.7 -2h-4a3 3 0 0 0 0 6h4a3 3 0 0 1 0 6h-4a3 3 0 0 1 -2.7 -2" /><path d="M12 3v3m0 12v3" /></svg>

After

Width:  |  Height:  |  Size: 431 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-git-merge"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 18a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M5 6a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M15 12a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M7 8l0 8" /><path d="M7 8a4 4 0 0 0 4 4h4" /></svg>

After

Width:  |  Height:  |  Size: 501 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-license"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 21h-9a3 3 0 0 1 -3 -3v-1h10v2a2 2 0 0 0 4 0v-14a2 2 0 1 1 2 2h-2m2 -4h-11a3 3 0 0 0 -3 3v11" /><path d="M9 7l4 0" /><path d="M9 11l4 0" /></svg>

After

Width:  |  Height:  |  Size: 455 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-user"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /></svg>

After

Width:  |  Height:  |  Size: 401 B

+313 -26
View File
@@ -1,7 +1,7 @@
import { state, getCurrentPageState } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js'; import { getStorageItem, getSessionItem, removeSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
import { import {
getCompleteApiConfig, getCompleteApiConfig,
getCurrentModelType, getCurrentModelType,
@@ -133,6 +133,16 @@ export class BaseModelApiClient {
pageState.hasMore = result.hasMore; pageState.hasMore = result.hasMore;
pageState.currentPage = pageState.currentPage + 1; pageState.currentPage = pageState.currentPage + 1;
// When resetting to page 1, scroll back to the top
// This covers: folder selection, filter/sort/search changes,
// favorites/update/excluded view toggles, alphabet filter, etc.
if (resetPage) {
const scrollContainer = document.querySelector('.page-content');
if (scrollContainer) {
scrollContainer.scrollTop = 0;
}
}
if (updateFolders) { if (updateFolders) {
sidebarManager.refresh(); sidebarManager.refresh();
} }
@@ -468,17 +478,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 +508,10 @@ export class BaseModelApiClient {
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success'); showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
} catch (error) { } catch (error) {
if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
return;
}
console.error('Refresh failed:', error); console.error('Refresh failed:', error);
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error'); showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error');
} finally { } finally {
@@ -547,6 +565,14 @@ export class BaseModelApiClient {
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`); ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
// Wait for WebSocket connection to establish
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
});
// Now that we're connected, set up the message/error handlers
// for the actual operation (separate from connection errors)
const operationComplete = new Promise((resolve, reject) => { const operationComplete = new Promise((resolve, reject) => {
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
@@ -556,25 +582,39 @@ export class BaseModelApiClient {
loading.setStatus('Starting metadata fetch...'); loading.setStatus('Starting metadata fetch...');
break; break;
case 'processing': case 'processing': {
const percent = ((data.processed / data.total) * 100).toFixed(1); const handled = data.handled || data.processed;
const percent = ((handled / data.total) * 100).toFixed(1);
loading.setProgress(percent); loading.setProgress(percent);
loading.setStatus( let statusText = `Processing (${handled}/${data.total}) ${data.current_name || ''}`;
`Processing (${data.processed}/${data.total}) ${data.current_name}` if (data.failure_count > 0) {
); statusText += ` | ❌ ${data.failure_count} failed`;
}
if (data.skipped_count > 0) {
statusText += ` | ⏭️ ${data.skipped_count} skipped`;
}
loading.setStatus(statusText);
break; break;
}
case 'completed': case 'completed': {
loading.setProgress(100); loading.setProgress(100);
loading.setStatus( let summaryText = `Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s`;
`Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s` if (data.failure_count > 0) {
); summaryText += ` | ❌ ${data.failure_count} failed`;
}
if (data.skipped_count > 0) {
summaryText += ` | ⏭️ ${data.skipped_count} skipped`;
}
summaryText += ` (⏱ ${data.elapsed_seconds || '?'}s)`;
loading.setStatus(summaryText);
resolve(data); resolve(data);
break; break;
}
case 'cancelled': case 'cancelled':
loading.setStatus('Operation cancelled by user'); loading.setStatus('Operation cancelled by user');
resolve(data); // Consider it complete but marked as cancelled resolve(data);
break; break;
case 'error': case 'error':
@@ -588,12 +628,6 @@ export class BaseModelApiClient {
}; };
}); });
// Wait for WebSocket connection to establish
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
});
const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, { const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -608,10 +642,10 @@ export class BaseModelApiClient {
const finalData = await operationComplete; const finalData = await operationComplete;
resetAndReload(false); resetAndReload(false);
if (finalData && finalData.status === 'cancelled') {
showToast('toast.api.operationCancelledPartial', { success: finalData.success, total: finalData.total }, 'info'); // Show result summary with failure details
} else { if (finalData) {
showToast('toast.api.metadataUpdateComplete', {}, 'success'); this._showMetadataRefreshResult(finalData);
} }
} catch (error) { } catch (error) {
console.error('Error fetching metadata:', error); console.error('Error fetching metadata:', error);
@@ -627,6 +661,205 @@ export class BaseModelApiClient {
}); });
} }
_showMetadataRefreshResult(data) {
const { success, total } = data;
if (data.status === 'cancelled') {
showToast('toast.api.operationCancelledPartial', { success, total }, 'info');
return;
}
this._showFailureDetailsModal(data);
}
_showFailureDetailsModal(data) {
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
// Build failure list HTML
const failureRows = failures.map((f, i) =>
`<tr>
<td class="failure-index">${i + 1}</td>
<td class="failure-name" title="${this._escapeHtml(f.name)}">${this._escapeHtml(f.name)}</td>
<td class="failure-error">${this._escapeHtml(f.error || 'Unknown')}</td>
</tr>`
).join('');
const modalHtml = `
<div id="metadataRefreshResultModal" class="modal" style="display: block;">
<div class="modal-content metadata-refresh-result-modal">
<button class="close" data-action="close-modal">&times;</button>
<h2>${translate('modals.metadataFetchSummary.title', {}, 'Metadata Fetch Summary')}</h2>
<div class="refresh-summary-stats">
<div class="stat-card stat-card-success">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSuccess', {}, 'Success')}</span>
<span class="stat-card-value">${success}</span>
</div>
</div>
<div class="stat-card stat-card-failure">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statFailed', {}, 'Failed')}</span>
<span class="stat-card-value">${failure_count}</span>
</div>
</div>
<div class="stat-card stat-card-skipped">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSkipped', {}, 'Skipped')}</span>
<span class="stat-card-value">${skipped_count}</span>
</div>
</div>
<div class="stat-card stat-card-total">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statTotal', {}, 'Total Scanned')}</span>
<span class="stat-card-value">${total || processed}</span>
</div>
</div>
<div class="stat-card stat-card-time">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statDuration', {}, 'Duration')}</span>
<span class="stat-card-value">${elapsed_seconds}s</span>
</div>
</div>
</div>
${failure_count > 0 ? `
<div class="refresh-failures-section">
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.metadataFetchSummary.failedItems', { count: failure_count }, 'Failed Items (' + failure_count + ')')}</h4>
<div class="failure-table-wrapper">
<table class="failure-table">
<thead>
<tr>
<th>#</th>
<th>${translate('modals.metadataFetchSummary.columnModelName', {}, 'Model Name')}</th>
<th>${translate('modals.metadataFetchSummary.columnError', {}, 'Error')}</th>
</tr>
</thead>
<tbody>${failureRows}</tbody>
</table>
</div>
</div>
` : `
<div class="refresh-success-message">
<i class="fas fa-check-circle"></i> ${translate('modals.metadataFetchSummary.successMessage', { count: success, type: this.apiConfig.config.displayName }, 'All ' + success + ' ' + this.apiConfig.config.displayName + 's updated successfully!')}
</div>
`}
<div class="modal-actions">
<button class="cancel-btn" data-action="close-modal">${translate('modals.metadataFetchSummary.close', {}, 'Close')}</button>
${failure_count > 0 ? `
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.metadataFetchSummary.copyReport', {}, 'Copy Report')}</button>
<button class="secondary-btn" data-action="download-csv"><i class="fas fa-download"></i> ${translate('modals.metadataFetchSummary.downloadCsv', {}, 'Download CSV')}</button>
` : ''}
</div>
</div>
</div>
`;
const existing = document.getElementById('metadataRefreshResultModal');
if (existing) existing.remove();
const container = document.createElement('div');
container.innerHTML = modalHtml;
const modal = container.firstElementChild;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]')?.dataset.action;
if (!action) return;
e.preventDefault();
switch (action) {
case 'close-modal':
modal.remove();
break;
case 'copy-report':
BaseModelApiClient._copyRefreshReport(e.target.closest('[data-action]'), data);
break;
case 'download-csv':
BaseModelApiClient._downloadRefreshReport(data);
break;
}
});
}
_escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
static _copyRefreshReport(btn, data) {
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
const lines = [
'=== Metadata Refresh Report ===',
`Date: ${new Date().toLocaleString()}`,
`Duration: ${elapsed_seconds}s`,
`Total scanned: ${total || processed}`,
`Successfully updated: ${success}`,
`Failed: ${failure_count}`,
`Skipped: ${skipped_count}`,
'',
];
if (failure_count > 0) {
lines.push('--- Failed Items ---');
failures.forEach((f, i) => {
lines.push(`${i + 1}. ${f.name || 'Unknown'}${f.error || 'Unknown error'}`);
});
lines.push('');
}
lines.push('====================');
const text = lines.join('\n');
navigator.clipboard.writeText(text).then(() => {
showToast('toast.api.copiedToClipboard', {}, 'success');
if (btn) {
const origHTML = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
}
}).catch(() => {
// Fallback
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('toast.api.copiedToClipboard', {}, 'success');
});
}
static _downloadRefreshReport(data) {
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
// CSV header
let csv = 'Model Name,Error\n';
failures.forEach(f => {
const name = (f.name || 'Unknown').replace(/"/g, '""');
const error = (f.error || 'Unknown').replace(/"/g, '""');
csv += `"${name}","${error}"\n`;
});
// Add summary as trailing comments
csv += `\n# Summary: ${success} success, ${failure_count} failed, ${skipped_count} skipped, ${elapsed_seconds}s\n`;
csv += `# Total scanned: ${total || processed}\n`;
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `metadata-refresh-failures-${Date.now()}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('toast.api.downloadStarted', {}, 'success');
}
async refreshBulkModelMetadata(filePaths) { async refreshBulkModelMetadata(filePaths) {
if (!filePaths || filePaths.length === 0) { if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided'); throw new Error('No file paths provided');
@@ -728,13 +961,19 @@ export class BaseModelApiClient {
throw new Error('No model IDs provided'); throw new Error('No model IDs provided');
} }
const abortController = new AbortController();
try { try {
state.loadingManager.show('Checking for updates...', 0); state.loadingManager.show('Checking for updates...', 0);
state.loadingManager.showCancelButton(() => this.cancelTask()); state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, { const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({ body: JSON.stringify({
model_ids: modelIds, model_ids: modelIds,
force force
@@ -759,6 +998,10 @@ export class BaseModelApiClient {
return payload; return payload;
} catch (error) { } catch (error) {
if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
return null;
}
console.error('Error refreshing updates for models:', error); console.error('Error refreshing updates for models:', error);
throw error; throw error;
} finally { } finally {
@@ -771,13 +1014,19 @@ export class BaseModelApiClient {
throw new Error('No folder path provided'); throw new Error('No folder path provided');
} }
const abortController = new AbortController();
try { try {
state.loadingManager.show('Checking for updates...', 0); state.loadingManager.show('Checking for updates...', 0);
state.loadingManager.showCancelButton(() => this.cancelTask()); state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, { const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({ body: JSON.stringify({
folder_path: folderPath, folder_path: folderPath,
force force
@@ -802,6 +1051,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 {
@@ -1018,6 +1271,12 @@ export class BaseModelApiClient {
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false'); params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
// Pass group-by-model mode to backend (skip when showing all versions of a specific model)
const vlmModelId = getSessionItem('vlm_model_id');
if (state.global.settings.group_by_model && !vlmModelId) {
params.append('group_by_model', 'true');
}
if (!isExcludedView && pageState.filters) { if (!isExcludedView && pageState.filters) {
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) { if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
Object.entries(pageState.filters.tags).forEach(([tag, state]) => { Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
@@ -1099,6 +1358,24 @@ export class BaseModelApiClient {
} }
_addModelSpecificParams(params, pageState) { _addModelSpecificParams(params, pageState) {
// Check for View Local Versions filter (takes priority over recipe filters)
const vlmModelId = getSessionItem('vlm_model_id');
const vlmPageType = getSessionItem('vlm_page_type');
if (vlmModelId && vlmPageType === this.modelType) {
params.append('civitai_model_id', vlmModelId);
const vlmBaseModel = getSessionItem('vlm_base_model');
if (vlmBaseModel) {
params.append('base_model', vlmBaseModel);
}
return;
} else if (vlmModelId && vlmPageType !== this.modelType) {
// Stale VLM data from a different page type — clean up
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
}
if (this.modelType === 'loras') { if (this.modelType === 'loras') {
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash'); const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes'); const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
@@ -1251,15 +1528,21 @@ export class BaseModelApiClient {
throw new Error('No file paths provided'); throw new Error('No file paths provided');
} }
const abortController = new AbortController();
try { try {
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`); state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`);
state.loadingManager.showCancelButton(() => this.cancelTask()); state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const response = await fetch(this.apiConfig.endpoints.bulkDelete, { const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
signal: abortController.signal,
body: JSON.stringify({ body: JSON.stringify({
file_paths: filePaths file_paths: filePaths
}) })
@@ -1282,6 +1565,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 {
+7
View File
@@ -9,6 +9,13 @@ export class LoraApiClient extends BaseModelApiClient {
* Add LoRA-specific parameters to query * Add LoRA-specific parameters to query
*/ */
_addModelSpecificParams(params, pageState) { _addModelSpecificParams(params, pageState) {
// Let parent handle View Local Versions filter first
super._addModelSpecificParams(params, pageState);
// If VLM filter was applied, skip recipe-specific filters
if (params.has('civitai_model_id')) {
return;
}
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash'); const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes'); const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
@@ -24,6 +24,14 @@ export class GlobalContextMenu extends BaseContextMenu {
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]'); const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]'); const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]'); const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
// Update check indicator for group-by-model
if (groupByModelCheck) {
const isEnabled = !!state.global.settings.group_by_model;
groupByModelCheck.style.display = isEnabled ? 'block' : 'none';
}
if (isRecipesPage) { if (isRecipesPage) {
modelUpdateItem?.classList.add('hidden'); modelUpdateItem?.classList.add('hidden');
@@ -31,6 +39,7 @@ export class GlobalContextMenu extends BaseContextMenu {
downloadExamplesItem?.classList.add('hidden'); downloadExamplesItem?.classList.add('hidden');
cleanupExamplesItem?.classList.add('hidden'); cleanupExamplesItem?.classList.add('hidden');
excludedModelsItem?.classList.add('hidden'); excludedModelsItem?.classList.add('hidden');
groupByModelItem?.classList.add('hidden');
repairRecipesItem?.classList.remove('hidden'); repairRecipesItem?.classList.remove('hidden');
} else { } else {
modelUpdateItem?.classList.remove('hidden'); modelUpdateItem?.classList.remove('hidden');
@@ -38,6 +47,7 @@ export class GlobalContextMenu extends BaseContextMenu {
downloadExamplesItem?.classList.remove('hidden'); downloadExamplesItem?.classList.remove('hidden');
cleanupExamplesItem?.classList.remove('hidden'); cleanupExamplesItem?.classList.remove('hidden');
excludedModelsItem?.classList.remove('hidden'); excludedModelsItem?.classList.remove('hidden');
groupByModelItem?.classList.remove('hidden');
repairRecipesItem?.classList.add('hidden'); repairRecipesItem?.classList.add('hidden');
} }
@@ -74,6 +84,9 @@ export class GlobalContextMenu extends BaseContextMenu {
case 'manage-excluded-models': case 'manage-excluded-models':
this.manageExcludedModels(); this.manageExcludedModels();
break; break;
case 'toggle-group-by-model':
this.toggleGroupByModel();
break;
default: default:
console.warn(`Unhandled global context menu action: ${action}`); console.warn(`Unhandled global context menu action: ${action}`);
break; break;
@@ -86,6 +99,25 @@ export class GlobalContextMenu extends BaseContextMenu {
}); });
} }
toggleGroupByModel() {
const sm = window.settingsManager;
if (!sm) {
console.error('settingsManager not available on window');
return;
}
const newValue = !state.global.settings.group_by_model;
state.global.settings.group_by_model = newValue;
sm.saveSetting('group_by_model', newValue).catch((error) => {
console.error('Failed to save group_by_model setting:', error);
// Revert state on failure
state.global.settings.group_by_model = !newValue;
});
sm.applyFrontendSettings();
sm.reloadContent();
}
async downloadExampleImages(menuItem) { async downloadExampleImages(menuItem) {
const downloadPath = state?.global?.settings?.example_images_path; const downloadPath = state?.global?.settings?.example_images_path;
if (!downloadPath) { if (!downloadPath) {
+130 -47
View File
@@ -1,9 +1,9 @@
import { updateService } from '../managers/UpdateService.js'; import { updateService } from '../managers/UpdateService.js';
import { toggleTheme } from '../utils/uiHelpers.js'; import { toggleTheme, setPreset, CYCLE_ORDER, PRESET_NAMES } from '../utils/uiHelpers.js';
import { SearchManager } from '../managers/SearchManager.js'; import { SearchManager } from '../managers/SearchManager.js';
import { FilterManager } from '../managers/FilterManager.js'; import { FilterManager } from '../managers/FilterManager.js';
import { initPageState } from '../state/index.js'; import { initPageState } from '../state/index.js';
import { getStorageItem } from '../utils/storageHelpers.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { updateElementAttribute } from '../utils/i18nHelpers.js'; import { updateElementAttribute } from '../utils/i18nHelpers.js';
import { renderSupporters } from '../services/supportersService.js'; import { renderSupporters } from '../services/supportersService.js';
@@ -47,25 +47,8 @@ export class HeaderManager {
} }
initializeCommonElements() { initializeCommonElements() {
// Handle theme toggle this.initializeThemePopover();
const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) {
const currentTheme = getStorageItem('theme') || 'auto';
themeToggle.classList.add(`theme-${currentTheme}`);
// Use i18nHelpers to update themeToggle's title
this.updateThemeTooltip(themeToggle, currentTheme);
themeToggle.addEventListener('click', async () => {
if (typeof toggleTheme === 'function') {
const newTheme = toggleTheme();
// Use i18nHelpers to update themeToggle's title
this.updateThemeTooltip(themeToggle, newTheme);
}
});
}
// Handle settings toggle
const settingsToggle = document.querySelector('.settings-toggle'); const settingsToggle = document.querySelector('.settings-toggle');
if (settingsToggle) { if (settingsToggle) {
settingsToggle.addEventListener('click', () => { settingsToggle.addEventListener('click', () => {
@@ -75,7 +58,6 @@ export class HeaderManager {
}); });
} }
// Handle update toggle
const updateToggle = document.getElementById('updateToggleBtn'); const updateToggle = document.getElementById('updateToggleBtn');
if (updateToggle) { if (updateToggle) {
updateToggle.addEventListener('click', () => { updateToggle.addEventListener('click', () => {
@@ -83,13 +65,11 @@ export class HeaderManager {
}); });
} }
// Handle support toggle
const supportToggle = document.getElementById('supportToggleBtn'); const supportToggle = document.getElementById('supportToggleBtn');
if (supportToggle) { if (supportToggle) {
supportToggle.addEventListener('click', async () => { supportToggle.addEventListener('click', async () => {
if (window.modalManager) { if (window.modalManager) {
window.modalManager.toggleModal('supportModal'); window.modalManager.toggleModal('supportModal');
// Load supporters data when modal opens
try { try {
await renderSupporters(); await renderSupporters();
} catch (error) { } catch (error) {
@@ -99,41 +79,144 @@ export class HeaderManager {
}); });
} }
// Handle QR code toggle
const qrToggle = document.getElementById('toggleQRCode'); const qrToggle = document.getElementById('toggleQRCode');
const qrContainer = document.getElementById('qrCodeContainer'); const qrContainer = document.getElementById('qrCodeContainer');
if (qrToggle && qrContainer) { if (qrToggle && qrContainer) {
qrToggle.addEventListener('click', function() { qrToggle.addEventListener('click', function () {
qrContainer.classList.toggle('show'); qrContainer.classList.toggle('show');
qrToggle.classList.toggle('active'); qrToggle.classList.toggle('active');
const toggleText = qrToggle.querySelector('.toggle-text'); const toggleText = qrToggle.querySelector('.toggle-text');
if (qrContainer.classList.contains('show')) { if (qrContainer.classList.contains('show')) {
toggleText.textContent = 'Hide WeChat QR Code'; toggleText.textContent = 'Hide WeChat QR Code';
// Add small delay to ensure DOM is updated before scrolling setTimeout(() => {
setTimeout(() => { const supportModal = document.querySelector('.support-modal');
const supportModal = document.querySelector('.support-modal'); if (supportModal) {
if (supportModal) { supportModal.scrollTo({
supportModal.scrollTo({ top: supportModal.scrollHeight,
top: supportModal.scrollHeight, behavior: 'smooth'
behavior: 'smooth' });
});
}
}, 250);
} else {
toggleText.textContent = 'Show WeChat QR Code';
} }
}); }, 250);
} else {
toggleText.textContent = 'Show WeChat QR Code';
}
});
} }
// Hide search functionality on Statistics page
this.updateHeaderForPage(); this.updateHeaderForPage();
// Initialize hamburger menu for mobile
this.initializeHamburgerMenu(); this.initializeHamburgerMenu();
} }
initializeThemePopover() {
const themeToggle = document.querySelector('.theme-toggle');
const themePopover = document.getElementById('themePopover');
if (!themeToggle || !themePopover) return;
const currentTheme = getStorageItem('theme') || 'auto';
const currentPreset = getStorageItem('theme_preset') || 'default';
themeToggle.classList.add(`theme-${currentTheme}`);
this.updateThemeTooltip(themeToggle, currentTheme);
this.updatePopoverActiveStates(currentTheme, currentPreset);
themeToggle.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = themePopover.classList.contains('active');
this.closeAllPopovers();
if (!isOpen) {
this.positionThemePopover();
themePopover.classList.add('active');
}
});
themePopover.addEventListener('click', (e) => {
e.stopPropagation();
const modeBtn = e.target.closest('.theme-mode-btn');
const presetBtn = e.target.closest('.theme-preset-btn');
if (modeBtn) {
const mode = modeBtn.dataset.mode;
this.setThemeMode(mode);
} else if (presetBtn) {
const preset = presetBtn.dataset.preset;
this.setThemePreset(preset);
}
});
document.addEventListener('click', (e) => {
if (!themeToggle.contains(e.target) && !themePopover.contains(e.target)) {
themePopover.classList.remove('active');
}
});
// Reposition on resize while popover is active
window.addEventListener('resize', () => {
if (themePopover.classList.contains('active')) {
this.positionThemePopover();
}
});
}
closeAllPopovers() {
const themePopover = document.getElementById('themePopover');
if (themePopover) {
themePopover.classList.remove('active');
}
}
positionThemePopover() {
const themeToggle = document.querySelector('.theme-toggle');
const themePopover = document.getElementById('themePopover');
if (!themeToggle || !themePopover) return;
const rect = themeToggle.getBoundingClientRect();
// Guard: toggle may be hidden on narrow viewports (≤950px CSS hides .header-controls)
if (rect.width === 0 || rect.height === 0) return;
themePopover.style.top = (rect.bottom + 8) + 'px';
themePopover.style.right = (window.innerWidth - rect.right - 8) + 'px';
}
setThemeMode(mode) {
setStorageItem('theme', mode);
const htmlElement = document.documentElement;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
htmlElement.removeAttribute('data-theme');
if (mode === 'dark' || (mode === 'auto' && prefersDark)) {
htmlElement.setAttribute('data-theme', 'dark');
document.body.dataset.theme = 'dark';
} else {
htmlElement.setAttribute('data-theme', 'light');
document.body.dataset.theme = 'light';
}
const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) {
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
themeToggle.classList.add(`theme-${mode}`);
this.updateThemeTooltip(themeToggle, mode);
}
this.updateHamburgerThemeIcon();
this.updatePopoverActiveStates(mode, getStorageItem('theme_preset') || 'default');
}
setThemePreset(preset) {
setPreset(preset);
this.updatePopoverActiveStates(getStorageItem('theme') || 'auto', preset);
this.updateHamburgerThemeIcon();
}
updatePopoverActiveStates(theme, preset) {
const popover = document.getElementById('themePopover');
if (!popover) return;
popover.querySelectorAll('.theme-mode-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === theme);
});
popover.querySelectorAll('.theme-preset-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.preset === preset);
});
}
initializeHamburgerMenu() { initializeHamburgerMenu() {
const hamburgerBtn = document.getElementById('hamburgerMenuBtn'); const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
const hamburgerDropdown = document.getElementById('hamburgerDropdown'); const hamburgerDropdown = document.getElementById('hamburgerDropdown');
@@ -188,7 +271,6 @@ export class HeaderManager {
case 'theme': case 'theme':
if (typeof toggleTheme === 'function') { if (typeof toggleTheme === 'function') {
const newTheme = toggleTheme(); const newTheme = toggleTheme();
// Update theme toggle in header if it exists
const themeToggle = document.querySelector('.theme-toggle'); const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) { if (themeToggle) {
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto'); themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
@@ -196,6 +278,7 @@ export class HeaderManager {
this.updateThemeTooltip(themeToggle, newTheme); this.updateThemeTooltip(themeToggle, newTheme);
} }
this.updateHamburgerThemeIcon(); this.updateHamburgerThemeIcon();
this.updatePopoverActiveStates(newTheme, getStorageItem('theme_preset') || 'default');
} }
break; break;
case 'settings': case 'settings':
+33 -242
View File
@@ -7,6 +7,8 @@ import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
import { downloadManager } from '../managers/DownloadManager.js'; import { downloadManager } from '../managers/DownloadManager.js';
import { MODEL_TYPES } from '../api/apiConfig.js'; import { MODEL_TYPES } from '../api/apiConfig.js';
import { openMediaViewer } from './shared/MediaViewer.js'; import { openMediaViewer } from './shared/MediaViewer.js';
import { renderCompactTags, setupTagTooltip } from './shared/utils.js';
import { setupTagEditMode } from './shared/ModelTags.js';
const ALLOWED_GEN_PARAM_KEYS = new Set([ const ALLOWED_GEN_PARAM_KEYS = new Set([
'prompt', 'prompt',
@@ -139,14 +141,6 @@ class RecipeModal {
this.saveTitleEdit(); this.saveTitleEdit();
} }
// Handle tags edit
const tagsEditor = document.getElementById('recipeTagsEditor');
if (tagsEditor && tagsEditor.classList.contains('active') &&
!tagsEditor.contains(event.target) &&
!event.target.closest('.edit-icon')) {
this.saveTagsEdit();
}
// Handle reconnect input // Handle reconnect input
const reconnectContainers = document.querySelectorAll('.lora-reconnect-container'); const reconnectContainers = document.querySelectorAll('.lora-reconnect-container');
reconnectContainers.forEach(container => { reconnectContainers.forEach(container => {
@@ -236,98 +230,10 @@ class RecipeModal {
this.filePath = hydratedRecipe.file_path; this.filePath = hydratedRecipe.file_path;
this.listFilePath = hydratedRecipe.file_path; this.listFilePath = hydratedRecipe.file_path;
// Set recipe tags if they exist // Render tags using shared utility
const tagsCompactElement = document.getElementById('recipeTagsCompact'); const tagsContainer = document.getElementById('recipeTagsContainer');
const tagsTooltipContent = document.getElementById('recipeTagsTooltipContent'); if (tagsContainer) {
this.updateTagsDisplay(tagsContainer, hydratedRecipe.tags || []);
if (tagsCompactElement) {
// Add tags container with edit functionality
tagsCompactElement.innerHTML = `
<div class="editable-content tags-content">
<div class="tags-display"></div>
<button class="edit-icon" title="Edit tags"><i class="fas fa-pencil-alt"></i></button>
</div>
<div id="recipeTagsEditor" class="content-editor tags-editor">
<input type="text" class="tags-input" placeholder="Enter tags separated by commas">
</div>
`;
const tagsDisplay = tagsCompactElement.querySelector('.tags-display');
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
// Limit displayed tags to 5, show a "+X more" button if needed
const maxVisibleTags = 5;
const visibleTags = hydratedRecipe.tags.slice(0, maxVisibleTags);
const remainingTags = hydratedRecipe.tags.length > maxVisibleTags ? hydratedRecipe.tags.slice(maxVisibleTags) : [];
// Add visible tags
visibleTags.forEach(tag => {
const tagElement = document.createElement('div');
tagElement.className = 'recipe-tag-compact';
tagElement.textContent = tag;
tagsDisplay.appendChild(tagElement);
});
// Add "more" button if needed
if (remainingTags.length > 0) {
const moreButton = document.createElement('div');
moreButton.className = 'recipe-tag-more';
moreButton.textContent = `+${remainingTags.length} more`;
tagsDisplay.appendChild(moreButton);
// Add tooltip functionality
moreButton.addEventListener('mouseenter', () => {
document.getElementById('recipeTagsTooltip').classList.add('visible');
});
moreButton.addEventListener('mouseleave', () => {
setTimeout(() => {
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
document.getElementById('recipeTagsTooltip').classList.remove('visible');
}
}, 300);
});
document.getElementById('recipeTagsTooltip').addEventListener('mouseleave', () => {
document.getElementById('recipeTagsTooltip').classList.remove('visible');
});
// Add all tags to tooltip
if (tagsTooltipContent) {
tagsTooltipContent.innerHTML = '';
hydratedRecipe.tags.forEach(tag => {
const tooltipTag = document.createElement('div');
tooltipTag.className = 'tooltip-tag';
tooltipTag.textContent = tag;
tagsTooltipContent.appendChild(tooltipTag);
});
}
}
} else {
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
}
// Add event listeners for tags editing
const editTagsIcon = tagsCompactElement.querySelector('.edit-icon');
const tagsInput = tagsCompactElement.querySelector('.tags-input');
// Set current tags in the input
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
tagsInput.value = hydratedRecipe.tags.join(', ');
}
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
// Add key event listener for Enter key
tagsInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.saveTagsEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
this.cancelTagsEdit();
}
});
} }
// Set recipe image // Set recipe image
@@ -609,17 +515,35 @@ class RecipeModal {
} }
syncTagsDisplay(tags) { syncTagsDisplay(tags) {
const tagsContainer = document.getElementById('recipeTagsCompact'); const container = document.getElementById('recipeTagsContainer');
if (!tagsContainer) { if (!container) return;
return; this.updateTagsDisplay(container, tags || []);
} }
this.updateTagsDisplay(tagsContainer, tags || []); // Re-render tags display using shared utility, wire edit mode with ModelTags
updateTagsDisplay(container, tags) {
const filePath = this.filePath || '';
const tagsInput = tagsContainer.querySelector('.tags-input'); container.innerHTML = renderCompactTags(tags, filePath);
if (tagsInput) {
tagsInput.value = tags && tags.length > 0 ? tags.join(', ') : ''; // Setup tooltip for all tags
} setupTagTooltip(container);
// Wire edit button using shared tag editing (no suggestions for recipes)
setupTagEditMode(null, {
container: container,
showSuggestions: false,
normalizeTag: false,
saveHandler: async (filePath, tags) => {
await updateRecipeMetadata(filePath, { tags }, this.getMetadataUpdateOptions());
},
onSaved: (tags) => {
this.currentRecipe.tags = tags;
this.commitField('tags');
const c = document.getElementById('recipeTagsContainer');
if (c) this.updateTagsDisplay(c, tags);
},
});
} }
syncPromptField(field, value, placeholder) { syncPromptField(field, value, placeholder) {
@@ -976,139 +900,6 @@ class RecipeModal {
} }
} }
// Tags editing methods
showTagsEditor() {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (tagsContainer) {
tagsContainer.querySelector('.editable-content').classList.add('hide');
const editor = tagsContainer.querySelector('#recipeTagsEditor');
editor.classList.add('active');
const input = editor.querySelector('input');
input.oninput = () => this.markFieldDirty('tags');
input.focus();
}
}
saveTagsEdit() {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (tagsContainer) {
const editor = tagsContainer.querySelector('#recipeTagsEditor');
const input = editor.querySelector('input');
const tagsText = input.value.trim();
// Parse tags
let newTags = [];
if (tagsText) {
newTags = tagsText.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0);
}
// Check if tags changed
const oldTags = this.currentRecipe.tags || [];
const tagsChanged =
newTags.length !== oldTags.length ||
newTags.some((tag, index) => tag !== oldTags[index]);
if (tagsChanged) {
// Update the recipe on the server
updateRecipeMetadata(this.filePath, { tags: newTags }, this.getMetadataUpdateOptions())
.then(data => {
// Show success toast
showToast('toast.recipes.tagsUpdated', {}, 'success');
// Update the current recipe object
this.currentRecipe.tags = newTags;
this.commitField('tags');
// Update tags in the UI
this.updateTagsDisplay(tagsContainer, newTags);
})
.catch(error => {
// Error is handled in the API function
this.clearFieldDirty('tags');
});
} else {
this.clearFieldDirty('tags');
}
// Hide editor
editor.classList.remove('active');
tagsContainer.querySelector('.editable-content').classList.remove('hide');
}
}
// Helper method to update tags display
updateTagsDisplay(tagsContainer, tags) {
const tagsDisplay = tagsContainer.querySelector('.tags-display');
tagsDisplay.innerHTML = '';
if (tags.length > 0) {
// Limit displayed tags to 5, show a "+X more" button if needed
const maxVisibleTags = 5;
const visibleTags = tags.slice(0, maxVisibleTags);
const remainingTags = tags.length > maxVisibleTags ? tags.slice(maxVisibleTags) : [];
// Add visible tags
visibleTags.forEach(tag => {
const tagElement = document.createElement('div');
tagElement.className = 'recipe-tag-compact';
tagElement.textContent = tag;
tagsDisplay.appendChild(tagElement);
});
// Add "more" button if needed
if (remainingTags.length > 0) {
const moreButton = document.createElement('div');
moreButton.className = 'recipe-tag-more';
moreButton.textContent = `+${remainingTags.length} more`;
tagsDisplay.appendChild(moreButton);
// Update tooltip content
const tooltipContent = document.getElementById('recipeTagsTooltipContent');
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const tooltipTag = document.createElement('div');
tooltipTag.className = 'tooltip-tag';
tooltipTag.textContent = tag;
tooltipContent.appendChild(tooltipTag);
});
}
// Re-add tooltip functionality
moreButton.addEventListener('mouseenter', () => {
document.getElementById('recipeTagsTooltip').classList.add('visible');
});
moreButton.addEventListener('mouseleave', () => {
setTimeout(() => {
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
document.getElementById('recipeTagsTooltip').classList.remove('visible');
}
}, 300);
});
}
} else {
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
}
}
cancelTagsEdit() {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (tagsContainer) {
// Reset input value
const editor = tagsContainer.querySelector('#recipeTagsEditor');
const input = editor.querySelector('input');
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
this.clearFieldDirty('tags');
// Hide editor
editor.classList.remove('active');
tagsContainer.querySelector('.editable-content').classList.remove('hide');
}
}
setupPromptEditors() { setupPromptEditors() {
const promptConfigs = [ const promptConfigs = [
{ {
+187 -496
View File
@@ -4,7 +4,7 @@
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js'; import { getModelApiClient } from '../api/modelApiFactory.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js'; import { bulkManager } from '../managers/BulkManager.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js'; import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
@@ -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() {
@@ -519,21 +457,69 @@ export class SidebarManager {
try { try {
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove); console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
let movedFiles = []; // Array of { original_file_path, new_file_path }
if (useBulkMove) { if (useBulkMove) {
await this.apiClient.moveBulkModels(this.draggedFilePaths, destination); const results = await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
movedFiles = (results || [])
.filter(r => r.success)
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
} else { } else {
await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination); const result = await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
if (result) {
movedFiles.push({
original_file_path: result.original_file_path || this.draggedFilePaths[0],
new_file_path: result.new_file_path
});
}
} }
console.log('[SidebarManager] apiClient.move successful'); console.log('[SidebarManager] apiClient.move successful');
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') { // Update VirtualScroller in-place instead of full reload
console.log('[SidebarManager] calling resetAndReload'); if (movedFiles.length > 0 && state.virtualScroller) {
await this.pageControls.resetAndReload(true); const pageState = getCurrentPageState();
} else { const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
console.log('[SidebarManager] calling refresh'); const isRecursive = pageState.searchOptions?.recursive ?? true;
await this.refresh(); const isFolderFiltered = pageState.activeFolder !== null;
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
// Determine if items in the target folder are visible in the current view
let itemsRemainVisible = true;
if (isFolderFiltered) {
if (isRecursive) {
itemsRemainVisible = normalizedActive === '' ||
normalizedTarget === normalizedActive ||
normalizedTarget.startsWith(normalizedActive + '/');
} else {
itemsRemainVisible = normalizedTarget === normalizedActive;
}
}
if (itemsRemainVisible) {
// Items stay visible — update each item's file_path to reflect new location
for (const moved of movedFiles) {
if (moved.original_file_path && moved.new_file_path) {
state.virtualScroller.updateSingleItem(moved.original_file_path, {
file_path: moved.new_file_path,
folder: normalizedTarget
});
}
}
} else {
// Items no longer visible in current folder — remove from VirtualScroller
const pathsToRemove = movedFiles
.map(m => m.original_file_path)
.filter(Boolean);
if (pathsToRemove.length > 0) {
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
}
}
} }
// Refresh sidebar folder tree only (no model data reload)
await this.refresh();
if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') { if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
bulkManager.toggleBulkMode(); bulkManager.toggleBulkMode();
} }
@@ -592,21 +578,69 @@ export class SidebarManager {
try { try {
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove); console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
let movedFiles = []; // Array of { original_file_path, new_file_path }
if (useBulkMove) { if (useBulkMove) {
await this.apiClient.moveBulkModels(draggedFilePaths, destination); const results = await this.apiClient.moveBulkModels(draggedFilePaths, destination);
movedFiles = (results || [])
.filter(r => r.success)
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
} else { } else {
await this.apiClient.moveSingleModel(draggedFilePaths[0], destination); const result = await this.apiClient.moveSingleModel(draggedFilePaths[0], destination);
if (result) {
movedFiles.push({
original_file_path: result.original_file_path || draggedFilePaths[0],
new_file_path: result.new_file_path
});
}
} }
console.log('[SidebarManager] apiClient.move successful'); console.log('[SidebarManager] apiClient.move successful');
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') { // Update VirtualScroller in-place instead of full reload
console.log('[SidebarManager] calling resetAndReload'); if (movedFiles.length > 0 && state.virtualScroller) {
await this.pageControls.resetAndReload(true); const pageState = getCurrentPageState();
} else { const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
console.log('[SidebarManager] calling refresh'); const isRecursive = pageState.searchOptions?.recursive ?? true;
await this.refresh(); const isFolderFiltered = pageState.activeFolder !== null;
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
// Determine if items in the target folder are visible in the current view
let itemsRemainVisible = true;
if (isFolderFiltered) {
if (isRecursive) {
itemsRemainVisible = normalizedActive === '' ||
normalizedTarget === normalizedActive ||
normalizedTarget.startsWith(normalizedActive + '/');
} else {
itemsRemainVisible = normalizedTarget === normalizedActive;
}
}
if (itemsRemainVisible) {
// Items stay visible — update each item's file_path to reflect new location
for (const moved of movedFiles) {
if (moved.original_file_path && moved.new_file_path) {
state.virtualScroller.updateSingleItem(moved.original_file_path, {
file_path: moved.new_file_path,
folder: normalizedTarget
});
}
}
} else {
// Items no longer visible in current folder — remove from VirtualScroller
const pathsToRemove = movedFiles
.map(m => m.original_file_path)
.filter(Boolean);
if (pathsToRemove.length > 0) {
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
}
}
} }
// Refresh sidebar folder tree only (no model data reload)
await this.refresh();
if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') { if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
bulkManager.toggleBulkMode(); bulkManager.toggleBulkMode();
} }
@@ -919,60 +953,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 +967,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 +992,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 +1032,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 +1053,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 +1065,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 +1079,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() {
@@ -1433,7 +1136,15 @@ export class SidebarManager {
<span class="sidebar-hidden-indicator-tooltip">${translate('sidebar.showSidebar')}</span> <span class="sidebar-hidden-indicator-tooltip">${translate('sidebar.showSidebar')}</span>
`; `;
// Subtle breathing animation on first sight to aid discoverability;
// stops permanently after user clicks the restore button once
const restoreKey = `${this.pageType}_restoreButtonUsed`;
if (!getStorageItem(restoreKey, false)) {
indicator.classList.add('breathing');
}
indicator.addEventListener('click', () => { indicator.addEventListener('click', () => {
setStorageItem(restoreKey, true);
this.toggleHideOnThisPage(); this.toggleHideOnThisPage();
}); });
@@ -1731,13 +1442,8 @@ export class SidebarManager {
this.pageControls.pageState.activeFolder = normalizedPath; this.pageControls.pageState.activeFolder = normalizedPath;
setStorageItem(`${this.pageType}_activeFolder`, normalizedPath); setStorageItem(`${this.pageType}_activeFolder`, normalizedPath);
// Reload models with new filter // Reload models with new filter (loadMoreWithVirtualScroll will scroll to top)
await this.pageControls.resetAndReload(); await this.pageControls.resetAndReload();
// Auto-hide sidebar on mobile after selection
if (window.innerWidth <= 1024) {
this.hideSidebar();
}
} }
handleFolderListClick(event) { handleFolderListClick(event) {
@@ -2047,65 +1753,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 +1814,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 +1825,7 @@ export class SidebarManager {
} }
async refresh() { async refresh() {
if (this.isDisabledBySetting || !this.isInitialized) { if (!this.isInitialized) {
return; return;
} }
@@ -95,6 +95,17 @@ export class CheckpointsControls extends PageControls {
* Clear checkpoint custom filter and reload * Clear checkpoint custom filter and reload
*/ */
async clearCustomFilter() { async clearCustomFilter() {
// Check for View Local Versions filter first
const vlmModelId = getSessionItem('vlm_model_id');
if (vlmModelId) {
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
window.location.reload();
return;
}
removeSessionItem('recipe_to_checkpoint_filterHash'); removeSessionItem('recipe_to_checkpoint_filterHash');
removeSessionItem('recipe_to_checkpoint_filterHashes'); removeSessionItem('recipe_to_checkpoint_filterHashes');
removeSessionItem('filterCheckpointRecipeName'); removeSessionItem('filterCheckpointRecipeName');
@@ -106,14 +117,4 @@ export class CheckpointsControls extends PageControls {
await resetAndReload(); await resetAndReload();
} }
/**
* Helper to truncate text with ellipsis
* @param {string} text
* @param {number} maxLength
* @returns {string}
*/
_truncateText(text, maxLength) {
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
}
} }
+11 -10
View File
@@ -112,6 +112,17 @@ export class LorasControls extends PageControls {
* Clear the custom filter and reload the page * Clear the custom filter and reload the page
*/ */
async clearCustomFilter() { async clearCustomFilter() {
// Check for View Local Versions filter first (handles VLM and reloads)
const vlmModelId = getSessionItem('vlm_model_id');
if (vlmModelId) {
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
window.location.reload();
return;
}
console.log("Clearing custom filter..."); console.log("Clearing custom filter...");
// Remove filter parameters from session storage // Remove filter parameters from session storage
removeSessionItem('recipe_to_lora_filterLoraHash'); removeSessionItem('recipe_to_lora_filterLoraHash');
@@ -134,16 +145,6 @@ export class LorasControls extends PageControls {
await resetAndReload(); await resetAndReload();
} }
/**
* Helper to truncate text with ellipsis
* @param {string} text - Text to truncate
* @param {number} maxLength - Maximum length before truncating
* @returns {string} - Truncated text
*/
_truncateText(text, maxLength) {
return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text;
}
/** /**
* Initialize the alphabet bar component * Initialize the alphabet bar component
*/ */
+68 -10
View File
@@ -1,6 +1,6 @@
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages // PageControls.js - Manages controls for both LoRAs and Checkpoints pages
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js'; import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js'; import { getStorageItem, setStorageItem, getSessionItem, setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js'; import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js'; import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js'; import { sidebarManager } from '../SidebarManager.js';
@@ -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);
} }
@@ -130,6 +129,9 @@ export class PageControls {
clearFilterBtn.addEventListener('click', () => this.clearCustomFilter()); clearFilterBtn.addEventListener('click', () => this.clearCustomFilter());
} }
// Check for View Local Versions filter
this.checkVlmFilter();
// Page-specific event listeners // Page-specific event listeners
this.initPageSpecificListeners(); this.initPageSpecificListeners();
} }
@@ -460,10 +462,65 @@ export class PageControls {
this.api.toggleBulkMode(); this.api.toggleBulkMode();
} }
/**
* Clear custom filter
*/
/**
* Check for View Local Versions filter in sessionStorage (page-type-scoped)
*/
checkVlmFilter() {
const vlmModelId = getSessionItem('vlm_model_id');
const vlmPageType = getSessionItem('vlm_page_type');
// Only show VLM indicator when it belongs to the current page type
if (vlmModelId && vlmPageType !== this.pageType) {
// Stale VLM data from a different page — clean up
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
return;
}
const vlmModelName = getSessionItem('vlm_model_name');
const vlmBaseModel = getSessionItem('vlm_base_model');
if (vlmModelId && vlmModelName) {
const indicator = document.getElementById('customFilterIndicator');
const filterText = indicator?.querySelector('.customFilterText');
if (indicator && filterText) {
indicator.classList.remove('hidden');
const prefix = vlmBaseModel
? 'Showing same-base versions from'
: 'Showing all versions from';
const displayText = `${prefix}: ${vlmModelName}`;
filterText.textContent = this._truncateText(displayText, 40);
filterText.setAttribute('title', displayText);
}
}
}
/** /**
* Clear custom filter * Clear custom filter
*/ */
async clearCustomFilter() { async clearCustomFilter() {
// Check for View Local Versions filter first
const vlmModelId = getSessionItem('vlm_model_id');
if (vlmModelId) {
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
// Full page reload to restore initial state (mirrors the "set" action)
window.location.reload();
return;
}
// Otherwise delegate to subclass for recipe filters
if (!this.api) { if (!this.api) {
console.error('API methods not registered'); console.error('API methods not registered');
return; return;
@@ -477,6 +534,14 @@ export class PageControls {
} }
} }
/**
* Truncate text with ellipsis
*/
_truncateText(text, maxLength) {
if (!text) return '';
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
}
/** /**
* Initialize the favorites filter button state * Initialize the favorites filter button state
*/ */
@@ -664,13 +729,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() {
+94 -1
View File
@@ -234,6 +234,95 @@ function renderLicenseIcons(modelData) {
</div>`; </div>`;
} }
// ── Set 2 (new CivitAI-style) permission icons ──
const NEW_LICENSE_ICON_CONFIG = [
{
key: 'commercial',
icon: 'currency-dollar.svg',
allowedFn: (license) => {
const uses = license.allowCommercialUse || [];
return uses.includes('Image') || uses.includes('Sell');
},
labelAllowed: 'Commercial use allowed',
labelDenied: 'No commercial use'
},
{
key: 'genServices',
icon: 'brush.svg',
allowedFn: (license) => {
const uses = license.allowCommercialUse || [];
return uses.includes('RentCivit') || uses.includes('Rent');
},
labelAllowed: 'Generation services allowed',
labelDenied: 'No generation services'
},
{
key: 'credit',
icon: 'user.svg',
allowedFn: (license) => !!license.allowNoCredit,
labelAllowed: 'No credit required',
labelDenied: 'Creator credit required'
},
{
key: 'derivatives',
icon: 'git-merge.svg',
allowedFn: (license) => !!license.allowDerivatives,
labelAllowed: 'Merges allowed',
labelDenied: 'No merges allowed'
},
{
key: 'relicense',
icon: 'license.svg',
allowedFn: (license) => !!license.allowDifferentLicense,
labelAllowed: 'Different permissions allowed on merges',
labelDenied: 'Same permissions required on merges'
}
];
function createNewLicenseIconMarkup(icon, allowed, label) {
const safeLabel = escapeAttribute(label);
const iconPath = `/loras_static/images/tabler/${icon}`;
const stateClass = allowed ? 'allowed' : 'denied';
return `<span class="license-icon-new ${stateClass}" role="img" aria-label="${safeLabel}" title="${safeLabel}" style="--license-icon-image: url('${iconPath}')"></span>`;
}
function renderNewLicenseIcons(modelData) {
const license = modelData?.civitai?.model;
if (!license) {
return '';
}
const icons = [];
NEW_LICENSE_ICON_CONFIG.forEach((config) => {
if (config.key === 'credit' && !hasLicenseField(license, 'allowNoCredit')) {
return;
}
if (config.key === 'derivatives' && !hasLicenseField(license, 'allowDerivatives')) {
return;
}
if (config.key === 'relicense' && !hasLicenseField(license, 'allowDifferentLicense')) {
return;
}
if ((config.key === 'commercial' || config.key === 'genServices') && !hasLicenseField(license, 'allowCommercialUse')) {
return;
}
const allowed = config.allowedFn(license);
const label = allowed ? config.labelAllowed : config.labelDenied;
icons.push(createNewLicenseIconMarkup(config.icon, allowed, label));
});
if (!icons.length) {
return '';
}
const containerLabel = translate('modals.model.license.restrictionsLabel', {}, 'License permissions');
const safeContainerLabel = escapeAttribute(containerLabel);
return `<div class="license-permissions" aria-label="${safeContainerLabel}" role="group">
${icons.join('\n ')}
</div>`;
}
/** /**
* Display the model modal with the given model data * Display the model modal with the given model data
* @param {Object} model - Model data object * @param {Object} model - Model data object
@@ -264,7 +353,10 @@ export async function showModelModal(model, modelType) {
}; };
const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || ''); const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || '');
const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A'); const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A');
const licenseIcons = renderLicenseIcons(modelWithFullData); const useNewIcons = state.global.settings.use_new_license_icons !== false;
const licenseIcons = useNewIcons
? renderNewLicenseIcons(modelWithFullData)
: renderLicenseIcons(modelWithFullData);
const viewOnCivitaiAction = modelWithFullData.from_civitai ? ` const viewOnCivitaiAction = modelWithFullData.from_civitai ? `
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}"> <div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')} <i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
@@ -660,6 +752,7 @@ export async function showModelModal(model, modelType) {
modelId: civitaiModelId, modelId: civitaiModelId,
currentVersionId: civitaiVersionId, currentVersionId: civitaiVersionId,
currentBaseModel: modelWithFullData.base_model, currentBaseModel: modelWithFullData.base_model,
modelName: model.model_name,
onUpdateStatusChange: handleUpdateStatusChange, onUpdateStatusChange: handleUpdateStatusChange,
}); });
setupEditableFields(modelWithFullData.file_path, modelType); setupEditableFields(modelWithFullData.file_path, modelType);
+123 -69
View File
@@ -29,6 +29,14 @@ let priorityTagSuggestionsLoaded = false;
let priorityTagSuggestionsPromise = null; let priorityTagSuggestionsPromise = null;
let activeTagDragState = null; let activeTagDragState = null;
// Configurable options for tag editing (set by setupTagEditMode)
let tagEditOptions = {
showSuggestions: true,
saveHandler: null,
onSaved: null,
normalizeTag: true,
};
function normalizeModelTypeKey(modelType) { function normalizeModelTypeKey(modelType) {
if (!modelType) { if (!modelType) {
return ''; return '';
@@ -140,13 +148,30 @@ let saveTagsHandler = null;
/** /**
* Set up tag editing mode * Set up tag editing mode
* @param {string|null} modelType - Model type for suggestions (e.g. 'loras', 'checkpoints')
* @param {Object} [options] - Optional configuration
* @param {boolean} [options.showSuggestions=true] - Show priority tag suggestions dropdown
* @param {Function} [options.saveHandler] - Custom save function, async (filePath, tags) => {}
* @param {Function} [options.onSaved] - Called after successful save, (tags) => {}
* @param {boolean} [options.normalizeTag=true] - Lowercase tag on add
*/ */
export function setupTagEditMode(modelType = null) { export function setupTagEditMode(modelType = null, options = {}) {
const editBtn = document.querySelector('.edit-tags-btn'); // Store options for use by saveTags and addNewTag
tagEditOptions = {
showSuggestions: options.showSuggestions !== false,
saveHandler: options.saveHandler || null,
onSaved: options.onSaved || null,
normalizeTag: options.normalizeTag !== false,
};
const root = options.container || document;
const editBtn = root.querySelector('.edit-tags-btn');
if (!editBtn) return; if (!editBtn) return;
setActiveModelTypeKey(modelType); if (tagEditOptions.showSuggestions) {
ensurePriorityTagSuggestions(); setActiveModelTypeKey(modelType);
ensurePriorityTagSuggestions();
}
// Store original tags for restoring on cancel // Store original tags for restoring on cancel
let originalTags = []; let originalTags = [];
@@ -158,7 +183,8 @@ export function setupTagEditMode(modelType = null) {
// Create new handler and store reference // Create new handler and store reference
const editBtnClickHandler = function() { const editBtnClickHandler = function() {
const tagsSection = document.querySelector('.model-tags-container'); const tagsSection = this.closest('.model-tags-container');
if (!tagsSection) return;
const isEditMode = tagsSection.classList.toggle('edit-mode'); const isEditMode = tagsSection.classList.toggle('edit-mode');
const filePath = this.dataset.filePath; const filePath = this.dataset.filePath;
@@ -193,16 +219,18 @@ export function setupTagEditMode(modelType = null) {
tagsSection.appendChild(editContainer); tagsSection.appendChild(editContainer);
// Setup the tag input field behavior // Setup the tag input field behavior
setupTagInput(); setupTagInput(tagsSection);
// Create and add preset suggestions dropdown // Create and add preset suggestions dropdown
const tagForm = editContainer.querySelector('.metadata-add-form'); if (tagEditOptions.showSuggestions) {
const suggestionsDropdown = createSuggestionsDropdown(originalTags); const tagForm = editContainer.querySelector('.metadata-add-form');
tagForm.appendChild(suggestionsDropdown); const suggestionsDropdown = createSuggestionsDropdown(originalTags);
tagForm.appendChild(suggestionsDropdown);
}
// Setup delete buttons for existing tags // Setup delete buttons for existing tags
setupDeleteButtons(); setupDeleteButtons();
setupTagDragAndDrop(); setupTagDragAndDrop(tagsSection);
// Transfer click event from original button to the cloned one // Transfer click event from original button to the cloned one
const newEditBtn = editContainer.querySelector('.metadata-header-btn'); const newEditBtn = editContainer.querySelector('.metadata-header-btn');
@@ -218,7 +246,7 @@ export function setupTagEditMode(modelType = null) {
// Just show the existing edit container // Just show the existing edit container
tagsEditContainer.style.display = 'block'; tagsEditContainer.style.display = 'block';
editBtn.style.display = 'none'; editBtn.style.display = 'none';
setupTagDragAndDrop(); setupTagDragAndDrop(tagsSection);
} }
} else { } else {
// Exit edit mode // Exit edit mode
@@ -255,7 +283,7 @@ export function setupTagEditMode(modelType = null) {
saveTagsHandler = function(e) { saveTagsHandler = function(e) {
if (e.target.classList.contains('save-tags-btn') || if (e.target.classList.contains('save-tags-btn') ||
e.target.closest('.save-tags-btn')) { e.target.closest('.save-tags-btn')) {
saveTags(); saveTags(e.target);
} }
}; };
@@ -267,19 +295,28 @@ export function setupTagEditMode(modelType = null) {
/** /**
* Save tags * Save tags
* @param {Element} [triggerElement] - The element that triggered the save (e.g. save button)
*/ */
async function saveTags() { async function saveTags(triggerElement = null) {
const editBtn = document.querySelector('.edit-tags-btn'); let editBtn;
if (!editBtn) return; let scope;
if (triggerElement) {
scope = triggerElement.closest('.model-tags-container');
editBtn = scope ? scope.querySelector('.edit-tags-btn') : document.querySelector('.edit-tags-btn');
} else {
scope = document.querySelector('.model-tags-container');
editBtn = scope ? scope.querySelector('.edit-tags-btn') : null;
}
if (!editBtn || !scope) return;
const filePath = editBtn.dataset.filePath; const filePath = editBtn.dataset.filePath;
const tagElements = document.querySelectorAll('.metadata-item'); const tagElements = scope.querySelectorAll('.metadata-item');
let tags = Array.from(tagElements).map(tag => tag.dataset.tag); let tags = Array.from(tagElements).map(tag => tag.dataset.tag);
// Flush uncommitted input as a tag so it's not silently lost on save // Flush uncommitted input as a tag so it's not silently lost on save
const tagInput = document.querySelector('.metadata-input'); const tagInput = scope.querySelector('.metadata-input');
if (tagInput) { if (tagInput) {
const pendingTag = tagInput.value.trim().toLowerCase(); const pendingTag = tagEditOptions.normalizeTag ? tagInput.value.trim().toLowerCase() : tagInput.value.trim();
if (pendingTag && !tags.includes(pendingTag)) { if (pendingTag && !tags.includes(pendingTag)) {
tags.push(pendingTag); tags.push(pendingTag);
} }
@@ -287,7 +324,7 @@ async function saveTags() {
} }
// Get original tags to compare // Get original tags to compare
const originalTagElements = document.querySelectorAll('.tooltip-tag'); const originalTagElements = scope.querySelectorAll('.tooltip-tag');
const originalTags = Array.from(originalTagElements).map(tag => tag.textContent); const originalTags = Array.from(originalTagElements).map(tag => tag.textContent);
// Check if tags have actually changed // Check if tags have actually changed
@@ -301,59 +338,68 @@ async function saveTags() {
} }
try { try {
// Save tags metadata // Use custom save handler if provided, otherwise default model API
await getModelApiClient().saveModelMetadata(filePath, { tags: tags }); if (tagEditOptions.saveHandler) {
await tagEditOptions.saveHandler(filePath, tags);
} else {
await getModelApiClient().saveModelMetadata(filePath, { tags: tags });
}
// Set flag to skip restoring original tags when exiting edit mode // Set flag to skip restoring original tags when exiting edit mode
editBtn.dataset.skipRestore = "true"; editBtn.dataset.skipRestore = "true";
// Update the compact tags display // Use custom onSaved if provided (e.g. for recipe dirty state + re-render)
const compactTagsContainer = document.querySelector('.model-tags-container'); if (tagEditOptions.onSaved) {
if (compactTagsContainer) { tagEditOptions.onSaved(tags);
// Generate new compact tags HTML } else {
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact'); // Update the compact tags display
const compactTagsContainer = scope;
if (compactTagsContainer) {
// Generate new compact tags HTML
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
if (compactTagsDisplay) { if (compactTagsDisplay) {
// Clear current tags // Clear current tags
compactTagsDisplay.innerHTML = ''; compactTagsDisplay.innerHTML = '';
// Add visible tags (up to 5) // Add visible tags (up to 5)
const visibleTags = tags.slice(0, 5); const visibleTags = tags.slice(0, 5);
visibleTags.forEach(tag => { visibleTags.forEach(tag => {
const span = document.createElement('span'); const span = document.createElement('span');
span.className = 'model-tag-compact'; span.className = 'model-tag-compact';
span.textContent = tag; span.textContent = tag;
compactTagsDisplay.appendChild(span); compactTagsDisplay.appendChild(span);
}); });
// Add more indicator if needed // Add more indicator if needed
const remainingCount = Math.max(0, tags.length - 5); const remainingCount = Math.max(0, tags.length - 5);
if (remainingCount > 0) { if (remainingCount > 0) {
const more = document.createElement('span'); const more = document.createElement('span');
more.className = 'model-tag-more'; more.className = 'model-tag-more';
more.dataset.count = remainingCount; more.dataset.count = remainingCount;
more.textContent = `+${remainingCount}`; more.textContent = `+${remainingCount}`;
compactTagsDisplay.appendChild(more); compactTagsDisplay.appendChild(more);
}
}
// Update tooltip content
const tooltipContent = compactTagsContainer.querySelector('.tooltip-content');
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const span = document.createElement('span');
span.className = 'tooltip-tag';
span.textContent = tag;
tooltipContent.appendChild(span);
});
} }
} }
// Update tooltip content // Exit edit mode
const tooltipContent = compactTagsContainer.querySelector('.tooltip-content'); editBtn.click();
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const span = document.createElement('span');
span.className = 'tooltip-tag';
span.textContent = tag;
tooltipContent.appendChild(span);
});
}
} }
// Exit edit mode
editBtn.click();
showToast('modelTags.messages.updated', {}, 'success'); showToast('modelTags.messages.updated', {}, 'success');
} catch (error) { } catch (error) {
console.error('Error saving tags:', error); console.error('Error saving tags:', error);
@@ -470,16 +516,19 @@ function renderPriorityTagSuggestions(container, existingTags = []) {
/** /**
* Set up tag input behavior * Set up tag input behavior
* @param {Element} scopeContainer - The .model-tags-container element
*/ */
function setupTagInput() { function setupTagInput(scopeContainer) {
const tagInput = document.querySelector('.metadata-input'); const tagInput = scopeContainer
? scopeContainer.querySelector('.metadata-input')
: document.querySelector('.metadata-input');
if (tagInput) { if (tagInput) {
tagInput.focus(); tagInput.focus();
tagInput.addEventListener('keydown', function(e) { tagInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
addNewTag(this.value); addNewTag(this.value, this);
this.value = ''; // Clear input after adding this.value = ''; // Clear input after adding
} }
}); });
@@ -504,9 +553,12 @@ function setupDeleteButtons() {
/** /**
* Enable drag-and-drop sorting for tag items * Enable drag-and-drop sorting for tag items
* @param {Element} [scopeContainer] - Optional scoped .model-tags-container element
*/ */
function setupTagDragAndDrop() { function setupTagDragAndDrop(scopeContainer) {
const container = document.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR); const container = scopeContainer
? scopeContainer.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR)
: document.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR);
if (!container) { if (!container) {
return; return;
} }
@@ -712,12 +764,14 @@ function finishPointerDrag() {
/** /**
* Add a new tag * Add a new tag
* @param {string} tag - Tag to add * @param {string} tag - Tag to add
* @param {Element} [scopeElement] - Element within the correct .model-tags-container for scoping
*/ */
function addNewTag(tag) { function addNewTag(tag, scopeElement = null) {
tag = tag.trim().toLowerCase(); tag = tagEditOptions.normalizeTag ? tag.trim().toLowerCase() : tag.trim();
if (!tag) return; if (!tag) return;
const tagsContainer = document.querySelector('.metadata-items'); const scope = scopeElement ? scopeElement.closest('.model-tags-container') : document;
const tagsContainer = scope.querySelector('.metadata-items');
if (!tagsContainer) return; if (!tagsContainer) return;
// Validation: Check length // Validation: Check length
@@ -762,7 +816,7 @@ function addNewTag(tag) {
}); });
tagsContainer.appendChild(newTag); tagsContainer.appendChild(newTag);
setupTagDragAndDrop(); setupTagDragAndDrop(scope);
// Update status of items in the suggestions dropdown // Update status of items in the suggestions dropdown
updateSuggestionsDropdown(); updateSuggestionsDropdown();
@@ -6,6 +6,7 @@ import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js'; import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
import { formatFileSize } from './utils.js'; import { formatFileSize } from './utils.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv']; const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png'; const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
@@ -744,7 +745,7 @@ function renderToolbar(record, toolbarState = {}) {
<button class="versions-toolbar-btn versions-toolbar-btn-primary" data-versions-action="toggle-model-ignore"> <button class="versions-toolbar-btn versions-toolbar-btn-primary" data-versions-action="toggle-model-ignore">
${escapeHtml(ignoreText)} ${escapeHtml(ignoreText)}
</button> </button>
<button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Coming soon'))}" disabled> <button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Show all local versions of this model on the main page'))}">
${escapeHtml(viewLocalText)} ${escapeHtml(viewLocalText)}
</button> </button>
</div> </div>
@@ -792,6 +793,7 @@ export function initVersionsTab({
modelId, modelId,
currentVersionId, currentVersionId,
currentBaseModel, currentBaseModel,
modelName,
onUpdateStatusChange, onUpdateStatusChange,
}) { }) {
const pane = document.querySelector(`#${modalId} #versions-tab`); const pane = document.querySelector(`#${modalId} #versions-tab`);
@@ -1019,6 +1021,32 @@ export function initVersionsTab({
render(controller.record); render(controller.record);
} }
function handleViewLocalVersions() {
if (!controller.record || !modelId) {
return;
}
// Determine base model filter based on current display mode
const baseModelInfo = getCurrentVersionBaseModel(controller.record, normalizedCurrentVersionId);
const isFilteringActive =
displayMode === DISPLAY_FILTER_MODES.SAME_BASE &&
Boolean(baseModelInfo.normalized);
// Write filter params to sessionStorage (page-scoped)
setSessionItem('vlm_model_id', String(modelId));
setSessionItem('vlm_model_name', modelName || String(modelId));
setSessionItem('vlm_page_type', modelType);
if (isFilteringActive) {
// Use raw (non-normalized) base model for exact backend matching
setSessionItem('vlm_base_model', baseModelInfo.raw);
} else {
removeSessionItem('vlm_base_model');
}
// Close the modal and reload the page to show filtered cards
modalManager.closeModal(modalId);
window.location.reload();
}
async function handleToggleVersionIgnore(button, versionId) { async function handleToggleVersionIgnore(button, versionId) {
if (!controller.record) { if (!controller.record) {
return; return;
@@ -1348,6 +1376,10 @@ export function initVersionsTab({
event.preventDefault(); event.preventDefault();
handleToggleVersionDisplayMode(); handleToggleVersionDisplayMode();
break; break;
case 'view-local':
event.preventDefault();
handleViewLocalVersions();
break;
default: default:
break; break;
} }
@@ -355,9 +355,9 @@ function renderImportInterface(isEmpty) {
<button class="select-files-btn" id="selectExampleFilesBtn"> <button class="select-files-btn" id="selectExampleFilesBtn">
<i class="fas fa-folder-open"></i> Select Files <i class="fas fa-folder-open"></i> Select Files
</button> </button>
<p class="import-formats">Supported formats: jpg, png, gif, webp, mp4, webm</p> <p class="import-formats">Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm</p>
</div> </div>
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;"> <input type="file" id="exampleFilesInput" multiple accept="image/*,image/avif,image/jxl,video/mp4,video/webm" style="display: none;">
<div class="import-progress-container" style="display: none;"> <div class="import-progress-container" style="display: none;">
<div class="import-progress"> <div class="import-progress">
<div class="progress-bar"></div> <div class="progress-bar"></div>
@@ -473,7 +473,7 @@ export function initExampleImport(modelHash, container) {
*/ */
async function handleImportFiles(files, modelHash, importContainer) { async function handleImportFiles(files, modelHash, importContainer) {
// Filter for supported file types // Filter for supported file types
const supportedImages = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; const supportedImages = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.jxl'];
const supportedVideos = ['.mp4', '.webm']; const supportedVideos = ['.mp4', '.webm'];
const supportedExtensions = [...supportedImages, ...supportedVideos]; const supportedExtensions = [...supportedImages, ...supportedVideos];
+5 -3
View File
@@ -78,10 +78,12 @@ export function renderCompactTags(tags, filePath = '') {
/** /**
* Set up tag tooltip functionality * Set up tag tooltip functionality
* @param {Element} [scopeContainer] - Optional container to scope the querySelector
*/ */
export function setupTagTooltip() { export function setupTagTooltip(scopeContainer = null) {
const tagsContainer = document.querySelector('.model-tags-container'); const root = scopeContainer || document;
const tooltip = document.querySelector('.model-tags-tooltip'); const tagsContainer = root.querySelector('.model-tags-container');
const tooltip = root.querySelector('.model-tags-tooltip');
if (tagsContainer && tooltip) { if (tagsContainer && tooltip) {
tagsContainer.addEventListener('mouseenter', () => { tagsContainer.addEventListener('mouseenter', () => {
+3 -1
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,
+9 -4
View File
@@ -327,10 +327,15 @@ export class DoctorManager {
case 'open-settings': case 'open-settings':
modalManager.showModal('settingsModal'); modalManager.showModal('settingsModal');
window.setTimeout(() => { window.setTimeout(() => {
const input = document.getElementById('civitaiApiKey'); // Open the API key editor directly
if (input) { if (typeof settingsManager.editApiKey === 'function') {
input.focus(); settingsManager.editApiKey();
input.scrollIntoView({ behavior: 'smooth', block: 'center' }); } else {
const input = document.getElementById('civitaiApiKey');
if (input) {
input.focus();
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} }
}, 100); }, 100);
break; break;
+1 -1
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...');
} }
}; };
+74 -9
View File
@@ -321,29 +321,94 @@ class MoveManager {
} }
try { try {
let movedFiles = []; // Array of { original_file_path, new_file_path }
if (this.bulkFilePaths) { if (this.bulkFilePaths) {
// Bulk move mode // Bulk move mode
await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath); const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
movedFiles = (results || [])
.filter(r => r.success)
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
// Deselect moving items // Deselect moving items
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path)); this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
} else { } else {
// Single move mode // Single move mode
await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath); const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
if (result) {
movedFiles.push({
original_file_path: result.original_file_path || this.currentFilePath,
new_file_path: result.new_file_path
});
}
// Deselect moving item // Deselect moving item
bulkManager.deselectItem(this.currentFilePath); bulkManager.deselectItem(this.currentFilePath);
} }
// Refresh UI by reloading the current page, same as drag-and-drop behavior // Update VirtualScroller in-place instead of full reload
// This ensures all metadata (like preview URLs) are correctly formatted by the backend if (movedFiles.length > 0 && state.virtualScroller) {
if (sidebarManager.pageControls && typeof sidebarManager.pageControls.resetAndReload === 'function') { // Get current page state for folder filter check
await sidebarManager.pageControls.resetAndReload(true); const pageState = getCurrentPageState();
} else if (sidebarManager.lastPageControls && typeof sidebarManager.lastPageControls.resetAndReload === 'function') { const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
await sidebarManager.lastPageControls.resetAndReload(true); const isRecursive = pageState.searchOptions?.recursive ?? true;
const isFolderFiltered = pageState.activeFolder !== null;
// Determine which items are still visible after the move
const pathsToRemove = [];
const pathsToUpdate = []; // { originalPath, newData }
for (const moved of movedFiles) {
if (!moved.original_file_path) continue;
if (isFolderFiltered) {
// Compute relative folder of the new path
const newRelativeFolder = this._getRelativeFolder(moved.new_file_path);
const normalizedNewFolder = newRelativeFolder.replace(/\\/g, '/').replace(/\/$/, '');
// Check if the new location is still within the active folder
let stillVisible;
if (isRecursive) {
stillVisible = normalizedActive === '' ||
normalizedNewFolder === normalizedActive ||
normalizedNewFolder.startsWith(normalizedActive + '/');
} else {
stillVisible = normalizedNewFolder === normalizedActive;
}
if (stillVisible) {
pathsToUpdate.push({
originalPath: moved.original_file_path,
newData: {
file_path: moved.new_file_path,
folder: newRelativeFolder
}
});
} else {
pathsToRemove.push(moved.original_file_path);
}
} else {
// No folder filter active — items remain visible, just update path
pathsToUpdate.push({
originalPath: moved.original_file_path,
newData: {
file_path: moved.new_file_path,
folder: this._getRelativeFolder(moved.new_file_path)
}
});
}
}
// Apply updates to the VirtualScroller
if (pathsToRemove.length > 0) {
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
}
for (const update of pathsToUpdate) {
state.virtualScroller.updateSingleItem(update.originalPath, update.newData);
}
} }
// Refresh folder tree in sidebar // Refresh folder tree in sidebar (no model data reload)
await sidebarManager.refresh(); await sidebarManager.refresh();
modalManager.closeModal('moveModal'); modalManager.closeModal('moveModal');
+156 -19
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']);
@@ -345,9 +344,14 @@ export class SettingsManager {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') { if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
this.isOpen = settingsModal.style.display === 'block'; this.isOpen = settingsModal.style.display === 'block';
// When modal is opened, update checkbox state from current settings
if (this.isOpen) { if (this.isOpen) {
this.loadSettingsToUI(); this.loadSettingsToUI();
} else {
// Reset API key edit mode on close
this.cancelEditApiKey(true);
// Clear proxy password on close
const proxyPasswordInput = document.getElementById('proxyPassword');
if (proxyPasswordInput) proxyPasswordInput.value = '';
} }
} }
}); });
@@ -804,11 +808,26 @@ export class SettingsManager {
); );
} }
// Set card blur amount slider
const cardBlurAmountInput = document.getElementById('cardBlurAmount');
const cardBlurValue = state.global.settings.card_blur_amount ?? 8;
if (cardBlurAmountInput) {
cardBlurAmountInput.value = cardBlurValue;
cardBlurAmountInput.style.setProperty('--range-fill', (cardBlurValue / 20 * 100) + '%');
}
const cardBlurAmountValue = document.getElementById('cardBlurAmountValue');
if (cardBlurAmountValue) {
cardBlurAmountValue.textContent = `${cardBlurValue}px`;
}
const usePortableCheckbox = document.getElementById('usePortableSettings'); const usePortableCheckbox = document.getElementById('usePortableSettings');
if (usePortableCheckbox) { if (usePortableCheckbox) {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings; usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
} }
// Update API key status display (do NOT pre-fill the input)
this.updateApiKeyStatus();
const civitaiHostSelect = document.getElementById('civitaiHost'); const civitaiHostSelect = document.getElementById('civitaiHost');
if (civitaiHostSelect) { if (civitaiHostSelect) {
civitaiHostSelect.value = state.global.settings.civitai_host || 'civitai.com'; civitaiHostSelect.value = state.global.settings.civitai_host || 'civitai.com';
@@ -874,12 +893,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) {
@@ -892,6 +905,12 @@ export class SettingsManager {
showVersionOnCardCheckbox.checked = state.global.settings.show_version_on_card !== false; showVersionOnCardCheckbox.checked = state.global.settings.show_version_on_card !== false;
} }
// Set group by model
const groupByModelCheckbox = document.getElementById('groupByModel');
if (groupByModelCheckbox) {
groupByModelCheckbox.checked = !!state.global.settings.group_by_model;
}
// Set model name display setting // Set model name display setting
const modelNameDisplaySelect = document.getElementById('modelNameDisplay'); const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
if (modelNameDisplaySelect) { if (modelNameDisplaySelect) {
@@ -998,6 +1017,12 @@ export class SettingsManager {
this.loadDownloadBackendSettings(); this.loadDownloadBackendSettings();
this.loadProxySettings(); this.loadProxySettings();
// Set license icon style
const useNewLicenseIconsCheckbox = document.getElementById('useNewLicenseIcons');
if (useNewLicenseIconsCheckbox) {
useNewLicenseIconsCheckbox.checked = state.global.settings.use_new_license_icons !== false;
}
} }
loadDownloadBackendSettings() { loadDownloadBackendSettings() {
@@ -1992,7 +2017,7 @@ export class SettingsManager {
} }
} }
if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content') { if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content' || settingKey === 'group_by_model') {
this.reloadContent(); this.reloadContent();
} }
@@ -2051,6 +2076,31 @@ export class SettingsManager {
} }
} }
async saveRangeSetting(elementId, displayId, settingKey) {
const element = document.getElementById(elementId);
if (!element) return;
const value = parseInt(element.value, 10);
try {
await this.saveSetting(settingKey, value);
this.applyFrontendSettings();
// Update the displayed value next to the slider
const displayEl = document.getElementById(displayId);
if (displayEl) {
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');
} catch (error) {
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
updateExampleImagesOpenSettingsVisibility() { updateExampleImagesOpenSettingsVisibility() {
const openMode = state.global.settings.example_images_open_mode || 'system'; const openMode = state.global.settings.example_images_open_mode || 'system';
const localRootSetting = document.getElementById('exampleImagesLocalRootSetting'); const localRootSetting = document.getElementById('exampleImagesLocalRootSetting');
@@ -2852,16 +2902,97 @@ export class SettingsManager {
} }
} }
// ── CivitAI API Key management ──────────────────────────────
updateApiKeyStatus() {
const hasKey = !!(state.global.settings.civitai_api_key_set ||
state.global.settings.civitai_api_key);
const statusEl = document.getElementById('civitaiApiKeyStatus');
const statusText = document.getElementById('civitaiApiKeyStatusText');
const actionBtn = document.getElementById('civitaiApiKeyActionBtn');
if (!statusText || !actionBtn) return;
if (hasKey) {
statusText.classList.remove('api-key-status--unconfigured');
statusText.classList.add('api-key-status--configured');
statusText.innerHTML = '<i class="fas fa-check-circle text-success"></i> '
+ translate('settings.civitaiApiKeyConfigured', {}, 'Configured');
actionBtn.textContent = translate('common.actions.change', {}, 'Change');
} else {
statusText.classList.remove('api-key-status--configured');
statusText.classList.add('api-key-status--unconfigured');
statusText.innerHTML = '<i class="fas fa-times-circle text-error"></i> '
+ translate('settings.civitaiApiKeyNotConfigured', {}, 'Not configured');
actionBtn.textContent = translate('settings.civitaiApiKeySet', {}, 'Set up');
}
}
editApiKey() {
const statusEl = document.getElementById('civitaiApiKeyStatus');
if (statusEl) statusEl.classList.add('is-hidden');
const editContainer = document.getElementById('civitaiApiKeyEdit');
if (editContainer) editContainer.classList.remove('is-hidden');
// Focus the input
const input = document.getElementById('civitaiApiKey');
if (input) {
input.value = ''; // Never pre-fill the secret
setTimeout(() => input.focus(), 50);
}
}
cancelEditApiKey(silent) {
const editContainer = document.getElementById('civitaiApiKeyEdit');
if (editContainer) editContainer.classList.add('is-hidden');
const statusContainer = document.getElementById('civitaiApiKeyStatus');
if (statusContainer) statusContainer.classList.remove('is-hidden');
// Clear any typed value
const input = document.getElementById('civitaiApiKey');
if (input) input.value = '';
if (!silent) {
this.updateApiKeyStatus();
}
}
async saveApiKey() {
const input = document.getElementById('civitaiApiKey');
if (!input) return;
const value = input.value.trim();
try {
await this.saveSetting('civitai_api_key', value);
showToast('toast.settings.settingsUpdated',
{ setting: 'CivitAI API Key' }, 'success');
} catch (error) {
showToast('toast.settings.settingSaveFailed',
{ message: error.message }, 'error');
return;
}
// Update the in-memory flag so the UI reflects the change
state.global.settings.civitai_api_key_set = !!value;
this.cancelEditApiKey(true);
this.updateApiKeyStatus();
}
toggleInputVisibility(button) { toggleInputVisibility(button) {
const input = button.parentElement.querySelector('input'); const input = button.parentElement.querySelector('input');
if (!input) return;
const icon = button.querySelector('i'); const icon = button.querySelector('i');
if (input.dataset.mask === 'css') {
if (input.type === 'password') { // CSS-masked input (CivitAI API key) — toggle class, not type
input.classList.toggle('api-key-masked');
if (icon) {
icon.className = input.classList.contains('api-key-masked')
? 'fas fa-eye'
: 'fas fa-eye-slash';
}
} else if (input.type === 'password') {
input.type = 'text'; input.type = 'text';
icon.className = 'fas fa-eye-slash'; if (icon) icon.className = 'fas fa-eye-slash';
} else { } else {
input.type = 'password'; input.type = 'password';
icon.className = 'fas fa-eye'; if (icon) icon.className = 'fas fa-eye';
} }
} }
@@ -2887,6 +3018,10 @@ export class SettingsManager {
} }
applyFrontendSettings() { applyFrontendSettings() {
// Apply card blur amount to CSS custom property
const cardBlurAmount = state.global.settings.card_blur_amount ?? 8;
document.documentElement.style.setProperty('--card-blur-amount', `${cardBlurAmount}px`);
// Apply autoplay setting to existing videos in card previews // Apply autoplay setting to existing videos in card previews
const autoplayOnHover = state.global.settings.autoplay_on_hover; const autoplayOnHover = state.global.settings.autoplay_on_hover;
document.querySelectorAll('.card-preview video').forEach(video => { document.querySelectorAll('.card-preview video').forEach(video => {
@@ -2913,12 +3048,14 @@ 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; // Apply license icon style
if (sidebarManager && typeof sidebarManager.setSidebarEnabled === 'function') { const useNewLicenseIcons = state.global.settings.use_new_license_icons !== false;
sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => { document.body.classList.toggle('use-new-license-icons', useNewLicenseIcons);
console.error('Failed to apply sidebar visibility setting:', error);
}); // Apply group-by-model mode
} const groupByModel = !!state.global.settings.group_by_model;
document.body.classList.toggle('group-by-model', groupByModel);
} }
} }
+9 -4
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);
} }
@@ -150,9 +149,10 @@ class RecipeManager {
_showCustomFilterIndicator() { _showCustomFilterIndicator() {
const indicator = document.getElementById('customFilterIndicator'); const indicator = document.getElementById('customFilterIndicator');
const textElement = document.getElementById('customFilterText'); if (!indicator) return;
const textElement = indicator.querySelector('.customFilterText');
if (!indicator || !textElement) return; if (!textElement) return;
// Update text based on filter type // Update text based on filter type
let filterText = ''; let filterText = '';
@@ -251,6 +251,11 @@ class RecipeManager {
bulkButton.addEventListener('click', () => window.bulkManager?.toggleBulkMode()); bulkButton.addEventListener('click', () => window.bulkManager?.toggleBulkMode());
} }
const duplicatesButton = document.querySelector('[data-action="find-duplicates"]');
if (duplicatesButton) {
duplicatesButton.addEventListener('click', () => this.findDuplicateRecipes());
}
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn'); const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
if (favoriteFilterBtn) { if (favoriteFilterBtn) {
favoriteFilterBtn.addEventListener('click', () => { favoriteFilterBtn.addEventListener('click', () => {
+4 -1
View File
@@ -5,6 +5,7 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/co
const DEFAULT_SETTINGS_BASE = Object.freeze({ const DEFAULT_SETTINGS_BASE = Object.freeze({
civitai_api_key: '', civitai_api_key: '',
civitai_api_key_set: false,
civitai_host: 'civitai.com', civitai_host: 'civitai.com',
download_backend: 'python', download_backend: 'python',
aria2c_path: '', aria2c_path: '',
@@ -32,10 +33,10 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
auto_download_example_images: false, auto_download_example_images: false,
blur_mature_content: true, blur_mature_content: true,
mature_blur_level: 'R', mature_blur_level: 'R',
card_blur_amount: 8,
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',
@@ -52,6 +53,8 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
backup_auto_enabled: true, backup_auto_enabled: true,
backup_retention_count: 5, backup_retention_count: 5,
strip_lora_on_copy: false, strip_lora_on_copy: false,
use_new_license_icons: true,
group_by_model: false,
}); });
export function createDefaultSettings() { export function createDefaultSettings() {
+128 -52
View File
@@ -1,6 +1,8 @@
// Statistics page functionality // Statistics page functionality
import { appCore } from './core.js'; import { appCore } from './core.js';
import { showToast } from './utils/uiHelpers.js'; import { showToast } from './utils/uiHelpers.js';
import { translate } from './utils/i18nHelpers.js';
import { i18n } from './i18n/index.js';
// Chart.js import (assuming it's available globally or via CDN) // Chart.js import (assuming it's available globally or via CDN)
// If Chart.js isn't available, we'll need to add it to the project // If Chart.js isn't available, we'll need to add it to the project
@@ -124,43 +126,43 @@ export class StatisticsManager {
{ {
icon: 'fas fa-magic', icon: 'fas fa-magic',
value: this.data.collection.total_models, value: this.data.collection.total_models,
label: 'Total Models', label: translate('statistics.metrics.totalModels'),
format: 'number' format: 'number'
}, },
{ {
icon: 'fas fa-database', icon: 'fas fa-database',
value: this.data.collection.total_size, value: this.data.collection.total_size,
label: 'Total Storage', label: translate('statistics.metrics.totalStorage'),
format: 'size' format: 'size'
}, },
{ {
icon: 'fas fa-play-circle', icon: 'fas fa-play-circle',
value: this.data.collection.total_generations, value: this.data.collection.total_generations,
label: 'Total Generations', label: translate('statistics.metrics.totalGenerations'),
format: 'number' format: 'number'
}, },
{ {
icon: 'fas fa-chart-line', icon: 'fas fa-chart-line',
value: this.calculateUsageRate(), value: this.calculateUsageRate(),
label: 'Usage Rate', label: translate('statistics.metrics.usageRate'),
format: 'percentage' format: 'percentage'
}, },
{ {
icon: 'fas fa-layer-group', icon: 'fas fa-layer-group',
value: this.data.collection.lora_count, value: this.data.collection.lora_count,
label: 'LoRAs', label: translate('statistics.metrics.loras'),
format: 'number' format: 'number'
}, },
{ {
icon: 'fas fa-check-circle', icon: 'fas fa-check-circle',
value: this.data.collection.checkpoint_count, value: this.data.collection.checkpoint_count,
label: 'Checkpoints', label: translate('statistics.metrics.checkpoints'),
format: 'number' format: 'number'
}, },
{ {
icon: 'fas fa-code', icon: 'fas fa-code',
value: this.data.collection.embedding_count, value: this.data.collection.embedding_count,
label: 'Embeddings', label: translate('statistics.metrics.embeddings'),
format: 'number' format: 'number'
} }
]; ];
@@ -189,18 +191,14 @@ export class StatisticsManager {
case 'size': case 'size':
return this.formatFileSize(value); return this.formatFileSize(value);
case 'percentage': case 'percentage':
return `${value.toFixed(1)}%`; return new Intl.NumberFormat(i18n.getCurrentLocale(), { style: 'percent', maximumFractionDigits: 1 }).format(value / 100);
default: default:
return value; return value;
} }
} }
formatFileSize(bytes) { formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes'; return i18n.formatFileSize(bytes);
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
} }
calculateUsageRate() { calculateUsageRate() {
@@ -240,6 +238,9 @@ export class StatisticsManager {
// Storage efficiency chart // Storage efficiency chart
this.createStorageEfficiencyChart(); this.createStorageEfficiencyChart();
// Model types chart (Collection tab)
this.createModelTypesChart();
} }
createCollectionPieChart() { createCollectionPieChart() {
@@ -247,7 +248,7 @@ export class StatisticsManager {
if (!ctx || !this.data.collection) return; if (!ctx || !this.data.collection) return;
const data = { const data = {
labels: ['LoRAs', 'Checkpoints', 'Embeddings'], labels: [translate('statistics.metrics.loras'), translate('statistics.metrics.checkpoints'), translate('statistics.metrics.embeddings')],
datasets: [{ datasets: [{
data: [ data: [
this.data.collection.lora_count, this.data.collection.lora_count,
@@ -287,28 +288,28 @@ export class StatisticsManager {
const checkpointData = this.data.baseModels.checkpoints; const checkpointData = this.data.baseModels.checkpoints;
const embeddingData = this.data.baseModels.embeddings; const embeddingData = this.data.baseModels.embeddings;
const allModels = new Set([ const allModels = Array.from(new Set([
...Object.keys(loraData), ...Object.keys(loraData),
...Object.keys(checkpointData), ...Object.keys(checkpointData),
...Object.keys(embeddingData) ...Object.keys(embeddingData)
]); ])).sort();
const data = { const data = {
labels: Array.from(allModels), labels: allModels,
datasets: [ datasets: [
{ {
label: 'LoRAs', label: translate('statistics.metrics.loras'),
data: Array.from(allModels).map(model => loraData[model] || 0), data: allModels.map(model => loraData[model] || 0),
backgroundColor: 'oklch(68% 0.28 256 / 0.7)' backgroundColor: 'oklch(68% 0.28 256 / 0.7)'
}, },
{ {
label: 'Checkpoints', label: translate('statistics.metrics.checkpoints'),
data: Array.from(allModels).map(model => checkpointData[model] || 0), data: allModels.map(model => checkpointData[model] || 0),
backgroundColor: 'oklch(68% 0.28 200 / 0.7)' backgroundColor: 'oklch(68% 0.28 200 / 0.7)'
}, },
{ {
label: 'Embeddings', label: translate('statistics.metrics.embeddings'),
data: Array.from(allModels).map(model => embeddingData[model] || 0), data: allModels.map(model => embeddingData[model] || 0),
backgroundColor: 'oklch(68% 0.28 120 / 0.7)' backgroundColor: 'oklch(68% 0.28 120 / 0.7)'
} }
] ]
@@ -342,21 +343,21 @@ export class StatisticsManager {
labels: timeline.map(item => new Date(item.date).toLocaleDateString()), labels: timeline.map(item => new Date(item.date).toLocaleDateString()),
datasets: [ datasets: [
{ {
label: 'LoRA Usage', label: translate('statistics.charts.loraUsage'),
data: timeline.map(item => item.lora_usage), data: timeline.map(item => item.lora_usage),
borderColor: 'oklch(68% 0.28 256)', borderColor: 'oklch(68% 0.28 256)',
backgroundColor: 'oklch(68% 0.28 256 / 0.1)', backgroundColor: 'oklch(68% 0.28 256 / 0.1)',
fill: true fill: true
}, },
{ {
label: 'Checkpoint Usage', label: translate('statistics.charts.checkpointUsage'),
data: timeline.map(item => item.checkpoint_usage), data: timeline.map(item => item.checkpoint_usage),
borderColor: 'oklch(68% 0.28 200)', borderColor: 'oklch(68% 0.28 200)',
backgroundColor: 'oklch(68% 0.28 200 / 0.1)', backgroundColor: 'oklch(68% 0.28 200 / 0.1)',
fill: true fill: true
}, },
{ {
label: 'Embedding Usage', label: translate('statistics.charts.embeddingUsage'),
data: timeline.map(item => item.embedding_usage), data: timeline.map(item => item.embedding_usage),
borderColor: 'oklch(68% 0.28 120)', borderColor: 'oklch(68% 0.28 120)',
backgroundColor: 'oklch(68% 0.28 120 / 0.1)', backgroundColor: 'oklch(68% 0.28 120 / 0.1)',
@@ -380,14 +381,14 @@ export class StatisticsManager {
display: true, display: true,
title: { title: {
display: true, display: true,
text: 'Date' text: translate('statistics.charts.date')
} }
}, },
y: { y: {
display: true, display: true,
title: { title: {
display: true, display: true,
text: 'Usage Count' text: translate('statistics.charts.usageCount')
} }
} }
} }
@@ -413,7 +414,7 @@ export class StatisticsManager {
const data = { const data = {
labels: allModels.map(model => model.name), labels: allModels.map(model => model.name),
datasets: [{ datasets: [{
label: 'Usage Count', label: translate('statistics.charts.usageCount'),
data: allModels.map(model => model.usage_count), data: allModels.map(model => model.usage_count),
backgroundColor: allModels.map(model => { backgroundColor: allModels.map(model => {
switch(model.type) { switch(model.type) {
@@ -447,7 +448,7 @@ export class StatisticsManager {
if (!ctx || !this.data.collection) return; if (!ctx || !this.data.collection) return;
const data = { const data = {
labels: ['LoRAs', 'Checkpoints', 'Embeddings'], labels: [translate('statistics.metrics.loras'), translate('statistics.metrics.checkpoints'), translate('statistics.metrics.embeddings')],
datasets: [{ datasets: [{
data: [ data: [
this.data.collection.lora_size, this.data.collection.lora_size,
@@ -501,7 +502,7 @@ export class StatisticsManager {
const data = { const data = {
datasets: [{ datasets: [{
label: 'Models', label: translate('statistics.charts.models'),
data: allData.map(item => ({ data: allData.map(item => ({
x: item.size, x: item.size,
y: item.usage_count, y: item.usage_count,
@@ -529,14 +530,14 @@ export class StatisticsManager {
x: { x: {
title: { title: {
display: true, display: true,
text: 'File Size (bytes)' text: translate('statistics.charts.fileSizeBytes')
}, },
type: 'logarithmic' type: 'logarithmic'
}, },
y: { y: {
title: { title: {
display: true, display: true,
text: 'Usage Count' text: translate('statistics.charts.usageCount')
} }
} }
}, },
@@ -545,7 +546,69 @@ export class StatisticsManager {
callbacks: { callbacks: {
label: (context) => { label: (context) => {
const point = context.raw; const point = context.raw;
return `${point.name}: ${this.formatFileSize(point.x)}, ${point.y} uses`; return translate('statistics.tooltips.chartUsage', { name: point.name, size: this.formatFileSize(point.x), count: point.y });
}
}
}
}
}
});
}
createModelTypesChart() {
const ctx = document.getElementById('modelTypesChart');
if (!ctx || !this.data.collection || !this.data.collection.model_types_distribution) return;
const distribution = this.data.collection.model_types_distribution;
const typeDisplayNames = {
lora: translate('statistics.modelTypes.lora'),
locon: translate('statistics.modelTypes.locon'),
dora: translate('statistics.modelTypes.dora'),
checkpoint: translate('statistics.modelTypes.checkpoint'),
diffusion_model: translate('statistics.modelTypes.diffusion_model'),
embedding: translate('statistics.modelTypes.embedding')
};
const colorPalette = {
lora: 'oklch(68% 0.28 256)',
locon: 'oklch(68% 0.25 190)',
dora: 'oklch(68% 0.25 330)',
checkpoint: 'oklch(68% 0.28 45)',
diffusion_model: 'oklch(68% 0.25 280)',
embedding: 'oklch(68% 0.25 120)'
};
const labels = Object.keys(distribution).map(k => typeDisplayNames[k] || k);
const values = Object.values(distribution);
const colors = Object.keys(distribution).map(k => colorPalette[k] || 'oklch(68% 0.15 0)');
const data = {
labels: labels,
datasets: [{
data: values,
backgroundColor: colors,
borderColor: getComputedStyle(document.documentElement).getPropertyValue('--border-color'),
borderWidth: 2
}]
};
this.charts.modelTypes = new Chart(ctx, {
type: 'doughnut',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
},
tooltip: {
callbacks: {
label: (context) => {
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const value = context.parsed;
const pct = ((value / total) * 100).toFixed(1);
return translate('statistics.tooltips.chartPercentage', { label: context.label, value, pct });
} }
} }
} }
@@ -589,7 +652,7 @@ export class StatisticsManager {
// Show loading indicator on initial load // Show loading indicator on initial load
if (state.offset === 0) { if (state.offset === 0) {
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-spinner fa-spin"></i> Loading...</div>'; container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-spinner fa-spin"></i> ' + translate('statistics.placeholders.loading') + '</div>';
} }
try { try {
@@ -605,7 +668,7 @@ export class StatisticsManager {
} }
if (items.length === 0 && state.offset === 0) { if (items.length === 0 && state.offset === 0) {
container.innerHTML = '<div class="loading-placeholder">No models found</div>'; container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.placeholders.noModels') + '</div>';
state.hasMore = false; state.hasMore = false;
} else if (items.length < state.limit) { } else if (items.length < state.limit) {
state.hasMore = false; state.hasMore = false;
@@ -618,7 +681,7 @@ export class StatisticsManager {
onerror="this.src='/loras_static/images/no-preview.png'"> onerror="this.src='/loras_static/images/no-preview.png'">
<div class="model-info"> <div class="model-info">
<div class="model-name" title="${model.name}">${model.name}</div> <div class="model-name" title="${model.name}">${model.name}</div>
<div class="model-meta">${model.base_model} ${model.folder || 'Root'}</div> <div class="model-meta">${model.base_model} ${model.folder || translate('statistics.placeholders.rootFolder')}</div>
</div> </div>
<div class="model-usage">${model.usage_count}</div> <div class="model-usage">${model.usage_count}</div>
</div> </div>
@@ -630,7 +693,7 @@ export class StatisticsManager {
} catch (error) { } catch (error) {
console.error(`Error loading ${type} list:`, error); console.error(`Error loading ${type} list:`, error);
if (state.offset === 0) { if (state.offset === 0) {
container.innerHTML = '<div class="loading-placeholder">Error loading data</div>'; container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.placeholders.errorLoading') + '</div>';
} }
} finally { } finally {
state.isLoading = false; state.isLoading = false;
@@ -653,7 +716,7 @@ export class StatisticsManager {
].sort((a, b) => b.size - a.size).slice(0, 10); ].sort((a, b) => b.size - a.size).slice(0, 10);
if (allModels.length === 0) { if (allModels.length === 0) {
container.innerHTML = '<div class="loading-placeholder">No storage data available</div>'; container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.placeholders.noStorageData') + '</div>';
return; return;
} }
@@ -661,7 +724,7 @@ export class StatisticsManager {
<div class="model-item"> <div class="model-item">
<div class="model-info"> <div class="model-info">
<div class="model-name" title="${model.name}">${model.name}</div> <div class="model-name" title="${model.name}">${model.name}</div>
<div class="model-meta">${model.type} ${model.base_model}</div> <div class="model-meta">${translate('statistics.modelTypes.' + model.type.toLowerCase())} ${model.base_model}</div>
</div> </div>
<div class="model-usage">${this.formatFileSize(model.size)}</div> <div class="model-usage">${this.formatFileSize(model.size)}</div>
</div> </div>
@@ -679,7 +742,7 @@ export class StatisticsManager {
const size = Math.ceil((tagData.count / maxCount) * 5); const size = Math.ceil((tagData.count / maxCount) * 5);
return ` return `
<span class="tag-cloud-item size-${size}" <span class="tag-cloud-item size-${size}"
title="${tagData.tag}: ${tagData.count} models"> title="${translate('statistics.tooltips.tagCount', { tag: tagData.tag, count: tagData.count })}">
${tagData.tag} ${tagData.tag}
</span> </span>
`; `;
@@ -693,17 +756,30 @@ export class StatisticsManager {
const insights = this.data.insights.insights; const insights = this.data.insights.insights;
if (insights.length === 0) { if (insights.length === 0) {
container.innerHTML = '<div class="loading-placeholder">No insights available</div>'; container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.insights.noInsights') + '</div>';
return; return;
} }
container.innerHTML = insights.map(insight => ` container.innerHTML = insights.map(insight => {
const params = insight.params || {};
let title, description, suggestion;
if (insight.key) {
title = translate('statistics.' + insight.key + '.title', params);
description = translate('statistics.' + insight.key + '.description', params);
suggestion = translate('statistics.' + insight.key + '.suggestion', params);
} else {
// Backward compatibility for insights without key/params
title = insight.title || '';
description = insight.description || '';
suggestion = insight.suggestion || '';
}
return `
<div class="insight-card type-${insight.type}"> <div class="insight-card type-${insight.type}">
<div class="insight-title">${insight.title}</div> <div class="insight-title">${title}</div>
<div class="insight-description">${insight.description}</div> <div class="insight-description">${description}</div>
<div class="insight-suggestion">${insight.suggestion}</div> <div class="insight-suggestion">${suggestion}</div>
</div> </div>
`).join(''); `}).join('');
// Render collection analysis cards // Render collection analysis cards
this.renderCollectionAnalysis(); this.renderCollectionAnalysis();
@@ -717,25 +793,25 @@ export class StatisticsManager {
{ {
icon: 'fas fa-percentage', icon: 'fas fa-percentage',
value: this.calculateUsageRate(), value: this.calculateUsageRate(),
label: 'Usage Rate', label: translate('statistics.metrics.usageRate'),
format: 'percentage' format: 'percentage'
}, },
{ {
icon: 'fas fa-tags', icon: 'fas fa-tags',
value: this.data.tags?.total_unique_tags || 0, value: this.data.tags?.total_unique_tags || 0,
label: 'Unique Tags', label: translate('statistics.metrics.uniqueTags'),
format: 'number' format: 'number'
}, },
{ {
icon: 'fas fa-clock', icon: 'fas fa-clock',
value: this.data.collection.unused_loras + this.data.collection.unused_checkpoints, value: this.data.collection.unused_loras + this.data.collection.unused_checkpoints,
label: 'Unused Models', label: translate('statistics.metrics.unusedModels'),
format: 'number' format: 'number'
}, },
{ {
icon: 'fas fa-chart-line', icon: 'fas fa-chart-line',
value: this.calculateAverageUsage(), value: this.calculateAverageUsage(),
label: 'Avg. Uses/Model', label: translate('statistics.metrics.avgUsesPerModel'),
format: 'decimal' format: 'decimal'
} }
]; ];
@@ -764,7 +840,7 @@ export class StatisticsManager {
const chartCanvases = document.querySelectorAll('canvas'); const chartCanvases = document.querySelectorAll('canvas');
chartCanvases.forEach(canvas => { chartCanvases.forEach(canvas => {
const container = canvas.parentElement; const container = canvas.parentElement;
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-chart-bar"></i> Chart requires Chart.js library</div>'; container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-chart-bar"></i> ' + translate('statistics.placeholders.chartLibraryMissing') + '</div>';
}); });
} }
+32
View File
@@ -931,6 +931,38 @@ export class VirtualScroller {
return true; return true;
} }
/**
* Remove multiple items by their file paths.
* More efficient than calling removeItemByFilePath individually.
* @param {string[]} filePaths - Array of file paths to remove
* @returns {boolean} - True if any items were removed
*/
removeMultipleItemsByFilePath(filePaths) {
if (!Array.isArray(filePaths) || filePaths.length === 0 || this.disabled || this.items.length === 0) return false;
// Build a set for fast lookup
const pathsToRemove = new Set(filePaths);
const originalLength = this.items.length;
// Filter out removed items; keep those not in the set
this.items = this.items.filter(item => !pathsToRemove.has(item.file_path));
const removedCount = originalLength - this.items.length;
if (removedCount === 0) return false;
this.totalItems = Math.max(0, this.totalItems - removedCount);
// Update the spacer height
this.updateSpacerHeight();
// Re-render to fill gaps left by removed items
this.clearRenderedItems();
this.scheduleRender();
console.log(`Removed ${removedCount} items from virtual scroller data`);
return true;
}
// Add keyboard navigation methods // Add keyboard navigation methods
handlePageUpDown(direction) { handlePageUpDown(direction) {
// Prevent duplicate animations by checking last trigger time // Prevent duplicate animations by checking last trigger time
+38 -18
View File
@@ -197,11 +197,22 @@ export function restoreFolderFilter() {
} }
} }
const CYCLE_ORDER = ['auto', 'light', 'dark'];
const PRESET_NAMES = ['default', 'nord', 'midnight', 'monokai', 'dracula', 'solarized'];
export { CYCLE_ORDER, PRESET_NAMES };
export function initTheme() { export function initTheme() {
const savedTheme = getStorageItem('theme') || 'auto'; const savedTheme = getStorageItem('theme') || 'auto';
// Migrate deprecated presets
let savedPreset = getStorageItem('theme_preset');
if (savedPreset === 'gruvbox') {
savedPreset = 'midnight';
setStorageItem('theme_preset', 'midnight');
}
applyTheme(savedTheme); applyTheme(savedTheme);
applyPreset(savedPreset || 'default');
// Update theme when system preference changes (for 'auto' mode)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const currentTheme = getStorageItem('theme') || 'auto'; const currentTheme = getStorageItem('theme') || 'auto';
if (currentTheme === 'auto') { if (currentTheme === 'auto') {
@@ -212,34 +223,44 @@ export function initTheme() {
export function toggleTheme() { export function toggleTheme() {
const currentTheme = getStorageItem('theme') || 'auto'; const currentTheme = getStorageItem('theme') || 'auto';
let newTheme; const currentIndex = CYCLE_ORDER.indexOf(currentTheme);
const nextIndex = (currentIndex + 1) % CYCLE_ORDER.length;
if (currentTheme === 'light') { const newTheme = CYCLE_ORDER[nextIndex];
newTheme = 'dark';
} else {
newTheme = 'light';
}
setStorageItem('theme', newTheme); setStorageItem('theme', newTheme);
applyTheme(newTheme); applyTheme(newTheme);
// Force a repaint to ensure theme changes are applied immediately
document.body.style.display = 'none'; document.body.style.display = 'none';
document.body.offsetHeight; // Trigger a reflow document.body.offsetHeight;
document.body.style.display = ''; document.body.style.display = '';
return newTheme; return newTheme;
} }
// Add a new helper function to apply the theme export function cyclePreset() {
const currentPreset = getStorageItem('theme_preset') || 'default';
const currentIndex = PRESET_NAMES.indexOf(currentPreset);
const nextIndex = (currentIndex + 1) % PRESET_NAMES.length;
const newPreset = PRESET_NAMES[nextIndex];
setStorageItem('theme_preset', newPreset);
applyPreset(newPreset);
return newPreset;
}
export function setPreset(name) {
if (!PRESET_NAMES.includes(name)) return;
setStorageItem('theme_preset', name);
applyPreset(name);
}
function applyTheme(theme) { function applyTheme(theme) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const htmlElement = document.documentElement; const htmlElement = document.documentElement;
// Remove any existing theme attributes
htmlElement.removeAttribute('data-theme'); htmlElement.removeAttribute('data-theme');
// Apply the appropriate theme
if (theme === 'dark' || (theme === 'auto' && prefersDark)) { if (theme === 'dark' || (theme === 'auto' && prefersDark)) {
htmlElement.setAttribute('data-theme', 'dark'); htmlElement.setAttribute('data-theme', 'dark');
document.body.dataset.theme = 'dark'; document.body.dataset.theme = 'dark';
@@ -248,19 +269,18 @@ function applyTheme(theme) {
document.body.dataset.theme = 'light'; document.body.dataset.theme = 'light';
} }
// Update the theme-toggle icon state
updateThemeToggleIcons(theme); updateThemeToggleIcons(theme);
} }
// New function to update theme toggle icons function applyPreset(preset) {
document.documentElement.setAttribute('data-theme-preset', preset);
}
function updateThemeToggleIcons(theme) { function updateThemeToggleIcons(theme) {
const themeToggle = document.querySelector('.theme-toggle'); const themeToggle = document.querySelector('.theme-toggle');
if (!themeToggle) return; if (!themeToggle) return;
// Remove any existing active classes
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto'); themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
// Add the appropriate class based on current theme
themeToggle.classList.add(`theme-${theme}`); themeToggle.classList.add(`theme-${theme}`);
} }
+24 -2
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);
+8 -4
View File
@@ -46,16 +46,20 @@
</script> </script>
<script> <script>
(function() { (function() {
// Apply theme immediately based on stored preference var STORAGE_PREFIX = 'lora_manager_';
const STORAGE_PREFIX = 'lora_manager_'; var savedTheme = localStorage.getItem(STORAGE_PREFIX + 'theme') || 'auto';
const savedTheme = localStorage.getItem(STORAGE_PREFIX + 'theme') || 'auto'; var savedPreset = localStorage.getItem(STORAGE_PREFIX + 'theme_preset') || 'default';
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme === 'dark' || (savedTheme === 'auto' && prefersDark)) { if (savedTheme === 'dark' || (savedTheme === 'auto' && prefersDark)) {
document.documentElement.setAttribute('data-theme', 'dark'); document.documentElement.setAttribute('data-theme', 'dark');
} else { } else {
document.documentElement.setAttribute('data-theme', 'light'); document.documentElement.setAttribute('data-theme', 'light');
} }
if (savedPreset && savedPreset !== 'default') {
document.documentElement.setAttribute('data-theme-preset', savedPreset);
}
})(); })();
</script> </script>
{% block head_scripts %}{% endblock %} {% block head_scripts %}{% endblock %}
+5
View File
@@ -158,6 +158,11 @@
<div class="context-menu-item" data-action="manage-excluded-models"> <div class="context-menu-item" data-action="manage-excluded-models">
<i class="fas fa-eye-slash"></i> <span>{{ t('globalContextMenu.manageExcludedModels.label', default='Manage Excluded Models') }}</span> <i class="fas fa-eye-slash"></i> <span>{{ t('globalContextMenu.manageExcludedModels.label', default='Manage Excluded Models') }}</span>
</div> </div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="toggle-group-by-model">
<i class="fas fa-layer-group"></i> <span>{{ t('globalContextMenu.groupByModel.label') }}</span>
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
</div>
<div class="context-menu-item" data-action="repair-recipes"> <div class="context-menu-item" data-action="repair-recipes">
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span> <i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
</div> </div>
+28 -30
View File
@@ -1,4 +1,5 @@
<div class="controls"> <div class="controls">
{% if page_id != 'recipes' %}
<div id="excludedViewBanner" class="excluded-view-banner hidden"> <div id="excludedViewBanner" class="excluded-view-banner hidden">
<div class="excluded-view-banner__content"> <div class="excluded-view-banner__content">
<div class="excluded-view-banner__title"> <div class="excluded-view-banner__title">
@@ -11,42 +12,52 @@
</button> </button>
</div> </div>
</div> </div>
{% endif %}
<div class="actions"> <div class="actions">
<div class="action-buttons"> <div class="action-buttons">
<div title="{{ t('loras.controls.sort.title') }}" class="control-group"> <div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.title') }}{% else %}{{ t('loras.controls.sort.title') }}{% endif %}" class="control-group">
<select id="sortSelect"> <select id="sortSelect">
<optgroup label="{{ t('loras.controls.sort.name') }}"> <optgroup label="{{ t('loras.controls.sort.name') }}">
<option value="name:asc">{{ t('loras.controls.sort.nameAsc') }}</option> <option value="name:asc">{{ t('loras.controls.sort.nameAsc') }}</option>
<option value="name:desc">{{ t('loras.controls.sort.nameDesc') }}</option> <option value="name:desc">{{ t('loras.controls.sort.nameDesc') }}</option>
</optgroup> </optgroup>
<optgroup label="{{ t('loras.controls.sort.date') }}"> <optgroup label="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.date') }}{% else %}{{ t('loras.controls.sort.date') }}{% endif %}">
<option value="date:desc">{{ t('loras.controls.sort.dateDesc') }}</option> <option value="date:desc">{{ t('loras.controls.sort.dateDesc') }}</option>
<option value="date:asc">{{ t('loras.controls.sort.dateAsc') }}</option> <option value="date:asc">{{ t('loras.controls.sort.dateAsc') }}</option>
</optgroup> </optgroup>
{% if page_id != 'recipes' %}
<optgroup label="{{ t('loras.controls.sort.size') }}"> <optgroup label="{{ t('loras.controls.sort.size') }}">
<option value="size:desc">{{ t('loras.controls.sort.sizeDesc') }}</option> <option value="size:desc">{{ t('loras.controls.sort.sizeDesc') }}</option>
<option value="size:asc">{{ t('loras.controls.sort.sizeAsc') }}</option> <option value="size:asc">{{ t('loras.controls.sort.sizeAsc') }}</option>
</optgroup> </optgroup>
{% if page_id != 'embeddings' %} {% endif %}
{% if page_id != 'embeddings' and page_id != 'recipes' %}
<optgroup label="{{ t('loras.controls.sort.usage', default='Usage') }}"> <optgroup label="{{ t('loras.controls.sort.usage', default='Usage') }}">
<option value="usage:desc">{{ t('loras.controls.sort.usageDesc', default='Times used (high to low)') }}</option> <option value="usage:desc">{{ t('loras.controls.sort.usageDesc', default='Times used (high to low)') }}</option>
<option value="usage:asc">{{ t('loras.controls.sort.usageAsc', default='Times used (low to high)') }}</option> <option value="usage:asc">{{ t('loras.controls.sort.usageAsc', default='Times used (low to high)') }}</option>
</optgroup> </optgroup>
{% endif %} {% endif %}
{% if page_id == 'recipes' %}
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
</optgroup>
{% endif %}
</select> </select>
</div> </div>
<div title="{{ t('loras.controls.refresh.title') }}" class="control-group dropdown-group"> <div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{ t('common.actions.refresh') }}</span></button> <button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{ t('common.actions.refresh') }}</span></button>
<button class="dropdown-toggle" aria-label="Show refresh options"> <button class="dropdown-toggle" aria-label="Show refresh options">
<i class="fas fa-caret-down"></i> <i class="fas fa-caret-down"></i>
</button> </button>
<div class="dropdown-menu"> <div class="dropdown-menu">
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('loras.controls.refresh.fullTooltip') }}"> <div class="dropdown-item" data-action="full-rebuild" title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}{% else %}{{ t('loras.controls.refresh.fullTooltip') }}{% endif %}">
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full') }}</span> <i class="fas fa-tools"></i> <span>{% if page_id == 'recipes' %}{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}{% else %}{{ t('loras.controls.refresh.full') }}{% endif %}</span>
</div> </div>
</div> </div>
</div> </div>
{% if page_id != 'recipes' %}
<div class="control-group"> <div class="control-group">
<button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span>{{ t('loras.controls.fetch.action') }}</span></button> <button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span>{{ t('loras.controls.fetch.action') }}</span></button>
</div> </div>
@@ -55,6 +66,15 @@
<i class="fas fa-cloud-download-alt"></i> <span>{{ t('loras.controls.download.action') }}</span> <i class="fas fa-cloud-download-alt"></i> <span>{{ t('loras.controls.download.action') }}</span>
</button> </button>
</div> </div>
{% endif %}
{% if page_id == 'recipes' %}
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{ t('recipes.controls.import.action') }}</button>
</div>
<div title="{{ t('recipes.batchImport.title') }}" class="control-group">
<button onclick="batchImportManager.showModal()"><i class="fas fa-layer-group"></i> {{ t('recipes.batchImport.action') }}</button>
</div>
{% endif %}
<div class="control-group"> <div class="control-group">
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}"> <button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span> <div class="shortcut-key">B</div></span> <i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span> <div class="shortcut-key">B</div></span>
@@ -71,6 +91,7 @@
<i class="fas fa-star"></i> <span>{{ t('loras.controls.favorites.action') }}</span> <i class="fas fa-star"></i> <span>{{ t('loras.controls.favorites.action') }}</span>
</button> </button>
</div> </div>
{% if page_id != 'recipes' %}
<div class="control-group dropdown-group update-filter-group"> <div class="control-group dropdown-group update-filter-group">
<button id="updateFilterBtn" data-action="toggle-updates" class="dropdown-main update-filter" title="{{ t('loras.controls.updates.title') }}"> <button id="updateFilterBtn" data-action="toggle-updates" class="dropdown-main update-filter" title="{{ t('loras.controls.updates.title') }}">
<i class="fas fa-exclamation-circle"></i> <span>{{ t('loras.controls.updates.action') }}</span> <i class="fas fa-exclamation-circle"></i> <span>{{ t('loras.controls.updates.action') }}</span>
@@ -84,6 +105,7 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<div id="customFilterIndicator" class="control-group hidden"> <div id="customFilterIndicator" class="control-group hidden">
<div class="filter-active"> <div class="filter-active">
<i class="fas fa-filter"></i> <span class="customFilterText" title=""></span> <i class="fas fa-filter"></i> <span class="customFilterText" title=""></span>
@@ -100,30 +122,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>
+2 -19
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">
+50
View File
@@ -120,6 +120,56 @@
</div> </div>
</header> </header>
<div class="theme-popover" id="themePopover" role="dialog" aria-label="{{ t('header.theme.toggle') }}">
<div class="theme-popover-section">
<div class="theme-popover-label">{{ t('header.theme.mode') }}</div>
<div class="theme-popover-modes">
<button class="theme-mode-btn" data-mode="light" title="{{ t('header.theme.light') }}">
<i class="fas fa-sun"></i>
<span>{{ t('header.theme.light') }}</span>
</button>
<button class="theme-mode-btn" data-mode="dark" title="{{ t('header.theme.dark') }}">
<i class="fas fa-moon"></i>
<span>{{ t('header.theme.dark') }}</span>
</button>
<button class="theme-mode-btn" data-mode="auto" title="{{ t('header.theme.auto') }}">
<i class="fas fa-adjust"></i>
<span>{{ t('header.theme.auto') }}</span>
</button>
</div>
</div>
<div class="theme-popover-divider"></div>
<div class="theme-popover-section">
<div class="theme-popover-label">{{ t('header.theme.presets') }}</div>
<div class="theme-popover-presets">
<button class="theme-preset-btn" data-preset="default" title="{{ t('header.theme.default') }}">
<span class="preset-swatch preset-swatch-default"></span>
<span>{{ t('header.theme.default') }}</span>
</button>
<button class="theme-preset-btn" data-preset="nord" title="{{ t('header.theme.nord') }}">
<span class="preset-swatch preset-swatch-nord"></span>
<span>{{ t('header.theme.nord') }}</span>
</button>
<button class="theme-preset-btn" data-preset="midnight" title="{{ t('header.theme.midnight') }}">
<span class="preset-swatch preset-swatch-midnight"></span>
<span>{{ t('header.theme.midnight') }}</span>
</button>
<button class="theme-preset-btn" data-preset="monokai" title="{{ t('header.theme.monokai') }}">
<span class="preset-swatch preset-swatch-monokai"></span>
<span>{{ t('header.theme.monokai') }}</span>
</button>
<button class="theme-preset-btn" data-preset="dracula" title="{{ t('header.theme.dracula') }}">
<span class="preset-swatch preset-swatch-dracula"></span>
<span>{{ t('header.theme.dracula') }}</span>
</button>
<button class="theme-preset-btn" data-preset="solarized" title="{{ t('header.theme.solarized') }}">
<span class="preset-swatch preset-swatch-solarized"></span>
<span>{{ t('header.theme.solarized') }}</span>
</button>
</div>
</div>
</div>
<!-- Add search options panel with context-aware options --> <!-- Add search options panel with context-aware options -->
<div id="searchOptionsPanel" class="search-options-panel hidden"> <div id="searchOptionsPanel" class="search-options-panel hidden">
<div class="options-header"> <div class="options-header">
+86 -29
View File
@@ -95,22 +95,36 @@
<div class="setting-item api-key-item"> <div class="setting-item api-key-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<label for="civitaiApiKey">{{ t('settings.civitaiApiKey') }}</label> <label>{{ t('settings.civitaiApiKey') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.civitaiApiKeyHelp') }}"></i> <i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.civitaiApiKeyHelp') }}"></i>
</div> </div>
<div class="setting-control"> <div class="setting-control">
<div class="api-key-input"> <!-- Status display (shown when not editing) -->
<input type="password" <div id="civitaiApiKeyStatus" class="api-key-status">
id="civitaiApiKey" <span id="civitaiApiKeyStatusText" class="api-key-status-text api-key-status--unconfigured">
placeholder="{{ t('settings.civitaiApiKeyPlaceholder') }}" <i class="fas fa-times-circle text-error"></i>
value="{{ settings.get('civitai_api_key', '') }}" {{ t('settings.civitaiApiKeyNotConfigured') }}
autocomplete="new-password" </span>
onblur="settingsManager.saveInputSetting('civitaiApiKey', 'civitai_api_key')" <button type="button" class="secondary-btn" id="civitaiApiKeyActionBtn" onclick="settingsManager.editApiKey()">
onkeydown="if(event.key === 'Enter') { this.blur(); }" /> {{ t('settings.civitaiApiKeySet') }}
<button class="toggle-visibility">
<i class="fas fa-eye"></i>
</button> </button>
</div> </div>
<!-- Inline edit view (shown when editing) -->
<div id="civitaiApiKeyEdit" class="api-key-edit is-hidden">
<div class="api-key-input">
<input type="text"
id="civitaiApiKey"
class="api-key-masked"
placeholder="{{ t('settings.civitaiApiKeyPlaceholder') }}"
autocomplete="off"
data-mask="css" />
<button type="button" class="toggle-visibility">
<i class="fas fa-eye"></i>
</button>
</div>
<button type="button" class="primary-btn" onclick="settingsManager.saveApiKey()">{{ t('common.actions.save') }}</button>
<button type="button" class="secondary-btn" onclick="settingsManager.cancelEditApiKey()">{{ t('common.actions.cancel') }}</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -448,6 +462,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Video Settings --> <!-- Video Settings -->
@@ -479,24 +494,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">
@@ -539,6 +536,25 @@
</div> </div>
</div> </div>
<!-- Group by model toggle -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="groupByModel">
{{ t('settings.layoutSettings.groupByModel') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.groupByModelHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="groupByModel"
onchange="settingsManager.saveToggleSetting('groupByModel', 'group_by_model')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
@@ -556,6 +572,23 @@
</div> </div>
</div> </div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="cardBlurAmount">
{{ t('settings.layoutSettings.cardBlurAmount') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.cardBlurAmountHelp') }}"></i>
</label>
</div>
<div class="setting-control range-control">
<input type="range" id="cardBlurAmount" min="0" max="20" value="8" step="1"
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')">
<span id="cardBlurAmountValue" class="range-value">8px</span>
</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">
@@ -592,6 +625,30 @@
</div> </div>
</div> </div>
<!-- License Icons -->
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>{{ t('settings.sections.licenseIcons') }}</h4>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="useNewLicenseIcons">
{{ t('settings.licenseIcons.useNewStyle') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.licenseIcons.useNewStyleHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="useNewLicenseIcons"
onchange="settingsManager.saveToggleSetting('useNewLicenseIcons', 'use_new_license_icons')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
<!-- Miscellaneous --> <!-- Miscellaneous -->
<div class="settings-subsection"> <div class="settings-subsection">
<div class="settings-subsection-header"> <div class="settings-subsection-header">
+2 -7
View File
@@ -6,13 +6,8 @@
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<!-- Header Actions: populated dynamically in RecipeModal.js --> <!-- Header Actions: populated dynamically in RecipeModal.js -->
<div class="recipe-header-actions" id="recipeHeaderActions"></div> <div class="recipe-header-actions" id="recipeHeaderActions"></div>
<!-- Recipe Tags Container --> <!-- Recipe Tags Container (rendered by renderCompactTags) -->
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
+3 -109
View File
@@ -62,115 +62,9 @@
{% block content %} {% block content %}
<!-- Recipe controls --> <!-- Recipe controls -->
<div class="controls"> {% include 'components/controls.html' %}
<div class="actions"> <!-- Breadcrumb Navigation -->
<div class="action-buttons"> {% include 'components/breadcrumb.html' %}
<div class="control-group">
<select id="sortSelect" title="{{ t('recipes.controls.sort.title') }}">
<optgroup label="{{ t('recipes.controls.sort.name') }}">
<option value="name:asc">{{ t('recipes.controls.sort.nameAsc') }}</option>
<option value="name:desc">{{ t('recipes.controls.sort.nameDesc') }}</option>
</optgroup>
<optgroup label="{{ t('recipes.controls.sort.date') }}">
<option value="date:desc">{{ t('recipes.controls.sort.dateDesc') }}</option>
<option value="date:asc">{{ t('recipes.controls.sort.dateAsc') }}</option>
</optgroup>
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
</optgroup>
</select>
</div>
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group dropdown-group">
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{
t('common.actions.refresh') }}</span></button>
<button class="dropdown-toggle" aria-label="Show refresh options">
<i class="fas fa-caret-down"></i>
</button>
<div class="dropdown-menu">
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}">
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span>
</div>
</div>
</div>
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{
t('recipes.controls.import.action') }}</button>
</div>
<div title="{{ t('recipes.batchImport.title') }}" class="control-group">
<button onclick="batchImportManager.showModal()"><i class="fas fa-layer-group"></i> {{
t('recipes.batchImport.action') }}</button>
</div>
<div class="control-group" title="{{ t('loras.controls.bulk.title') }}">
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span>
<div class="shortcut-key">B</div>
</span>
</button>
</div>
<!-- Add duplicate detection button -->
<div title="{{ t('loras.controls.duplicates.title') }}" class="control-group">
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{
t('loras.controls.duplicates.action') }}</button>
</div>
<div class="control-group">
<button id="favoriteFilterBtn" data-action="toggle-favorites" class="favorite-filter"
title="{{ t('recipes.controls.favorites.title') }}">
<i class="fas fa-star"></i> <span>{{ t('recipes.controls.favorites.action') }}</span>
</button>
</div>
<!-- Custom filter indicator button (hidden by default) -->
<div id="customFilterIndicator" class="control-group hidden">
<div class="filter-active">
<i class="fas fa-filter"></i> <span id="customFilterText">{{ t('recipes.controls.filteredByLora')
}}</span>
<i class="fas fa-times-circle clear-filter"></i>
</div>
</div>
</div>
<div class="controls-right">
<div class="control-group doctor-control-group">
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
<i class="fas fa-stethoscope"></i>
<span>{{ t('doctor.title', default='Doctor') }}</span>
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
</button>
</div>
<div 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>
<!-- Breadcrumb Navigation -->
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
<!-- Breadcrumbs will be populated by JavaScript -->
</nav>
</div>
</div>
<!-- Duplicates banner (hidden by default) --> <!-- Duplicates banner (hidden by default) -->
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;"> <div id="duplicatesBanner" class="duplicates-banner" style="display: none;">

Some files were not shown because too many files have changed in this diff Show More