Merge main into dev: bring dev up to date with restructured package
Resolves add/add conflicts in .gitignore, config.yaml, pyproject.toml and content conflict in README.md — taking main's version in all cases, as it reflects the complete package rewrite (gallery/ package, TUI, CI pipeline). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -1 +1,10 @@
|
||||
__pycache__
|
||||
**__pycache__**
|
||||
.vscode
|
||||
*.sif
|
||||
*.ipynb
|
||||
backups
|
||||
.pytest_cache
|
||||
.venv
|
||||
build
|
||||
*egg-info
|
||||
.claude
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# GitLab CI/CD Pipeline for Gallery Generator
|
||||
# Builds Apptainer container and runs tests inside it
|
||||
|
||||
stages:
|
||||
- build
|
||||
- test
|
||||
|
||||
variables:
|
||||
CONTAINER_IMAGE: "gallery-generator.sif"
|
||||
APPTAINER_CACHE_DIR: "$CI_PROJECT_DIR/.apptainer-cache"
|
||||
|
||||
# Cache to speed up builds
|
||||
cache:
|
||||
key: "$CI_COMMIT_REF_SLUG"
|
||||
paths:
|
||||
- .apptainer-cache/
|
||||
|
||||
# Build the Apptainer container
|
||||
build:container:
|
||||
stage: build
|
||||
tags:
|
||||
- apptainer
|
||||
script:
|
||||
- echo "Building Apptainer container..."
|
||||
- apptainer --version
|
||||
- apptainer build --fakeroot $CONTAINER_IMAGE Singularity.def
|
||||
- ls -lh $CONTAINER_IMAGE
|
||||
artifacts:
|
||||
paths:
|
||||
- $CONTAINER_IMAGE
|
||||
expire_in: 1 hour
|
||||
|
||||
# Run tests inside the container
|
||||
test:pytest:
|
||||
stage: test
|
||||
tags:
|
||||
- apptainer
|
||||
dependencies:
|
||||
- build:container
|
||||
script:
|
||||
- echo "Running pytest inside container..."
|
||||
- apptainer exec $CONTAINER_IMAGE pytest /src/tests/ -v
|
||||
artifacts:
|
||||
when: always
|
||||
|
||||
# Run tests with coverage
|
||||
test:coverage:
|
||||
stage: test
|
||||
tags:
|
||||
- apptainer
|
||||
dependencies:
|
||||
- build:container
|
||||
script:
|
||||
- echo "Running coverage analysis inside container..."
|
||||
- apptainer exec $CONTAINER_IMAGE pytest /src/tests/ --cov=/src --cov-report=xml --cov-report=term
|
||||
- apptainer exec $CONTAINER_IMAGE cat /src/coverage.xml > coverage.xml || echo "No coverage.xml found"
|
||||
coverage: '/TOTAL.*\s+(\d+%)$/'
|
||||
artifacts:
|
||||
reports:
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
paths:
|
||||
- coverage.xml
|
||||
expire_in: 30 days
|
||||
@@ -0,0 +1,113 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What This Project Does
|
||||
|
||||
A Python package that generates responsive static HTML galleries from scientific plot collections (PDFs and HTMLs). It converts PDFs to PNGs via ImageMagick, organizes plots hierarchically, propagates YAML/JSON metadata through directory trees, and renders everything via a Jinja2 template into a static website served from a web directory.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Install the package (editable)
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run all tests
|
||||
pytest tests/
|
||||
|
||||
# Run a single test file
|
||||
pytest tests/test_generate_gallery.py -v
|
||||
|
||||
# Run a single test by name
|
||||
pytest tests/test_generate_gallery.py::test_needs_update_missing_target -v
|
||||
|
||||
# Generate gallery
|
||||
gallery generate --verbose
|
||||
|
||||
# Generate with a non-default config
|
||||
gallery --config config.yaml generate --verbose
|
||||
|
||||
# Incremental update for one source only
|
||||
gallery generate --source /path/to/plots --verbose
|
||||
|
||||
# Clean regeneration
|
||||
gallery generate --clean --verbose
|
||||
|
||||
# Launch TUI
|
||||
gallery tui
|
||||
|
||||
# Serve output locally
|
||||
python -m http.server 8000 -d /web/kschmidt/public_html/
|
||||
```
|
||||
|
||||
Code style: black with `line-length = 120`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Execution Flow
|
||||
|
||||
```
|
||||
generate() [api.py]
|
||||
└── copy_assets() [builder.py] — copies assets/ to web output dir
|
||||
└── get_template() [builder.py] — loads gallery/templates/gallery.html
|
||||
└── build_gallery() [builder.py] — recursive per-source-directory walk
|
||||
└── load_folder_metadata() [utils/metadata.py]
|
||||
└── merge_metadata() [utils/metadata.py] — inherits from parent
|
||||
└── process_plot_files() [utils/processing.py] — PDF→PNG, copy files
|
||||
└── save_metadata_cache() [utils/metadata.py]
|
||||
└── render_gallery_page() [utils/processing.py] — writes index.html
|
||||
└── recurse into subdirs
|
||||
```
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `gallery/api.py` | `generate()` — primary public entry point; orchestrates everything |
|
||||
| `gallery/builder.py` | `build_gallery()` — recursive traversal; `get_template()`, `copy_assets()` |
|
||||
| `gallery/config/__init__.py` | `GalleryConfig`, `GallerySource`, `ConfigManager` dataclasses; YAML loading |
|
||||
| `gallery/cli.py` | CLI (`gallery` command) with subcommands: `generate`, `config`, `tui`, `install-completion` |
|
||||
| `gallery/tui.py` | TUI (`gallery tui`) built with Textual; interactive config editor + generation |
|
||||
| `gallery/utils/processing.py` | PDF→PNG via ImageMagick subprocess; `needs_update()` timestamp check; `render_gallery_page()` |
|
||||
| `gallery/utils/metadata.py` | Load/merge/cache YAML+JSON metadata; per-plot metadata resolution |
|
||||
| `gallery/utils/stats.py` | Directory size/count statistics |
|
||||
| `gallery/templates/gallery.html` | Single Jinja2 template for all gallery pages |
|
||||
| `gallery/assets/js/` | Vanilla JS modules loaded as ES modules; `GalleryApp` in `gallery-app.js` orchestrates all managers |
|
||||
| `gallery/assets/css/` | Modular CSS; `main.css` imports all others via `@import` |
|
||||
| `config.yaml` | Local deployment config (paths are machine-specific) |
|
||||
|
||||
### Config File Format
|
||||
|
||||
The YAML config uses a specific structure (not flat — must match `GalleryConfig.from_yaml()`):
|
||||
|
||||
```yaml
|
||||
paths:
|
||||
web_folder: "/web/user/public_html" # required
|
||||
gallery:
|
||||
plot_root: "gallery"
|
||||
png_dpi: 400
|
||||
sources:
|
||||
- name: "my_plots"
|
||||
path: "/path/to/plots"
|
||||
metadata:
|
||||
cache_enabled: true
|
||||
inherit_from_parent: true
|
||||
```
|
||||
|
||||
### Incremental Updates
|
||||
|
||||
`needs_update(source, target)` uses a **30-second buffer** on mtime comparisons to handle filesystem timing. This is intentional — avoid tightening it.
|
||||
|
||||
When `source_to_update` is passed to `generate()`, only that source's subdirectory is deleted and rebuilt; all other sources stay intact and the root index is re-rendered to include them.
|
||||
|
||||
### Metadata Inheritance
|
||||
|
||||
`metadata.yaml` (or `.yml`/`.json`) in any source directory is loaded and **merged with parent metadata** (`inherit_from_parent=True` by default). Child directories override parent keys. Per-plot overrides can live in `<plotname>.yaml` files alongside the plot.
|
||||
|
||||
### Frontend (Static JS/CSS)
|
||||
|
||||
The frontend is vanilla ES modules — no build step. `assets/js/main.js` imports `GalleryApp` from `gallery-app.js`, which instantiates all manager classes (`ThemeManager`, `SearchManager`, `NavigationManager`, etc.). Each manager is self-contained. The template embeds gallery data as JSON in the page; JS reads it at runtime.
|
||||
|
||||
### Deployment
|
||||
|
||||
The project ships a `Singularity.def` / `web.sif` Apptainer container for HPC environments. CI (`.gitlab-ci.yml`) builds the container and runs pytest inside it. For local development the `.venv` is sufficient.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Kylian Schmidt
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,141 +1,257 @@
|
||||
# Gallery Application - Restructured
|
||||
# Gallery: Scientific Plot Organizer
|
||||
|
||||
This document explains the new modular structure of the gallery application.
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
## Project Structure
|
||||
**Create responsive HTML galleries for scientific plot collections. Convert PDFs to PNG, organize plots hierarchically, and generate beautiful static websites.**
|
||||
|
||||
```
|
||||
web/
|
||||
├── assets/
|
||||
│ ├── css/
|
||||
│ │ ├── main.css # Main CSS file (imports all others)
|
||||
│ │ ├── variables.css # CSS custom properties and themes
|
||||
│ │ ├── base.css # Base layout and typography
|
||||
│ │ ├── navigation.css # Breadcrumb and navigation styles
|
||||
│ │ ├── search.css # Search functionality styles
|
||||
│ │ ├── folder-tree.css # Folder tree component styles
|
||||
│ │ ├── grid.css # Plot grid and selection styles
|
||||
│ │ ├── sidebar.css # Recent plots sidebar styles
|
||||
│ │ ├── floating-elements.css # Floating buttons and help
|
||||
│ │ ├── stats.css # Gallery statistics styles
|
||||
│ │ ├── comparison.css # Plot comparison overlay styles
|
||||
│ │ └── responsive.css # Mobile and responsive styles
|
||||
│ └── js/
|
||||
│ ├── main.js # Main entry point
|
||||
│ ├── gallery-app.js # Main application orchestrator
|
||||
│ ├── theme-manager.js # Theme switching functionality
|
||||
│ ├── navigation-manager.js # Breadcrumb and folder tree
|
||||
│ ├── search-manager.js # Search functionality
|
||||
│ ├── recent-plots-manager.js # Recent plots sidebar
|
||||
│ ├── comparison-manager.js # Plot comparison features
|
||||
│ ├── stats-manager.js # Gallery statistics
|
||||
│ ├── keyboard-manager.js # Keyboard shortcuts
|
||||
│ └── utils.js # Utility functions
|
||||
├── templates/
|
||||
│ └── gallery.html # Clean HTML template
|
||||
├── template.html # Original monolithic file (backup)
|
||||
├── config.py
|
||||
├── config.yaml
|
||||
├── generate_gallery.py
|
||||
└── README.md
|
||||
## Features
|
||||
|
||||
### Core Functionality
|
||||
|
||||
- PDF to PNG Conversion using ImageMagick
|
||||
- Incremental updates only when plot is newer than cached
|
||||
- Responsive design using Jinja2 (also on mobile)
|
||||
- Support for nested folder structures
|
||||
- Search bar
|
||||
- Breadcrump navigation
|
||||
|
||||
### Advanced Features
|
||||
|
||||
- Compare two plots from the same folder
|
||||
- YAML/JSON metadata with inheritance and display to properly label each folder
|
||||
- Recent plots
|
||||
- Dark and light mode
|
||||
- Keyboard shortcuts
|
||||
|
||||
### Developer-Friendly
|
||||
|
||||
- Full python API: `import gallery`
|
||||
- CLI: `gallery`
|
||||
- TUI: `gallery tui`
|
||||
- Config file stored under user `$HOME/.config/gallery`
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
### From GitLab
|
||||
|
||||
Pip install:
|
||||
|
||||
```bash
|
||||
pip install git+https://gitlab.etp.kit.edu/kschmidt/web
|
||||
```
|
||||
|
||||
## Key Improvements
|
||||
Or git clone and `pip install .`. After installation the `gallery` command is available in your shell. Verify with:
|
||||
|
||||
### 1. **Separation of Concerns**
|
||||
- **CSS**: Organized into logical components (navigation, search, grid, etc.)
|
||||
- **JavaScript**: Split into focused managers with single responsibilities
|
||||
- **HTML**: Clean template focusing on structure
|
||||
|
||||
### 2. **Modular Architecture**
|
||||
- Each JavaScript module handles a specific feature area
|
||||
- Modules can be independently maintained and tested
|
||||
- Clear dependencies and interfaces between modules
|
||||
|
||||
### 3. **Maintainability**
|
||||
- Individual files are much smaller and focused
|
||||
- Easy to locate and modify specific functionality
|
||||
- Reduced cognitive load when working on features
|
||||
|
||||
### 4. **Development Benefits**
|
||||
- Better IDE support with syntax highlighting and intellisense
|
||||
- Easier debugging with source maps
|
||||
- Ability to add build tools if needed
|
||||
|
||||
## Module Responsibilities
|
||||
|
||||
### CSS Modules
|
||||
- **variables.css**: Theme colors and CSS custom properties
|
||||
- **base.css**: Typography, basic layout, list styles
|
||||
- **navigation.css**: Breadcrumb and navigation button styles
|
||||
- **search.css**: Search box, results, and highlighting
|
||||
- **folder-tree.css**: Collapsible folder tree display
|
||||
- **grid.css**: Plot thumbnails grid and selection states
|
||||
- **sidebar.css**: Recent plots sidebar and overlay
|
||||
- **floating-elements.css**: Action buttons and keyboard help
|
||||
- **stats.css**: Gallery statistics display
|
||||
- **comparison.css**: Plot comparison overlay
|
||||
- **responsive.css**: Mobile and tablet adaptations
|
||||
|
||||
### JavaScript Modules
|
||||
- **ThemeManager**: Light/dark theme switching and persistence
|
||||
- **NavigationManager**: Breadcrumb building and folder tree construction
|
||||
- **SearchManager**: Plot search with debouncing and results display
|
||||
- **RecentPlotsManager**: Recent plots tracking and sidebar management
|
||||
- **ComparisonManager**: Plot comparison functionality
|
||||
- **StatsManager**: Gallery statistics calculation and display
|
||||
- **KeyboardManager**: Keyboard shortcuts and escape handling
|
||||
- **Utils**: File size formatting, thumbnail highlighting, gallery refresh
|
||||
|
||||
### Main Application
|
||||
- **GalleryApp**: Orchestrates all managers and provides unified interface
|
||||
- **main.js**: Entry point that initializes the application
|
||||
|
||||
## Usage
|
||||
|
||||
The restructured application maintains **full backward compatibility** with the original template. All existing functionality works exactly the same way.
|
||||
|
||||
### For Python Backend
|
||||
Update your template reference to use the new template:
|
||||
```python
|
||||
# Instead of template.html, use:
|
||||
template_path = 'templates/gallery.html'
|
||||
```bash
|
||||
gallery --help
|
||||
```
|
||||
|
||||
### CSS Asset Path
|
||||
The template expects CSS/JS assets to be served from `/assets/` relative to the gallery pages. Update your web server configuration to serve these static files.
|
||||
### Dependencies
|
||||
|
||||
### No Breaking Changes
|
||||
- All onclick handlers work the same
|
||||
- All CSS classes remain unchanged
|
||||
- All IDs and functionality preserved
|
||||
- Jinja2 template variables work identically
|
||||
- Python 3.8+
|
||||
- Python packages are installed automatically by pip (Jinja2, PyYAML, PyMuPDF, Textual, argcomplete, platformdirs)
|
||||
- [ImageMagick](https://imagemagick.org/) is **optional** — used as a fallback if `PyMuPDF` is not available
|
||||
|
||||
## Development Workflow
|
||||
### Shell Completion (optional)
|
||||
|
||||
### Adding New Features
|
||||
1. Identify which manager should handle the new functionality
|
||||
2. Add methods to the appropriate manager class
|
||||
3. Update the main GalleryApp class if needed
|
||||
4. Add any new CSS to the appropriate CSS module
|
||||
Install tab-completion for bash/zsh/fish:
|
||||
|
||||
### Modifying Existing Features
|
||||
1. Locate the relevant manager (theme, search, navigation, etc.)
|
||||
2. Make changes to the specific module
|
||||
3. Test that the feature works as expected
|
||||
```bash
|
||||
gallery install-completion
|
||||
```
|
||||
|
||||
### Styling Changes
|
||||
1. Identify the component being styled
|
||||
2. Edit the appropriate CSS module
|
||||
3. The main.css file will automatically include changes
|
||||
Then restart your shell or follow the printed instructions to activate it.
|
||||
|
||||
## Benefits of This Structure
|
||||
## CLI Usage
|
||||
|
||||
1. **Easier Debugging**: Each feature is isolated in its own file
|
||||
2. **Better Performance**: Browser can cache individual modules
|
||||
3. **Team Development**: Multiple developers can work on different features simultaneously
|
||||
4. **Code Reuse**: Managers can be reused in other projects
|
||||
5. **Testing**: Individual modules can be unit tested
|
||||
6. **Documentation**: Each file has a clear, focused purpose
|
||||
The `gallery` command has four subcommands: `generate`, `config`, `tui`, and `install-completion`.
|
||||
|
||||
This restructuring makes the gallery application much more maintainable while preserving all existing functionality.
|
||||
### Quick start
|
||||
|
||||
```bash
|
||||
# 1. Set your web output directory
|
||||
gallery config set paths.web_folder /path/to/your/public_html
|
||||
|
||||
# 2. Add one or more plot source directories
|
||||
gallery config add-source --path /path/to/plots --name my_analysis
|
||||
|
||||
# 3. Generate the gallery
|
||||
gallery generate
|
||||
```
|
||||
|
||||
### `gallery generate`
|
||||
|
||||
```bash
|
||||
# Full regeneration
|
||||
gallery generate
|
||||
|
||||
# Verbose output
|
||||
gallery generate --verbose
|
||||
|
||||
# Clean rebuild (delete output before generating)
|
||||
gallery generate --clean
|
||||
|
||||
# Regenerate a single source only (name will be the basename of the path)
|
||||
gallery generate --source /path/to/plots
|
||||
|
||||
# Use a non-default config file
|
||||
gallery --config /path/to/config.yaml generate
|
||||
```
|
||||
|
||||
### `gallery config`
|
||||
|
||||
```bash
|
||||
# Show all configuration values
|
||||
gallery config list
|
||||
|
||||
# Show the resolved config file path
|
||||
gallery config path
|
||||
|
||||
# List configured sources
|
||||
gallery config sources
|
||||
|
||||
# Get a single value
|
||||
gallery config get gallery.png_dpi
|
||||
|
||||
# Set a value (all standard YAML types accepted)
|
||||
gallery config set gallery.png_dpi 300
|
||||
gallery config set metadata.cache_enabled true
|
||||
|
||||
# Add a source directory
|
||||
gallery config add-source --path /path/to/plots --name my_plots
|
||||
|
||||
# Remove a source
|
||||
gallery config remove-source my_plots
|
||||
```
|
||||
|
||||
### Serving locally
|
||||
|
||||
```bash
|
||||
python -m http.server 8000 -d /path/to/public_html
|
||||
```
|
||||
|
||||
Then open `http://localhost:8000/gallery/` in your browser. You can of course use any port you want to.
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Main Gallery View
|
||||

|
||||
|
||||
### Metadata Display
|
||||

|
||||
|
||||
### Plot Comparison Tool
|
||||

|
||||
|
||||
### Search Functionality
|
||||
<img src="docs/images/search.png" width="500px">
|
||||
|
||||
|
||||
## TUI Usage
|
||||
|
||||
The TUI is a terminal user interface built with [Textual](https://textual.textualize.io/). It lets you edit the configuration and trigger generation without leaving your terminal.
|
||||
|
||||
```bash
|
||||
gallery tui
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The config file lives at `$HOME/.config/gallery/config.yaml` by default. You can point to a different file with `gallery --config /path/to/config.yaml <subcommand>`.
|
||||
|
||||
### Config structure
|
||||
|
||||
```yaml
|
||||
paths:
|
||||
web_folder: "/web/user/public_html" # required
|
||||
|
||||
gallery:
|
||||
plot_root: "gallery" # subdirectory inside web_folder
|
||||
png_dpi: 400 # thumbnail resolution
|
||||
backup_folder: "" # optional backup path
|
||||
|
||||
sources:
|
||||
- name: "analysis_results"
|
||||
path: "/path/to/plots/directory"
|
||||
- name: "specific_plot"
|
||||
path: "/path/to/another/directory"
|
||||
|
||||
metadata:
|
||||
cache_enabled: true
|
||||
inherit_from_parent: true
|
||||
```
|
||||
|
||||
### Metadata Files
|
||||
|
||||
Create `metadata.yaml` files in your source directories. Fields are arbitrary and rendered via YAML object interpretation (dict, list, …). Lower-level files override parent values, which is useful for labelling specific experiments.
|
||||
|
||||
```yaml
|
||||
# metadata.yaml
|
||||
title: "Analysis Results"
|
||||
description: "Results for my analysis"
|
||||
experiment: "CMS"
|
||||
dataset: "Run2_2016_nano_v9"
|
||||
|
||||
parameters:
|
||||
luminosity: "35.9 fb^{-1}"
|
||||
center_of_mass_energy: "13 TeV"
|
||||
selection: "baseline"
|
||||
|
||||
tags:
|
||||
- "physics"
|
||||
- "analysis"
|
||||
- "cms"
|
||||
|
||||
authors:
|
||||
- "Researcher A"
|
||||
- "Researcher B"
|
||||
```
|
||||
|
||||
LaTeX formulas are supported in metadata values and rendered with MathJax:
|
||||
|
||||
```yaml
|
||||
formula: "$$E = mc^2$$"
|
||||
```
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Icon | Button | Function | Shortcut |
|
||||
|------|---------|--------------------------|----------|
|
||||
| 🔍 | Search | Real-time plot search | `Ctrl+K` |
|
||||
| 📋 | Recent | Recently viewed plots | `Ctrl+R` |
|
||||
| ⚖️ | Compare | Side-by-side comparison | `Ctrl+C` |
|
||||
| ☀️ | Theme | Toggle dark/light theme | `Ctrl+T` |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | (Potential) Solution |
|
||||
|------------------------------------|----------|
|
||||
| `gallery` command not found | Run `pip install -e .` from the repo root |
|
||||
| PDF conversion fails — no renderer | Install PyMuPDF: `pip install pymupdf` (or `apt-get install imagemagick` as fallback) |
|
||||
| PDF conversion fails — corrupt file | Verify the PDF opens in a viewer; try `pip install --upgrade pymupdf` |
|
||||
| Permission denied on web dir | Check file permissions and web directory access |
|
||||
| Metadata not showing | Check YAML syntax with `python -c "import yaml; yaml.safe_load(open('metadata.yaml'))"` |
|
||||
|
||||
### ImageMagick memory limits (fallback backend only)
|
||||
|
||||
```bash
|
||||
export MAGICK_MEMORY_LIMIT=2GB
|
||||
export MAGICK_MAP_LIMIT=2GB
|
||||
gallery generate --verbose
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Disclaimer on the use of Artificial Intelligence
|
||||
|
||||
This project was made *entirely* using Claude Sonnet 4.0 and GPT 4.1. 100% of the code was AI generated and no human hand was involved other than prompting the Agent.
|
||||
|
||||
## Links
|
||||
|
||||
- [Example Gallery](https://etpwww.etp.kit.edu/~kschmidt/gallery/index.html)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
Bootstrap: docker
|
||||
From: python:3.11-slim
|
||||
|
||||
%post
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends imagemagick
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
pip install --no-cache-dir jinja2 pyyaml coverage pytest pytest-cov
|
||||
mkdir -p /src
|
||||
|
||||
%files
|
||||
. /src
|
||||
|
||||
%environment
|
||||
export PYTHONPATH=/src
|
||||
|
||||
%runscript
|
||||
cd /src
|
||||
exec python3 generate_gallery.py "$@"
|
||||
@@ -5,24 +5,18 @@
|
||||
paths:
|
||||
# Working directory where the script runs from
|
||||
work_dir: "/work/kschmidt/web"
|
||||
|
||||
|
||||
# Web hosting directory where gallery files are served
|
||||
web_folder: "/web/kschmidt/public_html/"
|
||||
|
||||
# CGI script path (relative to web folder)
|
||||
cgi_script: "cgi-bin/refresh_gallery.py"
|
||||
|
||||
# Config file path for CGI scripts
|
||||
config_path: "/work/kschmidt/web"
|
||||
|
||||
# Gallery Settings
|
||||
gallery:
|
||||
# Root folder name for plots in web directory
|
||||
plot_root: "gallery"
|
||||
|
||||
|
||||
# PNG conversion quality
|
||||
png_dpi: 400
|
||||
|
||||
|
||||
# Backup folder (leave empty to disable)
|
||||
backup_folder: ""
|
||||
|
||||
@@ -30,7 +24,7 @@ gallery:
|
||||
ui:
|
||||
# Maximum number of recent plots to track
|
||||
max_recent_plots: 20
|
||||
|
||||
|
||||
# Search settings
|
||||
search_debounce_ms: 300
|
||||
|
||||
@@ -38,14 +32,18 @@ ui:
|
||||
metadata:
|
||||
# Enable metadata caching
|
||||
cache_enabled: true
|
||||
|
||||
|
||||
# Inherit metadata from parent folders
|
||||
inherit_from_parent: true
|
||||
|
||||
# Data Sources
|
||||
# Each source represents a collection of plots to include in the gallery
|
||||
sources:
|
||||
sources:
|
||||
- name: "ttbar_analysis"
|
||||
path: "/work/kschmidt/NEEDLE/test_analysis/data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/"
|
||||
- name: "needle_benchmarks"
|
||||
path: "/work/kschmidt/NEEDLE/orchestrator/ml/benchmarks/plots"
|
||||
- name: "needle_fair_universe"
|
||||
path: "/work/kschmidt/NEEDLE/orchestrator/runs/fair_universe_demo_fixed_normalization/stat_only_histogram_mu_one/plots"
|
||||
- name: "aido_convergence_study"
|
||||
path: "/work/kschmidt/aido/results_convergence/plots/"
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
# Automated Coverage Testing Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This repository now includes automated code coverage testing using the `coverage.py` package. Coverage testing helps ensure that your tests adequately exercise your codebase and identifies untested code paths.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Container-based Coverage (Recommended)
|
||||
```bash
|
||||
# Build and test with coverage in container
|
||||
./tests/test_container.sh
|
||||
|
||||
# Or run coverage directly in container
|
||||
apptainer exec gallery-generator.sif python3 /src/tests/run_coverage.py
|
||||
```
|
||||
|
||||
### Local Coverage Testing
|
||||
```bash
|
||||
# Run coverage tests locally
|
||||
./tests/run_coverage_local.sh
|
||||
|
||||
# Or manually
|
||||
pip install coverage
|
||||
coverage run -m unittest tests.test_container
|
||||
coverage report
|
||||
coverage html
|
||||
```
|
||||
|
||||
## 📁 Coverage Files
|
||||
|
||||
### Core Coverage Files
|
||||
- **`.coveragerc`** - Coverage configuration file
|
||||
- **`tests/run_coverage.py`** - Automated coverage script for containers
|
||||
- **`tests/run_coverage_local.sh`** - Local coverage testing script
|
||||
|
||||
### Generated Reports
|
||||
- **`coverage.xml`** - XML format for CI/CD integration
|
||||
- **`coverage_html_report/`** - Interactive HTML reports
|
||||
- **`.coverage`** - Coverage data file
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Coverage Settings (`.coveragerc`)
|
||||
```ini
|
||||
[run]
|
||||
source = .
|
||||
omit =
|
||||
tests/* # Exclude test files
|
||||
__pycache__/* # Exclude cache
|
||||
assets/* # Exclude static assets
|
||||
docs/* # Exclude documentation
|
||||
templates/* # Exclude templates
|
||||
|
||||
[report]
|
||||
precision = 2 # 2 decimal places
|
||||
show_missing = True # Show missing line numbers
|
||||
skip_covered = False # Show all files
|
||||
|
||||
[html]
|
||||
directory = coverage_html_report
|
||||
title = Gallery Generator Coverage Report
|
||||
```
|
||||
|
||||
### Singularity Container Integration
|
||||
The coverage package is automatically installed in the container:
|
||||
```bash
|
||||
pip install --no-cache-dir jinja2 pyyaml coverage
|
||||
```
|
||||
|
||||
## 📊 Coverage Reports
|
||||
|
||||
### Console Report
|
||||
Shows coverage percentage and missing lines:
|
||||
```
|
||||
Name Stmts Miss Cover Missing
|
||||
-----------------------------------------------------
|
||||
generate_gallery.py 190 45 76.32% 156-167, 234-245
|
||||
orchestration/config.py 45 8 82.22% 78-82
|
||||
orchestration/logger.py 67 12 82.09% 45-48, 89-94
|
||||
-----------------------------------------------------
|
||||
TOTAL 302 65 78.48%
|
||||
```
|
||||
|
||||
### HTML Report
|
||||
Interactive report with:
|
||||
- Line-by-line coverage highlighting
|
||||
- Branch coverage details
|
||||
- Sortable file listings
|
||||
- Coverage trends
|
||||
|
||||
### XML Report
|
||||
Machine-readable format for CI/CD:
|
||||
- GitLab CI coverage visualization
|
||||
- External tool integration
|
||||
- Coverage badges
|
||||
|
||||
## 🎯 Coverage Targets
|
||||
|
||||
### Current Thresholds
|
||||
- **Minimum Target**: 80% overall coverage
|
||||
- **Warning Level**: Below 70% coverage
|
||||
- **Exclusions**: Test files, static assets, documentation
|
||||
|
||||
### Best Practices
|
||||
- **Focus on Core Logic**: Prioritize business logic coverage
|
||||
- **Test Edge Cases**: Include error handling and boundary conditions
|
||||
- **Regular Monitoring**: Run coverage with every commit
|
||||
- **Incremental Improvement**: Gradually increase coverage over time
|
||||
|
||||
## 🔄 CI/CD Integration
|
||||
|
||||
### GitLab CI Pipeline
|
||||
The coverage testing is integrated into the GitLab CI pipeline:
|
||||
|
||||
```yaml
|
||||
test:coverage:
|
||||
stage: test
|
||||
script:
|
||||
- apptainer exec $CONTAINER_IMAGE python3 /src/tests/run_coverage.py
|
||||
coverage: '/TOTAL.+?(\d+\.\d+)%/'
|
||||
artifacts:
|
||||
reports:
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
```
|
||||
|
||||
### Features
|
||||
- **Automatic Reports**: Coverage reports in merge requests
|
||||
- **Badge Integration**: Coverage badges in README
|
||||
- **Trend Tracking**: Historical coverage data
|
||||
- **Failure Thresholds**: Fail builds below minimum coverage
|
||||
|
||||
## 🛠️ Advanced Usage
|
||||
|
||||
### Custom Coverage Runs
|
||||
```bash
|
||||
# Test specific modules
|
||||
coverage run --source=orchestration -m unittest tests.test_metadata
|
||||
|
||||
# Include/exclude patterns
|
||||
coverage run --omit="*/tests/*" -m unittest discover
|
||||
|
||||
# Branch coverage (more detailed)
|
||||
coverage run --branch -m unittest tests.test_container
|
||||
```
|
||||
|
||||
### Coverage Analysis
|
||||
```bash
|
||||
# Show missing lines
|
||||
coverage report --show-missing
|
||||
|
||||
# Generate detailed HTML
|
||||
coverage html --show-contexts
|
||||
|
||||
# Export data
|
||||
coverage json
|
||||
coverage xml
|
||||
```
|
||||
|
||||
### Integration with IDEs
|
||||
- **VS Code**: Coverage Gutters extension
|
||||
- **PyCharm**: Built-in coverage runner
|
||||
- **Vim**: Coverage highlighting plugins
|
||||
|
||||
## 📈 Coverage Metrics
|
||||
|
||||
### What Coverage Measures
|
||||
- **Statement Coverage**: Lines of code executed
|
||||
- **Branch Coverage**: Decision paths taken
|
||||
- **Function Coverage**: Functions called
|
||||
- **Class Coverage**: Classes instantiated
|
||||
|
||||
### What Coverage Doesn't Measure
|
||||
- **Code Quality**: Coverage ≠ good tests
|
||||
- **Logic Correctness**: 100% coverage ≠ bug-free
|
||||
- **Performance**: Execution speed not measured
|
||||
- **Security**: Vulnerabilities not detected
|
||||
|
||||
## 🧪 Testing Strategy
|
||||
|
||||
### Container Test Suite Coverage
|
||||
Current test files and their focus:
|
||||
|
||||
#### `tests/test_container.py`
|
||||
- **Environment validation** - Container setup
|
||||
- **Utility functions** - Helper functions
|
||||
- **Metadata system** - YAML processing
|
||||
- **PDF processing** - ImageMagick integration
|
||||
- **Gallery generation** - End-to-end workflow
|
||||
|
||||
#### `tests/test_build_container.py`
|
||||
- **Container building** - Singularity build process
|
||||
- **Dependency validation** - Package installation
|
||||
- **Application functionality** - Script execution
|
||||
|
||||
### Coverage Gaps Analysis
|
||||
Use `tests/test_coverage.py` to analyze:
|
||||
- Missing function coverage
|
||||
- Untested code paths
|
||||
- Critical functionality gaps
|
||||
- Integration test needs
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### No Coverage Data
|
||||
```bash
|
||||
# Ensure coverage is running tests
|
||||
coverage run --debug=trace -m unittest tests.test_container
|
||||
```
|
||||
|
||||
#### Import Errors
|
||||
```bash
|
||||
# Check PYTHONPATH
|
||||
export PYTHONPATH=/src:$PYTHONPATH
|
||||
```
|
||||
|
||||
#### Permission Issues
|
||||
```bash
|
||||
# Container write permissions
|
||||
apptainer exec --writable-tmpfs container.sif python3 tests/run_coverage.py
|
||||
```
|
||||
|
||||
### Debug Commands
|
||||
```bash
|
||||
# Check coverage configuration
|
||||
coverage debug config
|
||||
|
||||
# Verify data collection
|
||||
coverage debug data
|
||||
|
||||
# Test discovery
|
||||
coverage debug sys
|
||||
```
|
||||
|
||||
## 📚 References
|
||||
|
||||
- **Coverage.py Documentation**: https://coverage.readthedocs.io/
|
||||
- **GitLab CI Coverage**: https://docs.gitlab.com/ee/ci/testing/code_coverage.html
|
||||
- **Testing Best Practices**: Python Testing 101
|
||||
- **Container Testing**: Singularity/Apptainer Documentation
|
||||
|
||||
## 🔄 Maintenance
|
||||
|
||||
### Regular Tasks
|
||||
- **Weekly**: Review coverage reports
|
||||
- **Monthly**: Update coverage targets
|
||||
- **Release**: Ensure minimum coverage met
|
||||
- **Quarterly**: Review exclusion patterns
|
||||
|
||||
### Cleanup
|
||||
```bash
|
||||
# Remove coverage files
|
||||
./tests/cleanup.sh
|
||||
|
||||
# Manual cleanup
|
||||
rm -f .coverage coverage.xml
|
||||
rm -rf coverage_html_report/
|
||||
```
|
||||
|
||||
### Updates
|
||||
```bash
|
||||
# Update coverage package
|
||||
pip install --upgrade coverage
|
||||
|
||||
# Update container
|
||||
apptainer build --force container.sif Singularity.def
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*This automated coverage system provides comprehensive testing insights while maintaining the containerized, dependency-free approach of the gallery generator project.*
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 128 KiB |
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Scientific Gallery Generator
|
||||
|
||||
A Python package for creating responsive HTML galleries from scientific plot
|
||||
collections. Supports PDF to PNG conversion, hierarchical directory structures,
|
||||
and can be used programmatically or via CLI.
|
||||
|
||||
Example usage:
|
||||
from gallery import generate, GalleryConfig, GallerySource
|
||||
|
||||
config = GalleryConfig(
|
||||
web_folder="/path/to/output",
|
||||
sources=[
|
||||
GallerySource(name="plots", path="/path/to/plots"),
|
||||
]
|
||||
)
|
||||
|
||||
success = generate(config, verbose=True)
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "K. Schmidt"
|
||||
|
||||
from gallery.config import (
|
||||
GalleryConfig,
|
||||
GallerySource,
|
||||
GalleryDefaults,
|
||||
)
|
||||
from gallery.api import generate
|
||||
|
||||
# Export utility functions for testing and advanced usage
|
||||
from gallery.utils.stats import (
|
||||
calculate_directory_stats,
|
||||
format_file_size,
|
||||
)
|
||||
from gallery.utils.processing import (
|
||||
convert_pdf_to_png,
|
||||
needs_update,
|
||||
process_plot_files,
|
||||
render_gallery_page,
|
||||
)
|
||||
from gallery.utils.metadata import (
|
||||
load_folder_metadata,
|
||||
merge_metadata,
|
||||
save_metadata_cache,
|
||||
load_metadata_file,
|
||||
resolve_metadata_for_plot,
|
||||
)
|
||||
from gallery.utils.datetime_utils import (
|
||||
datetime_from_timestamp,
|
||||
strftime_filter,
|
||||
)
|
||||
from gallery.builder import build_gallery, get_template
|
||||
|
||||
__all__ = [
|
||||
"generate",
|
||||
"GalleryConfig",
|
||||
"GallerySource",
|
||||
"GalleryDefaults",
|
||||
# Utilities
|
||||
"calculate_directory_stats",
|
||||
"format_file_size",
|
||||
"convert_pdf_to_png",
|
||||
"needs_update",
|
||||
"process_plot_files",
|
||||
"render_gallery_page",
|
||||
"load_folder_metadata",
|
||||
"merge_metadata",
|
||||
"save_metadata_cache",
|
||||
"load_metadata_file",
|
||||
"resolve_metadata_for_plot",
|
||||
"build_gallery",
|
||||
"get_template",
|
||||
"datetime_from_timestamp",
|
||||
"strftime_filter",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Main API for gallery generation.
|
||||
|
||||
Provides the primary entry point for programmatic gallery generation.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Union, List, Dict, Any
|
||||
|
||||
from gallery.config import GalleryConfig, GallerySource
|
||||
from gallery.builder import get_template, build_gallery, copy_assets
|
||||
|
||||
|
||||
def generate(
|
||||
config: Union[GalleryConfig, str, Path] = None,
|
||||
web_folder: Union[str, Path] = None,
|
||||
sources: List[Union[GallerySource, Dict[str, Any]]] = None,
|
||||
clean_first: bool = False,
|
||||
verbose: bool = False,
|
||||
source_to_update: GallerySource = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Generate a scientific gallery from plot sources.
|
||||
|
||||
Can be called in two ways:
|
||||
1. With a GalleryConfig object
|
||||
2. With explicit parameters (web_folder and sources)
|
||||
|
||||
Args:
|
||||
config: GalleryConfig object or path to YAML config file.
|
||||
If this is provided, other args are ignored.
|
||||
web_folder: Output directory for the gallery.
|
||||
Required if config is not provided.
|
||||
sources: List of GallerySource objects or dicts.
|
||||
Required if config is not provided.
|
||||
clean_first: If True, removes and recreates the gallery directory.
|
||||
If False (default), performs incremental update.
|
||||
verbose: If True, prints progress messages.
|
||||
source_to_update: Optional specific source to update. When provided,
|
||||
only this source is regenerated (incremental mode).
|
||||
Other sources in config are preserved in the index.
|
||||
Only effective when clean_first is False.
|
||||
|
||||
Returns:
|
||||
True if gallery generation was successful, False otherwise
|
||||
|
||||
Raises:
|
||||
ValueError: If required arguments are missing or invalid
|
||||
TypeError: If config type is invalid
|
||||
|
||||
Example:
|
||||
# Using GalleryConfig object
|
||||
from gallery import generate, GalleryConfig, GallerySource
|
||||
|
||||
config = GalleryConfig(
|
||||
web_folder="/output/path",
|
||||
sources=[
|
||||
GallerySource(name="plots", path="/path/to/plots"),
|
||||
]
|
||||
)
|
||||
success = generate(config, verbose=True)
|
||||
|
||||
# Using explicit parameters
|
||||
success = generate(
|
||||
web_folder="/output/path",
|
||||
sources=[
|
||||
{"name": "plots", "path": "/path/to/plots"},
|
||||
],
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Loading from YAML config
|
||||
success = generate(config="config.yaml", verbose=True)
|
||||
"""
|
||||
try:
|
||||
# Load or create configuration
|
||||
if config is not None:
|
||||
if isinstance(config, (str, Path)):
|
||||
config = GalleryConfig.from_yaml(config)
|
||||
elif not isinstance(config, GalleryConfig):
|
||||
raise TypeError(
|
||||
f"config must be GalleryConfig, str, or Path, "
|
||||
f"got {type(config)}"
|
||||
)
|
||||
else:
|
||||
if web_folder is None or sources is None:
|
||||
raise ValueError(
|
||||
"Either config or both web_folder and sources "
|
||||
"must be provided"
|
||||
)
|
||||
config = GalleryConfig(
|
||||
web_folder=web_folder,
|
||||
sources=sources or []
|
||||
)
|
||||
|
||||
# Validate configuration
|
||||
if not config.sources:
|
||||
if verbose:
|
||||
print("Warning: No sources configured")
|
||||
return False
|
||||
|
||||
# Check if web_folder is writable
|
||||
web_folder_path = Path(config.web_folder)
|
||||
if not _is_writable(web_folder_path):
|
||||
if verbose:
|
||||
print(
|
||||
f"Error: Cannot write to web_folder: "
|
||||
f"{config.web_folder}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Create gallery root directory
|
||||
gallery_root = web_folder_path / config.plot_root
|
||||
|
||||
if clean_first and gallery_root.exists():
|
||||
if verbose:
|
||||
print(f"Cleaning gallery directory {gallery_root}...")
|
||||
try:
|
||||
shutil.rmtree(gallery_root)
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f"Warning: Could not clean directory: {e}")
|
||||
return False
|
||||
elif source_to_update and gallery_root.exists():
|
||||
# Incremental mode: only clean the specific source subdirectory
|
||||
source_subdir = gallery_root / source_to_update.name
|
||||
if source_subdir.exists():
|
||||
if verbose:
|
||||
print(
|
||||
f"Updating source directory {source_to_update.name}..."
|
||||
)
|
||||
try:
|
||||
shutil.rmtree(source_subdir)
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(
|
||||
f"Warning: Could not clean source subdirectory "
|
||||
f"{source_subdir}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
gallery_root.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f"Error: Could not create gallery directory: {e}")
|
||||
return False
|
||||
|
||||
# Copy assets
|
||||
if not copy_assets(config, verbose=verbose):
|
||||
if verbose:
|
||||
print("Warning: Could not copy assets")
|
||||
# Don't fail, continue with generation
|
||||
|
||||
# Get template
|
||||
try:
|
||||
template = get_template()
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f"Error: Could not load template: {e}")
|
||||
return False
|
||||
|
||||
# Process sources
|
||||
source_subdirs = []
|
||||
for source in config.sources:
|
||||
# Skip sources not matching the update target (if specified)
|
||||
if source_to_update and source.name != source_to_update.name:
|
||||
# Still include them in the index if they exist
|
||||
source_web_dir = gallery_root / source.name
|
||||
if source_web_dir.exists():
|
||||
source_subdirs.append(source.name)
|
||||
continue
|
||||
|
||||
try:
|
||||
source_path = Path(source.path).resolve()
|
||||
|
||||
# Validate source exists
|
||||
if not source_path.exists():
|
||||
if verbose:
|
||||
print(
|
||||
f"Warning: Source {source.path} does not exist. "
|
||||
f"Skipping."
|
||||
)
|
||||
continue
|
||||
|
||||
source_web_dir = gallery_root / source.name
|
||||
try:
|
||||
source_web_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(
|
||||
f"Warning: Could not create directory "
|
||||
f"{source_web_dir}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
source_subdirs.append(source.name)
|
||||
|
||||
# Process source
|
||||
if source_path.is_file() and source_path.suffix == '.pdf':
|
||||
# Single PDF file
|
||||
from gallery.utils.processing import process_plot_files
|
||||
item = process_plot_files(
|
||||
config=config,
|
||||
plot_file=source_path,
|
||||
web_dir=source_web_dir,
|
||||
)
|
||||
from gallery.utils.processing import render_gallery_page
|
||||
render_gallery_page(
|
||||
config=config,
|
||||
template=template,
|
||||
web_dir=source_web_dir,
|
||||
items=[item],
|
||||
subdirs=[],
|
||||
relative_path=Path(source.name)
|
||||
)
|
||||
elif source_path.is_dir():
|
||||
# Directory of plots
|
||||
build_gallery(
|
||||
config,
|
||||
source_path,
|
||||
source_web_dir,
|
||||
template,
|
||||
Path(source.name)
|
||||
)
|
||||
else:
|
||||
if verbose:
|
||||
print(
|
||||
f"Warning: Source {source.path} is neither a "
|
||||
f"directory nor a PDF file. Skipping."
|
||||
)
|
||||
continue
|
||||
|
||||
if verbose:
|
||||
print(f"Processed {source.name}: {source.path}")
|
||||
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(
|
||||
f"Warning: Error processing source "
|
||||
f"{source.name}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Render gallery root index
|
||||
try:
|
||||
from gallery.utils.processing import render_gallery_page
|
||||
render_gallery_page(
|
||||
config=config,
|
||||
template=template,
|
||||
web_dir=gallery_root,
|
||||
items=[],
|
||||
subdirs=source_subdirs,
|
||||
relative_path=Path("."),
|
||||
title="Gallery Root"
|
||||
)
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f"Warning: Could not render gallery root: {e}")
|
||||
# Don't fail, gallery is still usable
|
||||
|
||||
plot_count = _count_gallery_plots(gallery_root)
|
||||
if plot_count == 0:
|
||||
print(f"Warning: Gallery at {gallery_root} appears to be empty — no plot files found!")
|
||||
else:
|
||||
print(f"✓ Gallery generated at {gallery_root} — {plot_count} plots total")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f"Error: Gallery generation failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _count_gallery_plots(gallery_root: Path) -> int:
|
||||
"""Recursively count plot files (PDFs and HTMLs, excluding index.html) in the gallery output."""
|
||||
count = 0
|
||||
for f in gallery_root.rglob('*'):
|
||||
if f.is_file() and f.suffix.lower() in ('.pdf', '.html') and f.name != 'index.html':
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _is_writable(path: Path) -> bool:
|
||||
"""
|
||||
Check if a path is writable.
|
||||
|
||||
Creates the directory if it doesn't exist.
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
|
||||
Returns:
|
||||
True if writable, False otherwise
|
||||
"""
|
||||
try:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
# Try to create a test file
|
||||
test_file = path / ".gallery_test"
|
||||
test_file.touch()
|
||||
test_file.unlink()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,71 @@
|
||||
/* ========================================
|
||||
BASE LAYOUT AND TYPOGRAPHY
|
||||
======================================== */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
padding-bottom: 400px; /* Further increased bottom padding to prevent overlap with stats box */
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--text-color);
|
||||
font-size: 1.3rem;
|
||||
margin: 1.5rem 0 0.5rem 0;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
SUBDIRECTORIES LIST
|
||||
======================================== */
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ul li {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
ul li a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
padding: 0.3rem 0;
|
||||
display: inline-block;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
ul li a:hover {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive design adjustments */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 0.5rem;
|
||||
padding-bottom: 300px; /* Further increased mobile bottom padding to match desktop */
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/* ========================================
|
||||
PLOT COMPARISON OVERLAY
|
||||
======================================== */
|
||||
.comparison-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
z-index: 2000;
|
||||
display: none;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.comparison-overlay.open {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.comparison-container {
|
||||
width: 95%;
|
||||
height: 90%;
|
||||
background: var(--bg-color);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.comparison-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.comparison-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.comparison-close {
|
||||
background: var(--button-bg);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.comparison-close:hover {
|
||||
background: var(--button-hover);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.comparison-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comparison-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comparison-panel-header {
|
||||
background: var(--tree-bg);
|
||||
padding: 0.8rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.comparison-panel-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.comparison-replace-btn {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.comparison-replace-btn:hover {
|
||||
background: var(--success-hover);
|
||||
}
|
||||
|
||||
.comparison-panel-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.comparison-plot-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.comparison-plot {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.comparison-plot:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.comparison-placeholder {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--breadcrumb-color);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.comparison-placeholder:hover {
|
||||
border-color: var(--button-bg);
|
||||
color: var(--button-bg);
|
||||
}
|
||||
|
||||
.comparison-plot-info {
|
||||
position: absolute;
|
||||
bottom: 0.5rem;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.comparison-plot-container:hover .comparison-plot-info {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
/* ========================================
|
||||
EXPORT FUNCTIONALITY STYLES
|
||||
======================================== */
|
||||
|
||||
/* Selection mode styles */
|
||||
.selection-mode .grid-item {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.selection-mode .grid-item:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Selection overlay */
|
||||
.selection-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.selection-mode .selection-overlay {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.selection-checkbox {
|
||||
background: var(--card-background);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.grid-item.selected .selection-checkbox {
|
||||
background: var(--primary-color, #007bff);
|
||||
border-color: var(--primary-color, #007bff);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.checkbox-icon {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Selection counter */
|
||||
.selection-counter {
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
right: 20px;
|
||||
background: var(--card-background);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* Export button styles */
|
||||
.export-btn {
|
||||
background: #28a745 !important;
|
||||
}
|
||||
|
||||
.export-btn:hover {
|
||||
background: #218838 !important;
|
||||
}
|
||||
|
||||
.export-btn:disabled {
|
||||
background: #6c757d !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Export messages */
|
||||
.export-message {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
z-index: 1001;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.export-message-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
.export-message-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.export-message-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeaa7;
|
||||
}
|
||||
|
||||
.export-message-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Selection mode indicator */
|
||||
.selection-mode::before {
|
||||
content: "Selection Mode - Click plots to select them";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--primary-color, #007bff);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Adjust main content when in selection mode */
|
||||
.selection-mode {
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
/* Export instructions overlay */
|
||||
.export-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1002;
|
||||
}
|
||||
|
||||
.export-instructions {
|
||||
background: var(--card-background);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.export-instructions h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.export-instructions p {
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.export-data {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.export-data textarea {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.export-commands {
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
background: var(--header-background);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.export-command-container {
|
||||
margin: 20px 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.export-command {
|
||||
background: var(--header-background);
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.export-command code {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-color);
|
||||
word-break: break-all;
|
||||
display: block;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.export-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: var(--card-background);
|
||||
}
|
||||
|
||||
.export-actions button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--card-background);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.export-actions button:hover {
|
||||
background: var(--header-background);
|
||||
}
|
||||
|
||||
.export-actions button:last-child {
|
||||
background: var(--primary-color, #007bff);
|
||||
color: white;
|
||||
border-color: var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
.export-actions button:last-child:hover {
|
||||
background: var(--primary-color-dark, #0056b3);
|
||||
}
|
||||
|
||||
.copy-btn, .close-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--card-background);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: var(--primary-color, #007bff);
|
||||
color: white;
|
||||
border-color: var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border-color: #dc3545;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #c82333;
|
||||
border-color: #bd2130;
|
||||
}
|
||||
|
||||
.export-details, .export-tips {
|
||||
margin: 20px 0;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.export-details {
|
||||
background: var(--header-background);
|
||||
}
|
||||
|
||||
.export-tips {
|
||||
background: var(--card-background);
|
||||
border-color: var(--primary-color, #007bff);
|
||||
border-left: 4px solid var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
.export-details h4, .export-tips h4 {
|
||||
margin: 0 0 12px 0;
|
||||
color: var(--text-color);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.export-details ul, .export-tips ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.export-details li, .export-tips li {
|
||||
margin: 8px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.export-details code, .export-tips code {
|
||||
background: var(--background-color);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.export-tips kbd {
|
||||
background: var(--header-background);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.copy-feedback {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
z-index: 1003;
|
||||
animation: fadeInOut 2s ease-in-out;
|
||||
}
|
||||
|
||||
.copy-feedback-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.copy-feedback-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
0% { opacity: 0; transform: translateY(-10px); }
|
||||
20% { opacity: 1; transform: translateY(0); }
|
||||
80% { opacity: 1; transform: translateY(0); }
|
||||
100% { opacity: 0; transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
[data-theme="dark"] .selection-checkbox {
|
||||
background: var(--card-background);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .grid-item.selected .selection-checkbox {
|
||||
background: var(--primary-color, #0d6efd);
|
||||
border-color: var(--primary-color, #0d6efd);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .export-message-info {
|
||||
background: #0c5460;
|
||||
color: #d1ecf1;
|
||||
border-color: #086972;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .export-message-success {
|
||||
background: #155724;
|
||||
color: #d4edda;
|
||||
border-color: #1e7e34;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .export-message-warning {
|
||||
background: #856404;
|
||||
color: #fff3cd;
|
||||
border-color: #b58b14;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .export-message-error {
|
||||
background: #721c24;
|
||||
color: #f8d7da;
|
||||
border-color: #a94442;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .copy-feedback-success {
|
||||
background: #155724;
|
||||
color: #d4edda;
|
||||
border-color: #1e7e34;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .copy-feedback-error {
|
||||
background: #721c24;
|
||||
color: #f8d7da;
|
||||
border-color: #a94442;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .export-tips kbd {
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/* ========================================
|
||||
FLOATING ACTION BUTTONS
|
||||
======================================== */
|
||||
.floating-buttons {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
right: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
z-index: 1000;
|
||||
/* Ensure buttons don't interfere with content */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.floating-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.3rem;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.3);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* Re-enable pointer events for buttons */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.floating-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
background: var(--button-bg);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
background: var(--button-bg);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.floating-buttons {
|
||||
bottom: 60px;
|
||||
right: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.floating-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
KEYBOARD SHORTCUTS HELP
|
||||
======================================== */
|
||||
.shortcuts-help {
|
||||
position: fixed;
|
||||
bottom: 220px;
|
||||
right: 15px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: none;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
|
||||
z-index: 1001;
|
||||
font-size: 0.9rem;
|
||||
max-width: 280px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.shortcuts-help h4 {
|
||||
margin: 0 0 0.8rem 0;
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
.shortcut-key {
|
||||
background: var(--border-color);
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.shortcuts-help {
|
||||
bottom: 180px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
max-width: none;
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/* ========================================
|
||||
FOLDER METADATA STYLES
|
||||
======================================== */
|
||||
|
||||
/* Folder Metadata Container */
|
||||
.folder-metadata-container {
|
||||
margin: 1rem 0;
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Folder Metadata Toggle Button */
|
||||
.folder-metadata-toggle {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--card-bg);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background-color 0.2s ease;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-color);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.folder-metadata-toggle:hover {
|
||||
background: var(--button-hover);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.folder-metadata-toggle:focus {
|
||||
outline: 2px solid var(--link-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.folder-metadata-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.folder-metadata-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.folder-metadata-arrow {
|
||||
transition: transform 0.2s ease;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.folder-metadata-container.expanded .folder-metadata-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Folder Metadata Content - HIDDEN BY DEFAULT */
|
||||
.folder-metadata-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease, opacity 0.3s ease;
|
||||
background: var(--bg-color);
|
||||
opacity: 0;
|
||||
display: none; /* Force hide initially */
|
||||
}
|
||||
|
||||
/* Show content when expanded */
|
||||
.folder-metadata-container.expanded .folder-metadata-content {
|
||||
max-height: 1000px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
opacity: 1;
|
||||
display: block; /* Show when expanded */
|
||||
}
|
||||
|
||||
/* Folder Metadata Grid */
|
||||
.folder-metadata-grid {
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
/* Folder Metadata Items */
|
||||
.folder-metadata-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.folder-metadata-key {
|
||||
font-weight: 600;
|
||||
color: var(--link-color);
|
||||
white-space: nowrap;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.folder-metadata-value {
|
||||
color: var(--text-color);
|
||||
word-break: break-word;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Tags for list items */
|
||||
.folder-metadata-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.folder-metadata-tag {
|
||||
background: var(--link-color);
|
||||
color: white;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Nested metadata */
|
||||
.folder-metadata-nested {
|
||||
background: var(--card-bg);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid var(--link-color);
|
||||
}
|
||||
|
||||
.folder-metadata-nested-item {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Long text handling */
|
||||
.folder-metadata-expand {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--link-color);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.folder-metadata-expand:hover {
|
||||
color: var(--link-hover);
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.folder-metadata-value a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.folder-metadata-value a:hover {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.folder-metadata-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.folder-metadata-item {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.folder-metadata-key {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/* ========================================
|
||||
FOLDER TREE
|
||||
======================================== */
|
||||
.folder-tree {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.tree-item {
|
||||
margin: 0.2rem 0;
|
||||
white-space: pre;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.tree-current {
|
||||
background: var(--tree-current-bg);
|
||||
color: white;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tree-link {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.tree-link:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--link-hover);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/* ========================================
|
||||
PLOT GRID
|
||||
======================================== */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.8rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
text-align: center;
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.grid-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.grid-item img {
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.grid-item a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.grid-item.highlighted {
|
||||
border-color: var(--button-bg);
|
||||
box-shadow: 0 0 15px rgba(0, 120, 212, 0.3);
|
||||
transform: translateY(-2px);
|
||||
animation: highlightPulse 2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes highlightPulse {
|
||||
0%, 100% { transform: translateY(-2px) scale(1); }
|
||||
50% { transform: translateY(-2px) scale(1.02); }
|
||||
}
|
||||
|
||||
.plot-name {
|
||||
margin-top: 0.8rem;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
max-height: 3.9rem;
|
||||
overflow: hidden;
|
||||
padding: 0 0.2rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Plot selection mode */
|
||||
.selecting-plots .grid-item {
|
||||
cursor: pointer !important;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selecting-plots .grid-item:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 6px 20px rgba(0, 120, 212, 0.3);
|
||||
border-color: var(--button-bg);
|
||||
}
|
||||
|
||||
.selecting-plots .grid-item::before {
|
||||
content: '📊 Click to compare';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 120, 212, 0.95);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selecting-plots .grid-item:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Ensure grid items are clickable in selection mode */
|
||||
.selecting-plots .grid-item * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.selecting-plots .grid-item {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* Styles for HTML plots */
|
||||
.plot-item.html-plot .plot-link {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.plot-item.html-plot .html-thumbnail {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.plot-item.html-plot .html-indicator {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.plot-item.html-plot .html-preview {
|
||||
color: #666;
|
||||
margin-top: 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Add a hover effect */
|
||||
.plot-item.html-plot .plot-link:hover .html-thumbnail {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/* ========================================
|
||||
GALLERY STYLES - MAIN ENTRY POINT
|
||||
======================================== */
|
||||
|
||||
/* Core styles */
|
||||
@import url('./variables.css');
|
||||
@import url('./base.css');
|
||||
|
||||
/* Component styles */
|
||||
@import url('./navigation.css');
|
||||
@import url('./search.css');
|
||||
@import url('./folder-tree.css');
|
||||
@import url('./grid.css');
|
||||
@import url('./sidebar.css');
|
||||
@import url('./floating-elements.css');
|
||||
@import url('./stats.css');
|
||||
@import url('./comparison.css');
|
||||
@import url('./metadata.css');
|
||||
@import url('./metadata-section.css');
|
||||
@import url('./folder-metadata.css');
|
||||
@import url('./export.css');
|
||||
|
||||
/* View controls - must come after grid.css to override */
|
||||
@import url('./view-controls.css');
|
||||
|
||||
/* Sort controls styling */
|
||||
@import url('./sort-controls.css');
|
||||
|
||||
/* View override - force grid layout to work */
|
||||
@import url('./view-override.css');
|
||||
|
||||
/* Responsive design - last to override everything */
|
||||
@import url('./responsive.css');
|
||||
@@ -0,0 +1,383 @@
|
||||
/* ========================================
|
||||
METADATA SECTION STYLES
|
||||
======================================== */
|
||||
|
||||
/* Metadata Section Container */
|
||||
.metadata-section {
|
||||
margin: 1rem 0;
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Toggle Button */
|
||||
.metadata-toggle-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--card-bg);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background-color 0.2s ease;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-color);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.metadata-toggle-btn:hover {
|
||||
background: var(--button-hover);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.metadata-toggle-btn:focus {
|
||||
outline: 2px solid var(--link-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.metadata-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.metadata-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metadata-arrow {
|
||||
transition: transform 0.2s ease;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Content Area - Hidden by default */
|
||||
.metadata-content {
|
||||
background: var(--bg-color);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: none; /* Hidden by default */
|
||||
}
|
||||
|
||||
/* Metadata Header with File Path */
|
||||
.metadata-header {
|
||||
padding: 1rem;
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.metadata-file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.file-path-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--link-color);
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.copy-path-btn {
|
||||
background: var(--link-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.copy-path-btn:hover {
|
||||
background: var(--link-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.copy-path-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.copy-path-btn.copied {
|
||||
background: #4CAF50;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Tip Icon with Hover Tooltip */
|
||||
.tip-icon {
|
||||
cursor: help;
|
||||
font-size: 1.2rem;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-left: 0.25rem;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.tip-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Custom tooltip for tip icon */
|
||||
.tip-icon::after {
|
||||
content: attr(title);
|
||||
position: absolute;
|
||||
bottom: 125%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #333;
|
||||
color: white;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
white-space: normal;
|
||||
width: 280px;
|
||||
text-align: left;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
line-height: 1.4;
|
||||
font-weight: normal;
|
||||
font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
||||
}
|
||||
|
||||
/* Tooltip arrow */
|
||||
.tip-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 115%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: #333;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.tip-icon:hover::after,
|
||||
.tip-icon:hover::before {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Grid Layout */
|
||||
.metadata-grid {
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem 1.5rem; /* Increased gaps to prevent overlapping */
|
||||
}
|
||||
|
||||
/* Metadata Items */
|
||||
.metadata-item {
|
||||
display: flex;
|
||||
flex-direction: column; /* Stack key and value vertically to prevent overlap */
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--card-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
word-wrap: break-word; /* Ensure long text wraps */
|
||||
overflow-wrap: break-word; /* Additional word wrapping */
|
||||
}
|
||||
|
||||
.metadata-key {
|
||||
font-weight: 600;
|
||||
color: var(--link-color);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.metadata-value {
|
||||
color: var(--text-color);
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.4;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* LaTeX content styling */
|
||||
.latex-content {
|
||||
color: var(--text-color);
|
||||
line-height: 1.6;
|
||||
font-family: 'Times New Roman', serif;
|
||||
}
|
||||
|
||||
/* Simple list items - proper list formatting */
|
||||
.metadata-list {
|
||||
color: var(--text-color);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
list-style-type: disc; /* Add bullet points */
|
||||
}
|
||||
|
||||
.metadata-list li {
|
||||
margin: 0.25rem 0;
|
||||
padding: 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* YAML-style formatting for metadata */
|
||||
.metadata-yaml-list {
|
||||
margin: 0.5rem 0;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
background: var(--card-bg);
|
||||
padding: 0.8rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.yaml-list-item {
|
||||
color: var(--text-color);
|
||||
margin: 0.25rem 0;
|
||||
padding-left: 0;
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
.metadata-yaml-object {
|
||||
margin: 0.5rem 0;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
background: var(--card-bg);
|
||||
padding: 0.8rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.yaml-object-item {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.yaml-key {
|
||||
color: var(--link-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.yaml-value {
|
||||
color: var(--text-color);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.yaml-nested-list {
|
||||
margin: 0.25rem 0 0 1.5rem;
|
||||
border-left: 2px solid var(--border-color);
|
||||
padding-left: 0.8rem;
|
||||
}
|
||||
|
||||
.yaml-nested-item {
|
||||
color: var(--text-color);
|
||||
margin: 0.2rem 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.yaml-nested-object {
|
||||
margin: 0.25rem 0 0 1.5rem;
|
||||
border-left: 2px solid var(--border-color);
|
||||
padding-left: 0.8rem;
|
||||
}
|
||||
|
||||
/* Remove the blue box styling for tags */
|
||||
.metadata-tag {
|
||||
display: inline;
|
||||
background: none;
|
||||
color: var(--text-color);
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Simplified nested metadata */
|
||||
.metadata-nested {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.metadata-nested-item {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Long text handling */
|
||||
.metadata-expand {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--link-color);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.metadata-expand:hover {
|
||||
color: var(--link-hover);
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.metadata-value a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.metadata-value a:hover {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.metadata-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.metadata-item {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.metadata-key {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.metadata-file-info {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/* ========================================
|
||||
METADATA POPUP STYLES
|
||||
======================================== */
|
||||
|
||||
/* Metadata button on thumbnails */
|
||||
.metadata-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.grid-item:hover .metadata-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.metadata-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.metadata-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Grid item positioning for metadata button */
|
||||
.grid-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Metadata popup */
|
||||
.metadata-popup {
|
||||
background: var(--card-background);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
max-width: 320px;
|
||||
min-width: 250px;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.metadata-popup.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.metadata-popup-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--header-background);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.metadata-popup-header h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.metadata-popup-content {
|
||||
padding: 12px 16px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.metadata-field {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.metadata-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.metadata-key {
|
||||
font-weight: 500;
|
||||
color: var(--accent-color);
|
||||
font-size: 12px;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metadata-value {
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
word-break: break-word;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.metadata-value code {
|
||||
background: var(--code-background);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.metadata-tag {
|
||||
background: var(--accent-color);
|
||||
color: var(--background-color);
|
||||
padding: 2px 6px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.metadata-more {
|
||||
color: var(--breadcrumb-color);
|
||||
font-style: italic;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.no-metadata {
|
||||
color: var(--breadcrumb-color);
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for metadata popup */
|
||||
.metadata-popup-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.metadata-popup-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.metadata-popup-content::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.metadata-popup-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.metadata-popup {
|
||||
max-width: calc(100vw - 32px);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.metadata-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 14px;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* ========================================
|
||||
NAVIGATION COMPONENTS
|
||||
======================================== */
|
||||
.breadcrumb {
|
||||
margin: 0.5rem 0 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--breadcrumb-color);
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--link-hover);
|
||||
}
|
||||
|
||||
.breadcrumb .separator {
|
||||
margin: 0 0.3rem;
|
||||
color: var(--breadcrumb-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: var(--button-bg);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: var(--button-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nav-btn:disabled {
|
||||
background: var(--disabled-color);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/* ========================================
|
||||
RESPONSIVE DESIGN
|
||||
======================================== */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
right: -100%;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* View controls responsive */
|
||||
.controls-container {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.sort-controls {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.view-controls {
|
||||
margin-right: 0.5rem !important;
|
||||
padding: 8px 12px !important;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
min-width: 40px !important;
|
||||
min-height: 40px !important;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.view-btn svg {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/* ========================================
|
||||
SEARCH FUNCTIONALITY
|
||||
======================================== */
|
||||
.search-container {
|
||||
position: relative;
|
||||
max-width: 500px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
padding: 0.8rem 3rem 0.8rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box:focus {
|
||||
border-color: var(--button-bg);
|
||||
box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.1);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
margin-top: 0.5rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
padding: 0.75rem;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.search-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
background: var(--button-bg);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.search-highlight {
|
||||
background: #ffeb3b;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
padding: 0.1rem 0.2rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/* ========================================
|
||||
SIDEBAR (RECENT PLOTS)
|
||||
======================================== */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -350px;
|
||||
width: 330px;
|
||||
height: 100vh;
|
||||
background: var(--card-bg);
|
||||
border-left: 2px solid var(--border-color);
|
||||
z-index: 2000;
|
||||
transition: right 0.3s ease;
|
||||
overflow-y: auto;
|
||||
box-shadow: -4px 0 15px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.2rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--card-bg);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
padding: 0.2rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-close:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.recent-plot {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
padding: 0.8rem;
|
||||
margin-bottom: 0.8rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.recent-plot:hover {
|
||||
background: var(--button-bg);
|
||||
color: white;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.recent-plot-thumb {
|
||||
width: 60px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recent-plot-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recent-plot-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.3rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.recent-plot-path {
|
||||
font-size: 0.8rem;
|
||||
color: var(--breadcrumb-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1500;
|
||||
display: none;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.sidebar-overlay.open {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/* ========================================
|
||||
SORT CONTROLS STYLING
|
||||
======================================== */
|
||||
|
||||
/* Clean styling for sort controls */
|
||||
.sort-controls {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 8px !important;
|
||||
padding: 12px 16px !important;
|
||||
background: var(--tree-bg) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 8px !important;
|
||||
margin-right: 1rem !important;
|
||||
}
|
||||
|
||||
.sort-label {
|
||||
font-size: 0.9rem !important;
|
||||
color: var(--text-color) !important;
|
||||
margin-right: 8px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
/* Button styling using theme variables */
|
||||
.sort-btn {
|
||||
background: var(--card-bg) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 8px 12px !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.2s ease !important;
|
||||
font-size: 0.85rem !important;
|
||||
color: var(--text-color) !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 4px !important;
|
||||
outline: none !important;
|
||||
text-decoration: none !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
.sort-btn:hover {
|
||||
background: var(--button-bg) !important;
|
||||
color: white !important;
|
||||
transform: translateY(-1px) !important;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2) !important;
|
||||
border-color: var(--button-bg) !important;
|
||||
}
|
||||
|
||||
.sort-btn.active {
|
||||
background: var(--button-bg) !important;
|
||||
color: white !important;
|
||||
border-color: var(--button-bg) !important;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2) !important;
|
||||
}
|
||||
|
||||
.sort-order-btn {
|
||||
background: var(--card-bg) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 8px 12px !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.2s ease !important;
|
||||
font-size: 1rem !important;
|
||||
color: var(--text-color) !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
min-width: 36px !important;
|
||||
outline: none !important;
|
||||
text-decoration: none !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
.sort-order-btn:hover {
|
||||
background: var(--button-bg) !important;
|
||||
color: white !important;
|
||||
transform: translateY(-1px) !important;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2) !important;
|
||||
border-color: var(--button-bg) !important;
|
||||
}
|
||||
|
||||
.sort-order-btn.active {
|
||||
background: var(--button-bg) !important;
|
||||
color: white !important;
|
||||
border-color: var(--button-bg) !important;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2) !important;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/* ========================================
|
||||
GALLERY STATISTICS
|
||||
======================================== */
|
||||
.gallery-stats {
|
||||
position: fixed;
|
||||
top: 40px;
|
||||
right: 40px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--breadcrumb-color);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
z-index: 500;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease, right 0.3s ease;
|
||||
max-width: 200px;
|
||||
/* Ensure stats don't interfere with content */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body:has(#sidebar.open) .gallery-stats {
|
||||
right: 345px;
|
||||
}
|
||||
|
||||
.gallery-stats:hover {
|
||||
opacity: 1;
|
||||
/* Re-enable pointer events on hover */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 0.2rem 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
margin-right: 0.8rem;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.gallery-stats {
|
||||
bottom: 60px;
|
||||
right: 10px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
body:has(#sidebar.open) .gallery-stats {
|
||||
right: 340px;
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/* ========================================
|
||||
CSS VARIABLES AND THEME DEFINITIONS
|
||||
======================================== */
|
||||
:root {
|
||||
--bg-color: #1e1e1e;
|
||||
--text-color: #ffffff;
|
||||
--card-bg: #2d2d2d;
|
||||
--border-color: #404040;
|
||||
--link-color: #569cd6;
|
||||
--link-hover: #4a9eff;
|
||||
--button-bg: #0078d4;
|
||||
--button-hover: #106ebe;
|
||||
--breadcrumb-color: #cccccc;
|
||||
--tree-bg: #252526;
|
||||
--tree-current-bg: #0078d4;
|
||||
--success-color: #28a745;
|
||||
--success-hover: #218838;
|
||||
--disabled-color: #6c757d;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-color: #ffffff;
|
||||
--text-color: #333333;
|
||||
--card-bg: #f8f8f8;
|
||||
--border-color: #ddd;
|
||||
--link-color: #007acc;
|
||||
--link-hover: #005a9e;
|
||||
--button-bg: #007acc;
|
||||
--button-hover: #005a9e;
|
||||
--breadcrumb-color: #666;
|
||||
--tree-bg: #f8f8f8;
|
||||
--tree-current-bg: #007acc;
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
/* ========================================
|
||||
VIEW CONTROLS AND LAYOUT MODES
|
||||
======================================== */
|
||||
|
||||
/* Controls Container */
|
||||
.controls-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 1rem 0;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Sort Controls */
|
||||
.sort-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--tree-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sort-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-color);
|
||||
margin-right: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sort-btn, .sort-order-btn {
|
||||
background: var(--card-bg) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 6px 12px !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.2s ease !important;
|
||||
font-size: 0.85rem !important;
|
||||
color: var(--text-color) !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 4px !important;
|
||||
outline: none !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.sort-btn:hover, .sort-order-btn:hover {
|
||||
background: var(--button-bg) !important;
|
||||
color: white !important;
|
||||
transform: translateY(-1px) !important;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
|
||||
}
|
||||
|
||||
.sort-btn.active, .sort-order-btn.active {
|
||||
background: var(--button-bg) !important;
|
||||
color: white !important;
|
||||
border-color: var(--button-bg) !important;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
|
||||
}
|
||||
|
||||
.sort-order-btn {
|
||||
min-width: 32px !important;
|
||||
justify-content: center !important;
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
|
||||
/* View Controls */
|
||||
.view-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--tree-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-color);
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.view-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
background: var(--button-bg);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: var(--button-bg);
|
||||
color: white;
|
||||
border-color: var(--button-bg);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Plot Container Base Styles */
|
||||
.plot-container {
|
||||
margin: 1rem 0;
|
||||
margin-bottom: 450px; /* Add extra bottom margin to prevent overlap with stats box */
|
||||
transition: all 0.3s ease;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.plot-container .plot-item {
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.plot-container .plot-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.plot-container .plot-link {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plot-container .plot-thumbnail {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.plot-container .plot-info {
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.plot-container .plot-name {
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.plot-container .plot-date {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.3rem;
|
||||
opacity: 0.7; /* Slightly lower opacity for differentiation */
|
||||
}
|
||||
|
||||
/* Grid View - Override any conflicting styles */
|
||||
.plot-container.grid-view {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)) !important;
|
||||
gap: 1.2rem !important;
|
||||
padding: 1rem 0 !important;
|
||||
}
|
||||
|
||||
.plot-container.grid-view .plot-item,
|
||||
.plot-container.grid-view .grid-item {
|
||||
text-align: center !important;
|
||||
background: var(--card-bg) !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 0.8rem !important;
|
||||
transition: all 0.2s ease !important;
|
||||
border: 1px solid transparent !important;
|
||||
display: block !important;
|
||||
width: auto !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.plot-container.grid-view .plot-item:hover,
|
||||
.plot-container.grid-view .grid-item:hover {
|
||||
transform: translateY(-2px) !important;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.plot-container.grid-view .plot-link {
|
||||
display: block !important;
|
||||
color: var(--link-color) !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.plot-container.grid-view .plot-thumbnail {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
object-fit: contain !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.plot-container.grid-view .plot-info {
|
||||
padding: 0.8rem 0 0 0 !important;
|
||||
}
|
||||
|
||||
.plot-container.grid-view .plot-name {
|
||||
max-height: 3.9rem !important;
|
||||
overflow: hidden !important;
|
||||
margin-top: 0.8rem !important;
|
||||
display: block !important;
|
||||
word-wrap: break-word !important;
|
||||
word-break: break-word !important;
|
||||
hyphens: auto !important;
|
||||
font-size: 0.9rem !important;
|
||||
line-height: 1.3 !important;
|
||||
font-weight: 500 !important;
|
||||
color: var(--text-color) !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
/* Large List View */
|
||||
.plot-container.list-large-view {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 0.8rem !important;
|
||||
}
|
||||
|
||||
.plot-container.list-large-view .plot-item {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
padding: 1rem !important;
|
||||
gap: 1rem !important;
|
||||
}
|
||||
|
||||
.plot-container.list-large-view .plot-link {
|
||||
flex-shrink: 0 !important;
|
||||
width: 120px !important;
|
||||
height: 90px !important;
|
||||
overflow: hidden !important;
|
||||
border-radius: 6px !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
}
|
||||
|
||||
.plot-container.list-large-view .plot-thumbnail {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
|
||||
.plot-container.list-large-view .plot-info {
|
||||
flex: 1 !important;
|
||||
padding: 0 !important;
|
||||
text-align: left !important;
|
||||
display: flex !important;
|
||||
justify-content: space-between !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.plot-container.list-large-view .plot-name {
|
||||
font-size: 1rem !important;
|
||||
line-height: 1.4 !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
flex: 1 !important;
|
||||
}
|
||||
|
||||
.plot-container.list-large-view .plot-date {
|
||||
flex-shrink: 0 !important;
|
||||
margin-left: 1rem !important;
|
||||
margin-top: 0 !important;
|
||||
font-size: 0.85rem !important;
|
||||
color: var(--text-secondary) !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
/* Compact List View */
|
||||
.plot-container.list-compact-view {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 0.4rem !important;
|
||||
}
|
||||
|
||||
.plot-container.list-compact-view .plot-item {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
padding: 0.6rem 1rem !important;
|
||||
gap: 0.8rem !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
/* In compact view the thumbnail is hidden, so collapse the link out of the
|
||||
flex flow and stretch it as an invisible overlay — this keeps the whole
|
||||
row clickable without the empty link consuming horizontal space. */
|
||||
.plot-container.list-compact-view .plot-item {
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.plot-container.list-compact-view .plot-link {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
flex: none !important;
|
||||
display: block !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.plot-container.list-compact-view .plot-thumbnail {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.plot-container.list-compact-view .plot-info {
|
||||
padding: 0 !important;
|
||||
flex: 1 !important;
|
||||
text-align: left !important;
|
||||
display: flex !important;
|
||||
justify-content: space-between !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.plot-container.list-compact-view .plot-name {
|
||||
font-size: 0.95rem !important;
|
||||
line-height: 1.2 !important;
|
||||
margin: 0 !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
flex: 1 !important;
|
||||
}
|
||||
|
||||
.plot-container.list-compact-view .plot-date {
|
||||
flex-shrink: 0 !important;
|
||||
margin-left: 1rem !important;
|
||||
margin-top: 0 !important;
|
||||
font-size: 0.8rem !important;
|
||||
color: var(--text-secondary) !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
/* Highlight effect for all views */
|
||||
.plot-item.highlighted {
|
||||
border-color: var(--button-bg);
|
||||
box-shadow: 0 0 15px rgba(0, 120, 212, 0.3);
|
||||
transform: translateY(-2px);
|
||||
animation: highlightPulse 2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes highlightPulse {
|
||||
0%, 100% { transform: translateY(-2px) scale(1); }
|
||||
50% { transform: translateY(-2px) scale(1.02); }
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.view-controls {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.plot-container.grid-view {
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.plot-container.list-large-view .plot-link {
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.plot-container.list-large-view .plot-item {
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.plot-container.list-compact-view .plot-item {
|
||||
padding: 0.5rem 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.view-controls {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
padding: 6px 8px;
|
||||
font-size: 1rem;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.plot-container.grid-view {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.plot-container.list-large-view .plot-link {
|
||||
width: 60px;
|
||||
height: 45px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* ========================================
|
||||
VIEW OVERRIDE - Ensure grid view works
|
||||
======================================== */
|
||||
|
||||
/* Force grid layout when grid-view class is present */
|
||||
body .plot-container.grid-view {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)) !important;
|
||||
gap: 1.2rem !important;
|
||||
padding: 1rem 0 !important;
|
||||
}
|
||||
|
||||
/* Force grid items to be proper tiles */
|
||||
body .plot-container.grid-view .plot-item,
|
||||
body .plot-container.grid-view .grid-item {
|
||||
display: block !important;
|
||||
width: auto !important;
|
||||
max-width: none !important;
|
||||
text-align: center !important;
|
||||
background: var(--card-bg) !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 0.8rem !important;
|
||||
border: 1px solid transparent !important;
|
||||
}
|
||||
|
||||
/* Ensure thumbnails are properly sized */
|
||||
body .plot-container.grid-view .plot-thumbnail {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
max-width: 100% !important;
|
||||
display: block !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 6px !important;
|
||||
object-fit: cover !important;
|
||||
aspect-ratio: 4/3;
|
||||
}
|
||||
|
||||
/* Force plot info styling */
|
||||
body .plot-container.grid-view .plot-info {
|
||||
padding: 0.5rem 0 0 0 !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
body .plot-container.grid-view .plot-name {
|
||||
font-size: 0.85rem !important;
|
||||
line-height: 1.2 !important;
|
||||
max-height: 2.4rem !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
display: -webkit-box !important;
|
||||
-webkit-line-clamp: 2 !important;
|
||||
line-clamp: 2 !important;
|
||||
-webkit-box-orient: vertical !important;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Plot comparison functionality
|
||||
*/
|
||||
export class ComparisonManager {
|
||||
constructor() {
|
||||
this.comparisonMode = false;
|
||||
this.comparisonSlot = null; // 'left' or 'right'
|
||||
this.plots = { left: null, right: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle comparison mode
|
||||
*/
|
||||
toggleCompareMode() {
|
||||
this.comparisonMode = !this.comparisonMode;
|
||||
const compareBtn = document.getElementById('compareToggle');
|
||||
|
||||
if (!compareBtn) return;
|
||||
|
||||
if (this.comparisonMode) {
|
||||
compareBtn.style.background = 'var(--success-color)';
|
||||
compareBtn.title = 'Exit Compare Mode (Ctrl+C)';
|
||||
this.showComparisonOverlay();
|
||||
} else {
|
||||
compareBtn.style.background = 'var(--button-bg)';
|
||||
compareBtn.title = 'Compare Plots (Ctrl+C)';
|
||||
this.hideComparisonOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show comparison overlay
|
||||
*/
|
||||
showComparisonOverlay() {
|
||||
const overlay = document.getElementById('comparisonOverlay');
|
||||
if (overlay) overlay.classList.add('open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide comparison overlay
|
||||
*/
|
||||
hideComparisonOverlay() {
|
||||
const overlay = document.getElementById('comparisonOverlay');
|
||||
if (overlay) overlay.classList.remove('open');
|
||||
this.comparisonMode = false;
|
||||
|
||||
const compareBtn = document.getElementById('compareToggle');
|
||||
if (compareBtn) {
|
||||
compareBtn.style.background = 'var(--button-bg)';
|
||||
compareBtn.title = 'Compare Plots (Ctrl+C)';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close comparison overlay (alias for hideComparisonOverlay)
|
||||
*/
|
||||
closeComparison() {
|
||||
this.hideComparisonOverlay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select plot for comparison - simple approach
|
||||
*/
|
||||
selectPlotForComparison(slot) {
|
||||
this.comparisonSlot = slot;
|
||||
|
||||
// Hide overlay temporarily by removing the 'open' class
|
||||
const overlay = document.getElementById('comparisonOverlay');
|
||||
if (overlay) overlay.classList.remove('open');
|
||||
|
||||
// Show simple alert with instructions
|
||||
const instruction = document.createElement('div');
|
||||
instruction.id = 'comparisonInstruction';
|
||||
instruction.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--button-bg);
|
||||
color: white;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 8px;
|
||||
z-index: 3000;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||
`;
|
||||
instruction.innerHTML = `📊 Click any plot to select for ${slot === 'left' ? 'Plot A' : 'Plot B'} (ESC to cancel)`;
|
||||
document.body.appendChild(instruction);
|
||||
|
||||
// Add one-time click listener to all grid items
|
||||
const gridItems = document.querySelectorAll('.grid-item');
|
||||
const handleClick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const gridItem = event.currentTarget;
|
||||
const img = gridItem.querySelector('img');
|
||||
const nameEl = gridItem.querySelector('.plot-name');
|
||||
|
||||
if (img && nameEl) {
|
||||
const plotInfo = {
|
||||
name: nameEl.textContent.trim(),
|
||||
imgSrc: img.src,
|
||||
pdfSrc: img.src.replace('.png', '.pdf'),
|
||||
path: window.location.pathname
|
||||
};
|
||||
|
||||
this.addPlotToComparison(plotInfo, slot);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
instruction.remove();
|
||||
gridItems.forEach(item => item.removeEventListener('click', handleClick));
|
||||
this.comparisonSlot = null;
|
||||
if (overlay) overlay.classList.add('open');
|
||||
};
|
||||
|
||||
gridItems.forEach(item => {
|
||||
item.style.cursor = 'pointer';
|
||||
item.style.border = '2px dashed var(--button-bg)';
|
||||
item.addEventListener('click', handleClick);
|
||||
});
|
||||
|
||||
// ESC to cancel
|
||||
const handleEscape = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
instruction.remove();
|
||||
gridItems.forEach(item => {
|
||||
item.removeEventListener('click', handleClick);
|
||||
item.style.cursor = '';
|
||||
item.style.border = '';
|
||||
});
|
||||
this.comparisonSlot = null;
|
||||
if (overlay) overlay.classList.add('open');
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add plot to comparison panel
|
||||
*/
|
||||
addPlotToComparison(plotInfo, slot) {
|
||||
this.plots[slot] = plotInfo;
|
||||
|
||||
const container = document.getElementById(`${slot}PlotContainer`);
|
||||
const title = document.getElementById(`${slot}PlotTitle`);
|
||||
const replaceBtn = document.getElementById(`${slot}ReplaceBtn`);
|
||||
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<img src="${plotInfo.imgSrc}" class="comparison-plot" alt="${plotInfo.name}"
|
||||
onclick="window.open('${plotInfo.pdfSrc}', '_blank')" />
|
||||
<div class="comparison-plot-info">
|
||||
<strong>${plotInfo.name}</strong><br>
|
||||
<small>${plotInfo.path}</small>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (title) title.textContent = plotInfo.name;
|
||||
if (replaceBtn) replaceBtn.style.display = 'block';
|
||||
|
||||
// Reset grid item styles
|
||||
const gridItems = document.querySelectorAll('.grid-item');
|
||||
gridItems.forEach(item => {
|
||||
item.style.cursor = '';
|
||||
item.style.border = '';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace plot in comparison
|
||||
*/
|
||||
replacePlot(slot) {
|
||||
this.selectPlotForComparison(slot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* Export Manager for Gallery
|
||||
* Handles exporting selected plots to merged PDF
|
||||
*/
|
||||
|
||||
export class ExportManager {
|
||||
constructor() {
|
||||
this.selectedPlots = new Set();
|
||||
this.maxPlots = 4;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createExportButton();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the export button in the floating buttons section
|
||||
*/
|
||||
createExportButton() {
|
||||
const floatingButtons = document.querySelector('.floating-buttons');
|
||||
if (!floatingButtons) return;
|
||||
|
||||
const exportBtn = document.createElement('button');
|
||||
exportBtn.className = 'floating-btn export-btn';
|
||||
exportBtn.id = 'exportBtn';
|
||||
exportBtn.title = 'Export Selected Plots (Ctrl+E)';
|
||||
exportBtn.innerHTML = '📄';
|
||||
exportBtn.style.display = 'none'; // Hidden by default
|
||||
exportBtn.onclick = () => this.exportSelectedPlots();
|
||||
|
||||
floatingButtons.appendChild(exportBtn);
|
||||
|
||||
// Add selection counter
|
||||
const selectionCounter = document.createElement('div');
|
||||
selectionCounter.className = 'selection-counter';
|
||||
selectionCounter.id = 'selectionCounter';
|
||||
selectionCounter.style.display = 'none';
|
||||
selectionCounter.innerHTML = '0/4 selected';
|
||||
floatingButtons.appendChild(selectionCounter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind events for plot selection
|
||||
*/
|
||||
bindEvents() {
|
||||
// Add selection mode toggle
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.key === 'e') {
|
||||
e.preventDefault();
|
||||
this.toggleSelectionMode();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
this.exitSelectionMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Add selection handlers to existing plots
|
||||
this.addSelectionHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add selection handlers to all plot items
|
||||
*/
|
||||
addSelectionHandlers() {
|
||||
const plotItems = document.querySelectorAll('.grid-item');
|
||||
plotItems.forEach(item => this.addSelectionHandler(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add selection handler to a single plot item
|
||||
*/
|
||||
addSelectionHandler(item) {
|
||||
// Create selection overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'selection-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="selection-checkbox">
|
||||
<span class="checkbox-icon">☐</span>
|
||||
</div>
|
||||
`;
|
||||
item.appendChild(overlay);
|
||||
|
||||
// Add click handler for selection
|
||||
overlay.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.togglePlotSelection(item);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle selection mode
|
||||
*/
|
||||
toggleSelectionMode() {
|
||||
const body = document.body;
|
||||
const isSelectionMode = body.classList.contains('selection-mode');
|
||||
|
||||
if (isSelectionMode) {
|
||||
this.exitSelectionMode();
|
||||
} else {
|
||||
this.enterSelectionMode();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter selection mode
|
||||
*/
|
||||
enterSelectionMode() {
|
||||
document.body.classList.add('selection-mode');
|
||||
document.getElementById('exportBtn').style.display = 'block';
|
||||
document.getElementById('selectionCounter').style.display = 'block';
|
||||
this.updateSelectionCounter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit selection mode
|
||||
*/
|
||||
exitSelectionMode() {
|
||||
const wasInSelectionMode = document.body.classList.contains('selection-mode');
|
||||
|
||||
document.body.classList.remove('selection-mode');
|
||||
document.getElementById('exportBtn').style.display = 'none';
|
||||
document.getElementById('selectionCounter').style.display = 'none';
|
||||
this.clearSelection();
|
||||
|
||||
// Show message if user was actually in selection mode
|
||||
if (wasInSelectionMode) {
|
||||
this.showMessage('Exited selection mode', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle plot selection
|
||||
*/
|
||||
togglePlotSelection(item) {
|
||||
const plotName = this.getPlotName(item);
|
||||
const plotPath = this.getPlotPath(item);
|
||||
|
||||
if (this.selectedPlots.has(plotName)) {
|
||||
this.selectedPlots.delete(plotName);
|
||||
item.classList.remove('selected');
|
||||
item.querySelector('.checkbox-icon').textContent = '☐';
|
||||
} else {
|
||||
if (this.selectedPlots.size >= this.maxPlots) {
|
||||
this.showMessage(`Maximum ${this.maxPlots} plots can be selected`, 'warning');
|
||||
return;
|
||||
}
|
||||
this.selectedPlots.add(plotName);
|
||||
item.classList.add('selected');
|
||||
item.querySelector('.checkbox-icon').textContent = '☑';
|
||||
}
|
||||
|
||||
this.updateSelectionCounter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plot name from grid item
|
||||
*/
|
||||
getPlotName(item) {
|
||||
const plotName = item.querySelector('.plot-name');
|
||||
return plotName ? plotName.textContent.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plot PDF path from grid item
|
||||
*/
|
||||
getPlotPath(item) {
|
||||
const link = item.querySelector('a[href$=".pdf"]');
|
||||
return link ? link.href : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update selection counter
|
||||
*/
|
||||
updateSelectionCounter() {
|
||||
const counter = document.getElementById('selectionCounter');
|
||||
if (counter) {
|
||||
counter.textContent = `${this.selectedPlots.size}/${this.maxPlots} selected`;
|
||||
}
|
||||
|
||||
const exportBtn = document.getElementById('exportBtn');
|
||||
if (exportBtn) {
|
||||
exportBtn.disabled = this.selectedPlots.size === 0;
|
||||
exportBtn.style.opacity = this.selectedPlots.size === 0 ? '0.5' : '1';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all selections
|
||||
*/
|
||||
clearSelection() {
|
||||
this.selectedPlots.clear();
|
||||
document.querySelectorAll('.grid-item.selected').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
const checkbox = item.querySelector('.checkbox-icon');
|
||||
if (checkbox) checkbox.textContent = '☐';
|
||||
});
|
||||
this.updateSelectionCounter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export selected plots to merged PDF
|
||||
*/
|
||||
async exportSelectedPlots() {
|
||||
if (this.selectedPlots.size === 0) {
|
||||
this.showMessage('No plots selected', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const plotPaths = Array.from(this.selectedPlots).map(plotName => {
|
||||
const item = Array.from(document.querySelectorAll('.grid-item'))
|
||||
.find(item => this.getPlotName(item) === plotName);
|
||||
return this.getPlotPath(item);
|
||||
});
|
||||
|
||||
this.showMessage('Preparing export...', 'info');
|
||||
|
||||
try {
|
||||
await this.createMergedPDF(plotPaths);
|
||||
} catch (error) {
|
||||
this.showMessage('Export failed: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create merged PDF using Python script
|
||||
*/
|
||||
async createMergedPDF(plotPaths) {
|
||||
// Convert file:// URLs to actual paths
|
||||
const actualPaths = plotPaths.map(url => {
|
||||
if (url.startsWith('file://')) {
|
||||
return url.substring(7); // Remove 'file://' prefix
|
||||
}
|
||||
return url;
|
||||
});
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0];
|
||||
const outputName = `merged_plots_${timestamp}.pdf`;
|
||||
|
||||
const payload = {
|
||||
plots: actualPaths,
|
||||
layout: this.calculateLayout(actualPaths.length),
|
||||
output_name: outputName
|
||||
};
|
||||
|
||||
// Generate a unique temporary filename
|
||||
const tempFileName = `export_request_${Date.now()}.json`;
|
||||
|
||||
// Save the request to a JSON file that can be picked up by a Python script
|
||||
const requestData = JSON.stringify(payload, null, 2);
|
||||
|
||||
// Show improved export instructions with full command
|
||||
this.showExportInstructions(requestData, tempFileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate optimal layout for given number of plots
|
||||
*/
|
||||
calculateLayout(numPlots) {
|
||||
switch (numPlots) {
|
||||
case 1: return { rows: 1, cols: 1 };
|
||||
case 2: return { rows: 1, cols: 2 };
|
||||
case 3: return { rows: 2, cols: 2 }; // 3 plots in 2x2 grid with one empty
|
||||
case 4: return { rows: 2, cols: 2 };
|
||||
default: return { rows: 2, cols: 2 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show export instructions to user
|
||||
*/
|
||||
showExportInstructions(requestData, tempFileName) {
|
||||
const tempFilePath = `/tmp/${tempFileName}`;
|
||||
const fullCommand = `echo '${requestData.replace(/'/g, "'\\''")}' > ${tempFilePath} && python export_plots.py ${tempFilePath}`;
|
||||
|
||||
const instructions = `
|
||||
<div class="export-instructions">
|
||||
<h3>🚀 Export Selected Plots</h3>
|
||||
<p>Run the following command in your terminal to export the selected plots:</p>
|
||||
|
||||
<div class="export-command-container">
|
||||
<div class="export-command">
|
||||
<code id="exportCommand">${fullCommand}</code>
|
||||
</div>
|
||||
<div class="export-actions">
|
||||
<button onclick="this.copyCommand()" class="copy-btn" title="Copy command to clipboard">
|
||||
📋 Copy Command
|
||||
</button>
|
||||
<button onclick="this.copyJSON()" class="copy-btn" title="Copy JSON only">
|
||||
📄 Copy JSON
|
||||
</button>
|
||||
<button onclick="this.close()" class="close-btn">
|
||||
✕ Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="export-details">
|
||||
<h4>📋 Command Breakdown:</h4>
|
||||
<ul>
|
||||
<li><strong>Creates temporary file:</strong> <code>${tempFilePath}</code></li>
|
||||
<li><strong>Runs export script:</strong> <code>python export_plots.py</code></li>
|
||||
<li><strong>Output file:</strong> Will be saved in the work directory</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="export-tips">
|
||||
<h4>💡 Tips:</h4>
|
||||
<ul>
|
||||
<li>The temporary JSON file will be automatically cleaned up after successful export</li>
|
||||
<li>Use <kbd>Esc</kbd> to exit selection mode</li>
|
||||
<li>Press <kbd>Ctrl+E</kbd> to toggle selection mode</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'export-overlay';
|
||||
overlay.innerHTML = instructions;
|
||||
|
||||
// Add methods to the overlay for button handlers
|
||||
overlay.copyCommand = function() {
|
||||
navigator.clipboard.writeText(fullCommand).then(() => {
|
||||
this.showCopyFeedback('Command copied to clipboard!');
|
||||
}).catch(() => {
|
||||
this.showCopyFeedback('Failed to copy. Please select and copy manually.', 'error');
|
||||
});
|
||||
};
|
||||
|
||||
overlay.copyJSON = function() {
|
||||
navigator.clipboard.writeText(requestData).then(() => {
|
||||
this.showCopyFeedback('JSON copied to clipboard!');
|
||||
}).catch(() => {
|
||||
this.showCopyFeedback('Failed to copy. Please select and copy manually.', 'error');
|
||||
});
|
||||
};
|
||||
|
||||
overlay.close = function() {
|
||||
this.remove();
|
||||
};
|
||||
|
||||
overlay.showCopyFeedback = function(message, type = 'success') {
|
||||
const feedback = document.createElement('div');
|
||||
feedback.className = `copy-feedback copy-feedback-${type}`;
|
||||
feedback.textContent = message;
|
||||
this.appendChild(feedback);
|
||||
|
||||
setTimeout(() => {
|
||||
if (feedback.parentNode) {
|
||||
feedback.parentNode.removeChild(feedback);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Close on ESC key
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
// Close on clicking outside
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the PDF blob
|
||||
*/
|
||||
downloadPDF(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `merged_plots_${new Date().toISOString().split('T')[0]}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show temporary message to user
|
||||
*/
|
||||
showMessage(text, type = 'info') {
|
||||
// Remove existing message
|
||||
const existing = document.querySelector('.export-message');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const message = document.createElement('div');
|
||||
message.className = `export-message export-message-${type}`;
|
||||
message.textContent = text;
|
||||
|
||||
document.body.appendChild(message);
|
||||
|
||||
setTimeout(() => {
|
||||
if (message.parentNode) {
|
||||
message.parentNode.removeChild(message);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Add these methods to ExportManager if not present
|
||||
ExportManager.prototype.isSelectionModeActive = function() {
|
||||
return document.body.classList.contains('selection-mode');
|
||||
};
|
||||
ExportManager.prototype.exitSelectionMode = function() {
|
||||
document.body.classList.remove('selection-mode');
|
||||
if (typeof this.clearSelection === 'function') {
|
||||
this.clearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure a single global instance
|
||||
window.exportManager = window.exportManager || new ExportManager();
|
||||
|
||||
// Listen for ESC key globally to exit selection mode
|
||||
// (This will work even if focus is not on a plot)
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && window.exportManager && window.exportManager.isSelectionModeActive()) {
|
||||
window.exportManager.exitSelectionMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Attach improved export logic to export button
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const exportBtn = document.getElementById('exportBtn');
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', function() {
|
||||
window.exportManager.exportSelectedPlots();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Folder Metadata functionality for Gallery
|
||||
*
|
||||
* Handles folder metadata dropdown display and interaction
|
||||
*/
|
||||
|
||||
// Define function immediately (not waiting for DOM)
|
||||
window.toggleFolderMetadata = function() {
|
||||
console.log('toggleFolderMetadata called');
|
||||
|
||||
const container = document.querySelector('.folder-metadata-container');
|
||||
const content = document.getElementById('folderMetadataContent');
|
||||
|
||||
if (!container) {
|
||||
console.log('No metadata container found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
console.log('No metadata content found');
|
||||
return;
|
||||
}
|
||||
|
||||
const isExpanded = container.classList.contains('expanded');
|
||||
console.log('Current state - expanded:', isExpanded);
|
||||
|
||||
if (isExpanded) {
|
||||
// Collapse
|
||||
container.classList.remove('expanded');
|
||||
content.style.display = 'none';
|
||||
console.log('Collapsed dropdown');
|
||||
} else {
|
||||
// Expand
|
||||
container.classList.add('expanded');
|
||||
content.style.display = 'block';
|
||||
console.log('Expanded dropdown');
|
||||
}
|
||||
|
||||
// Save state
|
||||
localStorage.setItem('folderMetadataExpanded', (!isExpanded).toString());
|
||||
};
|
||||
|
||||
// Also define as regular function for alternative access
|
||||
function toggleFolderMetadata() {
|
||||
window.toggleFolderMetadata();
|
||||
}
|
||||
|
||||
// Toggle long text display
|
||||
window.toggleMetadataText = function(button) {
|
||||
const longText = button.previousElementSibling;
|
||||
const fullText = button.nextElementSibling;
|
||||
|
||||
if (fullText.style.display === 'none') {
|
||||
longText.style.display = 'none';
|
||||
fullText.style.display = 'inline';
|
||||
button.textContent = 'Show less';
|
||||
} else {
|
||||
longText.style.display = 'inline';
|
||||
fullText.style.display = 'none';
|
||||
button.textContent = 'Show more';
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize folder metadata on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('Folder metadata script loaded');
|
||||
|
||||
// Make sure all containers start collapsed
|
||||
const containers = document.querySelectorAll('.folder-metadata-container');
|
||||
console.log('Found', containers.length, 'metadata containers');
|
||||
|
||||
containers.forEach(container => {
|
||||
const content = container.querySelector('.folder-metadata-content');
|
||||
if (content) {
|
||||
// Force initial hidden state
|
||||
container.classList.remove('expanded');
|
||||
content.style.display = 'none';
|
||||
console.log('Initialized container as collapsed');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Main Gallery Application
|
||||
* Orchestrates all the different managers and functionality
|
||||
*/
|
||||
import { ThemeManager } from './theme-manager.js';
|
||||
import { NavigationManager } from './navigation-manager.js';
|
||||
import { SearchManager } from './search-manager.js';
|
||||
import { RecentPlotsManager } from './recent-plots-manager.js';
|
||||
import { ComparisonManager } from './comparison-manager.js';
|
||||
import { StatsManager } from './stats-manager.js';
|
||||
import { KeyboardManager } from './keyboard-manager.js';
|
||||
import { ViewManager } from './view-manager.js';
|
||||
import { SortManager } from './sort-manager.js';
|
||||
import { Utils } from './utils.js';
|
||||
|
||||
/**
|
||||
* Main Gallery Application Class
|
||||
*/
|
||||
export class GalleryApp {
|
||||
constructor(config = {}) {
|
||||
// Configuration from backend template variables
|
||||
this.SEARCH_DEBOUNCE_MS = config.searchDebounceMs || 300;
|
||||
this.MAX_RECENT_PLOTS = config.maxRecentPlots || 20;
|
||||
this.stats = config.stats || null;
|
||||
|
||||
// Initialize managers
|
||||
this.themeManager = new ThemeManager();
|
||||
this.navigationManager = new NavigationManager();
|
||||
this.searchManager = new SearchManager(this.SEARCH_DEBOUNCE_MS);
|
||||
this.recentPlotsManager = new RecentPlotsManager(this.MAX_RECENT_PLOTS);
|
||||
this.comparisonManager = new ComparisonManager();
|
||||
this.statsManager = new StatsManager();
|
||||
this.viewManager = new ViewManager();
|
||||
this.sortManager = new SortManager();
|
||||
this.keyboardManager = new KeyboardManager(this);
|
||||
this.utils = Utils;
|
||||
|
||||
// Set global references for backward compatibility
|
||||
window.themeManager = this.themeManager;
|
||||
window.searchManager = this.searchManager;
|
||||
window.recentPlotsManager = this.recentPlotsManager;
|
||||
window.comparisonManager = this.comparisonManager;
|
||||
window.viewManager = this.viewManager;
|
||||
window.sortManager = this.sortManager;
|
||||
window.utils = this.utils;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the gallery application
|
||||
*/
|
||||
init() {
|
||||
this.navigationManager.buildBreadcrumb();
|
||||
this.navigationManager.buildFolderTree();
|
||||
|
||||
// Update stats with backend data if available
|
||||
if (this.stats) {
|
||||
this.statsManager.updateWithBackendStats(this.stats);
|
||||
}
|
||||
|
||||
// Handle URL-based thumbnail highlighting
|
||||
Utils.handleThumbnailHighlight();
|
||||
}
|
||||
|
||||
// Backward compatibility methods
|
||||
toggleTheme() { this.themeManager.toggle(); }
|
||||
toggleSidebar() { this.recentPlotsManager.toggleSidebar(); }
|
||||
toggleCompareMode() { this.comparisonManager.toggleCompareMode(); }
|
||||
closeComparison() { this.comparisonManager.closeComparison(); }
|
||||
selectPlotForComparison(slot) { this.comparisonManager.selectPlotForComparison(slot); }
|
||||
replacePlot(slot) { this.comparisonManager.replacePlot(slot); }
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Keyboard shortcuts management
|
||||
*/
|
||||
export class KeyboardManager {
|
||||
constructor(galleryApp) {
|
||||
this.app = galleryApp;
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize keyboard shortcuts
|
||||
*/
|
||||
init() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
const searchBox = document.getElementById('searchBox');
|
||||
if (searchBox) searchBox.focus();
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.key === 'r') {
|
||||
e.preventDefault();
|
||||
if (this.app.recentPlotsManager) {
|
||||
this.app.recentPlotsManager.toggleSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.key === 'c') {
|
||||
e.preventDefault();
|
||||
if (this.app.comparisonManager) {
|
||||
this.app.comparisonManager.toggleCompareMode();
|
||||
}
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.key === 't') {
|
||||
e.preventDefault();
|
||||
if (this.app.themeManager) {
|
||||
this.app.themeManager.toggle();
|
||||
}
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.key === 'v') {
|
||||
e.preventDefault();
|
||||
if (this.app.viewManager) {
|
||||
this.app.viewManager.cycleView();
|
||||
}
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.key === 'n') {
|
||||
e.preventDefault();
|
||||
if (this.app.sortManager) {
|
||||
this.app.sortManager.setSortType('name');
|
||||
}
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.key === 'm') {
|
||||
e.preventDefault();
|
||||
if (this.app.sortManager) {
|
||||
this.app.sortManager.setSortType('time');
|
||||
}
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.key === 'o') {
|
||||
e.preventDefault();
|
||||
if (this.app.sortManager) {
|
||||
this.app.sortManager.toggleSortOrder();
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === '?' && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
if (this.app.utils && this.app.utils.toggleShortcutsHelp) {
|
||||
this.app.utils.toggleShortcutsHelp();
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
this.handleEscape();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle escape key actions
|
||||
*/
|
||||
handleEscape() {
|
||||
// If in plot selection mode, cancel it
|
||||
if (this.app.comparisonManager && this.app.comparisonManager.comparisonSlot) {
|
||||
// Find and remove instruction element
|
||||
const instruction = document.getElementById('comparisonInstruction');
|
||||
if (instruction) instruction.remove();
|
||||
|
||||
// Reset grid item styles
|
||||
const gridItems = document.querySelectorAll('.grid-item');
|
||||
gridItems.forEach(item => {
|
||||
item.style.cursor = '';
|
||||
item.style.border = '';
|
||||
});
|
||||
|
||||
// Show overlay again
|
||||
const comparisonOverlay = document.getElementById('comparisonOverlay');
|
||||
if (comparisonOverlay) comparisonOverlay.classList.add('open');
|
||||
this.app.comparisonManager.comparisonSlot = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Close comparison overlay if open
|
||||
const comparisonOverlay = document.getElementById('comparisonOverlay');
|
||||
if (comparisonOverlay && comparisonOverlay.classList.contains('open')) {
|
||||
if (this.app.comparisonManager) {
|
||||
this.app.comparisonManager.hideComparisonOverlay();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Other ESC behaviors
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
if (searchResults) searchResults.style.display = 'none';
|
||||
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (sidebar && sidebar.classList.contains('open')) {
|
||||
if (this.app.recentPlotsManager) {
|
||||
this.app.recentPlotsManager.toggleSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
const shortcutsHelp = document.getElementById('shortcutsHelp');
|
||||
if (shortcutsHelp) shortcutsHelp.style.display = 'none';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Main entry point for the Gallery application
|
||||
* This file initializes the app when the DOM is ready
|
||||
*/
|
||||
import { GalleryApp } from './gallery-app.js';
|
||||
|
||||
// Global app instance for backward compatibility
|
||||
let app;
|
||||
|
||||
// Global functions for onclick handlers (backward compatibility)
|
||||
function toggleTheme() { app.toggleTheme(); }
|
||||
function toggleSidebar() { app.toggleSidebar(); }
|
||||
|
||||
// Make functions globally available
|
||||
window.toggleTheme = toggleTheme;
|
||||
window.toggleSidebar = toggleSidebar;
|
||||
|
||||
// Initialize application when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Configuration will be injected by the template
|
||||
const config = window.galleryConfig || {};
|
||||
|
||||
app = new GalleryApp(config);
|
||||
|
||||
// Make app globally available
|
||||
window.app = app;
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Metadata Popup functionality for Gallery
|
||||
*
|
||||
* Handles showing metadata in small popups overlaid on plot thumbnails
|
||||
*/
|
||||
|
||||
class MetadataPopup {
|
||||
constructor() {
|
||||
this.activePopup = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Close popup when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.metadata-btn') && !e.target.closest('.metadata-popup')) {
|
||||
this.hidePopup();
|
||||
}
|
||||
});
|
||||
|
||||
// Close popup on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.hidePopup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showPopup(button, plotName, metadata) {
|
||||
// Hide any existing popup
|
||||
this.hidePopup();
|
||||
|
||||
// Create popup element
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'metadata-popup';
|
||||
popup.innerHTML = this.formatMetadata(plotName, metadata);
|
||||
|
||||
// Position popup relative to button
|
||||
const rect = button.getBoundingClientRect();
|
||||
popup.style.position = 'fixed';
|
||||
popup.style.left = rect.left + 'px';
|
||||
popup.style.top = (rect.bottom + 5) + 'px';
|
||||
popup.style.zIndex = '1000';
|
||||
|
||||
// Add to DOM
|
||||
document.body.appendChild(popup);
|
||||
this.activePopup = popup;
|
||||
|
||||
// Adjust position if popup goes off screen
|
||||
setTimeout(() => {
|
||||
const popupRect = popup.getBoundingClientRect();
|
||||
|
||||
// Adjust horizontal position
|
||||
if (popupRect.right > window.innerWidth) {
|
||||
popup.style.left = (rect.right - popupRect.width) + 'px';
|
||||
}
|
||||
|
||||
// Adjust vertical position
|
||||
if (popupRect.bottom > window.innerHeight) {
|
||||
popup.style.top = (rect.top - popupRect.height - 5) + 'px';
|
||||
}
|
||||
}, 0);
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => {
|
||||
popup.classList.add('show');
|
||||
});
|
||||
}
|
||||
|
||||
hidePopup() {
|
||||
if (this.activePopup) {
|
||||
this.activePopup.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
if (this.activePopup && this.activePopup.parentNode) {
|
||||
this.activePopup.parentNode.removeChild(this.activePopup);
|
||||
}
|
||||
this.activePopup = null;
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
formatMetadata(plotName, metadata) {
|
||||
if (!metadata || Object.keys(metadata).length === 0) {
|
||||
return `
|
||||
<div class="metadata-popup-header">
|
||||
<h4>${plotName}</h4>
|
||||
</div>
|
||||
<div class="metadata-popup-content">
|
||||
<p class="no-metadata">No metadata available</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<div class="metadata-popup-header">
|
||||
<h4>${plotName}</h4>
|
||||
</div>
|
||||
<div class="metadata-popup-content">
|
||||
`;
|
||||
|
||||
// Show priority fields first
|
||||
const priorityFields = ['title', 'description', 'plot_type', 'experiment'];
|
||||
const processedKeys = new Set();
|
||||
|
||||
// Display priority fields first
|
||||
for (const key of priorityFields) {
|
||||
if (metadata[key] !== undefined) {
|
||||
html += this.formatMetadataField(key, metadata[key]);
|
||||
processedKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Show file info if available
|
||||
if (metadata.file_info) {
|
||||
html += `<div class="metadata-section-title">File Information</div>`;
|
||||
html += this.formatMetadataField('File Size', metadata.file_info.size);
|
||||
if (metadata.file_info.extension) {
|
||||
html += this.formatMetadataField('Format', metadata.file_info.extension);
|
||||
}
|
||||
processedKeys.add('file_info');
|
||||
}
|
||||
|
||||
// Show timestamps if available
|
||||
if (metadata.timestamps) {
|
||||
html += `<div class="metadata-section-title">Timestamps</div>`;
|
||||
if (metadata.timestamps.created_human) {
|
||||
html += this.formatMetadataField('Created', metadata.timestamps.created_human);
|
||||
}
|
||||
if (metadata.timestamps.modified_human) {
|
||||
html += this.formatMetadataField('Modified', metadata.timestamps.modified_human);
|
||||
}
|
||||
processedKeys.add('timestamps');
|
||||
}
|
||||
|
||||
// Show extracted info if available
|
||||
if (metadata.extracted_info && Object.keys(metadata.extracted_info).length > 0) {
|
||||
html += `<div class="metadata-section-title">Plot Details</div>`;
|
||||
for (const [key, value] of Object.entries(metadata.extracted_info)) {
|
||||
html += this.formatMetadataField(key, value);
|
||||
}
|
||||
processedKeys.add('extracted_info');
|
||||
}
|
||||
|
||||
// Display other fields (excluding generation info unless it's the only data)
|
||||
const otherKeys = Object.keys(metadata).filter(key =>
|
||||
!processedKeys.has(key) && key !== 'generation'
|
||||
);
|
||||
|
||||
if (otherKeys.length > 0) {
|
||||
html += `<div class="metadata-section-title">Additional Information</div>`;
|
||||
for (const key of otherKeys) {
|
||||
html += this.formatMetadataField(key, metadata[key]);
|
||||
}
|
||||
}
|
||||
|
||||
// Show generation info last if there's no other meaningful data
|
||||
if (processedKeys.size <= 2 && metadata.generation) {
|
||||
html += `<div class="metadata-section-title">Generation Info</div>`;
|
||||
if (metadata.generation.generation_time) {
|
||||
const genDate = new Date(metadata.generation.generation_time);
|
||||
html += this.formatMetadataField('Generated', genDate.toLocaleString());
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
formatMetadataField(key, value) {
|
||||
const displayKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
|
||||
let formattedValue;
|
||||
if (value === null || value === undefined) {
|
||||
formattedValue = '<em>null</em>';
|
||||
} else if (typeof value === 'object') {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length <= 3) {
|
||||
formattedValue = value.map(item => `<span class="metadata-tag">${item}</span>`).join(' ');
|
||||
} else {
|
||||
formattedValue = `${value.slice(0, 3).map(item => `<span class="metadata-tag">${item}</span>`).join(' ')} <span class="metadata-more">+${value.length - 3} more</span>`;
|
||||
}
|
||||
} else {
|
||||
// Show object as compact JSON for small objects, or just key count for large ones
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length <= 3) {
|
||||
formattedValue = '<code>' + JSON.stringify(value) + '</code>';
|
||||
} else {
|
||||
formattedValue = `<em>Object with ${keys.length} properties</em>`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Truncate long strings
|
||||
const str = String(value);
|
||||
formattedValue = str.length > 50 ? str.substring(0, 47) + '...' : str;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="metadata-field">
|
||||
<span class="metadata-key">${displayKey}:</span>
|
||||
<span class="metadata-value">${formattedValue}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
window.metadataPopup = new MetadataPopup();
|
||||
|
||||
// Global function for template usage
|
||||
window.showMetadataPopup = function(button, plotName, metadata) {
|
||||
window.metadataPopup.showPopup(button, plotName, metadata);
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Simple Metadata Section Toggle
|
||||
*
|
||||
* Handles showing/hiding the metadata grid with a simple button
|
||||
*/
|
||||
|
||||
// Global function to toggle metadata section visibility
|
||||
function toggleMetadataSection() {
|
||||
console.log('toggleMetadataSection called');
|
||||
|
||||
const content = document.getElementById('metadataContent');
|
||||
const arrow = document.getElementById('metadataArrow');
|
||||
|
||||
if (!content) {
|
||||
console.log('No metadata content found');
|
||||
return;
|
||||
}
|
||||
|
||||
const isVisible = content.style.display !== 'none';
|
||||
|
||||
if (isVisible) {
|
||||
// Hide the content
|
||||
content.style.display = 'none';
|
||||
if (arrow) arrow.textContent = '▼';
|
||||
console.log('Metadata hidden');
|
||||
} else {
|
||||
// Show the content
|
||||
content.style.display = 'block';
|
||||
if (arrow) arrow.textContent = '▲';
|
||||
console.log('Metadata shown');
|
||||
|
||||
// Trigger MathJax rendering for LaTeX content
|
||||
if (typeof MathJax !== 'undefined') {
|
||||
MathJax.typesetPromise([content]).catch(function (err) {
|
||||
console.log('MathJax typeset failed: ' + err.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Save state to localStorage
|
||||
localStorage.setItem('metadataVisible', (!isVisible).toString());
|
||||
}
|
||||
|
||||
// Function to expand long text
|
||||
function expandText(button) {
|
||||
const longText = button.previousElementSibling;
|
||||
const fullText = button.nextElementSibling;
|
||||
|
||||
if (fullText.style.display === 'none') {
|
||||
longText.style.display = 'none';
|
||||
fullText.style.display = 'inline';
|
||||
button.textContent = 'Show less';
|
||||
} else {
|
||||
longText.style.display = 'inline';
|
||||
fullText.style.display = 'none';
|
||||
button.textContent = 'Show more';
|
||||
}
|
||||
}
|
||||
|
||||
// Copy metadata file path to clipboard
|
||||
async function copyMetadataPath() {
|
||||
const pathElement = document.getElementById('metadata-file-path');
|
||||
const copyBtn = document.querySelector('.copy-path-btn');
|
||||
|
||||
if (!pathElement || !copyBtn) {
|
||||
console.log('Path element or copy button not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const path = pathElement.textContent;
|
||||
console.log('Attempting to copy path:', path);
|
||||
|
||||
try {
|
||||
// Try using the modern clipboard API
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(path);
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = path;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
textArea.remove();
|
||||
}
|
||||
|
||||
// Visual feedback
|
||||
const originalText = copyBtn.innerHTML;
|
||||
copyBtn.innerHTML = '✅ Copied!';
|
||||
copyBtn.classList.add('copied');
|
||||
|
||||
setTimeout(() => {
|
||||
copyBtn.innerHTML = originalText;
|
||||
copyBtn.classList.remove('copied');
|
||||
}, 2000);
|
||||
|
||||
console.log('Path copied successfully');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to copy path: ', err);
|
||||
|
||||
// Show error feedback
|
||||
const originalText = copyBtn.innerHTML;
|
||||
copyBtn.innerHTML = '❌ Failed';
|
||||
|
||||
setTimeout(() => {
|
||||
copyBtn.innerHTML = originalText;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Make functions globally available
|
||||
window.toggleMetadataSection = toggleMetadataSection;
|
||||
window.expandText = expandText;
|
||||
window.copyMetadataPath = copyMetadataPath;
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('Metadata section script loaded');
|
||||
|
||||
const content = document.getElementById('metadataContent');
|
||||
if (content) {
|
||||
// Check if user previously had it expanded
|
||||
const wasVisible = localStorage.getItem('metadataVisible') === 'true';
|
||||
|
||||
if (wasVisible) {
|
||||
content.style.display = 'block';
|
||||
const arrow = document.getElementById('metadataArrow');
|
||||
if (arrow) arrow.textContent = '▲';
|
||||
} else {
|
||||
content.style.display = 'none';
|
||||
const arrow = document.getElementById('metadataArrow');
|
||||
if (arrow) arrow.textContent = '▼';
|
||||
}
|
||||
|
||||
console.log('Metadata section initialized, visible:', wasVisible);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Navigation functionality - breadcrumbs and folder tree
|
||||
*/
|
||||
export class NavigationManager {
|
||||
/**
|
||||
* Build breadcrumb navigation based on current path
|
||||
*/
|
||||
buildBreadcrumb() {
|
||||
const currentPath = window.location.pathname;
|
||||
const pathParts = currentPath.split('/').filter(part => part !== '' && part !== 'index.html');
|
||||
const breadcrumb = document.getElementById('breadcrumb');
|
||||
|
||||
if (!breadcrumb) return;
|
||||
|
||||
if (pathParts.length === 0) {
|
||||
breadcrumb.innerHTML = '<span>🏠 Root</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<a href="/">🏠 Root</a>';
|
||||
|
||||
for (let i = 0; i < pathParts.length; i++) {
|
||||
const part = pathParts[i];
|
||||
html += '<span class="separator">/</span>';
|
||||
|
||||
if (i === pathParts.length - 1) {
|
||||
html += `<span>${decodeURIComponent(part)}</span>`;
|
||||
} else {
|
||||
const levelsUp = pathParts.length - 1 - i;
|
||||
const relativePath = '../'.repeat(levelsUp) + 'index.html';
|
||||
html += `<a href="${relativePath}">${decodeURIComponent(part)}</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
breadcrumb.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract subdirs and item count from a parsed page document.
|
||||
* Reads from the embedded #gallery-data JSON element.
|
||||
*/
|
||||
extractPageData(doc) {
|
||||
const dataEl = doc.getElementById('gallery-data');
|
||||
if (dataEl) {
|
||||
try {
|
||||
const data = JSON.parse(dataEl.textContent);
|
||||
return { subdirs: data.subdirs || [], itemCount: data.item_count || 0 };
|
||||
} catch {}
|
||||
}
|
||||
// Fallback for pages that pre-date the data element
|
||||
return { subdirs: [], itemCount: doc.querySelectorAll('.grid-item').length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and display the folder tree structure
|
||||
*/
|
||||
async buildFolderTree() {
|
||||
const treeContainer = document.getElementById('folderTree');
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
if (!treeContainer) return;
|
||||
|
||||
try {
|
||||
const tree = await this.buildTreeRecursive(currentPath, 0, currentPath);
|
||||
treeContainer.innerHTML = tree;
|
||||
} catch (error) {
|
||||
console.error('Error building folder tree:', error);
|
||||
treeContainer.innerHTML = '<div class="tree-item">❌ Error loading folder tree</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively build tree structure for folders with collapsed empty directories
|
||||
*/
|
||||
async buildTreeRecursive(path, depth, currentPath, maxDepth = 5) {
|
||||
if (depth > maxDepth) {
|
||||
const indent = ' '.repeat(depth);
|
||||
return `<div class="tree-item">${indent}└─ ...</div>`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(path);
|
||||
const htmlContent = await response.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlContent, 'text/html');
|
||||
|
||||
const { subdirs, itemCount } = this.extractPageData(doc);
|
||||
const baseUrl = path.replace(/\/[^\/]*$/, '/');
|
||||
|
||||
// Check if this is an empty directory (only one subdirectory, no items)
|
||||
if (itemCount === 0 && subdirs.length === 1) {
|
||||
const subPath = baseUrl + subdirs[0] + '/index.html';
|
||||
const collapsedPath = await this.getCollapsedPath(path, subPath);
|
||||
return await this.buildCollapsedTreeItem(collapsedPath, depth, currentPath, maxDepth);
|
||||
}
|
||||
|
||||
// Normal directory processing
|
||||
const indent = ' '.repeat(depth);
|
||||
const folderName = path.split('/').filter(p => p !== '' && p !== 'index.html').pop() || 'Gallery';
|
||||
const totalItems = itemCount + subdirs.length;
|
||||
const arrow = depth === 0 ? '' : '└─ ';
|
||||
|
||||
let html = '';
|
||||
if (path === currentPath) {
|
||||
html += `<div class="tree-item">${indent}${arrow}📁 <span class="tree-current">${folderName}</span> (${totalItems} items)</div>`;
|
||||
} else {
|
||||
html += `<div class="tree-item">${indent}${arrow}📁 <a href="${path}" class="tree-link">${folderName}</a> (${totalItems} items)</div>`;
|
||||
}
|
||||
|
||||
for (const subdir of subdirs) {
|
||||
const subPath = baseUrl + subdir + '/index.html';
|
||||
html += await this.buildTreeRecursive(subPath, depth + 1, currentPath, maxDepth);
|
||||
}
|
||||
|
||||
return html;
|
||||
} catch (error) {
|
||||
const indent = ' '.repeat(depth);
|
||||
const arrow = depth === 0 ? '' : '└─ ';
|
||||
const folderName = path.split('/').filter(p => p !== '' && p !== 'index.html').pop() || 'Gallery';
|
||||
return `<div class="tree-item">${indent}${arrow}📁 ${folderName} (error loading)</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the collapsed path by following empty directories
|
||||
*/
|
||||
async getCollapsedPath(startPath, currentPath) {
|
||||
const pathSegments = [];
|
||||
let path = startPath;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const response = await fetch(path);
|
||||
const htmlContent = await response.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlContent, 'text/html');
|
||||
|
||||
const { subdirs, itemCount } = this.extractPageData(doc);
|
||||
const folderName = path.split('/').filter(p => p !== '' && p !== 'index.html').pop() || 'Gallery';
|
||||
pathSegments.push({ name: folderName, path: path });
|
||||
|
||||
if (itemCount > 0 || subdirs.length !== 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const baseUrl = path.replace(/\/[^\/]*$/, '/');
|
||||
path = baseUrl + subdirs[0] + '/index.html';
|
||||
} catch (error) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { segments: pathSegments, finalPath: path };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a collapsed tree item for empty directory chains
|
||||
*/
|
||||
async buildCollapsedTreeItem(collapsedPath, depth, currentPath, maxDepth) {
|
||||
const indent = ' '.repeat(depth);
|
||||
const arrow = depth === 0 ? '' : '└─ ';
|
||||
|
||||
const displayName = collapsedPath.segments.map(seg => seg.name).join(' / ');
|
||||
const finalPath = collapsedPath.finalPath;
|
||||
|
||||
let totalItems = 0;
|
||||
let subdirs = [];
|
||||
try {
|
||||
const response = await fetch(finalPath);
|
||||
const htmlContent = await response.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlContent, 'text/html');
|
||||
|
||||
const data = this.extractPageData(doc);
|
||||
totalItems = data.itemCount + data.subdirs.length;
|
||||
subdirs = data.subdirs;
|
||||
} catch (error) {}
|
||||
|
||||
let html = '';
|
||||
if (finalPath === currentPath) {
|
||||
html += `<div class="tree-item">${indent}${arrow}📁 <span class="tree-current">${displayName}</span> (${totalItems} items)</div>`;
|
||||
} else {
|
||||
html += `<div class="tree-item">${indent}${arrow}📁 <a href="${finalPath}" class="tree-link">${displayName}</a> (${totalItems} items)</div>`;
|
||||
}
|
||||
|
||||
const baseUrl = finalPath.replace(/\/[^\/]*$/, '/');
|
||||
for (const subdir of subdirs) {
|
||||
const subPath = baseUrl + subdir + '/index.html';
|
||||
html += await this.buildTreeRecursive(subPath, depth + 1, currentPath, maxDepth);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Recent plots sidebar management
|
||||
*/
|
||||
export class RecentPlotsManager {
|
||||
constructor(maxRecentPlots = 20) {
|
||||
this.MAX_RECENT_PLOTS = maxRecentPlots;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.updateRecentPlotsDisplay();
|
||||
this.trackPlotClicks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add plot to recent plots list
|
||||
*/
|
||||
addToRecentPlots(plotHref) {
|
||||
const plotName = plotHref.split('/').pop().replace('.pdf', '');
|
||||
const pathParts = plotHref.split('/').filter(p => p !== '' && p !== plotName + '.pdf');
|
||||
const plotPath = pathParts.join(' / ');
|
||||
const thumbUrl = plotHref.replace('.pdf', '.png');
|
||||
|
||||
// Determine the gallery page URL (directory containing the plot)
|
||||
const plotDir = plotHref.substring(0, plotHref.lastIndexOf('/'));
|
||||
const galleryUrl = plotDir + '/index.html';
|
||||
|
||||
const plotInfo = {
|
||||
name: plotName,
|
||||
path: plotPath,
|
||||
href: plotHref,
|
||||
thumbUrl: thumbUrl,
|
||||
galleryUrl: galleryUrl,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
let recentPlots = JSON.parse(localStorage.getItem('recentPlots') || '[]');
|
||||
recentPlots = recentPlots.filter(p => p.href !== plotHref);
|
||||
recentPlots.unshift(plotInfo);
|
||||
recentPlots = recentPlots.slice(0, this.MAX_RECENT_PLOTS);
|
||||
|
||||
localStorage.setItem('recentPlots', JSON.stringify(recentPlots));
|
||||
this.updateRecentPlotsDisplay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update recent plots sidebar display
|
||||
*/
|
||||
updateRecentPlotsDisplay() {
|
||||
const sidebarContent = document.getElementById('sidebarContent');
|
||||
if (!sidebarContent) return;
|
||||
|
||||
const recentPlots = JSON.parse(localStorage.getItem('recentPlots') || '[]');
|
||||
|
||||
if (recentPlots.length === 0) {
|
||||
sidebarContent.innerHTML = `
|
||||
<div style="text-align: center; color: var(--breadcrumb-color); margin: 2rem 0;">
|
||||
📭 No recent plots yet<br>
|
||||
<small style="opacity: 0.7;">Open some plots to see them here</small>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
recentPlots.forEach(plot => {
|
||||
html += `
|
||||
<div class="recent-plot" onclick="window.recentPlotsManager.openRecentPlot('${plot.galleryUrl || plot.href}', '${plot.name}')" title="${plot.name}">
|
||||
<img src="${plot.thumbUrl}" class="recent-plot-thumb" alt="${plot.name}" />
|
||||
<div class="recent-plot-info">
|
||||
<div class="recent-plot-name">${plot.name}</div>
|
||||
<div class="recent-plot-path">📍 ${plot.path}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
sidebarContent.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open recent plot gallery page and highlight thumbnail
|
||||
*/
|
||||
openRecentPlot(galleryUrl, plotName) {
|
||||
// Navigate to gallery page with plot highlight parameter
|
||||
const url = new URL(galleryUrl, window.location.origin);
|
||||
url.searchParams.set('highlight', plotName);
|
||||
window.location.href = url.toString();
|
||||
this.toggleSidebar();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle recent plots sidebar
|
||||
*/
|
||||
toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('sidebarOverlay');
|
||||
|
||||
if (sidebar) sidebar.classList.toggle('open');
|
||||
if (overlay) overlay.classList.toggle('open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Track clicks on plot links
|
||||
*/
|
||||
trackPlotClicks() {
|
||||
document.addEventListener('click', (e) => {
|
||||
const link = e.target.closest('a[href$=".pdf"]');
|
||||
if (link) {
|
||||
this.addToRecentPlots(link.href);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Search functionality
|
||||
*/
|
||||
export class SearchManager {
|
||||
constructor(debounceMs = 300) {
|
||||
this.searchTimeout = null;
|
||||
this.SEARCH_DEBOUNCE_MS = debounceMs;
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize search functionality with debouncing
|
||||
*/
|
||||
init() {
|
||||
const searchBox = document.getElementById('searchBox');
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
|
||||
if (!searchBox || !searchResults) return;
|
||||
|
||||
searchBox.addEventListener('input', (e) => {
|
||||
clearTimeout(this.searchTimeout);
|
||||
const query = e.target.value.trim();
|
||||
|
||||
if (query.length === 0) {
|
||||
searchResults.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.performSearch(query);
|
||||
}, this.SEARCH_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!searchBox.contains(e.target) && !searchResults.contains(e.target)) {
|
||||
searchResults.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform search across plot names
|
||||
*/
|
||||
async performSearch(query) {
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
if (!searchResults) return;
|
||||
|
||||
searchResults.innerHTML = '<div style="padding: 1rem;">🔍 Searching...</div>';
|
||||
searchResults.style.display = 'block';
|
||||
|
||||
try {
|
||||
const results = await this.searchPlots(query);
|
||||
this.displaySearchResults(results, query);
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
searchResults.innerHTML = '<div style="padding: 1rem; color: red;">❌ Search failed</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for plots matching the query
|
||||
*/
|
||||
async searchPlots(query) {
|
||||
const results = [];
|
||||
const visited = new Set();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
await this.searchInPage(window.location.pathname, lowerQuery, results, visited);
|
||||
await this.searchRecursive(window.location.pathname, lowerQuery, results, visited, 0, 5);
|
||||
|
||||
return results.slice(0, 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for plots in a specific page
|
||||
*/
|
||||
async searchInPage(path, query, results, visited, maxResults = 50) {
|
||||
if (visited.has(path) || results.length >= maxResults) return;
|
||||
visited.add(path);
|
||||
|
||||
try {
|
||||
const response = await fetch(path);
|
||||
const html = await response.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
const items = doc.querySelectorAll('.grid-item');
|
||||
items.forEach(item => {
|
||||
const nameElement = item.querySelector('.plot-name');
|
||||
const imgElement = item.querySelector('img');
|
||||
const linkElement = item.querySelector('a');
|
||||
|
||||
if (nameElement && imgElement && linkElement) {
|
||||
const name = nameElement.textContent.toLowerCase();
|
||||
if (name.includes(query)) {
|
||||
results.push({
|
||||
name: nameElement.textContent,
|
||||
path: path,
|
||||
href: linkElement.href,
|
||||
imgSrc: imgElement.src,
|
||||
relevance: this.calculateRelevance(name, query)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error searching in', path, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively search in subdirectories
|
||||
*/
|
||||
async searchRecursive(path, query, results, visited, depth, maxDepth) {
|
||||
if (depth >= maxDepth || results.length >= 50) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(path);
|
||||
const html = await response.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
const subdirs = doc.querySelectorAll('h2 + ul li a');
|
||||
for (const subdir of subdirs) {
|
||||
const href = subdir.getAttribute('href');
|
||||
if (href) {
|
||||
const baseUrl = path.replace(/\/[^\/]*$/, '/');
|
||||
const subPath = baseUrl + href;
|
||||
await this.searchInPage(subPath, query, results, visited);
|
||||
await this.searchRecursive(subPath, query, results, visited, depth + 1, maxDepth);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in recursive search:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate search relevance score
|
||||
*/
|
||||
calculateRelevance(text, query) {
|
||||
const exactMatch = text === query;
|
||||
const startsWith = text.startsWith(query);
|
||||
const wordMatch = text.split(/\s+/).some(word => word.startsWith(query));
|
||||
|
||||
if (exactMatch) return 100;
|
||||
if (startsWith) return 80;
|
||||
if (wordMatch) return 60;
|
||||
return 40;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display search results with highlighting
|
||||
*/
|
||||
displaySearchResults(results, query) {
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
if (!searchResults) return;
|
||||
|
||||
if (results.length === 0) {
|
||||
searchResults.innerHTML = '<div style="padding: 1rem;">📭 No plots found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.relevance - a.relevance);
|
||||
|
||||
let html = '';
|
||||
results.forEach(result => {
|
||||
const highlightedName = this.highlightText(result.name, query);
|
||||
const relativePath = this.getRelativePath(result.path);
|
||||
|
||||
html += `
|
||||
<div class="search-result-item" onclick="window.searchManager.openSearchResult('${result.href}')" title="${result.name}">
|
||||
<div style="display: flex; gap: 0.7rem; align-items: center;">
|
||||
<img src="${result.imgSrc}" style="width: 50px; height: 40px; object-fit: cover; border-radius: 4px;" />
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="font-weight: 600; margin-bottom: 0.3rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||
${highlightedName}
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: var(--breadcrumb-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||
📍 ${relativePath}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
searchResults.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight search query in text
|
||||
*/
|
||||
highlightText(text, query) {
|
||||
const regex = new RegExp(`(${query})`, 'gi');
|
||||
return text.replace(regex, '<span class="search-highlight">$1</span>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative path for display
|
||||
*/
|
||||
getRelativePath(fullPath) {
|
||||
const parts = fullPath.split('/').filter(p => p !== '' && p !== 'index.html');
|
||||
return parts.length > 0 ? parts.join(' / ') : 'Root';
|
||||
}
|
||||
|
||||
/**
|
||||
* Open search result and track it
|
||||
*/
|
||||
openSearchResult(href) {
|
||||
if (window.recentPlotsManager) {
|
||||
window.recentPlotsManager.addToRecentPlots(href);
|
||||
}
|
||||
window.open(href, '_blank');
|
||||
document.getElementById('searchResults').style.display = 'none';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Sort Manager - handles sorting of plot items by name and creation time
|
||||
*/
|
||||
export class SortManager {
|
||||
constructor() {
|
||||
this.currentSort = 'name';
|
||||
this.currentOrder = 'asc';
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize sort controls
|
||||
*/
|
||||
init() {
|
||||
// Use setTimeout to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
this.setupSortButtons();
|
||||
this.loadSavedSort();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup sort button event listeners
|
||||
*/
|
||||
setupSortButtons() {
|
||||
const sortButtons = document.querySelectorAll('.sort-btn');
|
||||
const orderButton = document.querySelector('.sort-order-btn');
|
||||
|
||||
if (sortButtons.length === 0) {
|
||||
setTimeout(() => this.setupSortButtons(), 500);
|
||||
return;
|
||||
}
|
||||
|
||||
sortButtons.forEach((button) => {
|
||||
const sortType = button.getAttribute('data-sort');
|
||||
|
||||
button.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.setSortType(sortType);
|
||||
});
|
||||
});
|
||||
|
||||
if (orderButton) {
|
||||
orderButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.toggleSortOrder();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize button states
|
||||
this.updateSortButtons();
|
||||
this.updateOrderButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the sort type (name or time)
|
||||
*/
|
||||
setSortType(sortType) {
|
||||
if (sortType === this.currentSort) return;
|
||||
|
||||
this.currentSort = sortType;
|
||||
this.updateSortButtons();
|
||||
this.sortPlots();
|
||||
this.saveSortPreference();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle sort order between ascending and descending
|
||||
*/
|
||||
toggleSortOrder() {
|
||||
this.currentOrder = this.currentOrder === 'asc' ? 'desc' : 'asc';
|
||||
this.updateOrderButton();
|
||||
this.sortPlots();
|
||||
this.saveSortPreference();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visual state of sort buttons
|
||||
*/
|
||||
updateSortButtons() {
|
||||
const sortButtons = document.querySelectorAll('.sort-btn');
|
||||
|
||||
sortButtons.forEach(btn => {
|
||||
if (btn.getAttribute('data-sort') === this.currentSort) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visual state of order button
|
||||
*/
|
||||
updateOrderButton() {
|
||||
const orderButton = document.querySelector('.sort-order-btn');
|
||||
if (orderButton) {
|
||||
orderButton.textContent = this.currentOrder === 'asc' ? '↑' : '↓';
|
||||
orderButton.setAttribute('data-order', this.currentOrder);
|
||||
orderButton.title = `Sort Order: ${this.currentOrder === 'asc' ? 'Ascending' : 'Descending'}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the plot items
|
||||
*/
|
||||
sortPlots() {
|
||||
const plotContainer = document.getElementById('plotContainer');
|
||||
if (!plotContainer) return;
|
||||
|
||||
const plotItems = Array.from(plotContainer.children);
|
||||
|
||||
plotItems.sort((a, b) => {
|
||||
let valueA, valueB;
|
||||
|
||||
if (this.currentSort === 'name') {
|
||||
valueA = a.getAttribute('data-name') || '';
|
||||
valueB = b.getAttribute('data-name') || '';
|
||||
|
||||
// Natural sort for better number handling
|
||||
const result = valueA.localeCompare(valueB, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
});
|
||||
return this.currentOrder === 'asc' ? result : -result;
|
||||
} else if (this.currentSort === 'time') {
|
||||
valueA = parseInt(a.getAttribute('data-time') || '0');
|
||||
valueB = parseInt(b.getAttribute('data-time') || '0');
|
||||
|
||||
const result = valueA - valueB;
|
||||
return this.currentOrder === 'asc' ? result : -result;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Re-append sorted items
|
||||
plotItems.forEach(item => {
|
||||
plotContainer.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save sort preferences to localStorage
|
||||
*/
|
||||
saveSortPreference() {
|
||||
try {
|
||||
localStorage.setItem('gallery-sort-type', this.currentSort);
|
||||
localStorage.setItem('gallery-sort-order', this.currentOrder);
|
||||
} catch (e) {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load saved sort preferences
|
||||
*/
|
||||
loadSavedSort() {
|
||||
try {
|
||||
const savedSort = localStorage.getItem('gallery-sort-type');
|
||||
const savedOrder = localStorage.getItem('gallery-sort-order');
|
||||
|
||||
if (savedSort && ['name', 'time'].includes(savedSort)) {
|
||||
this.currentSort = savedSort;
|
||||
}
|
||||
|
||||
if (savedOrder && ['asc', 'desc'].includes(savedOrder)) {
|
||||
this.currentOrder = savedOrder;
|
||||
}
|
||||
|
||||
this.updateSortButtons();
|
||||
this.updateOrderButton();
|
||||
|
||||
// Sort immediately if there are plots
|
||||
setTimeout(() => this.sortPlots(), 100);
|
||||
} catch (e) {
|
||||
// Ignore localStorage errors, use defaults
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current sort settings
|
||||
*/
|
||||
getCurrentSort() {
|
||||
return {
|
||||
type: this.currentSort,
|
||||
order: this.currentOrder
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh sorting (call this when plot content changes)
|
||||
*/
|
||||
refresh() {
|
||||
this.sortPlots();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Statistics manager for gallery display
|
||||
*/
|
||||
export class StatsManager {
|
||||
constructor() {
|
||||
this.updateGalleryStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update gallery statistics display
|
||||
*/
|
||||
updateGalleryStats() {
|
||||
// Use stats passed from Python backend if available
|
||||
// Otherwise fallback to DOM counting
|
||||
const gridItems = document.querySelectorAll('.grid-item');
|
||||
const subdirLinks = document.querySelectorAll('a[href$="/index.html"]');
|
||||
|
||||
const fileCountEl = document.getElementById('fileCount');
|
||||
const folderCountEl = document.getElementById('folderCount');
|
||||
const totalSizeEl = document.getElementById('totalSize');
|
||||
const lastUpdatedEl = document.getElementById('lastUpdated');
|
||||
|
||||
if (fileCountEl) fileCountEl.textContent = gridItems.length;
|
||||
if (folderCountEl) folderCountEl.textContent = subdirLinks.length;
|
||||
if (totalSizeEl) totalSizeEl.textContent = 'Unknown';
|
||||
|
||||
// Set last updated time
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
if (lastUpdatedEl) lastUpdatedEl.textContent = timeStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stats with backend data
|
||||
*/
|
||||
updateWithBackendStats(stats) {
|
||||
const fileCountEl = document.getElementById('fileCount');
|
||||
const folderCountEl = document.getElementById('folderCount');
|
||||
const totalSizeEl = document.getElementById('totalSize');
|
||||
|
||||
if (fileCountEl && stats.file_count !== undefined) {
|
||||
fileCountEl.textContent = stats.file_count;
|
||||
}
|
||||
if (folderCountEl && stats.folder_count !== undefined) {
|
||||
folderCountEl.textContent = stats.folder_count;
|
||||
}
|
||||
if (totalSizeEl && stats.total_size !== undefined) {
|
||||
totalSizeEl.textContent = stats.total_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Theme management functionality
|
||||
*/
|
||||
export class ThemeManager {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize theme system and load saved preference
|
||||
*/
|
||||
init() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const html = document.documentElement;
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
|
||||
if (savedTheme === 'light') {
|
||||
html.setAttribute('data-theme', 'light');
|
||||
if (themeToggle) themeToggle.textContent = '🌙';
|
||||
} else {
|
||||
html.removeAttribute('data-theme');
|
||||
if (themeToggle) themeToggle.textContent = '☀️';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between light and dark themes
|
||||
*/
|
||||
toggle() {
|
||||
const html = document.documentElement;
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
|
||||
if (html.getAttribute('data-theme') === 'light') {
|
||||
html.removeAttribute('data-theme');
|
||||
if (themeToggle) themeToggle.textContent = '☀️';
|
||||
localStorage.setItem('theme', 'dark');
|
||||
} else {
|
||||
html.setAttribute('data-theme', 'light');
|
||||
if (themeToggle) themeToggle.textContent = '🌙';
|
||||
localStorage.setItem('theme', 'light');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Utility functions and helpers
|
||||
*/
|
||||
export class Utils {
|
||||
/**
|
||||
* Format file size in human readable format
|
||||
*/
|
||||
static formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle thumbnail highlighting from URL parameters
|
||||
*/
|
||||
static handleThumbnailHighlight() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const highlightPlot = urlParams.get('highlight');
|
||||
|
||||
if (highlightPlot) {
|
||||
// Find and highlight the thumbnail
|
||||
const gridItems = document.querySelectorAll('.grid-item');
|
||||
gridItems.forEach(item => {
|
||||
const plotName = item.querySelector('.plot-name');
|
||||
if (plotName && plotName.textContent.trim() === highlightPlot) {
|
||||
item.classList.add('highlighted');
|
||||
// Scroll to the highlighted item
|
||||
setTimeout(() => {
|
||||
item.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
}, 100);
|
||||
// Remove highlight after animation
|
||||
setTimeout(() => {
|
||||
item.classList.remove('highlighted');
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up URL
|
||||
const newUrl = new URL(window.location);
|
||||
newUrl.searchParams.delete('highlight');
|
||||
window.history.replaceState({}, document.title, newUrl.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate approximate total size of displayed files
|
||||
*/
|
||||
static async calculateApproximateSize() {
|
||||
const images = document.querySelectorAll('.grid-item img');
|
||||
let totalSize = 0;
|
||||
let loadedCount = 0;
|
||||
|
||||
const sizeElement = document.getElementById('totalSize');
|
||||
if (!sizeElement) return;
|
||||
|
||||
sizeElement.textContent = 'Loading...';
|
||||
|
||||
// Estimate size based on a sample of images
|
||||
const sampleSize = Math.min(images.length, 5);
|
||||
const sampleImages = Array.from(images).slice(0, sampleSize);
|
||||
|
||||
if (sampleImages.length === 0) {
|
||||
sizeElement.textContent = '0 KB';
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate average size from sample
|
||||
for (const img of sampleImages) {
|
||||
try {
|
||||
const response = await fetch(img.src, { method: 'HEAD' });
|
||||
const size = parseInt(response.headers.get('content-length') || '0');
|
||||
if (size > 0) {
|
||||
totalSize += size;
|
||||
loadedCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback: estimate 100KB per image
|
||||
totalSize += 102400;
|
||||
loadedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedCount > 0) {
|
||||
const averageSize = totalSize / loadedCount;
|
||||
const estimatedTotal = averageSize * images.length;
|
||||
sizeElement.textContent = Utils.formatFileSize(estimatedTotal);
|
||||
} else {
|
||||
sizeElement.textContent = 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle keyboard shortcuts help display
|
||||
*/
|
||||
static toggleShortcutsHelp() {
|
||||
const help = document.getElementById('shortcutsHelp');
|
||||
if (help) {
|
||||
help.style.display = help.style.display === 'block' ? 'none' : 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* View Controls Manager - handles switching between grid, list-large, and list-compact views
|
||||
*/
|
||||
export class ViewManager {
|
||||
constructor() {
|
||||
this.currentView = 'grid';
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize view controls
|
||||
*/
|
||||
init() {
|
||||
this.setupViewButtons();
|
||||
this.loadSavedView();
|
||||
this.updateControlsVisibility();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visibility of view controls based on plot content
|
||||
*/
|
||||
updateControlsVisibility() {
|
||||
const plotContainer = document.getElementById('plotContainer');
|
||||
const controlsContainer = document.querySelector('.controls-container');
|
||||
|
||||
if (!plotContainer || !controlsContainer) return;
|
||||
|
||||
const hasPlots = plotContainer.children.length > 0;
|
||||
controlsContainer.style.display = hasPlots ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup view toggle buttons
|
||||
*/
|
||||
setupViewButtons() {
|
||||
const viewButtons = document.querySelectorAll('.view-btn');
|
||||
|
||||
viewButtons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const newView = button.getAttribute('data-view');
|
||||
this.switchView(newView);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different view mode
|
||||
*/
|
||||
switchView(viewMode) {
|
||||
if (viewMode === this.currentView) return;
|
||||
|
||||
const plotContainer = document.getElementById('plotContainer');
|
||||
const viewButtons = document.querySelectorAll('.view-btn');
|
||||
|
||||
if (!plotContainer) return;
|
||||
|
||||
// Remove current view class
|
||||
plotContainer.classList.remove(
|
||||
'grid-view',
|
||||
'list-large-view',
|
||||
'list-compact-view'
|
||||
);
|
||||
|
||||
// Add new view class
|
||||
switch (viewMode) {
|
||||
case 'grid':
|
||||
plotContainer.classList.add('grid-view');
|
||||
break;
|
||||
case 'list-large':
|
||||
plotContainer.classList.add('list-large-view');
|
||||
break;
|
||||
case 'list-compact':
|
||||
plotContainer.classList.add('list-compact-view');
|
||||
break;
|
||||
default:
|
||||
plotContainer.classList.add('grid-view');
|
||||
viewMode = 'grid';
|
||||
}
|
||||
|
||||
// Update button states
|
||||
viewButtons.forEach(btn => {
|
||||
if (btn.getAttribute('data-view') === viewMode) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Save the preference
|
||||
this.currentView = viewMode;
|
||||
this.saveViewPreference(viewMode);
|
||||
|
||||
// Trigger any necessary layout updates
|
||||
this.onViewChanged(viewMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save view preference to localStorage
|
||||
*/
|
||||
saveViewPreference(viewMode) {
|
||||
try {
|
||||
localStorage.setItem('gallery-view-mode', viewMode);
|
||||
} catch (e) {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load saved view preference
|
||||
*/
|
||||
loadSavedView() {
|
||||
try {
|
||||
const savedView = localStorage.getItem('gallery-view-mode');
|
||||
if (savedView && ['grid', 'list-large', 'list-compact'].includes(savedView)) {
|
||||
this.switchView(savedView);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore localStorage errors, use default
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle view change events - can be extended for additional functionality
|
||||
*/
|
||||
onViewChanged(viewMode) {
|
||||
// Dispatch custom event for other components that might need to know
|
||||
const event = new CustomEvent('viewChanged', {
|
||||
detail: { viewMode }
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
// Update any other UI elements that depend on view mode
|
||||
this.updateUIForView(viewMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI elements based on current view
|
||||
*/
|
||||
updateUIForView(viewMode) {
|
||||
// You can add view-specific UI updates here
|
||||
// For example, adjusting search result highlighting, etc.
|
||||
|
||||
// Update any tooltips or help text
|
||||
const viewButtons = document.querySelectorAll('.view-btn');
|
||||
viewButtons.forEach(btn => {
|
||||
const btnView = btn.getAttribute('data-view');
|
||||
if (btnView === viewMode) {
|
||||
btn.style.transform = 'scale(1.05)';
|
||||
} else {
|
||||
btn.style.transform = 'scale(1)';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current view mode
|
||||
*/
|
||||
getCurrentView() {
|
||||
return this.currentView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh controls visibility (call this when gallery content changes)
|
||||
*/
|
||||
refresh() {
|
||||
this.updateControlsVisibility();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current view is grid mode
|
||||
*/
|
||||
isGridView() {
|
||||
return this.currentView === 'grid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current view is list mode (either variant)
|
||||
*/
|
||||
isListView() {
|
||||
return this.currentView === 'list-large' || this.currentView === 'list-compact';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycle through view modes (useful for keyboard shortcuts)
|
||||
*/
|
||||
cycleView() {
|
||||
const views = ['grid', 'list-large', 'list-compact'];
|
||||
const currentIndex = views.indexOf(this.currentView);
|
||||
const nextIndex = (currentIndex + 1) % views.length;
|
||||
this.switchView(views[nextIndex]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Gallery building and rendering logic."""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Union
|
||||
from jinja2 import Environment, FileSystemLoader, Template
|
||||
|
||||
from gallery.config import GalleryConfig
|
||||
from gallery.utils.datetime_utils import (
|
||||
datetime_from_timestamp,
|
||||
strftime_filter,
|
||||
)
|
||||
from gallery.utils.metadata import (
|
||||
load_folder_metadata,
|
||||
merge_metadata,
|
||||
save_metadata_cache,
|
||||
)
|
||||
from gallery.utils.processing import (
|
||||
process_plot_files,
|
||||
needs_update,
|
||||
render_gallery_page,
|
||||
)
|
||||
|
||||
|
||||
def get_template(template_dir: Optional[Union[Path, str]] = None):
|
||||
"""
|
||||
Get the Jinja2 template for gallery rendering.
|
||||
|
||||
Args:
|
||||
template_dir: Path to template directory. If None,
|
||||
uses package default.
|
||||
|
||||
Returns:
|
||||
Jinja2 Template object
|
||||
"""
|
||||
if isinstance(template_dir, str):
|
||||
template_dir = Path(template_dir)
|
||||
|
||||
if template_dir is None:
|
||||
# Use package-included template
|
||||
import gallery
|
||||
gallery_module_path = Path(gallery.__file__).parent
|
||||
template_dir = gallery_module_path / "templates"
|
||||
|
||||
env = Environment(loader=FileSystemLoader(str(template_dir)))
|
||||
|
||||
env.filters['datetime_from_timestamp'] = datetime_from_timestamp
|
||||
env.filters['strftime'] = strftime_filter
|
||||
|
||||
return env.get_template("gallery.html")
|
||||
|
||||
|
||||
def build_gallery(
|
||||
config: GalleryConfig,
|
||||
source_dir: Path,
|
||||
web_dir: Path,
|
||||
template: Template = None,
|
||||
relative_path: Path = None,
|
||||
inherited_metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Recursively build gallery structure from source directory.
|
||||
|
||||
Processes all PDF files in the source directory, converts them to PNG,
|
||||
copies both to the web directory, and generates index.html files with
|
||||
navigation and thumbnails. Includes metadata support.
|
||||
|
||||
Args:
|
||||
config: Gallery configuration object
|
||||
template: Jinja2 template for rendering
|
||||
source_dir: Source directory containing PDF files
|
||||
web_dir: Target web directory for gallery output
|
||||
relative_path: Relative path from gallery root (for navigation)
|
||||
inherited_metadata: Metadata inherited from parent directories
|
||||
"""
|
||||
if not template:
|
||||
template = Template("./templates/gallery.html")
|
||||
if relative_path is None:
|
||||
relative_path = Path(".")
|
||||
|
||||
if inherited_metadata is None:
|
||||
inherited_metadata = {}
|
||||
|
||||
folder_metadata = load_folder_metadata(source_dir)
|
||||
current_metadata = merge_metadata(inherited_metadata, folder_metadata)
|
||||
|
||||
# Find all plot files (both PDF and HTML)
|
||||
pdf_files = list(source_dir.glob("*.pdf"))
|
||||
html_files = list(source_dir.glob("*.html"))
|
||||
plot_files = pdf_files + html_files
|
||||
|
||||
items = []
|
||||
plot_metadata_cache = {}
|
||||
|
||||
# Process all plot files (PDFs and HTMLs)
|
||||
for plot_file in plot_files:
|
||||
item = process_plot_files(
|
||||
config=config,
|
||||
plot_file=plot_file,
|
||||
web_dir=web_dir,
|
||||
current_metadata=current_metadata,
|
||||
)
|
||||
items.append(item)
|
||||
plot_metadata_cache[plot_file.stem] = item["metadata"]
|
||||
|
||||
if config.cache_enabled:
|
||||
save_metadata_cache(web_dir, plot_metadata_cache)
|
||||
|
||||
# Process subdirectories
|
||||
subdirs = [d for d in source_dir.iterdir() if d.is_dir()]
|
||||
subdir_names = []
|
||||
for subdir in subdirs:
|
||||
subdir_web = web_dir / subdir.name
|
||||
subdir_web.mkdir(exist_ok=True)
|
||||
subdir_relative = relative_path / subdir.name
|
||||
build_gallery(
|
||||
config,
|
||||
subdir,
|
||||
subdir_web,
|
||||
template,
|
||||
subdir_relative,
|
||||
current_metadata if config.inherit_from_parent else {}
|
||||
)
|
||||
subdir_names.append(subdir.name)
|
||||
|
||||
render_gallery_page(
|
||||
config=config,
|
||||
template=template,
|
||||
web_dir=web_dir,
|
||||
items=items,
|
||||
subdirs=subdir_names,
|
||||
relative_path=relative_path,
|
||||
metadata=current_metadata
|
||||
)
|
||||
|
||||
|
||||
def copy_assets(
|
||||
config: GalleryConfig,
|
||||
assets_src: Optional[Path] = None,
|
||||
verbose: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Copy assets to the web directory.
|
||||
|
||||
Args:
|
||||
config: Gallery configuration object
|
||||
assets_src: Path to assets source. If None, uses package default.
|
||||
verbose: Whether to print status messages
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if assets_src is None:
|
||||
# Use package-included assets
|
||||
import gallery
|
||||
gallery_module_path = Path(gallery.__file__).parent
|
||||
assets_src = gallery_module_path / "assets"
|
||||
|
||||
if not assets_src.exists():
|
||||
if verbose:
|
||||
print(
|
||||
f"Warning: Assets directory {assets_src} not found"
|
||||
)
|
||||
return False
|
||||
|
||||
gallery_root = Path(config.web_folder) / config.plot_root
|
||||
assets_dst = gallery_root.parent / "assets"
|
||||
|
||||
# Use the newest mtime across all source asset files as the staleness check,
|
||||
# so any change to any JS/CSS file triggers a redeploy.
|
||||
sentinel_dst = assets_dst / "css" / "main.css"
|
||||
newest_src_mtime = max(
|
||||
(f.stat().st_mtime for f in assets_src.rglob('*') if f.is_file()),
|
||||
default=0,
|
||||
)
|
||||
dst_mtime = sentinel_dst.stat().st_mtime if sentinel_dst.exists() else 0
|
||||
|
||||
if not assets_dst.exists() or newest_src_mtime > (dst_mtime + 30):
|
||||
if assets_dst.exists():
|
||||
shutil.rmtree(assets_dst)
|
||||
shutil.copytree(assets_src, assets_dst)
|
||||
if verbose:
|
||||
print(f"Updated assets from {assets_src} to {assets_dst}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f"Warning: Could not copy assets: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Command-line interface for gallery generation.
|
||||
|
||||
Provides a CLI entry point for gallery generation and config management.
|
||||
Shell autocomplete: add the following line to your .bashrc / .zshrc:
|
||||
eval "$(register-python-argcomplete gallery)"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import argcomplete
|
||||
|
||||
from gallery import generate
|
||||
from gallery.config import (
|
||||
ConfigManager, GalleryConfig, GallerySource,
|
||||
default_config_path, get_active_config_path, ensure_user_config,
|
||||
)
|
||||
|
||||
_WELCOME = """\
|
||||
Gallery - Scientific Plot Gallery Generator
|
||||
===========================================
|
||||
|
||||
Generates responsive static HTML galleries from collections of PDFs and HTMLs.
|
||||
|
||||
Getting started:
|
||||
1. Set your web output directory:
|
||||
gallery config set paths.web_folder /path/to/your/public_html
|
||||
|
||||
2. Add one or more plot sources:
|
||||
gallery config add-source --path /path/to/plots
|
||||
|
||||
3. Generate your gallery:
|
||||
gallery generate
|
||||
|
||||
Config commands:
|
||||
gallery config list Show all settings
|
||||
gallery config get <key> Get a single value (e.g. gallery.png_dpi)
|
||||
gallery config set <key> <value> Update a setting (e.g. paths.web_folder /my/web)
|
||||
gallery config add-source --path P Add a plot source (--name defaults to dir name)
|
||||
gallery config remove-source <name> Remove a plot source
|
||||
gallery config sources List configured sources
|
||||
gallery config path Show config file location
|
||||
|
||||
Generate commands:
|
||||
gallery generate Generate gallery from config
|
||||
gallery generate --config myconfig.yaml Use a custom config file
|
||||
gallery generate --clean Clean and regenerate everything
|
||||
gallery generate --source /path/to/plots Regenerate one source only
|
||||
gallery generate --verbose Print detailed output
|
||||
|
||||
Shell autocomplete (run once after install):
|
||||
gallery install-completion
|
||||
|
||||
Config file: {config_path}
|
||||
"""
|
||||
|
||||
|
||||
def _is_configured(config_path: Path) -> bool:
|
||||
"""Return True if web_folder is set to a non-empty value."""
|
||||
try:
|
||||
web_folder = ConfigManager(config_path).get("paths.web_folder")
|
||||
return bool(web_folder and str(web_folder).strip())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _source_names(prefix, parsed_args, **kwargs):
|
||||
"""Autocomplete helper: return configured source names."""
|
||||
try:
|
||||
config_path = Path(parsed_args.config) if getattr(parsed_args, "config", None) else get_active_config_path()
|
||||
return [s["name"] for s in ConfigManager(config_path).list_sources()]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _config_keys(prefix, parsed_args, **kwargs):
|
||||
"""Autocomplete helper: return known dot-notation config keys."""
|
||||
try:
|
||||
config_path = Path(parsed_args.config) if getattr(parsed_args, "config", None) else get_active_config_path()
|
||||
data = ConfigManager(config_path).list_all()
|
||||
keys = []
|
||||
for section, values in data.items():
|
||||
if isinstance(values, dict):
|
||||
for k in values:
|
||||
keys.append(f"{section}.{k}")
|
||||
else:
|
||||
keys.append(section)
|
||||
return [k for k in keys if k.startswith(prefix)]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parser construction (separated so TUI can reuse the structure)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="gallery",
|
||||
description="Scientific Plot Gallery Generator",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=str,
|
||||
default=None,
|
||||
metavar="FILE",
|
||||
help="Path to config file (default: package bundled config)",
|
||||
).completer = argcomplete.completers.FilesCompleter(["yaml", "yml"])
|
||||
|
||||
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
||||
|
||||
# --- generate -----------------------------------------------------------
|
||||
gen = sub.add_parser(
|
||||
"generate",
|
||||
help="Generate the gallery",
|
||||
description="Generate scientific gallery from plot collections",
|
||||
)
|
||||
gen.add_argument("--clean", action="store_true", help="Clean gallery directory before generation")
|
||||
gen.add_argument(
|
||||
"--source",
|
||||
type=str,
|
||||
default=None,
|
||||
metavar="DIR",
|
||||
help="Only recompute a specific source directory (name defaults to dir name)",
|
||||
).completer = argcomplete.completers.DirectoriesCompleter()
|
||||
gen.add_argument("-v", "--verbose", action="store_true", help="Print verbose output")
|
||||
|
||||
# --- config -------------------------------------------------------------
|
||||
cfg = sub.add_parser(
|
||||
"config",
|
||||
help="Read and write configuration",
|
||||
description="Read and write gallery configuration",
|
||||
)
|
||||
cfg_sub = cfg.add_subparsers(dest="action", metavar="ACTION")
|
||||
|
||||
sub.add_parser("install-completion", help="Install shell tab-completion (bash/zsh)")
|
||||
sub.add_parser("tui", help="Launch the interactive TUI")
|
||||
|
||||
cfg_sub.add_parser("list", help="Print all config values")
|
||||
cfg_sub.add_parser("path", help="Print the resolved config file path")
|
||||
cfg_sub.add_parser("sources", help="List all configured sources")
|
||||
|
||||
p_get = cfg_sub.add_parser("get", help="Get a config value")
|
||||
p_get.add_argument("key", help="Dot-separated key, e.g. gallery.png_dpi").completer = _config_keys
|
||||
|
||||
p_set = cfg_sub.add_parser("set", help="Set a config value")
|
||||
p_set.add_argument("key", help="Dot-separated key, e.g. gallery.png_dpi").completer = _config_keys
|
||||
p_set.add_argument("value", help="New value (YAML-parsed: use true/false for bools)")
|
||||
|
||||
p_add = cfg_sub.add_parser("add-source", help="Add a plot source")
|
||||
p_add.add_argument("--name", default=None, help="Source name (default: bottom-level directory name)")
|
||||
p_add.add_argument("--path", required=True, metavar="DIR", help="Path to source directory").completer = (
|
||||
argcomplete.completers.DirectoriesCompleter()
|
||||
)
|
||||
|
||||
p_rm = cfg_sub.add_parser("remove-source", help="Remove a plot source by name")
|
||||
p_rm.add_argument("name", help="Source name to remove").completer = _source_names
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_generate(args: argparse.Namespace) -> int:
|
||||
config_path = Path(args.config) if args.config else get_active_config_path()
|
||||
try:
|
||||
config = GalleryConfig.from_yaml(config_path)
|
||||
|
||||
source_to_update = None
|
||||
if args.source:
|
||||
source_path = Path(args.source).resolve()
|
||||
matching = next((s for s in config.sources if Path(s.path).resolve() == source_path), None)
|
||||
if matching is None:
|
||||
source_to_update = GallerySource(name=source_path.name, path=source_path)
|
||||
config.sources.append(source_to_update)
|
||||
if args.verbose:
|
||||
print(f"Source {args.source} not in config. Adding temporarily as '{source_path.name}'")
|
||||
else:
|
||||
source_to_update = matching
|
||||
|
||||
success = generate(
|
||||
config=config,
|
||||
clean_first=args.clean,
|
||||
verbose=args.verbose,
|
||||
source_to_update=source_to_update,
|
||||
)
|
||||
return 0 if success else 1
|
||||
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _run_config(args: argparse.Namespace) -> int:
|
||||
if args.config:
|
||||
config_path = Path(args.config)
|
||||
elif args.action in ("set", "add-source", "remove-source"):
|
||||
# Writes go to the user config; create it from the template if needed
|
||||
config_path = ensure_user_config()
|
||||
else:
|
||||
config_path = get_active_config_path()
|
||||
mgr = ConfigManager(config_path)
|
||||
|
||||
if args.action == "path":
|
||||
print(mgr.path)
|
||||
|
||||
elif args.action == "list":
|
||||
import yaml
|
||||
|
||||
print(yaml.dump(mgr.list_all(), default_flow_style=False).rstrip())
|
||||
|
||||
elif args.action == "sources":
|
||||
sources = mgr.list_sources()
|
||||
if not sources:
|
||||
print("No sources configured.")
|
||||
else:
|
||||
for s in sources:
|
||||
print(f" {s['name']}: {s['path']}")
|
||||
|
||||
elif args.action == "get":
|
||||
try:
|
||||
print(mgr.get(args.key))
|
||||
except KeyError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
elif args.action == "set":
|
||||
try:
|
||||
mgr.set(args.key, args.value)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
elif args.action == "add-source":
|
||||
try:
|
||||
name = args.name or Path(args.path).resolve().name
|
||||
mgr.add_source(name, args.path)
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
elif args.action == "remove-source":
|
||||
try:
|
||||
mgr.remove_source(args.name)
|
||||
except KeyError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
else:
|
||||
# `gallery config` with no action → show config help
|
||||
build_parser().parse_args(["config", "--help"])
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# install-completion handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_COMPLETION_LINE = 'eval "$(register-python-argcomplete gallery)"'
|
||||
|
||||
_SHELL_RC = {
|
||||
"zsh": ".zshrc",
|
||||
"bash": ".bashrc",
|
||||
"fish": ".config/fish/config.fish",
|
||||
}
|
||||
|
||||
|
||||
def _run_install_completion() -> int:
|
||||
shell = Path(os.environ.get("SHELL", "")).name # e.g. "bash", "zsh"
|
||||
rc_name = _SHELL_RC.get(shell, ".bashrc")
|
||||
rc = Path.home() / rc_name
|
||||
|
||||
if rc.exists() and _COMPLETION_LINE in rc.read_text():
|
||||
print(f"Shell completion already configured in {rc}")
|
||||
return 0
|
||||
|
||||
with open(rc, "a") as f:
|
||||
f.write(f"\n# gallery shell completion\n{_COMPLETION_LINE}\n")
|
||||
|
||||
print(f"Shell completion installed to {rc}")
|
||||
print(f"Reload with: source {rc}")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
argcomplete.autocomplete(parser) # no-op when not completing; exits during completion
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "generate":
|
||||
sys.exit(_run_generate(args))
|
||||
elif args.command == "config":
|
||||
sys.exit(_run_config(args))
|
||||
elif args.command == "install-completion":
|
||||
sys.exit(_run_install_completion())
|
||||
elif args.command == "tui":
|
||||
from gallery.tui import GalleryTUI
|
||||
config_path = Path(args.config) if args.config else None
|
||||
GalleryTUI(config_path=config_path).run()
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(_WELCOME.format(config_path=get_active_config_path()))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Configuration Management for Scientific Gallery Generator
|
||||
|
||||
This module provides dataclasses for managing configuration, including
|
||||
defaults for gallery generation settings.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
import yaml
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
|
||||
def default_config_path() -> Path:
|
||||
"""Return the path to the package-bundled read-only template config."""
|
||||
return Path(__file__).parent / "config.yaml"
|
||||
|
||||
|
||||
def user_config_path() -> Path:
|
||||
"""Return the user-level config path (~/.config/gallery/config.yaml).
|
||||
|
||||
Follows the XDG Base Directory spec via platformdirs:
|
||||
Linux/macOS → ~/.config/gallery/config.yaml
|
||||
Windows → %APPDATA%/gallery/config.yaml
|
||||
"""
|
||||
return Path(user_config_dir("gallery", appauthor=False)) / "config.yaml"
|
||||
|
||||
|
||||
def get_active_config_path() -> Path:
|
||||
"""Return the config path to use, with this precedence:
|
||||
|
||||
1. User config (~/.config/gallery/config.yaml) — if it exists
|
||||
2. Package-bundled template — fallback
|
||||
"""
|
||||
ucp = user_config_path()
|
||||
return ucp if ucp.exists() else default_config_path()
|
||||
|
||||
|
||||
def ensure_user_config() -> Path:
|
||||
"""Ensure ~/.config/gallery/config.yaml exists, creating it from the
|
||||
package template if needed. Returns the path.
|
||||
"""
|
||||
ucp = user_config_path()
|
||||
if not ucp.exists():
|
||||
ucp.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(default_config_path(), ucp)
|
||||
return ucp
|
||||
|
||||
|
||||
def _load_raw(path: Path) -> Dict[str, Any]:
|
||||
with open(path, "r") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
def _save_raw(path: Path, data: Dict[str, Any]) -> None:
|
||||
with open(path, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
|
||||
def _get_nested(data: Dict, keys: List[str]) -> Any:
|
||||
for k in keys:
|
||||
if not isinstance(data, dict) or k not in data:
|
||||
raise KeyError(f"Key '{'.'.join(keys)}' not found in config")
|
||||
data = data[k]
|
||||
return data
|
||||
|
||||
|
||||
def _set_nested(data: Dict, keys: List[str], value: Any) -> None:
|
||||
for k in keys[:-1]:
|
||||
data = data.setdefault(k, {})
|
||||
data[keys[-1]] = value
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""Read/write access to a gallery config YAML file.
|
||||
|
||||
Intended to be reused by both the CLI and a future TUI layer.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: Optional[Path] = None):
|
||||
self.path = Path(config_path) if config_path else default_config_path()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
"""Return the value at dot-separated *key* (e.g. 'gallery.png_dpi')."""
|
||||
data = _load_raw(self.path)
|
||||
return _get_nested(data, key.split("."))
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
"""Set *key* to *value*, coercing type via YAML parsing."""
|
||||
data = _load_raw(self.path)
|
||||
parsed = yaml.safe_load(value)
|
||||
_set_nested(data, key.split("."), parsed)
|
||||
_save_raw(self.path, data)
|
||||
|
||||
def list_all(self) -> Dict[str, Any]:
|
||||
"""Return the full config dict."""
|
||||
return _load_raw(self.path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Source helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_source(self, name: str, path: Union[str, Path]) -> None:
|
||||
"""Append a new source entry; raises ValueError if name already exists."""
|
||||
data = _load_raw(self.path)
|
||||
sources: List[Dict] = data.setdefault("sources", [])
|
||||
if any(s.get("name") == name for s in sources):
|
||||
raise ValueError(f"Source '{name}' already exists")
|
||||
sources.append({"name": name, "path": str(path)})
|
||||
_save_raw(self.path, data)
|
||||
|
||||
def remove_source(self, name: str) -> None:
|
||||
"""Remove the source with the given name; raises KeyError if not found."""
|
||||
data = _load_raw(self.path)
|
||||
sources: List[Dict] = data.get("sources", [])
|
||||
filtered = [s for s in sources if s.get("name") != name]
|
||||
if len(filtered) == len(sources):
|
||||
raise KeyError(f"Source '{name}' not found")
|
||||
data["sources"] = filtered
|
||||
_save_raw(self.path, data)
|
||||
|
||||
def list_sources(self) -> List[Dict]:
|
||||
"""Return the list of source dicts."""
|
||||
return _load_raw(self.path).get("sources", [])
|
||||
|
||||
|
||||
@dataclass
|
||||
class GalleryDefaults:
|
||||
"""Default values for gallery generation."""
|
||||
png_dpi: int = 400
|
||||
plot_root: str = "gallery"
|
||||
cache_enabled: bool = True
|
||||
inherit_from_parent: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class GallerySource:
|
||||
"""Represents a single data source for the gallery."""
|
||||
name: str
|
||||
path: Union[str, Path]
|
||||
|
||||
def __post_init__(self):
|
||||
if isinstance(self.path, str):
|
||||
self.path = Path(self.path)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GalleryConfig:
|
||||
"""
|
||||
Main configuration for gallery generation.
|
||||
|
||||
Can be created programmatically or loaded from YAML.
|
||||
"""
|
||||
web_folder: Union[str, Path]
|
||||
sources: List[Union[GallerySource, Dict[str, Any]]] = field(default_factory=list)
|
||||
png_dpi: int = GalleryDefaults.png_dpi
|
||||
plot_root: str = GalleryDefaults.plot_root
|
||||
cache_enabled: bool = GalleryDefaults.cache_enabled
|
||||
inherit_from_parent: bool = GalleryDefaults.inherit_from_parent
|
||||
backup_folder: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if isinstance(self.web_folder, str):
|
||||
self.web_folder = Path(self.web_folder)
|
||||
|
||||
normalized_sources = []
|
||||
for source in self.sources:
|
||||
if isinstance(source, dict):
|
||||
source = GallerySource(**source)
|
||||
elif not isinstance(source, GallerySource):
|
||||
raise TypeError(f"Source must be dict or GallerySource, got {type(source)}")
|
||||
normalized_sources.append(source)
|
||||
self.sources = normalized_sources
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, yaml_file: Union[str, Path]) -> "GalleryConfig":
|
||||
"""Load configuration from a YAML file."""
|
||||
yaml_path = Path(yaml_file)
|
||||
if not yaml_path.exists():
|
||||
raise FileNotFoundError(f"Config file not found: {yaml_file}")
|
||||
|
||||
with open(yaml_path, "r") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
web_folder = data.get("paths", {}).get("web_folder")
|
||||
if not web_folder:
|
||||
raise ValueError("web_folder must be specified in config under paths")
|
||||
|
||||
gallery_cfg = data.get("gallery", {})
|
||||
sources_data = data.get("sources", [])
|
||||
sources = [{"name": s["name"], "path": s["path"]} for s in sources_data]
|
||||
|
||||
return cls(
|
||||
web_folder=web_folder,
|
||||
sources=sources,
|
||||
png_dpi=gallery_cfg.get("png_dpi", GalleryDefaults.png_dpi),
|
||||
plot_root=gallery_cfg.get("plot_root", GalleryDefaults.plot_root),
|
||||
cache_enabled=data.get("metadata", {}).get("cache_enabled", GalleryDefaults.cache_enabled),
|
||||
inherit_from_parent=data.get("metadata", {}).get("inherit_from_parent", GalleryDefaults.inherit_from_parent),
|
||||
backup_folder=gallery_cfg.get("backup_folder", ""),
|
||||
)
|
||||
|
||||
def to_yaml(self, yaml_file: Union[str, Path]) -> None:
|
||||
"""Save the current configuration to a YAML file."""
|
||||
yaml_path = Path(yaml_file)
|
||||
|
||||
data = {
|
||||
"paths": {
|
||||
"web_folder": str(self.web_folder),
|
||||
},
|
||||
"gallery": {
|
||||
"plot_root": self.plot_root,
|
||||
"png_dpi": self.png_dpi,
|
||||
"backup_folder": self.backup_folder,
|
||||
},
|
||||
"ui": {
|
||||
"max_recent_plots": 20,
|
||||
"search_debounce_ms": 300,
|
||||
},
|
||||
"metadata": {
|
||||
"cache_enabled": self.cache_enabled,
|
||||
"inherit_from_parent": self.inherit_from_parent,
|
||||
"supported_formats": [".yaml", ".yml", ".json"],
|
||||
},
|
||||
"sources": [{"name": s.name, "path": str(s.path)} for s in self.sources],
|
||||
}
|
||||
|
||||
with open(yaml_path, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False)
|
||||
@@ -0,0 +1,14 @@
|
||||
gallery:
|
||||
backup_folder: ''
|
||||
plot_root: gallery
|
||||
png_dpi: 400
|
||||
metadata:
|
||||
cache_enabled: true
|
||||
inherit_from_parent: true
|
||||
paths:
|
||||
web_folder: ''
|
||||
work_dir: ''
|
||||
sources: []
|
||||
ui:
|
||||
max_recent_plots: 20
|
||||
search_debounce_ms: 300
|
||||
@@ -0,0 +1,363 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ title }}</title>
|
||||
<link rel="stylesheet" href="{{ assets_path }}/css/main.css">
|
||||
<link rel="stylesheet" href="{{ assets_path }}/css/html-plots.css">
|
||||
|
||||
<!-- MathJax for LaTeX rendering -->
|
||||
<script>
|
||||
MathJax = {
|
||||
tex: {
|
||||
inlineMath: [['$', '$'], ['\\(', '\\)']],
|
||||
displayMath: [['$$', '$$'], ['\\[', '\\]']]
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Main Content -->
|
||||
<h1>{{ title }}</h1>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="search-container">
|
||||
<input type="text" class="search-box" id="searchBox" placeholder="Search plots..." />
|
||||
<span class="search-icon">🔍</span>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<div class="breadcrumb" id="breadcrumb"></div>
|
||||
|
||||
<!-- Navigation Buttons -->
|
||||
<div class="navigation">
|
||||
<button class="nav-btn" onclick="window.history.back()">
|
||||
← Back
|
||||
</button>
|
||||
{% if relpath != "." %}
|
||||
<a href="../index.html" class="nav-btn">
|
||||
↑ Parent Directory
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Folder Tree -->
|
||||
<div class="folder-tree" id="folderTree"></div>
|
||||
|
||||
<!-- Folder Metadata Section -->
|
||||
{% if folder_metadata %}
|
||||
<div class="metadata-section">
|
||||
<button class="metadata-toggle-btn" onclick="toggleMetadataSection()">
|
||||
<span class="metadata-icon">📋</span>
|
||||
<span class="metadata-label">Folder Information</span>
|
||||
<span class="metadata-arrow" id="metadataArrow">▼</span>
|
||||
</button>
|
||||
|
||||
<div class="metadata-content" id="metadataContent" style="display: none;">
|
||||
<div class="metadata-header">
|
||||
<div class="metadata-file-info">
|
||||
<span class="file-path-label">📁 Metadata file:</span>
|
||||
<code class="file-path" id="metadata-file-path">{{ metadata_file_path }}</code>
|
||||
<button class="copy-path-btn" onclick="copyMetadataPath()" title="Copy path to clipboard">
|
||||
📋 Copy
|
||||
</button>
|
||||
<span class="tip-icon" title="Tip: Create this file in the source directory to add folder-level metadata that will be inherited by all plots in this folder and its subdirectories. Supports both YAML (.yaml/.yml) and JSON (.json) formats.">
|
||||
💡
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metadata-grid">
|
||||
{% for key, value in folder_metadata.items() %}
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-key">{{ key }}:</span>
|
||||
<span class="metadata-value">
|
||||
{% if value is string and (value.startswith('http://') or value.startswith('https://')) %}
|
||||
<a href="{{ value }}" target="_blank" rel="noopener noreferrer">{{ value }}</a>
|
||||
{% elif value is string and '$$' in value %}
|
||||
<span class="latex-content">{{ value }}</span>
|
||||
{% elif value is string and value|length > 100 %}
|
||||
<span class="metadata-long-text">{{ value[:100] }}...</span>
|
||||
<button class="metadata-expand" onclick="expandText(this)">Show more</button>
|
||||
<span class="metadata-full-text" style="display: none;">{{ value }}</span>
|
||||
{% elif value is iterable and value is not string and value is not mapping %}
|
||||
<div class="metadata-yaml-list">
|
||||
{% for item in value %}
|
||||
<div class="yaml-list-item">- {{ item }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif value is mapping %}
|
||||
<div class="metadata-yaml-object">
|
||||
{% for subkey, subvalue in value.items() %}
|
||||
<div class="yaml-object-item">
|
||||
<span class="yaml-key">{{ subkey }}:</span>
|
||||
{% if subvalue is iterable and subvalue is not string and subvalue is not mapping %}
|
||||
<div class="yaml-nested-list">
|
||||
{% for nested_item in subvalue %}
|
||||
<div class="yaml-nested-item">- {{ nested_item }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif subvalue is mapping %}
|
||||
<div class="yaml-nested-object">
|
||||
{% for nested_key, nested_value in subvalue.items() %}
|
||||
<div class="yaml-nested-item">{{ nested_key }}: {{ nested_value }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="yaml-value"> {{ subvalue }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ value }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- View Toggle Controls - Only show if there are plot items -->
|
||||
{% if items %}
|
||||
<div class="controls-container">
|
||||
<div class="sort-controls">
|
||||
<label class="sort-label">Sort by:</label>
|
||||
<button class="sort-btn active" data-sort="name" title="Sort by Name">
|
||||
📝 Name
|
||||
</button>
|
||||
<button class="sort-btn" data-sort="time" title="Sort by Creation Time">
|
||||
🕒 Time
|
||||
</button>
|
||||
<button class="sort-order-btn" data-order="asc" title="Sort Order">
|
||||
↑
|
||||
</button>
|
||||
</div>
|
||||
<div class="view-controls">
|
||||
<button class="view-btn active" data-view="grid" title="Grid View">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16">
|
||||
<rect x="1" y="1" width="6" height="6" fill="currentColor"/>
|
||||
<rect x="9" y="1" width="6" height="6" fill="currentColor"/>
|
||||
<rect x="1" y="9" width="6" height="6" fill="currentColor"/>
|
||||
<rect x="9" y="9" width="6" height="6" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="view-btn" data-view="list-large" title="Large List View">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16">
|
||||
<rect x="1" y="2" width="4" height="3" fill="currentColor"/>
|
||||
<rect x="7" y="2" width="8" height="1" fill="currentColor"/>
|
||||
<rect x="7" y="4" width="6" height="1" fill="currentColor"/>
|
||||
<rect x="1" y="7" width="4" height="3" fill="currentColor"/>
|
||||
<rect x="7" y="7" width="8" height="1" fill="currentColor"/>
|
||||
<rect x="7" y="9" width="6" height="1" fill="currentColor"/>
|
||||
<rect x="1" y="12" width="4" height="3" fill="currentColor"/>
|
||||
<rect x="7" y="12" width="8" height="1" fill="currentColor"/>
|
||||
<rect x="7" y="14" width="6" height="1" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="view-btn" data-view="list-compact" title="Compact List View">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16">
|
||||
<rect x="1" y="3" width="14" height="1" fill="currentColor"/>
|
||||
<rect x="1" y="6" width="14" height="1" fill="currentColor"/>
|
||||
<rect x="1" y="9" width="14" height="1" fill="currentColor"/>
|
||||
<rect x="1" y="12" width="14" height="1" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Plot Container -->
|
||||
<div class="plot-container grid-view" id="plotContainer">
|
||||
{% for item in items %}
|
||||
<div class="plot-item grid-item {% if item.is_html %}html-plot{% endif %}"
|
||||
data-name="{{ item.name }}"
|
||||
data-time="{{ item.creation_time|default(0) }}">
|
||||
{% if item.is_html %}
|
||||
<a href="{{ item.html_href }}" class="plot-link" target="_blank">
|
||||
<div class="html-thumbnail">
|
||||
<div class="html-indicator">HTML</div>
|
||||
<div class="html-preview">Click to open interactive plot</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ item.pdf_href }}" class="plot-link">
|
||||
<img src="{{ item.png_href }}" alt="{{ item.name }}" class="plot-thumbnail">
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="plot-info">
|
||||
<div class="plot-name" title="{{ item.name }}">{{ item.name }}</div>
|
||||
<div class="plot-date" title="Created: {{ item.creation_time|default(0)|int|datetime_from_timestamp|strftime('%Y-%m-%d %H:%M') if item.creation_time and item.creation_time|int > 0 else 'Unknown' }}">
|
||||
{% if item.creation_time and item.creation_time|int > 0 %}
|
||||
{{ item.creation_time|int|datetime_from_timestamp|strftime('%Y-%m-%d') }}
|
||||
{% else %}
|
||||
Unknown
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Recent Plots Sidebar -->
|
||||
<div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleSidebar()"></div>
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h3 class="sidebar-title">Recent Plots</h3>
|
||||
<button class="sidebar-close" onclick="toggleSidebar()">×</button>
|
||||
</div>
|
||||
<div class="sidebar-content" id="sidebarContent">
|
||||
<div style="text-align: center; color: var(--breadcrumb-color); margin: 2rem 0;">
|
||||
No recent plots yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Action Buttons -->
|
||||
<div class="floating-buttons">
|
||||
<button class="floating-btn sidebar-toggle" onclick="toggleSidebar()" id="sidebarToggle" title="Recent Plots (Ctrl+R)">
|
||||
📋
|
||||
</button>
|
||||
<button class="floating-btn compare-toggle" onclick="app.toggleCompareMode()" id="compareToggle" title="Compare Plots (Ctrl+C)">
|
||||
⚖️
|
||||
</button>
|
||||
<button class="floating-btn theme-toggle" onclick="toggleTheme()" id="themeToggle" title="Toggle Theme (Ctrl+T)">
|
||||
☀️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard Shortcuts Help -->
|
||||
<div class="shortcuts-help" id="shortcutsHelp">
|
||||
<h4>Keyboard Shortcuts</h4>
|
||||
<div class="shortcut-item">
|
||||
<span>Search</span>
|
||||
<span class="shortcut-key">Ctrl+K</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span>Recent plots</span>
|
||||
<span class="shortcut-key">Ctrl+R</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span>Compare plots</span>
|
||||
<span class="shortcut-key">Ctrl+C</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span>Export plots</span>
|
||||
<span class="shortcut-key">Ctrl+E</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span>Exit selection mode</span>
|
||||
<span class="shortcut-key">Esc</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span>Toggle theme</span>
|
||||
<span class="shortcut-key">Ctrl+T</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span>Toggle view</span>
|
||||
<span class="shortcut-key">Ctrl+V</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span>Sort by name</span>
|
||||
<span class="shortcut-key">Ctrl+N</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span>Sort by time</span>
|
||||
<span class="shortcut-key">Ctrl+M</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span>Toggle sort order</span>
|
||||
<span class="shortcut-key">Ctrl+O</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span>Help</span>
|
||||
<span class="shortcut-key">?</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gallery Statistics -->
|
||||
<div class="gallery-stats" id="galleryStats">
|
||||
<div class="stats-item">
|
||||
<span class="stats-label">📊 Files:</span>
|
||||
<span class="stats-value" id="fileCount">0</span>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<span class="stats-label">📁 Folders:</span>
|
||||
<span class="stats-value" id="folderCount">0</span>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<span class="stats-label">💾 Size:</span>
|
||||
<span class="stats-value" id="totalSize">0 KB</span>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<span class="stats-label">🕒 Updated:</span>
|
||||
<span class="stats-value" id="lastUpdated">Now</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plot Comparison Overlay -->
|
||||
<div class="comparison-overlay" id="comparisonOverlay">
|
||||
<div class="comparison-container">
|
||||
<div class="comparison-header">
|
||||
<h2 class="comparison-title">Plot Comparison</h2>
|
||||
<button class="comparison-close" onclick="app.closeComparison()" title="Close Comparison (Esc)">×</button>
|
||||
</div>
|
||||
<div class="comparison-content">
|
||||
<div class="comparison-panel">
|
||||
<div class="comparison-panel-header">
|
||||
<span class="comparison-panel-title" id="leftPlotTitle">Plot A</span>
|
||||
<button class="comparison-replace-btn" onclick="app.replacePlot('left')" id="leftReplaceBtn">Replace</button>
|
||||
</div>
|
||||
<div class="comparison-panel-content">
|
||||
<div class="comparison-plot-container" id="leftPlotContainer">
|
||||
<div class="comparison-placeholder" onclick="app.selectPlotForComparison('left')">
|
||||
📊 Click to select first plot
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comparison-panel">
|
||||
<div class="comparison-panel-header">
|
||||
<span class="comparison-panel-title" id="rightPlotTitle">Plot B</span>
|
||||
<button class="comparison-replace-btn" onclick="app.replacePlot('right')" id="rightReplaceBtn">Replace</button>
|
||||
</div>
|
||||
<div class="comparison-panel-content">
|
||||
<div class="comparison-plot-container" id="rightPlotContainer">
|
||||
<div class="comparison-placeholder" onclick="app.selectPlotForComparison('right')">
|
||||
📊 Click to select second plot
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration for JavaScript -->
|
||||
<script>
|
||||
// Configuration object for the gallery app
|
||||
window.galleryConfig = {
|
||||
searchDebounceMs: {{ ui.search_debounce_ms|default(300) }},
|
||||
maxRecentPlots: {{ ui.max_recent_plots|default(20) }},
|
||||
stats: {% if stats %}{{ stats|tojson }}{% else %}null{% endif %}
|
||||
};
|
||||
</script>
|
||||
<script id="gallery-data" type="application/json">{"subdirs": {{ subdirs|tojson }}, "item_count": {{ items|length }}}</script>
|
||||
|
||||
<!-- Metadata Popup Script -->
|
||||
<script src="{{ assets_path }}/js/metadata-popup.js"></script>
|
||||
|
||||
<!-- Metadata Section Script -->
|
||||
<script src="{{ assets_path }}/js/metadata-section.js"></script>
|
||||
|
||||
<!-- Folder Metadata Script -->
|
||||
<script src="{{ assets_path }}/js/folder-metadata.js"></script>
|
||||
|
||||
<!-- Main JavaScript Application -->
|
||||
<script type="module" src="{{ assets_path }}/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,487 @@
|
||||
"""
|
||||
Gallery TUI — interactive configuration and generation interface.
|
||||
|
||||
Built with Textual (https://textual.textualize.io/).
|
||||
|
||||
Key Textual concepts used here:
|
||||
App — the root class; owns the event loop and screen stack
|
||||
compose() — declarative method that yields widgets to build the UI tree
|
||||
Collapsible — a section that can be expanded/collapsed by the user
|
||||
Input — single-line editable text field
|
||||
Button — clickable button that emits Button.Pressed messages
|
||||
RichLog — scrollable log pane that accepts Rich markup
|
||||
reactive — a descriptor that rerenders the UI whenever its value changes
|
||||
watch_* — method called automatically when a reactive changes
|
||||
@on — decorator that binds a method to a specific widget message
|
||||
@work — decorator that runs a method in a background thread,
|
||||
keeping the UI responsive during long operations
|
||||
call_from_thread() — safely posts a UI update from a background thread
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from textual import on, work
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, ScrollableContainer, Vertical
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Button, Collapsible, Footer, Header, Input, Label, RichLog, Static
|
||||
|
||||
from gallery.config import ConfigManager, ensure_user_config, get_active_config_path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Field definitions
|
||||
# Each tuple: (widget_id, config_key, required)
|
||||
# widget_id is used as the HTML-style id (#web-folder) in CSS selectors
|
||||
# ---------------------------------------------------------------------------
|
||||
CONFIG_FIELDS = [
|
||||
("web-folder", "paths.web_folder", True),
|
||||
("plot-root", "gallery.plot_root", False),
|
||||
("png-dpi", "gallery.png_dpi", False),
|
||||
("backup-folder", "gallery.backup_folder", False),
|
||||
("cache-enabled", "metadata.cache_enabled", False),
|
||||
("inherit-meta", "metadata.inherit_from_parent",False),
|
||||
]
|
||||
|
||||
REQUIRED_IDS = {fid for fid, _, req in CONFIG_FIELDS if req}
|
||||
|
||||
|
||||
class GalleryTUI(App):
|
||||
"""Single-screen TUI for configuring and running the gallery generator."""
|
||||
|
||||
TITLE = "Gallery"
|
||||
SUB_TITLE = "Scientific Plot Gallery Generator"
|
||||
|
||||
# DEFAULT_CSS is Textual's inline stylesheet (TCSS — a CSS subset).
|
||||
# Each rule targets widgets by type, id (#), or class (.).
|
||||
DEFAULT_CSS = """
|
||||
/* Main scrollable area fills all available space */
|
||||
ScrollableContainer {
|
||||
height: 1fr;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
/* Active config file path shown at top */
|
||||
#config-path-label {
|
||||
color: $text-muted;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
/* Breathing room between collapsible sections */
|
||||
Collapsible {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
/* One-line field rows — 3 cells tall (border + content + border), no gap */
|
||||
.field-row {
|
||||
height: 3;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.field-row Label {
|
||||
width: 16;
|
||||
padding-top: 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
.field-row Input {
|
||||
width: 1fr;
|
||||
height: 3;
|
||||
}
|
||||
|
||||
/* Red border on required inputs that are empty */
|
||||
.required-empty {
|
||||
border: tall $error;
|
||||
}
|
||||
|
||||
/* Source rows — one row per source with inline name/path inputs */
|
||||
.source-row {
|
||||
height: 3;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
.source-remove-btn {
|
||||
width: 5;
|
||||
min-width: 5;
|
||||
height: 3;
|
||||
margin-right: 1;
|
||||
}
|
||||
.source-name {
|
||||
width: 22;
|
||||
height: 3;
|
||||
margin-right: 1;
|
||||
}
|
||||
.source-path {
|
||||
width: 1fr;
|
||||
height: 3;
|
||||
}
|
||||
#add-source-row-btn {
|
||||
width: auto;
|
||||
height: 3;
|
||||
}
|
||||
|
||||
/* Generate section — no extra top margin; Collapsible already has bottom margin */
|
||||
#generate-section {
|
||||
height: auto;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
#generate-hint {
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
padding: 0 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
#generate-section.ran #generate-hint {
|
||||
display: none;
|
||||
}
|
||||
/* Style the generate button as a wide flat status box */
|
||||
#generate-status {
|
||||
width: 1fr;
|
||||
height: 3;
|
||||
background: $primary-darken-3;
|
||||
border: tall $primary;
|
||||
color: $primary-lighten-2;
|
||||
text-align: left;
|
||||
content-align: left middle;
|
||||
}
|
||||
#generate-status:hover {
|
||||
background: $primary-darken-2;
|
||||
}
|
||||
#generate-status.running {
|
||||
background: $warning-darken-3;
|
||||
border: tall $warning;
|
||||
color: $warning;
|
||||
}
|
||||
#generate-status.success {
|
||||
background: $success-darken-3;
|
||||
border: tall $success;
|
||||
color: $success;
|
||||
}
|
||||
#generate-status.error {
|
||||
background: $error-darken-3;
|
||||
border: tall $error;
|
||||
color: $error;
|
||||
}
|
||||
#generate-log {
|
||||
height: 1;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Bottom action bar — docked so it is always visible regardless of
|
||||
how tall the ScrollableContainer grows */
|
||||
#footer-bar {
|
||||
dock: bottom;
|
||||
height: 3;
|
||||
align: right middle;
|
||||
padding: 0 1;
|
||||
border-top: solid $accent;
|
||||
}
|
||||
#footer-bar Button {
|
||||
margin-left: 2;
|
||||
}
|
||||
|
||||
/* Dirty indicator shown in subtitle */
|
||||
#dirty-indicator {
|
||||
color: $warning;
|
||||
text-style: bold;
|
||||
}
|
||||
"""
|
||||
|
||||
# BINDINGS wires keyboard shortcuts to action_* methods.
|
||||
# Entries with show=True appear in the Footer widget automatically.
|
||||
# ctrl+S (capital S) is how Textual represents Ctrl+Shift+S.
|
||||
BINDINGS = [
|
||||
Binding("ctrl+s", "save_config", "Save Config"),
|
||||
Binding("ctrl+S", "save_config", show=False), # Ctrl+Shift+S alias
|
||||
Binding("ctrl+q", "quit", "Quit"),
|
||||
]
|
||||
|
||||
# reactive is a Textual descriptor. When `dirty` changes value, Textual
|
||||
# automatically calls `watch_dirty()` and re-renders any widget that
|
||||
# depends on it.
|
||||
dirty: reactive[bool] = reactive(False)
|
||||
|
||||
def __init__(self, config_path=None):
|
||||
super().__init__()
|
||||
self.config_path = Path(config_path) if config_path else get_active_config_path()
|
||||
self.mgr = ConfigManager(self.config_path)
|
||||
self._next_row_id: int = 0
|
||||
self._log_lines: int = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# compose() — builds the widget tree declaratively.
|
||||
# Textual calls this once at startup; yield order = render order.
|
||||
# ------------------------------------------------------------------
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
|
||||
with ScrollableContainer():
|
||||
# Config file path — read-only info line
|
||||
yield Static(id="config-path-label")
|
||||
|
||||
# ── Generate ────────────────────────────────────────────
|
||||
# Placed first as the primary action. The hint below guides
|
||||
# first-time users without cluttering the rest of the UI.
|
||||
with Vertical(id="generate-section"):
|
||||
yield Button("Generate", id="generate-status")
|
||||
yield Static(
|
||||
"Set a web folder path under Paths and add at least one source before generating. "
|
||||
"Save your config first — then click Generate or press the button in the footer.",
|
||||
id="generate-hint",
|
||||
)
|
||||
yield RichLog(id="generate-log", highlight=True, markup=True)
|
||||
|
||||
# ── Paths ───────────────────────────────────────────────
|
||||
# Collapsible wraps any widgets in a togglable section.
|
||||
with Collapsible(title="Paths", collapsed=False):
|
||||
with Horizontal(classes="field-row"):
|
||||
yield Label("Web folder")
|
||||
yield Input(
|
||||
id="web-folder",
|
||||
placeholder="required — /path/to/public_html",
|
||||
)
|
||||
|
||||
# ── Gallery settings ────────────────────────────────────
|
||||
with Collapsible(title="Gallery Settings", collapsed=False):
|
||||
with Horizontal(classes="field-row"):
|
||||
yield Label("Plot root")
|
||||
yield Input(id="plot-root", placeholder="gallery")
|
||||
with Horizontal(classes="field-row"):
|
||||
yield Label("PNG DPI")
|
||||
yield Input(id="png-dpi", placeholder="400")
|
||||
with Horizontal(classes="field-row"):
|
||||
yield Label("Backup folder")
|
||||
yield Input(id="backup-folder", placeholder="leave empty to disable")
|
||||
with Horizontal(classes="field-row"):
|
||||
yield Label("Cache metadata")
|
||||
yield Input(id="cache-enabled", placeholder="true")
|
||||
with Horizontal(classes="field-row"):
|
||||
yield Label("Inherit meta")
|
||||
yield Input(id="inherit-meta", placeholder="true")
|
||||
|
||||
# ── Sources ─────────────────────────────────────────────
|
||||
# Each source is an inline editable row; rows are mounted
|
||||
# dynamically in on_mount() after the config is read.
|
||||
with Collapsible(title="Sources", collapsed=False):
|
||||
yield Vertical(id="sources-list")
|
||||
yield Button("+ Add Source", id="add-source-row-btn", variant="primary")
|
||||
|
||||
# Bottom bar — always visible outside the scroll area
|
||||
with Horizontal(id="footer-bar"):
|
||||
yield Static("", id="dirty-indicator")
|
||||
yield Button("Save Config", id="save-btn", variant="success")
|
||||
yield Button("Generate", id="generate-btn", variant="primary")
|
||||
yield Button("Quit", id="quit-btn", variant="error")
|
||||
|
||||
yield Footer()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# on_mount — called once after compose(); safe to query widgets here
|
||||
# ------------------------------------------------------------------
|
||||
def on_mount(self) -> None:
|
||||
self._load_config_into_fields()
|
||||
self.query_one("#config-path-label", Static).update(
|
||||
f"Config: {self.config_path}"
|
||||
)
|
||||
|
||||
def _make_source_row(self, name: str = "", path: str = "") -> Horizontal:
|
||||
"""Return a single editable source row widget."""
|
||||
row_id = f"source-row-{self._next_row_id}"
|
||||
self._next_row_id += 1
|
||||
return Horizontal(
|
||||
Button("−", classes="source-remove-btn", variant="error"),
|
||||
Input(value=name, placeholder="name (optional)", classes="source-name"),
|
||||
Input(value=path, placeholder="/path/to/plots", classes="source-path"),
|
||||
classes="source-row",
|
||||
id=row_id,
|
||||
)
|
||||
|
||||
def _collect_sources(self) -> list[dict]:
|
||||
"""Read current values from all source rows into a list of dicts."""
|
||||
sources = []
|
||||
for row in self.query(".source-row"):
|
||||
path = row.query_one(".source-path", Input).value.strip()
|
||||
name = row.query_one(".source-name", Input).value.strip()
|
||||
if path:
|
||||
sources.append({"name": name or Path(path).resolve().name, "path": path})
|
||||
return sources
|
||||
|
||||
def _load_config_into_fields(self) -> None:
|
||||
"""Read config file and populate every Input widget."""
|
||||
for widget_id, config_key, _required in CONFIG_FIELDS:
|
||||
try:
|
||||
value = self.mgr.get(config_key)
|
||||
inp: Input = self.query_one(f"#{widget_id}", Input)
|
||||
inp.value = str(value) if value is not None else ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Validate required fields on load
|
||||
self._validate_required()
|
||||
|
||||
# Mount one editable row per configured source
|
||||
sources_list = self.query_one("#sources-list", Vertical)
|
||||
for row in self.query(".source-row"):
|
||||
row.remove()
|
||||
for s in self.mgr.list_sources():
|
||||
sources_list.mount(self._make_source_row(s.get("name", ""), s.get("path", "")))
|
||||
|
||||
# Config is freshly loaded — not dirty yet
|
||||
self.dirty = False
|
||||
|
||||
def _validate_required(self) -> None:
|
||||
"""Add/remove .required-empty CSS class on required inputs."""
|
||||
for widget_id in REQUIRED_IDS:
|
||||
try:
|
||||
inp: Input = self.query_one(f"#{widget_id}", Input)
|
||||
# add_class / remove_class toggle CSS classes on a widget
|
||||
if not inp.value.strip():
|
||||
inp.add_class("required-empty")
|
||||
else:
|
||||
inp.remove_class("required-empty")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# watch_dirty — Textual calls this automatically whenever `dirty` changes.
|
||||
# Naming convention: watch_<reactive_name>
|
||||
# ------------------------------------------------------------------
|
||||
def watch_dirty(self, value: bool) -> None:
|
||||
indicator: Static = self.query_one("#dirty-indicator", Static)
|
||||
indicator.update("● unsaved changes" if value else "")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers — @on(MessageType, "#widget-id") binds a method to a
|
||||
# specific message from a specific widget (or any widget of that type).
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@on(Input.Changed)
|
||||
def _on_any_input_changed(self, event: Input.Changed) -> None:
|
||||
"""Mark config dirty and re-validate required fields on every keystroke."""
|
||||
self.dirty = True
|
||||
self._validate_required()
|
||||
|
||||
# -- Save ------------------------------------------------------------
|
||||
|
||||
# action_* methods are called by BINDINGS — same name after "action_"
|
||||
def action_save_config(self) -> None:
|
||||
self._save_config()
|
||||
|
||||
@on(Button.Pressed, "#quit-btn")
|
||||
def action_quit(self) -> None:
|
||||
self.exit()
|
||||
|
||||
@on(Button.Pressed, "#save-btn")
|
||||
def _save_config(self) -> None:
|
||||
"""Write all field values back to the config file via ConfigManager.
|
||||
|
||||
On first save the package template is copied to ~/.config/gallery/config.yaml
|
||||
so that the package-bundled file is never modified.
|
||||
"""
|
||||
# ensure_user_config() copies the template to ~/.config/gallery/config.yaml
|
||||
# if it doesn't exist yet, then returns that path.
|
||||
self.config_path = ensure_user_config()
|
||||
self.mgr = ConfigManager(self.config_path)
|
||||
self.query_one("#config-path-label", Static).update(f"Config: {self.config_path}")
|
||||
|
||||
for widget_id, config_key, _ in CONFIG_FIELDS:
|
||||
try:
|
||||
inp: Input = self.query_one(f"#{widget_id}", Input)
|
||||
self.mgr.set(config_key, inp.value.strip() or '""')
|
||||
except Exception as exc:
|
||||
self.notify(f"Could not save {config_key}: {exc}", severity="error")
|
||||
return
|
||||
|
||||
# Sync sources: remove all then re-add from current UI rows.
|
||||
# ConfigManager.set("sources", ...) would lose the list structure,
|
||||
# so we use the dedicated source helpers instead.
|
||||
try:
|
||||
for s in self.mgr.list_sources():
|
||||
self.mgr.remove_source(s["name"])
|
||||
for s in self._collect_sources():
|
||||
self.mgr.add_source(s["name"], s["path"])
|
||||
except Exception as exc:
|
||||
self.notify(f"Could not save sources: {exc}", severity="error")
|
||||
return
|
||||
|
||||
self.dirty = False
|
||||
# notify() shows a transient toast message at the bottom of the screen
|
||||
self.notify("Config saved.", severity="information")
|
||||
|
||||
# -- Source row add/remove -------------------------------------------
|
||||
|
||||
@on(Button.Pressed, "#add-source-row-btn")
|
||||
def _add_source_row(self) -> None:
|
||||
"""Append a new empty source row and focus its path input."""
|
||||
row = self._make_source_row()
|
||||
self.query_one("#sources-list", Vertical).mount(row)
|
||||
self.call_after_refresh(lambda: row.query_one(".source-path", Input).focus())
|
||||
self.dirty = True
|
||||
|
||||
@on(Button.Pressed, ".source-remove-btn")
|
||||
def _remove_source_row(self, event: Button.Pressed) -> None:
|
||||
"""Remove the row whose − button was pressed."""
|
||||
event.button.parent.remove()
|
||||
self.dirty = True
|
||||
|
||||
# -- Generate --------------------------------------------------------
|
||||
|
||||
def _append_log(self, line: str) -> None:
|
||||
"""Write one line to the log and grow its height up to 10 rows."""
|
||||
log: RichLog = self.query_one("#generate-log", RichLog)
|
||||
log.write(line)
|
||||
self._log_lines += 1
|
||||
log.styles.height = min(self._log_lines, 10)
|
||||
|
||||
@on(Button.Pressed, "#generate-btn")
|
||||
@on(Button.Pressed, "#generate-status")
|
||||
def _start_generate(self) -> None:
|
||||
status: Button = self.query_one("#generate-status", Button)
|
||||
log: RichLog = self.query_one("#generate-log", RichLog)
|
||||
status.set_classes("running")
|
||||
status.label = "Generate ● running…"
|
||||
log.clear()
|
||||
self._log_lines = 0
|
||||
log.styles.height = 1
|
||||
self.query_one("#generate-section").add_class("ran")
|
||||
self._generate_worker()
|
||||
|
||||
# @work(thread=True) runs the decorated method in a background thread.
|
||||
# Without this, the subprocess call would block the UI event loop and
|
||||
# the screen would freeze until generation finishes.
|
||||
@work(thread=True)
|
||||
def _generate_worker(self) -> None:
|
||||
log: RichLog = self.query_one("#generate-log", RichLog)
|
||||
status: Button = self.query_one("#generate-status", Button)
|
||||
|
||||
def set_status(label: str, css_class: str) -> None:
|
||||
# Bundled into one callable so both updates happen atomically
|
||||
# in the event-loop thread.
|
||||
status.set_classes(css_class)
|
||||
status.label = label
|
||||
|
||||
# --config is a top-level flag (before the subcommand) in the CLI parser
|
||||
cmd = [sys.executable, "-m", "gallery.cli",
|
||||
"--config", str(self.config_path), "generate", "--verbose"]
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
for line in proc.stdout:
|
||||
line = line.rstrip()
|
||||
if line:
|
||||
# call_from_thread() is required when touching UI widgets
|
||||
# from a background thread — Textual's event loop is not
|
||||
# thread-safe, so all UI mutations must go through this.
|
||||
self.call_from_thread(self._append_log, line)
|
||||
proc.wait()
|
||||
if proc.returncode == 0:
|
||||
self.call_from_thread(set_status, "Generate ✓ done", "success")
|
||||
else:
|
||||
self.call_from_thread(set_status, f"Generate ✗ failed (exit {proc.returncode})", "error")
|
||||
except Exception as exc:
|
||||
self.call_from_thread(log.write, str(exc))
|
||||
self.call_from_thread(set_status, "Generate ✗ error", "error")
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility modules for gallery generation."""
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Backup utilities for gallery."""
|
||||
|
||||
import zipfile
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def create_backup(
|
||||
web_folder: Path,
|
||||
backup_folder: Path
|
||||
) -> bool:
|
||||
"""
|
||||
Create a backup of the web folder.
|
||||
|
||||
Args:
|
||||
web_folder: Path to the web folder to backup
|
||||
backup_folder: Path to the backup directory
|
||||
|
||||
Returns:
|
||||
True if backup was created successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
today = datetime.date.today().strftime("%Y%m%d")
|
||||
backup_name = f"backup-{today}.zip"
|
||||
backup_path = backup_folder / backup_name
|
||||
|
||||
backup_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if backup_path.exists():
|
||||
return True
|
||||
|
||||
with zipfile.ZipFile(backup_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
for path in web_folder.rglob("*"):
|
||||
if path.is_file():
|
||||
arcname = path.relative_to(web_folder.parent)
|
||||
zipf.write(path, arcname)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not create backup: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Date and time utility functions."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def datetime_from_timestamp(timestamp: float) -> datetime:
|
||||
"""
|
||||
Convert a Unix timestamp to a datetime object.
|
||||
|
||||
Args:
|
||||
timestamp: Unix timestamp as float
|
||||
|
||||
Returns:
|
||||
datetime object
|
||||
"""
|
||||
return datetime.fromtimestamp(timestamp)
|
||||
|
||||
|
||||
def strftime_filter(dt: datetime, fmt: str) -> str:
|
||||
"""
|
||||
Format a datetime object using strftime.
|
||||
|
||||
Args:
|
||||
dt: datetime object to format
|
||||
fmt: strftime format string
|
||||
|
||||
Returns:
|
||||
Formatted datetime string
|
||||
"""
|
||||
return dt.strftime(fmt)
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Metadata Management for Scientific Gallery Generator
|
||||
|
||||
This module handles loading, parsing, and caching of metadata for plots
|
||||
and folders in the gallery system. Supports YAML and JSON formats with
|
||||
hierarchical inheritance.
|
||||
|
||||
Features:
|
||||
- Load metadata from YAML/JSON files
|
||||
- Hierarchical metadata inheritance from parent folders
|
||||
- Plot-specific metadata overrides
|
||||
- Metadata caching for performance
|
||||
"""
|
||||
|
||||
import json
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
def load_metadata_file(metadata_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Load metadata from a YAML or JSON file.
|
||||
|
||||
Args:
|
||||
metadata_path: Path to the metadata file
|
||||
|
||||
Returns:
|
||||
Dictionary containing the metadata, empty dict if file doesn't exist
|
||||
or can't be parsed
|
||||
"""
|
||||
if not metadata_path.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with metadata_path.open('r', encoding='utf-8') as f:
|
||||
suffix_lower = metadata_path.suffix.lower()
|
||||
if suffix_lower == '.yaml' or suffix_lower == '.yml':
|
||||
return yaml.safe_load(f) or {}
|
||||
elif metadata_path.suffix.lower() == '.json':
|
||||
return json.load(f) or {}
|
||||
else:
|
||||
print(f"Warning: Unknown metadata file format: "
|
||||
f"{metadata_path}")
|
||||
return {}
|
||||
except (yaml.YAMLError, json.JSONDecodeError, IOError) as e:
|
||||
print(f"Warning: Could not parse metadata file {metadata_path}: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
def load_folder_metadata(folder_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Load folder-level metadata from metadata.yaml, metadata.yml, or metadata.json.
|
||||
|
||||
Args:
|
||||
folder_path: Path to the folder to check for metadata
|
||||
|
||||
Returns:
|
||||
Dictionary containing the folder metadata
|
||||
"""
|
||||
# Try YAML first, then JSON for backwards compatibility
|
||||
for filename in ['metadata.yaml', 'metadata.yml', 'metadata.json']:
|
||||
metadata_path = folder_path / filename
|
||||
|
||||
if metadata_path.exists():
|
||||
return load_metadata_file(metadata_path)
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def get_metadata_file_path(folder_path: Path) -> str:
|
||||
"""
|
||||
Get the metadata file path for a folder.
|
||||
|
||||
Returns existing file if found, otherwise suggests metadata.yaml.
|
||||
|
||||
Args:
|
||||
folder_path: Path to the folder to check for metadata
|
||||
|
||||
Returns:
|
||||
String path to the metadata file (existing or suggested)
|
||||
"""
|
||||
# Preferred order: YAML first, then JSON
|
||||
preferred_files = ['metadata.yaml', 'metadata.yml', 'metadata.json']
|
||||
|
||||
for filename in preferred_files:
|
||||
metadata_path = folder_path / filename
|
||||
if metadata_path.exists():
|
||||
return str(metadata_path)
|
||||
|
||||
# If no file exists, suggest metadata.yaml (preferred format)
|
||||
return str(folder_path / 'metadata.yaml')
|
||||
|
||||
|
||||
def merge_metadata(
|
||||
parent_metadata: Dict[str, Any],
|
||||
child_metadata: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge parent and child metadata, with child values overriding parent.
|
||||
|
||||
Args:
|
||||
parent_metadata: Metadata from parent folder
|
||||
child_metadata: Metadata from current folder
|
||||
|
||||
Returns:
|
||||
Merged metadata dictionary
|
||||
"""
|
||||
merged = parent_metadata.copy()
|
||||
merged.update(child_metadata)
|
||||
return merged
|
||||
|
||||
|
||||
def resolve_metadata_for_plot(
|
||||
plot_path: Path,
|
||||
inherited_metadata: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Resolve metadata for a specific plot.
|
||||
|
||||
Merges inherited metadata with plot-specific metadata.
|
||||
|
||||
Args:
|
||||
plot_path: Path to the plot file (PDF)
|
||||
inherited_metadata: Metadata inherited from folder hierarchy
|
||||
|
||||
Returns:
|
||||
Final merged metadata for the plot
|
||||
"""
|
||||
plot_stem = plot_path.stem
|
||||
plot_dir = plot_path.parent
|
||||
|
||||
# Check for plot-specific metadata files
|
||||
for suffix in ['.yaml', '.yml', '.json']:
|
||||
plot_metadata_path = plot_dir / f"{plot_stem}{suffix}"
|
||||
if plot_metadata_path.exists():
|
||||
plot_metadata = load_metadata_file(plot_metadata_path)
|
||||
return merge_metadata(inherited_metadata, plot_metadata)
|
||||
|
||||
# No plot-specific metadata found, return inherited metadata
|
||||
return inherited_metadata.copy()
|
||||
|
||||
|
||||
def save_metadata_cache(
|
||||
web_dir: Path,
|
||||
plot_metadata_cache: Dict[str, Dict[str, Any]]
|
||||
) -> None:
|
||||
"""
|
||||
Save plot metadata cache to meta_cache.json in the web directory.
|
||||
|
||||
Args:
|
||||
web_dir: Web directory where the cache file should be saved
|
||||
plot_metadata_cache: Dictionary mapping plot names to their metadata
|
||||
"""
|
||||
cache_path = web_dir / "meta_cache.json"
|
||||
try:
|
||||
with cache_path.open('w', encoding='utf-8') as f:
|
||||
json.dump(plot_metadata_cache, f, indent=2, ensure_ascii=False)
|
||||
except IOError as e:
|
||||
print(f"Warning: Could not save metadata cache {cache_path}: {e}")
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Plot file processing and HTML rendering."""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
_PYMUPDF_AVAILABLE = True
|
||||
except ImportError:
|
||||
_PYMUPDF_AVAILABLE = False
|
||||
|
||||
_IMAGEMAGICK_AVAILABLE = shutil.which("convert") is not None
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from gallery.utils.metadata import (
|
||||
resolve_metadata_for_plot,
|
||||
get_metadata_file_path,
|
||||
)
|
||||
from gallery.utils.stats import (
|
||||
calculate_directory_stats,
|
||||
format_file_size,
|
||||
)
|
||||
from gallery.config import GalleryConfig
|
||||
|
||||
|
||||
def process_html_file(
|
||||
html_file: Path,
|
||||
web_dir: Path,
|
||||
current_metadata: Dict[str, Any] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Process HTML plot file, copying it to web directory.
|
||||
|
||||
Args:
|
||||
html_file: Path to the source HTML file
|
||||
web_dir: Target web directory
|
||||
current_metadata: Current metadata dictionary for the plot
|
||||
|
||||
Returns:
|
||||
Dictionary containing plot information
|
||||
"""
|
||||
web_html = web_dir / html_file.name
|
||||
|
||||
if needs_update(html_file, web_html):
|
||||
shutil.copy2(html_file, web_html)
|
||||
|
||||
# Get source file creation time
|
||||
source_creation_time = int(html_file.stat().st_ctime)
|
||||
|
||||
# Resolve metadata if provided
|
||||
plot_metadata = {}
|
||||
if current_metadata is not None:
|
||||
plot_metadata = resolve_metadata_for_plot(html_file, current_metadata)
|
||||
|
||||
return {
|
||||
"name": html_file.stem,
|
||||
"html_href": html_file.name,
|
||||
"is_html": True,
|
||||
"metadata": plot_metadata,
|
||||
"creation_time": source_creation_time
|
||||
}
|
||||
|
||||
|
||||
def process_plot_files(
|
||||
config: GalleryConfig,
|
||||
plot_file: Path,
|
||||
web_dir: Path,
|
||||
current_metadata: Dict[str, Any] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Process plot files (PDF/PNG or HTML), handling conversion and copying.
|
||||
|
||||
Args:
|
||||
config: Gallery configuration object
|
||||
plot_file: Path to the source plot file (PDF or HTML)
|
||||
web_dir: Target web directory
|
||||
current_metadata: Current metadata dictionary for the plot
|
||||
|
||||
Returns:
|
||||
Dictionary containing plot information
|
||||
"""
|
||||
if plot_file.suffix.lower() == '.html':
|
||||
return process_html_file(plot_file, web_dir, current_metadata)
|
||||
|
||||
# Handle PDF files
|
||||
png_file = plot_file.with_suffix(".png")
|
||||
web_pdf = web_dir / plot_file.name
|
||||
web_png = web_dir / png_file.name
|
||||
|
||||
if needs_update(plot_file, web_pdf):
|
||||
shutil.copy2(plot_file, web_pdf)
|
||||
|
||||
if not png_file.exists():
|
||||
convert_pdf_to_png(plot_file, config=config)
|
||||
|
||||
if needs_update(png_file, web_png):
|
||||
shutil.copy2(png_file, web_png)
|
||||
|
||||
source_creation_time = int(plot_file.stat().st_ctime)
|
||||
|
||||
plot_metadata = {}
|
||||
if current_metadata is not None:
|
||||
plot_metadata = resolve_metadata_for_plot(plot_file, current_metadata)
|
||||
|
||||
return {
|
||||
"name": plot_file.stem,
|
||||
"pdf_href": plot_file.name,
|
||||
"png_href": png_file.name,
|
||||
"is_html": False,
|
||||
"metadata": plot_metadata,
|
||||
"creation_time": source_creation_time
|
||||
}
|
||||
|
||||
|
||||
def render_gallery_page(
|
||||
config: GalleryConfig,
|
||||
template: Template,
|
||||
web_dir: Path,
|
||||
items: list,
|
||||
subdirs: list,
|
||||
relative_path: Path,
|
||||
title: str = None,
|
||||
metadata: dict = None
|
||||
) -> None:
|
||||
"""
|
||||
Unified template rendering for all gallery pages.
|
||||
|
||||
Args:
|
||||
config: Gallery configuration object
|
||||
template: Jinja2 template object
|
||||
web_dir: Target web directory
|
||||
items: List of plot items
|
||||
subdirs: List of subdirectory names
|
||||
relative_path: Relative path from gallery root
|
||||
title: Page title (optional)
|
||||
metadata: Metadata dictionary (optional)
|
||||
"""
|
||||
if title is None:
|
||||
title = "Gallery" if relative_path == Path(
|
||||
".") else f"Gallery: {relative_path}"
|
||||
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
|
||||
# Calculate statistics
|
||||
current_stats = calculate_directory_stats(web_dir)
|
||||
stats = {
|
||||
"file_count": len(items),
|
||||
"folder_count": len(subdirs),
|
||||
"total_size": format_file_size(current_stats["total_size"]),
|
||||
"total_size_bytes": current_stats["total_size"]
|
||||
}
|
||||
|
||||
# Calculate relative path to assets
|
||||
if relative_path == Path("."):
|
||||
assets_path = "../assets"
|
||||
else:
|
||||
depth = len(relative_path.parts)
|
||||
assets_path = "../" * (depth + 1) + "assets"
|
||||
|
||||
# For root level, show only directory structure
|
||||
if relative_path == Path("."):
|
||||
items = []
|
||||
|
||||
output_html = web_dir / "index.html"
|
||||
with output_html.open("w") as f:
|
||||
paths_dict = {
|
||||
"web_folder": str(config.web_folder),
|
||||
}
|
||||
ui_dict = {
|
||||
"max_recent_plots": 20,
|
||||
"search_debounce_ms": 300,
|
||||
}
|
||||
rendered_html = template.render(
|
||||
title=title,
|
||||
items=items,
|
||||
subdirs=subdirs,
|
||||
relpath=str(relative_path),
|
||||
paths=paths_dict,
|
||||
ui=ui_dict,
|
||||
stats=stats,
|
||||
folder_metadata=metadata,
|
||||
assets_path=assets_path,
|
||||
source_dir=str(web_dir),
|
||||
metadata_file_path=get_metadata_file_path(web_dir)
|
||||
)
|
||||
f.write(rendered_html)
|
||||
|
||||
|
||||
def convert_pdf_to_png(pdf_path: Path, config: GalleryConfig) -> None:
|
||||
"""
|
||||
Convert a PDF file to PNG format.
|
||||
|
||||
Uses PyMuPDF (fitz) when available; falls back to ImageMagick otherwise.
|
||||
Only converts if the PNG doesn't exist or if the PDF is newer than
|
||||
the PNG (with a 30-second buffer to handle filesystem timing issues).
|
||||
|
||||
Raises:
|
||||
RuntimeError: If neither PyMuPDF nor ImageMagick is available.
|
||||
subprocess.CalledProcessError: If the ImageMagick fallback fails.
|
||||
"""
|
||||
png_path = pdf_path.with_suffix(".png")
|
||||
|
||||
if png_path.exists():
|
||||
pdf_mtime = pdf_path.stat().st_mtime
|
||||
png_mtime = png_path.stat().st_mtime
|
||||
if png_mtime >= (pdf_mtime + 30):
|
||||
return
|
||||
|
||||
if _PYMUPDF_AVAILABLE:
|
||||
_convert_pdf_pymupdf(pdf_path, png_path, config.png_dpi)
|
||||
elif _IMAGEMAGICK_AVAILABLE:
|
||||
_convert_pdf_imagemagick(pdf_path, png_path, config.png_dpi)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"No PDF renderer found. Install PyMuPDF (`pip install pymupdf`) "
|
||||
"or ImageMagick (`apt-get install imagemagick`)."
|
||||
)
|
||||
|
||||
|
||||
def _convert_pdf_pymupdf(pdf_path: Path, png_path: Path, dpi: int) -> None:
|
||||
zoom = dpi / 72 # PDF coordinate space is 72 pt/inch
|
||||
doc = fitz.open(str(pdf_path))
|
||||
try:
|
||||
pix = doc[0].get_pixmap(matrix=fitz.Matrix(zoom, zoom))
|
||||
pix.save(str(png_path))
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
|
||||
def _convert_pdf_imagemagick(pdf_path: Path, png_path: Path, dpi: int) -> None:
|
||||
subprocess.run([
|
||||
"convert",
|
||||
"-density", str(dpi),
|
||||
str(pdf_path),
|
||||
"-quality", "95",
|
||||
str(png_path),
|
||||
], check=True)
|
||||
|
||||
|
||||
def needs_update(source_file: Path, target_file: Path) -> bool:
|
||||
"""
|
||||
Check if target file needs updating based on source modification time.
|
||||
|
||||
Args:
|
||||
source_file: Path to the source file
|
||||
target_file: Path to the target file
|
||||
|
||||
Returns:
|
||||
True if target needs update, False otherwise
|
||||
"""
|
||||
if not target_file.exists():
|
||||
return True
|
||||
|
||||
source_mtime = source_file.stat().st_mtime
|
||||
target_mtime = target_file.stat().st_mtime
|
||||
|
||||
return source_mtime > (target_mtime + 30)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Statistics calculation for gallery directories."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def calculate_directory_stats(directory: Path) -> dict:
|
||||
"""
|
||||
Calculate statistics for a directory.
|
||||
|
||||
Args:
|
||||
directory: Path to the directory to analyze
|
||||
|
||||
Returns:
|
||||
Dictionary containing file count, folder count, and total size
|
||||
"""
|
||||
stats = {
|
||||
"file_count": 0,
|
||||
"folder_count": 0,
|
||||
"total_size": 0,
|
||||
"pdf_size": 0,
|
||||
"png_size": 0,
|
||||
}
|
||||
|
||||
if not directory.exists():
|
||||
return stats
|
||||
|
||||
for item in directory.rglob("*"):
|
||||
if item.is_file():
|
||||
stats["file_count"] += 1
|
||||
size = item.stat().st_size
|
||||
stats["total_size"] += size
|
||||
|
||||
if item.suffix.lower() == '.pdf':
|
||||
stats["pdf_size"] += size
|
||||
elif item.suffix.lower() == '.png':
|
||||
stats["png_size"] += size
|
||||
elif item.is_dir():
|
||||
stats["folder_count"] += 1
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def format_file_size(size_bytes: int) -> str:
|
||||
"""
|
||||
Format file size in human readable format.
|
||||
|
||||
Args:
|
||||
size_bytes: Size in bytes
|
||||
|
||||
Returns:
|
||||
Formatted size string
|
||||
"""
|
||||
if size_bytes == 0:
|
||||
return "0 B"
|
||||
|
||||
size_names = ["B", "KB", "MB", "GB", "TB"]
|
||||
size = float(size_bytes)
|
||||
i = 0
|
||||
while size >= 1024 and i < len(size_names) - 1:
|
||||
size /= 1024
|
||||
i += 1
|
||||
|
||||
return f"{size:.1f} {size_names[i]}"
|
||||
@@ -1,16 +1,67 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=65.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "plot-gallery"
|
||||
version = "0.1.0"
|
||||
description = "Host your plots on a personal website"
|
||||
authors = [
|
||||
{ name = "K. Schmidt" }
|
||||
]
|
||||
name = "gallery"
|
||||
version = "0.1.3"
|
||||
description = "Scientific Gallery Generator - Create responsive HTML galleries from plot collections"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
requires-python = ">=3.8"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "K. Schmidt"},
|
||||
]
|
||||
keywords = [
|
||||
"gallery",
|
||||
"plots",
|
||||
"scientific-computing",
|
||||
"html-generation",
|
||||
"pdf-to-png",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Scientific/Engineering :: Visualization",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"Jinja2>=3.0.0",
|
||||
"PyYAML>=5.0",
|
||||
"argcomplete>=3.0",
|
||||
"platformdirs>=3.0",
|
||||
"pymupdf>=1.23",
|
||||
"pytest",
|
||||
"textual>=0.50",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"black>=22.0",
|
||||
"pylint>=2.0",
|
||||
"mypy>=0.900",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
gallery = "gallery.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["gallery", "gallery.utils", "gallery.config"]
|
||||
package-data = {gallery = ["templates/*", "assets/css/*", "assets/js/*", "config/*"]}
|
||||
include-package-data = true
|
||||
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
target-version = ['py39']
|
||||
target-version = ['py38']
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
@@ -19,7 +70,3 @@ line_length = 120
|
||||
[tool.flake8]
|
||||
max-line-length = 120
|
||||
extend-ignore = ["E203", "W503"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
|
||||
|
||||
sys.path.append("..")
|
||||
@@ -0,0 +1,214 @@
|
||||
import zipfile
|
||||
import datetime
|
||||
from unittest.mock import patch
|
||||
from utils import backup
|
||||
|
||||
|
||||
def test_backup_creates_zip(tmp_path, monkeypatch):
|
||||
# Setup fake web folder
|
||||
web_folder = tmp_path / 'plots'
|
||||
web_folder.mkdir()
|
||||
(web_folder / 'file1.txt').write_text('abc')
|
||||
(web_folder / 'file2.txt').write_text('def')
|
||||
backup_folder = tmp_path / 'backups'
|
||||
backup_folder.mkdir()
|
||||
|
||||
# Patch the module variables
|
||||
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
|
||||
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
|
||||
|
||||
# Call the backup function
|
||||
backup.create_backup()
|
||||
|
||||
# Check that backup was created
|
||||
today = datetime.date.today().strftime('%Y%m%d')
|
||||
backup_name = f'backup-{today}.zip'
|
||||
backup_path = backup_folder / backup_name
|
||||
|
||||
assert backup_path.exists()
|
||||
with zipfile.ZipFile(backup_path, 'r') as z:
|
||||
names = z.namelist()
|
||||
assert any('file1.txt' in n for n in names)
|
||||
assert any('file2.txt' in n for n in names)
|
||||
|
||||
# Cleanup: remove the backup file after test
|
||||
backup_path.unlink()
|
||||
|
||||
|
||||
def test_backup_with_subdirectories(tmp_path, monkeypatch):
|
||||
# Setup fake web folder with subdirectories
|
||||
web_folder = tmp_path / 'plots'
|
||||
web_folder.mkdir()
|
||||
(web_folder / 'file1.txt').write_text('content1')
|
||||
|
||||
subdir = web_folder / 'subdir'
|
||||
subdir.mkdir()
|
||||
(subdir / 'file2.txt').write_text('content2')
|
||||
|
||||
nested_subdir = subdir / 'nested'
|
||||
nested_subdir.mkdir()
|
||||
(nested_subdir / 'file3.txt').write_text('content3')
|
||||
|
||||
backup_folder = tmp_path / 'backups'
|
||||
backup_folder.mkdir()
|
||||
|
||||
# Patch the module variables
|
||||
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
|
||||
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
|
||||
|
||||
# Call the backup function
|
||||
backup.create_backup()
|
||||
|
||||
# Check that backup was created with all files
|
||||
today = datetime.date.today().strftime('%Y%m%d')
|
||||
backup_name = f'backup-{today}.zip'
|
||||
backup_path = backup_folder / backup_name
|
||||
|
||||
assert backup_path.exists()
|
||||
with zipfile.ZipFile(backup_path, 'r') as z:
|
||||
names = z.namelist()
|
||||
assert any('file1.txt' in n for n in names)
|
||||
assert any('file2.txt' in n for n in names)
|
||||
assert any('file3.txt' in n for n in names)
|
||||
|
||||
# Cleanup
|
||||
backup_path.unlink()
|
||||
|
||||
|
||||
def test_backup_existing_file(tmp_path, monkeypatch, capsys):
|
||||
# Setup fake web folder
|
||||
web_folder = tmp_path / 'plots'
|
||||
web_folder.mkdir()
|
||||
(web_folder / 'file1.txt').write_text('abc')
|
||||
|
||||
backup_folder = tmp_path / 'backups'
|
||||
backup_folder.mkdir()
|
||||
|
||||
# Create existing backup file
|
||||
today = datetime.date.today().strftime('%Y%m%d')
|
||||
backup_name = f'backup-{today}.zip'
|
||||
backup_path = backup_folder / backup_name
|
||||
backup_path.write_text('existing backup')
|
||||
|
||||
# Patch the module variables
|
||||
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
|
||||
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
|
||||
|
||||
# Call the backup function
|
||||
backup.create_backup()
|
||||
|
||||
# Check that message about existing backup was printed
|
||||
captured = capsys.readouterr()
|
||||
assert f"Backup already exists: {backup_path}" in captured.out
|
||||
|
||||
# Cleanup
|
||||
backup_path.unlink()
|
||||
|
||||
|
||||
def test_backup_empty_folder(tmp_path, monkeypatch):
|
||||
# Setup empty web folder
|
||||
web_folder = tmp_path / 'plots'
|
||||
web_folder.mkdir()
|
||||
|
||||
backup_folder = tmp_path / 'backups'
|
||||
backup_folder.mkdir()
|
||||
|
||||
# Patch the module variables
|
||||
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
|
||||
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
|
||||
|
||||
# Call the backup function
|
||||
backup.create_backup()
|
||||
|
||||
# Check that backup was created (empty)
|
||||
today = datetime.date.today().strftime('%Y%m%d')
|
||||
backup_name = f'backup-{today}.zip'
|
||||
backup_path = backup_folder / backup_name
|
||||
|
||||
assert backup_path.exists()
|
||||
with zipfile.ZipFile(backup_path, 'r') as z:
|
||||
assert len(z.namelist()) == 0
|
||||
|
||||
# Cleanup
|
||||
backup_path.unlink()
|
||||
|
||||
|
||||
def test_backup_nonexistent_web_folder(tmp_path, monkeypatch):
|
||||
# Setup nonexistent web folder
|
||||
web_folder = tmp_path / 'nonexistent_plots'
|
||||
backup_folder = tmp_path / 'backups'
|
||||
backup_folder.mkdir()
|
||||
|
||||
# Patch the module variables
|
||||
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
|
||||
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
|
||||
|
||||
# Call the backup function
|
||||
backup.create_backup()
|
||||
|
||||
# Check that backup was created (empty since source doesn't exist)
|
||||
today = datetime.date.today().strftime('%Y%m%d')
|
||||
backup_name = f'backup-{today}.zip'
|
||||
backup_path = backup_folder / backup_name
|
||||
|
||||
assert backup_path.exists()
|
||||
with zipfile.ZipFile(backup_path, 'r') as z:
|
||||
assert len(z.namelist()) == 0
|
||||
|
||||
# Cleanup
|
||||
backup_path.unlink()
|
||||
|
||||
|
||||
@patch('datetime.date')
|
||||
def test_backup_with_custom_date(mock_date, tmp_path, monkeypatch):
|
||||
# Mock date to return a specific date
|
||||
mock_date.today.return_value.strftime.return_value = "20230908"
|
||||
|
||||
# Setup fake web folder
|
||||
web_folder = tmp_path / 'plots'
|
||||
web_folder.mkdir()
|
||||
(web_folder / 'file1.txt').write_text('test')
|
||||
|
||||
backup_folder = tmp_path / 'backups'
|
||||
backup_folder.mkdir()
|
||||
|
||||
# Patch the module variables
|
||||
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
|
||||
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
|
||||
|
||||
# Call the backup function
|
||||
backup.create_backup()
|
||||
|
||||
# Check that backup was created with custom date
|
||||
backup_path = backup_folder / "backup-20230908.zip"
|
||||
assert backup_path.exists()
|
||||
|
||||
# Cleanup
|
||||
backup_path.unlink()
|
||||
|
||||
|
||||
def test_backup_folder_creation(tmp_path, monkeypatch):
|
||||
# Setup fake web folder
|
||||
web_folder = tmp_path / 'plots'
|
||||
web_folder.mkdir()
|
||||
(web_folder / 'file1.txt').write_text('test')
|
||||
|
||||
# Don't create backup folder - let function create it
|
||||
backup_folder = tmp_path / 'new_backups'
|
||||
|
||||
# Patch the module variables
|
||||
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
|
||||
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
|
||||
|
||||
# Call the backup function
|
||||
backup.create_backup()
|
||||
|
||||
# Check that backup folder was created
|
||||
assert backup_folder.exists()
|
||||
assert backup_folder.is_dir()
|
||||
|
||||
# Check that backup file was created
|
||||
today = datetime.date.today().strftime('%Y%m%d')
|
||||
backup_name = f'backup-{today}.zip'
|
||||
backup_path = backup_folder / backup_name
|
||||
assert backup_path.exists()
|
||||
@@ -0,0 +1,195 @@
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import pytest
|
||||
import yaml
|
||||
from utils import config
|
||||
|
||||
|
||||
def test_path_config():
|
||||
pc = config.PathConfig(work_dir='/tmp', web_folder='/web')
|
||||
assert pc.work_dir == '/tmp'
|
||||
assert pc.web_folder == '/web'
|
||||
|
||||
|
||||
def test_gallery_config():
|
||||
gc = config.GalleryConfig(
|
||||
plot_root='plots', png_dpi=150, backup_folder='backups')
|
||||
assert gc.plot_root == 'plots'
|
||||
assert gc.png_dpi == 150
|
||||
assert gc.backup_folder == 'backups'
|
||||
|
||||
|
||||
def test_ui_config():
|
||||
ui = config.UIConfig(max_recent_plots=10, search_debounce_ms=200)
|
||||
assert ui.max_recent_plots == 10
|
||||
assert ui.search_debounce_ms == 200
|
||||
|
||||
|
||||
def test_metadata_config_defaults():
|
||||
mc = config.MetadataConfig()
|
||||
assert mc.cache_enabled is True
|
||||
assert mc.inherit_from_parent is True
|
||||
assert mc.supported_formats == ['.yaml', '.yml', '.json']
|
||||
|
||||
|
||||
def test_metadata_config_custom():
|
||||
mc = config.MetadataConfig(
|
||||
cache_enabled=False,
|
||||
inherit_from_parent=False,
|
||||
supported_formats=['.yaml']
|
||||
)
|
||||
assert mc.cache_enabled is False
|
||||
assert mc.inherit_from_parent is False
|
||||
assert mc.supported_formats == ['.yaml']
|
||||
|
||||
|
||||
def test_gallery_item():
|
||||
item = config.GalleryItem(name="test", path=Path("/test/path"))
|
||||
assert item.name == "test"
|
||||
assert item.path == Path("/test/path")
|
||||
|
||||
|
||||
def test_config_creation():
|
||||
paths = config.PathConfig(work_dir="/work", web_folder="/web")
|
||||
gallery = config.GalleryConfig(
|
||||
plot_root="plots", png_dpi=300, backup_folder="backups")
|
||||
ui = config.UIConfig(max_recent_plots=5, search_debounce_ms=100)
|
||||
metadata = config.MetadataConfig()
|
||||
|
||||
cfg = config.Config(
|
||||
paths=paths,
|
||||
gallery=gallery,
|
||||
ui=ui,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
assert cfg.paths == paths
|
||||
assert cfg.gallery == gallery
|
||||
assert cfg.ui == ui
|
||||
assert cfg.metadata == metadata
|
||||
assert cfg.sources == []
|
||||
|
||||
|
||||
def test_config_backward_compatibility_properties():
|
||||
paths = config.PathConfig(work_dir="/work", web_folder="/web")
|
||||
gallery = config.GalleryConfig(
|
||||
plot_root="plots", png_dpi=300, backup_folder="backups")
|
||||
ui = config.UIConfig(max_recent_plots=5, search_debounce_ms=100)
|
||||
metadata = config.MetadataConfig()
|
||||
|
||||
cfg = config.Config(
|
||||
paths=paths,
|
||||
gallery=gallery,
|
||||
ui=ui,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
assert cfg.web_folder == "/web"
|
||||
assert cfg.png_dpi == 300
|
||||
assert cfg.plot_root == "plots"
|
||||
assert cfg.backup_folder == "backups"
|
||||
|
||||
|
||||
def test_config_from_yaml(tmp_path):
|
||||
yaml_content = {
|
||||
'paths': {
|
||||
'work_dir': '/test/work',
|
||||
'web_folder': '/test/web'
|
||||
},
|
||||
'gallery': {
|
||||
'plot_root': 'test_plots',
|
||||
'png_dpi': 200,
|
||||
'backup_folder': 'test_backups'
|
||||
},
|
||||
'ui': {
|
||||
'max_recent_plots': 15,
|
||||
'search_debounce_ms': 300
|
||||
},
|
||||
'metadata': {
|
||||
'cache_enabled': False,
|
||||
'inherit_from_parent': False
|
||||
},
|
||||
'sources': [
|
||||
{'name': 'source1', 'path': '/path1'},
|
||||
{'name': 'source2', 'path': '/path2'}
|
||||
]
|
||||
}
|
||||
|
||||
yaml_file = tmp_path / 'test_config.yaml'
|
||||
with yaml_file.open('w') as f:
|
||||
yaml.dump(yaml_content, f)
|
||||
|
||||
cfg = config.Config.from_yaml(str(yaml_file))
|
||||
|
||||
assert cfg.paths.work_dir == '/test/work'
|
||||
assert cfg.paths.web_folder == '/test/web'
|
||||
assert cfg.gallery.plot_root == 'test_plots'
|
||||
assert cfg.gallery.png_dpi == 200
|
||||
assert cfg.ui.max_recent_plots == 15
|
||||
assert cfg.metadata.cache_enabled is False
|
||||
assert len(cfg.sources) == 2
|
||||
assert cfg.sources[0].name == 'source1'
|
||||
assert cfg.sources[0].path == Path('/path1')
|
||||
|
||||
|
||||
def test_config_from_yaml_missing_file():
|
||||
with pytest.raises(FileNotFoundError):
|
||||
config.Config.from_yaml('/nonexistent/file.yaml')
|
||||
|
||||
|
||||
def test_config_from_yaml_malformed():
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml',
|
||||
delete=False) as f:
|
||||
f.write('invalid: yaml: content: [')
|
||||
f.flush()
|
||||
|
||||
with pytest.raises(yaml.YAMLError):
|
||||
config.Config.from_yaml(f.name)
|
||||
|
||||
|
||||
def test_config_to_yaml(tmp_path):
|
||||
paths = config.PathConfig(work_dir="/work", web_folder="/web")
|
||||
gallery = config.GalleryConfig(
|
||||
plot_root="plots", png_dpi=300, backup_folder="backups")
|
||||
ui = config.UIConfig(max_recent_plots=5, search_debounce_ms=100)
|
||||
metadata = config.MetadataConfig()
|
||||
sources = [config.GalleryItem(name="test", path=Path("/test"))]
|
||||
|
||||
cfg = config.Config(
|
||||
paths=paths,
|
||||
gallery=gallery,
|
||||
ui=ui,
|
||||
metadata=metadata,
|
||||
sources=sources
|
||||
)
|
||||
|
||||
yaml_file = tmp_path / 'output_config.yaml'
|
||||
cfg.to_yaml(str(yaml_file))
|
||||
|
||||
assert yaml_file.exists()
|
||||
|
||||
# Just verify the file contains expected content (no Path parsing)
|
||||
content = yaml_file.read_text()
|
||||
assert 'work_dir: /work' in content
|
||||
assert 'png_dpi: 300' in content
|
||||
assert 'name: test' in content
|
||||
|
||||
|
||||
def test_config_from_yaml_partial_data(tmp_path):
|
||||
# Test with minimal YAML data
|
||||
yaml_content = {
|
||||
'paths': {'work_dir': '/min', 'web_folder': '/min_web'},
|
||||
'gallery': {'plot_root': 'min_plots', 'png_dpi': 100,
|
||||
'backup_folder': 'min_backup'},
|
||||
'ui': {'max_recent_plots': 3, 'search_debounce_ms': 50}
|
||||
}
|
||||
|
||||
yaml_file = tmp_path / 'minimal_config.yaml'
|
||||
with yaml_file.open('w') as f:
|
||||
yaml.dump(yaml_content, f)
|
||||
|
||||
cfg = config.Config.from_yaml(str(yaml_file))
|
||||
|
||||
# Should use defaults for metadata and empty sources
|
||||
assert cfg.metadata.cache_enabled is True # default
|
||||
assert cfg.sources == [] # default empty list
|
||||
@@ -0,0 +1,138 @@
|
||||
import pytest
|
||||
import tempfile
|
||||
import time
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def test_python_version():
|
||||
"""Test that Python 3.9+ is available."""
|
||||
version = sys.version_info
|
||||
assert version.major >= 3
|
||||
assert version.minor >= 9
|
||||
|
||||
|
||||
def test_required_modules():
|
||||
"""Test that required Python modules are installed."""
|
||||
try:
|
||||
import jinja2 # noqa: F401
|
||||
import yaml # noqa: F401
|
||||
except ImportError as e:
|
||||
pytest.fail(f"Required module not found: {e}")
|
||||
|
||||
|
||||
def test_imagemagick_available():
|
||||
"""Test that ImageMagick is installed and accessible."""
|
||||
try:
|
||||
result = subprocess.run(['convert', '-version'], capture_output=True, text=True, timeout=10)
|
||||
assert result.returncode == 0
|
||||
assert 'ImageMagick' in result.stdout
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pytest.fail("ImageMagick not available or not working")
|
||||
|
||||
|
||||
def test_format_file_size():
|
||||
# Add current directory to path instead of /src
|
||||
sys.path.insert(0, '.')
|
||||
from gallery import format_file_size
|
||||
assert format_file_size(0) == "0 B"
|
||||
assert format_file_size(1024) == "1.0 KB"
|
||||
assert format_file_size(1048576) == "1.0 MB"
|
||||
assert format_file_size(1073741824) == "1.0 GB"
|
||||
|
||||
|
||||
def test_needs_update():
|
||||
sys.path.insert(0, '.')
|
||||
from gallery import needs_update
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
source = temp_path / "source.txt"
|
||||
target = temp_path / "target.txt"
|
||||
source.write_text("test")
|
||||
assert needs_update(source, target)
|
||||
target.write_text("test")
|
||||
time.sleep(0.1)
|
||||
os.utime(target, (time.time(), time.time()))
|
||||
assert not needs_update(source, target)
|
||||
|
||||
|
||||
def test_metadata_loading():
|
||||
sys.path.insert(0, '.')
|
||||
from utils.metadata import load_metadata_file
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
yaml_file = temp_path / "test.yaml"
|
||||
yaml_content = "title: Test\nauthor: Container Test\n"
|
||||
yaml_file.write_text(yaml_content)
|
||||
metadata = load_metadata_file(yaml_file)
|
||||
assert metadata['title'] == 'Test'
|
||||
assert metadata['author'] == 'Container Test'
|
||||
|
||||
|
||||
def test_metadata_inheritance():
|
||||
sys.path.insert(0, '.')
|
||||
from utils.metadata import merge_metadata
|
||||
parent = {'project': 'Test', 'version': '1.0'}
|
||||
child = {'experiment': 'A', 'version': '1.1'}
|
||||
merged = merge_metadata(parent, child)
|
||||
assert merged['project'] == 'Test'
|
||||
assert merged['experiment'] == 'A'
|
||||
assert merged['version'] == '1.1'
|
||||
|
||||
|
||||
def create_mock_pdf(path: Path):
|
||||
path.write_text("%PDF-1.4\nMock PDF for testing")
|
||||
|
||||
|
||||
def test_pdf_conversion(tmpdir):
|
||||
sys.path.insert(0, '/src')
|
||||
from gallery import convert_pdf_to_png, GalleryConfig
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
pdf_path = temp_path / "test.pdf"
|
||||
create_mock_pdf(pdf_path)
|
||||
try:
|
||||
convert_pdf_to_png(pdf_path, GalleryConfig(tmpdir))
|
||||
png_path = pdf_path.with_suffix('.png')
|
||||
assert png_path.exists()
|
||||
except subprocess.CalledProcessError:
|
||||
pytest.skip("Mock PDF not processable by ImageMagick")
|
||||
|
||||
|
||||
def create_test_structure(source_dir):
|
||||
pdf_path = source_dir / "test_plot.pdf"
|
||||
pdf_path.write_text("%PDF-1.4\nTest plot content")
|
||||
metadata_path = source_dir / "metadata.yaml"
|
||||
metadata_path.write_text("title: Container Test\nauthor: CI Pipeline\n")
|
||||
|
||||
|
||||
def test_build_gallery(tmpdir):
|
||||
sys.path.insert(0, '.')
|
||||
from gallery import build_gallery, GalleryConfig
|
||||
from unittest.mock import patch
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
source_dir = temp_path / "source"
|
||||
web_dir = temp_path / "web"
|
||||
source_dir.mkdir()
|
||||
web_dir.mkdir()
|
||||
create_test_structure(source_dir)
|
||||
|
||||
# Mock the PDF conversion and create the expected PNG file
|
||||
def mock_convert_pdf_to_png(pdf_path):
|
||||
png_path = pdf_path.with_suffix('.png')
|
||||
png_path.write_text("Mock PNG content")
|
||||
|
||||
with patch('gallery.convert_pdf_to_png', side_effect=mock_convert_pdf_to_png):
|
||||
try:
|
||||
build_gallery(GalleryConfig(tmpdir), source_dir=source_dir, web_dir=web_dir)
|
||||
html_file = web_dir / "index.html"
|
||||
assert html_file.exists()
|
||||
pdf_file = web_dir / "test_plot.pdf"
|
||||
assert pdf_file.exists()
|
||||
png_file = web_dir / "test_plot.png"
|
||||
assert png_file.exists()
|
||||
except Exception as e:
|
||||
pytest.skip(f"Gallery generation failed: {e}")
|
||||
@@ -0,0 +1,290 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
from gallery import (
|
||||
convert_pdf_to_png,
|
||||
needs_update,
|
||||
build_gallery,
|
||||
calculate_directory_stats,
|
||||
format_file_size,
|
||||
datetime_from_timestamp,
|
||||
strftime_filter
|
||||
)
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def test_format_file_size():
|
||||
assert format_file_size(0) == "0 B"
|
||||
assert format_file_size(1) == "1.0 B"
|
||||
assert format_file_size(1024) == "1.0 KB"
|
||||
assert format_file_size(1048576) == "1.0 MB"
|
||||
assert format_file_size(1073741824) == "1.0 GB"
|
||||
assert format_file_size(1099511627776) == "1.0 TB"
|
||||
|
||||
|
||||
def test_format_file_size_edge_cases():
|
||||
assert format_file_size(1023) == "1023.0 B"
|
||||
assert format_file_size(1536) == "1.5 KB"
|
||||
assert format_file_size(2621440) == "2.5 MB"
|
||||
|
||||
|
||||
def test_needs_update_missing_target(tmp_path):
|
||||
source = tmp_path / "source.txt"
|
||||
target = tmp_path / "target.txt"
|
||||
source.write_text("test content")
|
||||
|
||||
assert needs_update(source, target) is True
|
||||
|
||||
|
||||
def test_needs_update_target_newer(tmp_path):
|
||||
source = tmp_path / "source.txt"
|
||||
target = tmp_path / "target.txt"
|
||||
|
||||
source.write_text("test")
|
||||
target.write_text("test")
|
||||
|
||||
# Make target newer by modifying its timestamp
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
target.touch()
|
||||
|
||||
assert needs_update(source, target) is False
|
||||
|
||||
|
||||
def test_needs_update_source_newer(tmp_path):
|
||||
source = tmp_path / "source.txt"
|
||||
target = tmp_path / "target.txt"
|
||||
|
||||
target.write_text("test")
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
source.write_text("test")
|
||||
|
||||
# Force different modification times with 31+ second buffer
|
||||
target_time = target.stat().st_mtime
|
||||
source_time = target_time + 40 # 40 seconds newer (> 30 second buffer)
|
||||
os.utime(source, (source_time, source_time))
|
||||
|
||||
assert needs_update(source, target) is True
|
||||
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_convert_pdf_to_png_success(mock_run, tmp_path):
|
||||
from gallery import GalleryConfig
|
||||
|
||||
pdf_path = tmp_path / "test.pdf"
|
||||
png_path = tmp_path / "test.png"
|
||||
pdf_path.write_text("fake pdf")
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
convert_pdf_to_png(pdf_path, GalleryConfig(tmp_path))
|
||||
|
||||
mock_run.assert_called_once()
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert call_args[0] == "convert"
|
||||
assert str(pdf_path) in call_args
|
||||
assert str(png_path) in call_args
|
||||
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_convert_pdf_to_png_already_exists_newer(mock_run, tmp_path):
|
||||
from gallery import GalleryConfig
|
||||
pdf_path = tmp_path / "test.pdf"
|
||||
png_path = tmp_path / "test.png"
|
||||
|
||||
pdf_path.write_text("fake pdf")
|
||||
png_path.write_text("fake png")
|
||||
|
||||
# Make PNG much newer than PDF using explicit time
|
||||
pdf_time = pdf_path.stat().st_mtime
|
||||
png_time = pdf_time + 100 # PNG is 100 seconds newer
|
||||
os.utime(png_path, (png_time, png_time))
|
||||
|
||||
convert_pdf_to_png(pdf_path, GalleryConfig(tmp_path))
|
||||
|
||||
# Should not call subprocess since PNG is newer
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_convert_pdf_to_png_pdf_newer(mock_run, tmp_path):
|
||||
from gallery import GalleryConfig
|
||||
pdf_path = tmp_path / "test.pdf"
|
||||
png_path = tmp_path / "test.png"
|
||||
|
||||
png_path.write_text("fake png")
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
pdf_path.write_text("fake pdf")
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
convert_pdf_to_png(pdf_path, GalleryConfig(tmp_path))
|
||||
|
||||
# Should call subprocess since PDF is newer
|
||||
mock_run.assert_called_once()
|
||||
|
||||
|
||||
def test_calculate_directory_stats_empty(tmp_path):
|
||||
stats = calculate_directory_stats(tmp_path)
|
||||
|
||||
assert stats["file_count"] == 0
|
||||
assert stats["folder_count"] == 0
|
||||
assert stats["total_size"] == 0
|
||||
assert stats["pdf_size"] == 0
|
||||
assert stats["png_size"] == 0
|
||||
|
||||
|
||||
def test_calculate_directory_stats_with_files(tmp_path):
|
||||
# Create test files
|
||||
(tmp_path / "test.pdf").write_text("pdf content")
|
||||
(tmp_path / "test.png").write_text("png content")
|
||||
(tmp_path / "test.txt").write_text("txt content")
|
||||
|
||||
# Create subdirectory
|
||||
subdir = tmp_path / "subdir"
|
||||
subdir.mkdir()
|
||||
(subdir / "nested.pdf").write_text("nested pdf")
|
||||
|
||||
stats = calculate_directory_stats(tmp_path)
|
||||
|
||||
assert stats["file_count"] == 4
|
||||
assert stats["folder_count"] == 1
|
||||
assert stats["total_size"] > 0
|
||||
assert stats["pdf_size"] > 0
|
||||
assert stats["png_size"] > 0
|
||||
|
||||
|
||||
def test_calculate_directory_stats_nonexistent():
|
||||
nonexistent = Path("/nonexistent/path")
|
||||
stats = calculate_directory_stats(nonexistent)
|
||||
|
||||
assert stats["file_count"] == 0
|
||||
assert stats["folder_count"] == 0
|
||||
assert stats["total_size"] == 0
|
||||
|
||||
|
||||
def test_datetime_from_timestamp():
|
||||
timestamp = 1630000000 # Some Unix timestamp
|
||||
dt = datetime_from_timestamp(timestamp)
|
||||
|
||||
assert isinstance(dt, datetime)
|
||||
assert dt.timestamp() == timestamp
|
||||
|
||||
|
||||
def test_strftime_filter():
|
||||
dt = datetime(2023, 9, 8, 14, 30, 0)
|
||||
formatted = strftime_filter(dt, "%Y-%m-%d %H:%M")
|
||||
|
||||
assert formatted == "2023-09-08 14:30"
|
||||
|
||||
|
||||
@patch('gallery.builder.render_gallery_page')
|
||||
@patch('gallery.utils.metadata.save_metadata_cache')
|
||||
@patch('gallery.utils.metadata.resolve_metadata_for_plot')
|
||||
@patch('gallery.utils.metadata.merge_metadata')
|
||||
@patch('gallery.utils.metadata.load_folder_metadata')
|
||||
@patch('gallery.utils.processing.convert_pdf_to_png')
|
||||
@patch('shutil.copy2')
|
||||
def test_build_gallery_basic(
|
||||
mock_copy,
|
||||
mock_convert,
|
||||
mock_load_folder,
|
||||
mock_merge,
|
||||
mock_resolve,
|
||||
mock_save_cache,
|
||||
mock_template,
|
||||
tmp_path
|
||||
):
|
||||
from gallery import GalleryConfig
|
||||
source_dir = tmp_path / "source"
|
||||
web_dir = tmp_path / "web"
|
||||
source_dir.mkdir()
|
||||
web_dir.mkdir()
|
||||
|
||||
# Create a test PDF
|
||||
pdf_file = source_dir / "test.pdf"
|
||||
pdf_file.write_text("fake pdf content")
|
||||
|
||||
# Don't create PNG - this will trigger convert_pdf_to_png call
|
||||
|
||||
# Mock returns
|
||||
mock_load_folder.return_value = {"folder": "metadata"}
|
||||
mock_merge.return_value = {"merged": "metadata"}
|
||||
mock_resolve.return_value = {"plot": "metadata"}
|
||||
mock_template.render.return_value = "<html>test</html>"
|
||||
|
||||
build_gallery(GalleryConfig(tmp_path), source_dir, web_dir)
|
||||
|
||||
# Verify mocks were called
|
||||
mock_convert.assert_called_once_with(pdf_file)
|
||||
mock_copy.assert_called() # Should be called for PDF
|
||||
mock_save_cache.assert_called_once()
|
||||
|
||||
# Check HTML file was created
|
||||
html_file = web_dir / "index.html"
|
||||
assert html_file.exists()
|
||||
|
||||
|
||||
@patch('gallery.builder.render_gallery_page')
|
||||
@patch('gallery.utils.metadata.save_metadata_cache')
|
||||
@patch('gallery.utils.metadata.load_folder_metadata')
|
||||
def test_build_gallery_with_subdirs(
|
||||
mock_load_folder,
|
||||
mock_save_cache,
|
||||
mock_template,
|
||||
tmp_path
|
||||
):
|
||||
source_dir = tmp_path / "source"
|
||||
web_dir = tmp_path / "web"
|
||||
source_dir.mkdir()
|
||||
web_dir.mkdir()
|
||||
|
||||
# Create subdirectory
|
||||
subdir = source_dir / "subdir"
|
||||
subdir.mkdir()
|
||||
|
||||
mock_load_folder.return_value = {}
|
||||
mock_template.render.return_value = "<html>test</html>"
|
||||
|
||||
from gallery import GalleryConfig
|
||||
build_gallery(config=GalleryConfig(tmp_path), source_dir=source_dir, web_dir=web_dir)
|
||||
|
||||
# Check subdirectory was created in web
|
||||
web_subdir = web_dir / "subdir"
|
||||
assert web_subdir.exists()
|
||||
assert web_subdir.is_dir()
|
||||
|
||||
|
||||
@patch('gallery.utils.processing.needs_update')
|
||||
@patch('shutil.copy2')
|
||||
def test_build_gallery_skip_up_to_date(mock_copy, mock_needs_update, tmp_path):
|
||||
source_dir = tmp_path / "source"
|
||||
web_dir = tmp_path / "web"
|
||||
source_dir.mkdir()
|
||||
web_dir.mkdir()
|
||||
|
||||
# Create test files
|
||||
pdf_file = source_dir / "test.pdf"
|
||||
png_file = source_dir / "test.png"
|
||||
pdf_file.write_text("pdf")
|
||||
png_file.write_text("png")
|
||||
|
||||
# Create target files
|
||||
web_pdf = web_dir / "test.pdf"
|
||||
web_png = web_dir / "test.png"
|
||||
web_pdf.write_text("pdf")
|
||||
web_png.write_text("png")
|
||||
|
||||
# Mock needs_update to return False (up to date)
|
||||
mock_needs_update.return_value = False
|
||||
|
||||
from gallery import get_template
|
||||
get_template()
|
||||
from gallery import GalleryConfig
|
||||
build_gallery(GalleryConfig(tmp_path), source_dir, web_dir)
|
||||
|
||||
# copy2 should not be called since files are up to date
|
||||
mock_copy.assert_not_called()
|
||||
@@ -0,0 +1,202 @@
|
||||
import json
|
||||
import pytest
|
||||
from utils import metadata
|
||||
import yaml
|
||||
|
||||
|
||||
def test_load_metadata_file_yaml(tmp_path):
|
||||
data = {'a': 1, 'b': 'test'}
|
||||
yaml_path = tmp_path / 'meta.yaml'
|
||||
yaml_path.write_text(yaml.dump(data))
|
||||
result = metadata.load_metadata_file(yaml_path)
|
||||
assert result == data
|
||||
|
||||
|
||||
def test_load_metadata_file_json(tmp_path):
|
||||
data = {'x': 42, 'y': 'hello'}
|
||||
json_path = tmp_path / 'meta.json'
|
||||
json_path.write_text(json.dumps(data))
|
||||
result = metadata.load_metadata_file(json_path)
|
||||
assert result == data
|
||||
|
||||
|
||||
def test_load_metadata_file_missing(tmp_path):
|
||||
missing_path = tmp_path / 'nope.yaml'
|
||||
result = metadata.load_metadata_file(missing_path)
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_load_metadata_file_yml_extension(tmp_path):
|
||||
data = {'test': 'yml_format'}
|
||||
yml_path = tmp_path / 'meta.yml'
|
||||
yml_path.write_text(yaml.dump(data))
|
||||
result = metadata.load_metadata_file(yml_path)
|
||||
assert result == data
|
||||
|
||||
|
||||
def test_load_metadata_file_unknown_format(tmp_path):
|
||||
txt_path = tmp_path / 'meta.txt'
|
||||
txt_path.write_text('some text')
|
||||
result = metadata.load_metadata_file(txt_path)
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_load_metadata_file_malformed_yaml(tmp_path):
|
||||
yaml_path = tmp_path / 'bad.yaml'
|
||||
yaml_path.write_text('invalid: yaml: content: [')
|
||||
with pytest.raises(yaml.YAMLError):
|
||||
metadata.load_metadata_file(yaml_path)
|
||||
|
||||
|
||||
def test_load_metadata_file_malformed_json(tmp_path):
|
||||
json_path = tmp_path / 'bad.json'
|
||||
json_path.write_text('{"invalid": json}')
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
metadata.load_metadata_file(json_path)
|
||||
|
||||
|
||||
def test_load_folder_metadata_yaml(tmp_path):
|
||||
data = {'folder': 'metadata'}
|
||||
metadata_path = tmp_path / 'metadata.yaml'
|
||||
metadata_path.write_text(yaml.dump(data))
|
||||
result = metadata.load_folder_metadata(tmp_path)
|
||||
assert result == data
|
||||
|
||||
|
||||
def test_load_folder_metadata_yml(tmp_path):
|
||||
data = {'folder': 'metadata_yml'}
|
||||
metadata_path = tmp_path / 'metadata.yml'
|
||||
metadata_path.write_text(yaml.dump(data))
|
||||
result = metadata.load_folder_metadata(tmp_path)
|
||||
assert result == data
|
||||
|
||||
|
||||
def test_load_folder_metadata_json(tmp_path):
|
||||
data = {'folder': 'metadata_json'}
|
||||
metadata_path = tmp_path / 'metadata.json'
|
||||
metadata_path.write_text(json.dumps(data))
|
||||
result = metadata.load_folder_metadata(tmp_path)
|
||||
assert result == data
|
||||
|
||||
|
||||
def test_load_folder_metadata_missing(tmp_path):
|
||||
result = metadata.load_folder_metadata(tmp_path)
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_get_metadata_file_path_existing_yaml(tmp_path):
|
||||
metadata_path = tmp_path / 'metadata.yaml'
|
||||
metadata_path.write_text('test: data')
|
||||
result = metadata.get_metadata_file_path(tmp_path)
|
||||
assert result == str(metadata_path)
|
||||
|
||||
|
||||
def test_get_metadata_file_path_existing_yml(tmp_path):
|
||||
metadata_path = tmp_path / 'metadata.yml'
|
||||
metadata_path.write_text('test: data')
|
||||
result = metadata.get_metadata_file_path(tmp_path)
|
||||
assert result == str(metadata_path)
|
||||
|
||||
|
||||
def test_get_metadata_file_path_existing_json(tmp_path):
|
||||
metadata_path = tmp_path / 'metadata.json'
|
||||
metadata_path.write_text('{"test": "data"}')
|
||||
result = metadata.get_metadata_file_path(tmp_path)
|
||||
assert result == str(metadata_path)
|
||||
|
||||
|
||||
def test_get_metadata_file_path_none_existing(tmp_path):
|
||||
result = metadata.get_metadata_file_path(tmp_path)
|
||||
expected = str(tmp_path / 'metadata.yaml')
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_merge_metadata():
|
||||
parent = {'project': 'Test', 'version': '1.0', 'author': 'Parent'}
|
||||
child = {'experiment': 'A', 'version': '1.1'}
|
||||
merged = metadata.merge_metadata(parent, child)
|
||||
expected = {'project': 'Test', 'version': '1.1',
|
||||
'author': 'Parent', 'experiment': 'A'}
|
||||
assert merged == expected
|
||||
|
||||
|
||||
def test_merge_metadata_empty_parent():
|
||||
parent = {}
|
||||
child = {'experiment': 'A', 'version': '1.1'}
|
||||
merged = metadata.merge_metadata(parent, child)
|
||||
assert merged == child
|
||||
|
||||
|
||||
def test_merge_metadata_empty_child():
|
||||
parent = {'project': 'Test', 'version': '1.0'}
|
||||
child = {}
|
||||
merged = metadata.merge_metadata(parent, child)
|
||||
assert merged == parent
|
||||
|
||||
|
||||
def test_resolve_metadata_for_plot_with_specific_yaml(tmp_path):
|
||||
# Create plot-specific metadata file
|
||||
plot_path = tmp_path / 'test_plot.pdf'
|
||||
plot_metadata_path = tmp_path / 'test_plot.yaml'
|
||||
plot_metadata = {'specific': 'plot_data', 'override': 'plot_value'}
|
||||
plot_metadata_path.write_text(yaml.dump(plot_metadata))
|
||||
|
||||
inherited = {'general': 'data', 'override': 'inherited_value'}
|
||||
result = metadata.resolve_metadata_for_plot(plot_path, inherited)
|
||||
|
||||
expected = {'general': 'data',
|
||||
'override': 'plot_value', 'specific': 'plot_data'}
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_resolve_metadata_for_plot_with_specific_json(tmp_path):
|
||||
plot_path = tmp_path / 'test_plot.pdf'
|
||||
plot_metadata_path = tmp_path / 'test_plot.json'
|
||||
plot_metadata = {'specific': 'plot_data_json'}
|
||||
plot_metadata_path.write_text(json.dumps(plot_metadata))
|
||||
|
||||
inherited = {'general': 'data'}
|
||||
result = metadata.resolve_metadata_for_plot(plot_path, inherited)
|
||||
|
||||
expected = {'general': 'data', 'specific': 'plot_data_json'}
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_resolve_metadata_for_plot_no_specific(tmp_path):
|
||||
plot_path = tmp_path / 'test_plot.pdf'
|
||||
inherited = {'general': 'data', 'inherited': 'value'}
|
||||
result = metadata.resolve_metadata_for_plot(plot_path, inherited)
|
||||
|
||||
# Should return copy of inherited metadata
|
||||
assert result == inherited
|
||||
assert result is not inherited # Should be a copy
|
||||
|
||||
|
||||
def test_save_metadata_cache(tmp_path):
|
||||
cache_data = {
|
||||
'plot1': {'title': 'Plot 1', 'author': 'Test'},
|
||||
'plot2': {'title': 'Plot 2', 'experiment': 'B'}
|
||||
}
|
||||
|
||||
metadata.save_metadata_cache(tmp_path, cache_data)
|
||||
|
||||
cache_file = tmp_path / 'meta_cache.json'
|
||||
assert cache_file.exists()
|
||||
|
||||
with cache_file.open('r') as f:
|
||||
loaded_data = json.load(f)
|
||||
|
||||
assert loaded_data == cache_data
|
||||
|
||||
|
||||
def test_save_metadata_cache_empty(tmp_path):
|
||||
cache_data = {}
|
||||
metadata.save_metadata_cache(tmp_path, cache_data)
|
||||
|
||||
cache_file = tmp_path / 'meta_cache.json'
|
||||
assert cache_file.exists()
|
||||
|
||||
with cache_file.open('r') as f:
|
||||
loaded_data = json.load(f)
|
||||
|
||||
assert loaded_data == {}
|
||||