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>
This commit is contained in:
2026-06-29 11:45:45 +02:00
74 changed files with 11213 additions and 150 deletions
+10 -1
View File
@@ -1 +1,10 @@
__pycache__
**__pycache__**
.vscode
*.sif
*.ipynb
backups
.pytest_cache
.venv
build
*egg-info
.claude
+65
View File
@@ -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
+113
View File
@@ -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.
+21
View File
@@ -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.
+241 -125
View File
@@ -1,141 +1,257 @@
# Gallery Application - Restructured
# Gallery: Scientific Plot Organizer
This document explains the new modular structure of the gallery application.
[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
![gallery_view](docs/images/main_gallery_view.png)
### Metadata Display
![metadata](docs/images/metadata_view.png)
### Plot Comparison Tool
![plot_comparison](docs/images/plot_comparison.png)
### 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)
+20
View File
@@ -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 "$@"
+10 -12
View File
@@ -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/"
+275
View File
@@ -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.*
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

+77
View File
@@ -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",
]
+306
View File
@@ -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
+71
View File
@@ -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;
}
}
+180
View File
@@ -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;
}
+440
View File
@@ -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);
}
+112
View File
@@ -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;
}
}
+179
View File
@@ -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;
}
}
+33
View File
@@ -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);
}
+106
View File
@@ -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;
}
+38
View File
@@ -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;
}
+33
View File
@@ -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');
+383
View File
@@ -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;
}
}
+180
View File
@@ -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;
}
}
+57
View File
@@ -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;
}
+59
View File
@@ -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;
}
}
+72
View File
@@ -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;
}
+121
View File
@@ -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;
}
+88
View File
@@ -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;
}
View File
+67
View File
@@ -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;
}
}
+33
View File
@@ -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;
}
+414
View File
@@ -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;
}
}
+54
View File
@@ -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;
}
+180
View File
@@ -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);
}
}
+444
View File
@@ -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();
});
}
});
+81
View File
@@ -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');
}
});
});
+73
View File
@@ -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); }
}
+130
View File
@@ -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';
}
}
+27
View File
@@ -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;
});
+212
View File
@@ -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);
};
+142
View File
@@ -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);
}
});
+194
View File
@@ -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;
}
}
+114
View File
@@ -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);
}
});
}
}
+217
View File
@@ -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';
}
}
+197
View File
@@ -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();
}
}
+54
View File
@@ -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;
}
}
}
+43
View File
@@ -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');
}
}
}
+107
View File
@@ -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';
}
}
}
+192
View File
@@ -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]);
}
}
+190
View File
@@ -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
+324
View File
@@ -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()
+238
View File
@@ -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)
+14
View File
@@ -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
+363
View File
@@ -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>
+487
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
"""Utility modules for gallery generation."""
+40
View File
@@ -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
+30
View File
@@ -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)
+160
View File
@@ -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}")
+261
View File
@@ -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)
+63
View File
@@ -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]}"
+59 -12
View File
@@ -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"
+4
View File
@@ -0,0 +1,4 @@
import sys
sys.path.append("..")
+214
View File
@@ -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()
+195
View File
@@ -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
+138
View File
@@ -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}")
+290
View File
@@ -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()
+202
View File
@@ -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 == {}
Generated
+1675
View File
File diff suppressed because it is too large Load Diff