From 976d90f26b846b1209c5e998ecbdff1b9e17af57 Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Mon, 7 Jul 2025 13:27:25 +0200 Subject: [PATCH 01/35] Refactor gallery generator with smart file handling and improved UI Key improvements: - Add intelligent file update checking with 30-second buffer to avoid unnecessary operations - Implement efficient PDF to PNG conversion (only when source is newer) - Add smart file copying (skip if target is up-to-date) - Create proper folder structure using plot_root/source_name pattern - Improve template with better text handling for long plot names - Add text wrapping, truncation, and hover tooltips for plot names - Remove unnecessary directory cleaning for true incremental updates - Fix title display issue (was showing '.' instead of 'Gallery') - Add comprehensive logging showing what's processed vs skipped Performance benefits: - Subsequent runs are significantly faster (only processes changed files) - Reduces ImageMagick conversions and file I/O operations - Maintains file system timing robustness with buffer delays UI improvements: - Better handling of long filenames with word wrapping - Constrained text areas prevent overlap between thumbnails - Hover tooltips show full names when truncated - Responsive grid layout maintained --- README.md | 92 +---------- __pycache__/config.cpython-312.pyc | Bin 0 -> 2907 bytes backup.py | 23 +++ config.py | 64 ++++++++ config.yaml | 7 + generate_gallery.py | 184 ++++++++++++++++++++++ template.html | 59 ++++++++ test.ipynb | 235 +++++++++++++++++++++++++++++ 8 files changed, 576 insertions(+), 88 deletions(-) create mode 100644 __pycache__/config.cpython-312.pyc create mode 100644 backup.py create mode 100644 config.py create mode 100644 config.yaml create mode 100644 generate_gallery.py create mode 100644 template.html create mode 100644 test.ipynb diff --git a/README.md b/README.md index b839b08..f1b6159 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,9 @@ -# web - - - -## Getting started - -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - -## Add your files - -- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files -- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command: - -``` -cd existing_repo -git remote add origin https://gitlab.etp.kit.edu/kschmidt/web.git -git branch -M main -git push -uf origin main -``` - -## Integrate with your tools - -- [ ] [Set up project integrations](https://gitlab.etp.kit.edu/kschmidt/web/-/settings/integrations) - -## Collaborate with your team - -- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) -- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) -- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) -- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) -- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/) - -## Test and Deploy - -Use the built-in continuous integration in GitLab. - -- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/) -- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) -- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) -- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) -- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) - -*** - -# Editing this README - -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. - -## Suggestions for a good README - -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. - -## Name -Choose a self-explaining name for your project. - -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. - -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. - -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. +# Automated web-based plotting tool ## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. +Requires `Jinja2` -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. +## Config -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. - -## Contributing -State if you are open to contributions and what your requirements are for accepting them. - -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. - -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. - -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. - -## License -For open source projects, say how it is licensed. - -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. +## Usage \ No newline at end of file diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1dd0de69c3dbdd63929862dcf0479c265c4b5197 GIT binary patch literal 2907 zcmcH*OKcm*b#`ZVNiIK3cS%cb zPX)Rp-psuB=Dm5}nGdsB1Ht#A?kp8_gg%#x{-?#0t@mMa4N*iD7gd##QdLW86_>Cg zajHwIT1gA%*wsrqQ&e;cQE~}U?WP*ZxEYHoC1R=$)Wm;KlPpD(%aaK4eJWzf!7hUt z=f+|Y+XkAtsYFW!HE|Ocjep2x5sMVH1H0_nzAvOp6SJl^(9JG?x+xS`uhlxYA3hkd}}u2$I3lx03xZlrk*MvipDn{3*%7a?}9F zr75Zqnz=*3Rpqb0S3y6; z=Va8L_$a2N&+`hpLdxnQDyu&pN2pDLRG+y;s3!dzq_<&oiRtURH7KjA>SdFFj5cXu z8pcvZEku(JYT28`6-00^^iR|6GO_IYrtVaDI9o=wdtwd*{r z1+&7vs%e{NXI^{Nl%bk32HZ1z(LXj=NfCp9nv+mf#bc%{a5M1CV2N!P(bp$OV1#a9 zPA+(!Tihv5l4mRC8D3}4n%s7L=9_lSWSo24bSiMHW!5yE+D`E%^?(nG*<|M(Kj_n= zd^W>YG_~W@v6_>%>oapQPS@hIrcEj6b6u$gCh!m7tPAyk`H!9OdYHIR_#x7D%$%+H zfn6&z)2qN49Y4}OP%acc$De|*pX4&FxXk#X`gqQVB_bh+x58?INbic2Z&#S*LZAd* zWI@z^WVaHU10B#WCIv3LK0hc?N<|no*07?G3heW;P?m-Arbs&R(R|szqzB~3cgtNV z8FNc&yD%JCGBv>L1qS~Q09Mh%ag^KLEnc6$n=Pz;_peVM`t8N{F5a*=o<7n!{RpGn zM9)C^iE9_HUR-l-@4Y*AbW6hpfycOJj`HuekZ*NS$^q0)c z=Gf8ulB;8MzrLP4c;7%HWA_mrN#Dy)e3GAB&rjaXAAYFe{l;hc!&gaH=|0~BS-H*| zZ@;pcKm0&aLjFz?v=8SyLyr!?Nj}OJ{`Eiyx&PcUfWsdJ1Kt@uF@pbi=!8lB^sVfP zXL28mXeSOP=619o>DRCYbzt(px1eCqWLksf(1J#E3*U@e7u?xI{`zePZc*}NDK|+} z%0=zMTZ0sux8G!8`j_jRvs&;(^+M_WziL+~jg!#ccMsaT>oqJtXu52!NO%Dp#^oI@ z2(4FFxV-S-H9&mkR>IumUj~dX%^&1lBJiTD%f(q2uV0XyM*u+1<#yy;X86kN+q1u^ zTtD-!yEb#@nU6Csg_-wBwy>TpeBsNgdS~Km_>MNCB#OJZY}H&|(OfoNF4KVLBp8xF zzH4|MfWS^I;QQrLUMPaN&_}{T4a-fW!zNN?!IDS$bCOLSF|5|;zL#ahNBThO@&f?y zFc{6Rp6=oelHc5SVD+``3mfFX=AO~jQ=Rz@GP=2Ue08?tZ;pxThY_@@t2B2!a-*kvtx~ zx?bep09JW}`woB}Rutv$=*T)c@;8+K9Ho0YQqzxART=6b0AQ4w?uAPuBs{0gC_N-$ rObQV{Q4q;=F8mqq`!u`fmp@oNxk-#)%>8Vx`^}F@p{F6TKeYbeZi%CL literal 0 HcmV?d00001 diff --git a/backup.py b/backup.py new file mode 100644 index 0000000..d1937a4 --- /dev/null +++ b/backup.py @@ -0,0 +1,23 @@ +import zipfile +import datetime +from pathlib import Path + +WEB_FOLDER = Path("plots") +BACKUP_FOLDER = Path("backups") + +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(): + print(f"Backup already exists: {backup_path}") +else: + print(f"Creating backup: {backup_path}") + 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) + print("✅ Backup complete.") diff --git a/config.py b/config.py new file mode 100644 index 0000000..c59f1ec --- /dev/null +++ b/config.py @@ -0,0 +1,64 @@ +from dataclasses import dataclass, field, asdict +from pathlib import Path + +import yaml + + +@dataclass +class GalleryItem: + name: str + path: Path + + +@dataclass +class Config: + web_folder: str = "" + backup_folder: str = "" + png_dpi: int = 400 + plot_root: str = "gallery" + sources: list[GalleryItem] = field(default_factory=list) + + @classmethod + def from_yaml(cls, yaml_file: str) -> "Config": + """ + Load configuration from a YAML file. + + Args: + yaml_file (str): Path to the YAML file. + strict (bool): + If True, raises an error if a key in the YAML file does not exist in the Config class. + If False (default), adds all keys as attributes + + Returns: + Config: Instance of this class + """ + + with open(yaml_file, "r") as f: + new_config: dict = yaml.safe_load(f) + + new_config["sources"] = [ + GalleryItem(name=src["name"], path=Path(src["path"])) + for src in new_config.get("sources", False) + ] + + instance = cls(**{ + k: v + for k, v in new_config.items() + }) + + return instance + + def to_yaml(self, yaml_file: str) -> None: + """ + Save the current configuration to a YAML file. + + Args: + yaml_file (str): Path to the YAML file. + """ + with open(yaml_file, "w") as f: + yaml.dump(asdict(self), f, default_flow_style=False) + + +if __name__ == "__main__": + config = Config() + config.to_yaml("config.yaml") diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..0b38414 --- /dev/null +++ b/config.yaml @@ -0,0 +1,7 @@ +backup_folder: '' +plot_root: gallery +png_dpi: 400 +sources: + - name: "ttbar_analysis" + path: "/work/kschmidt/NEEDLE/test_analysis/data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/" +web_folder: '/web/kschmidt/public_html/' diff --git a/generate_gallery.py b/generate_gallery.py new file mode 100644 index 0000000..ac47a18 --- /dev/null +++ b/generate_gallery.py @@ -0,0 +1,184 @@ +import subprocess +import shutil + +from pathlib import Path +from jinja2 import Environment, FileSystemLoader +from config import Config + + +CONFIG = Config.from_yaml("config.yaml") + +env = Environment(loader=FileSystemLoader(".")) +template = env.get_template("template.html") + + +def convert_pdf_to_png(pdf_path: Path): + png_path = pdf_path.with_suffix(".png") + + # Check if PNG exists and is newer than PDF (with 30 second buffer) + if png_path.exists(): + pdf_mtime = pdf_path.stat().st_mtime + png_mtime = png_path.stat().st_mtime + if png_mtime >= (pdf_mtime + 30): # 30 second buffer + # PNG is up to date, no need to convert + return + else: + print(f"PDF {pdf_path.name} is newer than PNG, reconverting...") + + print(f"Converting {pdf_path} → {png_path}") + subprocess.run([ + "convert", + "-density", str(CONFIG.png_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 to be updated based on source file modification time""" + if not target_file.exists(): + return True + + source_mtime = source_file.stat().st_mtime + target_mtime = target_file.stat().st_mtime + + # Return True if source is newer than target (with 30 second buffer) + return source_mtime > (target_mtime + 30) + + +def build_gallery(source_dir: Path, web_dir: Path, relative_path: Path = None): + """ + Build gallery for a directory, copying files to web directory and generating index.html + + Args: + source_dir: Source directory containing PDFs + web_dir: Target web directory + relative_path: Relative path for navigation (None for root) + """ + if relative_path is None: + relative_path = Path(".") + + # Ensure web directory exists + target_dir = web_dir / relative_path + target_dir.mkdir(parents=True, exist_ok=True) + + # Find all PDFs in source directory + pdfs = sorted(source_dir.glob("*.pdf")) + items = [] + + for pdf in pdfs: + # Convert PDF to PNG in source directory + convert_pdf_to_png(pdf) + png_path = pdf.with_suffix(".png") + + # Copy both PDF and PNG to target directory only if needed + target_pdf = target_dir / pdf.name + target_png = target_dir / png_path.name + + if needs_update(pdf, target_pdf): + print(f"Copying {pdf.name} to {target_pdf}") + shutil.copy2(pdf, target_pdf) + else: + print(f"Skipping {pdf.name} (up to date)") + + if png_path.exists() and needs_update(png_path, target_png): + print(f"Copying {png_path.name} to {target_png}") + shutil.copy2(png_path, target_png) + elif png_path.exists(): + print(f"Skipping {png_path.name} (up to date)") + + items.append({ + "name": pdf.name, + "pdf_href": pdf.name, + "png_href": png_path.name + }) + + # Find subdirectories in source + subdirs = sorted([d.name for d in source_dir.iterdir() if d.is_dir()]) + + # Generate index.html in target directory + output_html = target_dir / "index.html" + + # Create a meaningful title + if str(relative_path) == ".": + title = "Gallery" + else: + title = f"Gallery: {relative_path}" + + with output_html.open("w") as f: + f.write(template.render( + title=title, + items=items, + subdirs=subdirs, + relpath=str(relative_path) + )) + + print(f"Generated {output_html}") + + # Recursively process subdirectories + for subdir in subdirs: + source_subdir = source_dir / subdir + new_relative_path = relative_path / subdir + build_gallery(source_subdir, web_dir, new_relative_path) + + +def loop_over_sources(): + web_root = Path(CONFIG.web_folder) + + for source in CONFIG.sources: + print(f"Processing {source.name}: {source.path}") + if source.path.is_dir(): + # Create a subdirectory based on plot_root and source name + source_web_dir = web_root / CONFIG.plot_root / source.name + build_gallery(source.path, source_web_dir) + print(f"Processed {source.name}: {source.path}") + elif source.path.suffix == ".pdf": + # For single PDF files, copy to web root with plot_root and source name as directory + source_web_dir = web_root / CONFIG.plot_root / source.name + convert_pdf_to_png(source.path) + png_path = source.path.with_suffix(".png") + + # Ensure web directory exists + source_web_dir.mkdir(parents=True, exist_ok=True) + + # Copy PDF and PNG to web directory only if needed + target_pdf = source_web_dir / source.path.name + target_png = source_web_dir / png_path.name + + if needs_update(source.path, target_pdf): + print(f"Copying {source.path.name} to {target_pdf}") + shutil.copy2(source.path, target_pdf) + else: + print(f"Skipping {source.path.name} (up to date)") + + if png_path.exists() and needs_update(png_path, target_png): + print(f"Copying {png_path.name} to {target_png}") + shutil.copy2(png_path, target_png) + elif png_path.exists(): + print(f"Skipping {png_path.name} (up to date)") + + # Create items list for template + items = [{ + "name": source.path.name, + "pdf_href": source.path.name, + "png_href": png_path.name + }] + + # Generate index.html for single PDF + output_html = source_web_dir / "index.html" + with output_html.open("w") as f: + f.write(template.render( + title=source.name, + items=items, + subdirs=[], + relpath=source.name + )) + + print(f"Generated {output_html}") + print(f"Processed {source.name}: {source.path}") + + +if __name__ == "__main__": + loop_over_sources() + print("Done") diff --git a/template.html b/template.html new file mode 100644 index 0000000..036fb6a --- /dev/null +++ b/template.html @@ -0,0 +1,59 @@ + + + + + {{ title }} + + + +

{{ relpath or 'Root' }}

+
+ {% for item in items %} +
+ + {{ item.name }} + +
{{ item.name }}
+
+ {% endfor %} +
+ + {% if subdirs %} +

Subdirectories

+
    + {% for sub in subdirs %} +
  • {{ sub }}
  • + {% endfor %} +
+ {% endif %} + + diff --git a/test.ipynb b/test.ipynb new file mode 100644 index 0000000..d5d9ae8 --- /dev/null +++ b/test.ipynb @@ -0,0 +1,235 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 2, + "id": "aedbb9f5", + "metadata": {}, + "outputs": [], + "source": [ + "from dataclasses import dataclass, field, asdict\n", + "from pathlib import Path\n", + "\n", + "import yaml\n", + "\n", + "\n", + "@dataclass\n", + "class GalleryItem:\n", + " name: str\n", + " path: Path\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "66624faf", + "metadata": {}, + "outputs": [], + "source": [ + "yaml_file = Path(\"config.yaml\")\n", + "\n", + "with open(yaml_file, \"r\") as f:\n", + " new_config: dict = yaml.safe_load(f)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "40a472f8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'name': 'test_1_plot',\n", + " 'path': 'data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1/plot__proc_3_5606769559__cat_incl__var_jet1_pt.pdf'}" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "new_config[\"sources\"][0]" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "745c97ad", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[GalleryItem(name='test_1_plot', path=PosixPath('data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1/plot__proc_3_5606769559__cat_incl__var_jet1_pt.pdf')),\n", + " GalleryItem(name='test_2_dir', path=PosixPath('data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1'))]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "sources = [\n", + " GalleryItem(name=src[\"name\"], path=Path(src[\"path\"]))\n", + " for src in new_config.get(\"sources\", False)\n", + "]\n", + "sources" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "a0102f9d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Config(web_folder='', backup_folder='', png_dpi=400, plot_root='gallery', sources=[{'name': 'test_1_plot', 'path': 'data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1/plot__proc_3_5606769559__cat_incl__var_jet1_pt.pdf'}, {'name': 'test_2_dir', 'path': 'data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1/'}])" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "\n", + "\n", + "@dataclass\n", + "class Config:\n", + " web_folder: str = \"\"\n", + " backup_folder: str = \"\"\n", + " png_dpi: int = 400\n", + " plot_root: str = \"gallery\"\n", + " sources: list[GalleryItem] = field(default_factory=list)\n", + "\n", + " @classmethod\n", + " def from_yaml(cls, yaml_file: str, strict: bool = False) -> \"Config\":\n", + " \"\"\"\n", + " Load configuration from a YAML file.\n", + "\n", + " Args:\n", + " yaml_file (str): Path to the YAML file.\n", + " strict (bool):\n", + " If True, raises an error if a key in the YAML file does not exist in the Config class.\n", + " If False (default), adds all keys as attributes\n", + "\n", + " Returns:\n", + " Config: Instance of this class\n", + " \"\"\"\n", + "\n", + " with open(yaml_file, \"r\") as f:\n", + " new_config: dict = yaml.safe_load(f)\n", + "\n", + " new_config[\"sources\"] = [\n", + " GalleryItem(name=src[\"name\"], path=Path(src[\"path\"]))\n", + " for src in new_config.get(\"sources\", False)\n", + " ]\n", + "\n", + " instance = cls(**{\n", + " k: v\n", + " for k, v in new_config.items()\n", + " if hasattr(cls, k) or not strict\n", + " })\n", + "\n", + " for key in new_config.keys():\n", + " if strict and not hasattr(instance, key):\n", + " raise KeyError(f\"Key '{key}' not found in Config class\")\n", + "\n", + " return instance\n", + "\n", + " def to_yaml(self, yaml_file: str) -> None:\n", + " \"\"\"\n", + " Save the current configuration to a YAML file.\n", + "\n", + " Args:\n", + " yaml_file (str): Path to the YAML file.\n", + " \"\"\"\n", + " with open(yaml_file, \"w\") as f:\n", + " yaml.dump(asdict(self), f, default_flow_style=False)\n", + "\n", + "\n", + "instance = Config(**{\n", + " k: v\n", + " for k, v in new_config.items()\n", + " #if hasattr(Config, k)\n", + "})\n", + "instance" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "0b516ae9", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[{'name': 'test_1_plot',\n", + " 'path': 'data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1/plot__proc_3_5606769559__cat_incl__var_jet1_pt.pdf'},\n", + " {'name': 'test_2_dir',\n", + " 'path': 'data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1/'}]" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "new_config[\"sources\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "b047f0c5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[{'name': 'test_1_plot',\n", + " 'path': 'data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1/plot__proc_3_5606769559__cat_incl__var_jet1_pt.pdf'},\n", + " {'name': 'test_2_dir',\n", + " 'path': 'data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/calib__test/sel__test/prod__test/weight__test/nominal/datasets_ttbar_dl/dev1/'}]" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "instance.sources" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} -- 2.39.5 From 9041abb72f11031ca7f3b47b73cb44fcf9e5c7dd Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Mon, 7 Jul 2025 14:44:09 +0200 Subject: [PATCH 02/35] Update and refactor --- config.py | 112 +++- config.yaml | 42 +- generate_gallery.py | 217 +++++--- template.html | 1267 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 1510 insertions(+), 128 deletions(-) diff --git a/config.py b/config.py index c59f1ec..fc798e4 100644 --- a/config.py +++ b/config.py @@ -1,64 +1,128 @@ +""" +Scientific Gallery Configuration Management + +This module provides dataclasses and utilities for managing configuration +of the scientific gallery system, including paths, gallery settings, +UI preferences, and data sources. +""" + from dataclasses import dataclass, field, asdict from pathlib import Path - import yaml +@dataclass +class PathConfig: + """Configuration for system paths and directories.""" + work_dir: str + web_folder: str + cgi_script: str + config_path: str + + +@dataclass +class GalleryConfig: + """Configuration for gallery generation and display settings.""" + plot_root: str + png_dpi: int + backup_folder: str + + +@dataclass +class UIConfig: + """Configuration for user interface behavior and preferences.""" + max_recent_plots: int + search_debounce_ms: int + + @dataclass class GalleryItem: + """Represents a single data source for the gallery.""" name: str path: Path @dataclass class Config: - web_folder: str = "" - backup_folder: str = "" - png_dpi: int = 400 - plot_root: str = "gallery" + """ + Main configuration class that aggregates all gallery settings. + + Provides backward compatibility properties and methods for loading + configuration from YAML files. + """ + paths: PathConfig + gallery: GalleryConfig + ui: UIConfig sources: list[GalleryItem] = field(default_factory=list) + @property + def web_folder(self): + """Backward compatibility property for web folder path.""" + return self.paths.web_folder + + @property + def png_dpi(self): + """Backward compatibility property for PNG conversion DPI.""" + return self.gallery.png_dpi + + @property + def plot_root(self): + """Backward compatibility property for plot root directory.""" + return self.gallery.plot_root + + @property + def backup_folder(self): + """Backward compatibility property for backup folder path.""" + return self.gallery.backup_folder + @classmethod def from_yaml(cls, yaml_file: str) -> "Config": """ Load configuration from a YAML file. Args: - yaml_file (str): Path to the YAML file. - strict (bool): - If True, raises an error if a key in the YAML file does not exist in the Config class. - If False (default), adds all keys as attributes + yaml_file: Path to the YAML configuration file Returns: - Config: Instance of this class + Config instance with loaded settings + + Raises: + FileNotFoundError: If the YAML file doesn't exist + yaml.YAMLError: If the YAML file is malformed """ - with open(yaml_file, "r") as f: - new_config: dict = yaml.safe_load(f) + data = yaml.safe_load(f) - new_config["sources"] = [ - GalleryItem(name=src["name"], path=Path(src["path"])) - for src in new_config.get("sources", False) + paths_data = data.get('paths', {}) + gallery_data = data.get('gallery', {}) + ui_data = data.get('ui', {}) + sources_data = data.get('sources', []) + + paths = PathConfig(**paths_data) + gallery = GalleryConfig(**gallery_data) + ui = UIConfig(**ui_data) + + sources = [ + GalleryItem(name=source["name"], path=Path(source["path"])) + for source in sources_data ] - instance = cls(**{ - k: v - for k, v in new_config.items() - }) - - return instance + return cls(paths=paths, gallery=gallery, ui=ui, sources=sources) def to_yaml(self, yaml_file: str) -> None: """ Save the current configuration to a YAML file. Args: - yaml_file (str): Path to the YAML file. + yaml_file: Path where to save the YAML configuration + + Raises: + IOError: If unable to write to the specified file """ with open(yaml_file, "w") as f: yaml.dump(asdict(self), f, default_flow_style=False) if __name__ == "__main__": - config = Config() - config.to_yaml("config.yaml") + config = Config.from_yaml("config.yaml") + print("Loaded config successfully:", config) diff --git a/config.yaml b/config.yaml index 0b38414..a4d5b46 100644 --- a/config.yaml +++ b/config.yaml @@ -1,7 +1,41 @@ -backup_folder: '' -plot_root: gallery -png_dpi: 400 +# Gallery Configuration +# =================== + +# Paths Configuration +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: "" + +# UI Settings +ui: + # Maximum number of recent plots to track + max_recent_plots: 20 + + # Search settings + search_debounce_ms: 300 + +# Data Sources +# Each source represents a collection of plots to include in the gallery sources: - name: "ttbar_analysis" path: "/work/kschmidt/NEEDLE/test_analysis/data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/" -web_folder: '/web/kschmidt/public_html/' diff --git a/generate_gallery.py b/generate_gallery.py index ac47a18..22d0f65 100644 --- a/generate_gallery.py +++ b/generate_gallery.py @@ -1,6 +1,20 @@ +""" +Scientific Gallery Generator + +This module generates static HTML galleries from scientific plot collections. +It converts PDF plots to PNG thumbnails, creates responsive web interfaces, +and organizes plots into hierarchical directory structures. + +Features: +- PDF to PNG conversion with configurable DPI +- Incremental updates (only converts when source is newer) +- Jinja2 templating for consistent HTML generation +- Support for nested folder structures +- Responsive grid layout with search and navigation +""" + import subprocess import shutil - from pathlib import Path from jinja2 import Environment, FileSystemLoader from config import Config @@ -12,15 +26,25 @@ env = Environment(loader=FileSystemLoader(".")) template = env.get_template("template.html") -def convert_pdf_to_png(pdf_path: Path): +def convert_pdf_to_png(pdf_path: Path) -> None: + """ + Convert a PDF file to PNG format using ImageMagick. + + 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). + + Args: + pdf_path: Path to the source PDF file + + Raises: + subprocess.CalledProcessError: If ImageMagick conversion fails + """ png_path = pdf_path.with_suffix(".png") - # Check if PNG exists and is newer than PDF (with 30 second buffer) if png_path.exists(): pdf_mtime = pdf_path.stat().st_mtime png_mtime = png_path.stat().st_mtime - if png_mtime >= (pdf_mtime + 30): # 30 second buffer - # PNG is up to date, no need to convert + if png_mtime >= (pdf_mtime + 30): return else: print(f"PDF {pdf_path.name} is newer than PNG, reconverting...") @@ -36,72 +60,85 @@ def convert_pdf_to_png(pdf_path: Path): def needs_update(source_file: Path, target_file: Path) -> bool: - """Check if target file needs to be updated based on source file modification time""" + """ + 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 True if source is newer than target (with 30 second buffer) return source_mtime > (target_mtime + 30) -def build_gallery(source_dir: Path, web_dir: Path, relative_path: Path = None): +def build_gallery(source_dir: Path, web_dir: Path, + relative_path: Path = None) -> None: """ - Build gallery for a directory, copying files to web directory and generating index.html + 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. Args: - source_dir: Source directory containing PDFs - web_dir: Target web directory - relative_path: Relative path for navigation (None for root) + source_dir: Source directory containing PDF files + web_dir: Target web directory for gallery output + relative_path: Relative path from gallery root (for navigation) """ if relative_path is None: relative_path = Path(".") - # Ensure web directory exists - target_dir = web_dir / relative_path - target_dir.mkdir(parents=True, exist_ok=True) + pdf_files = list(source_dir.glob("*.pdf")) + subdirs = [d for d in source_dir.iterdir() if d.is_dir()] - # Find all PDFs in source directory - pdfs = sorted(source_dir.glob("*.pdf")) items = [] - for pdf in pdfs: - # Convert PDF to PNG in source directory - convert_pdf_to_png(pdf) - png_path = pdf.with_suffix(".png") + for pdf_file in pdf_files: + png_file = pdf_file.with_suffix(".png") - # Copy both PDF and PNG to target directory only if needed - target_pdf = target_dir / pdf.name - target_png = target_dir / png_path.name + web_pdf = web_dir / pdf_file.name + web_png = web_dir / png_file.name - if needs_update(pdf, target_pdf): - print(f"Copying {pdf.name} to {target_pdf}") - shutil.copy2(pdf, target_pdf) + if needs_update(pdf_file, web_pdf): + print(f"Copying {pdf_file} to {web_pdf}") + shutil.copy2(pdf_file, web_pdf) else: - print(f"Skipping {pdf.name} (up to date)") + print(f"Skipping {pdf_file.name} (up to date)") - if png_path.exists() and needs_update(png_path, target_png): - print(f"Copying {png_path.name} to {target_png}") - shutil.copy2(png_path, target_png) - elif png_path.exists(): - print(f"Skipping {png_path.name} (up to date)") + if not png_file.exists(): + convert_pdf_to_png(pdf_file) + + if needs_update(png_file, web_png): + print(f"Copying {png_file} to {web_png}") + shutil.copy2(png_file, web_png) + else: + print(f"Skipping {png_file.name} (up to date)") items.append({ - "name": pdf.name, - "pdf_href": pdf.name, - "png_href": png_path.name + "name": pdf_file.stem, + "pdf_href": pdf_file.name, + "png_href": png_file.name }) - # Find subdirectories in source - subdirs = sorted([d.name 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(subdir, subdir_web, subdir_relative) + subdir_names.append(subdir.name) - # Generate index.html in target directory - output_html = target_dir / "index.html" + output_html = web_dir / "index.html" - # Create a meaningful title - if str(relative_path) == ".": + if relative_path == Path("."): title = "Gallery" else: title = f"Gallery: {relative_path}" @@ -110,75 +147,89 @@ def build_gallery(source_dir: Path, web_dir: Path, relative_path: Path = None): f.write(template.render( title=title, items=items, - subdirs=subdirs, - relpath=str(relative_path) + subdirs=subdir_names, + relpath=str(relative_path), + ui=CONFIG.ui )) print(f"Generated {output_html}") - # Recursively process subdirectories - for subdir in subdirs: - source_subdir = source_dir / subdir - new_relative_path = relative_path / subdir - build_gallery(source_subdir, web_dir, new_relative_path) +def main() -> None: + """ + Main entry point for gallery generation. -def loop_over_sources(): - web_root = Path(CONFIG.web_folder) + Processes all configured sources and generates the complete gallery + structure in the web directory. Cleans up the gallery directory + on first run to ensure consistency. + """ + gallery_root = Path(CONFIG.web_folder) / CONFIG.plot_root + + if gallery_root.exists(): + print(f"Gallery directory {gallery_root} exists, cleaning up...") + shutil.rmtree(gallery_root) + + gallery_root.mkdir(parents=True, exist_ok=True) for source in CONFIG.sources: - print(f"Processing {source.name}: {source.path}") - if source.path.is_dir(): - # Create a subdirectory based on plot_root and source name - source_web_dir = web_root / CONFIG.plot_root / source.name - build_gallery(source.path, source_web_dir) - print(f"Processed {source.name}: {source.path}") - elif source.path.suffix == ".pdf": - # For single PDF files, copy to web root with plot_root and source name as directory - source_web_dir = web_root / CONFIG.plot_root / source.name - convert_pdf_to_png(source.path) - png_path = source.path.with_suffix(".png") + source_path = Path(source.path) - # Ensure web directory exists + if source_path.is_file() and source_path.suffix == '.pdf': + source_web_dir = gallery_root / source.name source_web_dir.mkdir(parents=True, exist_ok=True) - # Copy PDF and PNG to web directory only if needed - target_pdf = source_web_dir / source.path.name - target_png = source_web_dir / png_path.name + pdf_name = source_path.name + png_name = source_path.with_suffix('.png').name - if needs_update(source.path, target_pdf): - print(f"Copying {source.path.name} to {target_pdf}") - shutil.copy2(source.path, target_pdf) + web_pdf_path = source_web_dir / pdf_name + web_png_path = source_web_dir / png_name + source_png_path = source_path.with_suffix('.png') + + if needs_update(source_path, web_pdf_path): + print(f"Copying {source_path} to {web_pdf_path}") + shutil.copy2(source_path, web_pdf_path) else: - print(f"Skipping {source.path.name} (up to date)") + print(f"Skipping {source_path.name} (up to date)") - if png_path.exists() and needs_update(png_path, target_png): - print(f"Copying {png_path.name} to {target_png}") - shutil.copy2(png_path, target_png) - elif png_path.exists(): - print(f"Skipping {png_path.name} (up to date)") + if not source_png_path.exists(): + convert_pdf_to_png(source_path) + + if needs_update(source_png_path, web_png_path): + print(f"Copying {source_png_path} to {web_png_path}") + shutil.copy2(source_png_path, web_png_path) + else: + print(f"Skipping {source_png_path.name} (up to date)") - # Create items list for template items = [{ - "name": source.path.name, - "pdf_href": source.path.name, - "png_href": png_path.name + "name": source_path.stem, + "pdf_href": pdf_name, + "png_href": png_name }] - # Generate index.html for single PDF output_html = source_web_dir / "index.html" with output_html.open("w") as f: f.write(template.render( title=source.name, items=items, subdirs=[], - relpath=source.name + relpath=source.name, + ui=CONFIG.ui )) print(f"Generated {output_html}") print(f"Processed {source.name}: {source.path}") + elif source_path.is_dir(): + source_web_dir = gallery_root / source.name + source_web_dir.mkdir(parents=True, exist_ok=True) + build_gallery(source_path, source_web_dir, Path(source.name)) + print(f"Processed {source.name}: {source.path}") + else: + print(f"Warning: Source {source.path} is neither a " + f"directory nor a PDF file") + + print("Done") + if __name__ == "__main__": - loop_over_sources() - print("Done") + main() diff --git a/template.html b/template.html index 036fb6a..d46ee3c 100644 --- a/template.html +++ b/template.html @@ -4,42 +4,606 @@ {{ title }} -

{{ relpath or 'Root' }}

+ +

{{ title }}

+ + +
+ + 🔍 +
+
+ + + + + + + + +
+ +
{% for item in items %}
- + {{ item.name }}
{{ item.name }}
@@ -47,13 +611,682 @@ {% endfor %}
+ {% if subdirs %}

Subdirectories

{% endif %} + + + + + + +
+ + + +
+ + +
+

Keyboard Shortcuts

+
+ Search + Ctrl+K +
+
+ Recent plots + Ctrl+R +
+
+ Refresh + F5 +
+
+ Toggle theme + Ctrl+T +
+
+ Help + ? +
+
+ + -- 2.39.5 From b5b2c676b32a6af9f09084fd74871570bec51cda Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Mon, 7 Jul 2025 15:26:01 +0200 Subject: [PATCH 03/35] Update --- .gitignore | 1 + generate_gallery.py | 84 ++++++++- template.html | 406 +++++++++++++++++++++++++++++++++++--------- 3 files changed, 406 insertions(+), 85 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed8ebf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/generate_gallery.py b/generate_gallery.py index 22d0f65..6b8dee4 100644 --- a/generate_gallery.py +++ b/generate_gallery.py @@ -143,18 +143,88 @@ def build_gallery(source_dir: Path, web_dir: Path, else: title = f"Gallery: {relative_path}" + # Calculate statistics for current directory + current_stats = calculate_directory_stats(web_dir) + stats = { + "file_count": len(items), + "folder_count": len(subdir_names), + "total_size": format_file_size(current_stats["total_size"]), + "total_size_bytes": current_stats["total_size"] + } + with output_html.open("w") as f: f.write(template.render( title=title, items=items, subdirs=subdir_names, relpath=str(relative_path), - ui=CONFIG.ui + ui=CONFIG.ui, + stats=stats )) print(f"Generated {output_html}") +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]}" + + def main() -> None: """ Main entry point for gallery generation. @@ -206,6 +276,15 @@ def main() -> None: "png_href": png_name }] + # Calculate statistics for single file + current_stats = calculate_directory_stats(source_web_dir) + stats = { + "file_count": 1, + "folder_count": 0, + "total_size": format_file_size(current_stats["total_size"]), + "total_size_bytes": current_stats["total_size"] + } + output_html = source_web_dir / "index.html" with output_html.open("w") as f: f.write(template.render( @@ -213,7 +292,8 @@ def main() -> None: items=items, subdirs=[], relpath=source.name, - ui=CONFIG.ui + ui=CONFIG.ui, + stats=stats )) print(f"Generated {output_html}") diff --git a/template.html b/template.html index d46ee3c..cebcfe1 100644 --- a/template.html +++ b/template.html @@ -278,6 +278,18 @@ 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; @@ -509,6 +521,47 @@ font-weight: 500; } + /* ======================================== + GALLERY STATISTICS + ======================================== */ + .gallery-stats { + position: fixed; + bottom: 20px; + left: 20px; + 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; + max-width: 200px; + } + + .gallery-stats:hover { + opacity: 1; + } + + .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); + } + /* ======================================== SUBDIRECTORIES LIST ======================================== */ @@ -583,9 +636,6 @@
+ + + + + + + + -- 2.39.5 From 72ebc5e103ab8d06d3d84edc49bff31f0729e9fb Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Tue, 8 Jul 2025 12:33:49 +0200 Subject: [PATCH 06/35] feat: Implement metadata system and reorganize project structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ Features: - Add comprehensive metadata system with YAML/JSON support - Implement hierarchical metadata inheritance from parent folders - Support plot-specific metadata overrides - Add metadata caching for performance optimization 🗂️ Code Organization: - Move Python orchestration code to orchestration/ folder - Move validation utilities to tests/ folder - Move documentation to docs/ folder - Separate metadata functionality into dedicated module 🔧 Infrastructure: - Add automatic asset copying to web directory - Fix asset path resolution for nested directories - Update template to use dynamic asset paths - Add MetadataConfig class with inheritance options 📚 Documentation: - Add comprehensive metadata usage guide (METADATA_USAGE.md) - Add implementation documentation (METADATA_IMPLEMENTATION.md) - Include example metadata files in examples/ - Add metadata validation utility script 🐛 Bug Fixes: - Fix breadcrumb navigation and JavaScript functionality - Resolve asset path issues in nested directories - Update template imports for modular CSS/JS structure This commit introduces a flexible metadata system that allows users to add rich metadata to plots and folders using YAML or JSON files, with full hierarchical inheritance and plot-specific overrides. The project structure is now better organized with clear separation of concerns. --- __pycache__/config.cpython-312.pyc | Bin 2907 -> 5718 bytes config.yaml | 8 + docs/METADATA_IMPLEMENTATION.md | 103 ++ docs/METADATA_USAGE.md | 114 ++ examples/meta.yaml | 34 + examples/specific_plot.json | 22 + generate_gallery.py | 80 +- backup.py => orchestration/backup.py | 0 config.py => orchestration/config.py | 21 +- orchestration/metadata.py | 132 ++ template.html | 2005 -------------------------- templates/gallery.html | 4 +- tests/validate_metadata.py | 139 ++ 13 files changed, 645 insertions(+), 2017 deletions(-) create mode 100644 docs/METADATA_IMPLEMENTATION.md create mode 100644 docs/METADATA_USAGE.md create mode 100644 examples/meta.yaml create mode 100644 examples/specific_plot.json rename backup.py => orchestration/backup.py (100%) rename config.py => orchestration/config.py (85%) create mode 100644 orchestration/metadata.py delete mode 100644 template.html create mode 100644 tests/validate_metadata.py diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc index 1dd0de69c3dbdd63929862dcf0479c265c4b5197..9d7e9207ae3eb0fd9a86e3e13268665cfa31f846 100644 GIT binary patch literal 5718 zcmb_gU2Gf25#IaZ-;oq4>c_T}#HUF9q3hC4kTj`aq;>4dQDG}?oD_+G_IkQoQfD83 zx_7ipfocud0TduJdS|)W=Q+IX4)*T1xdc{=hu4P-U1+8M0sbzfKs+3ftyH}-JRNYysIvZav z1z&d>j!VmjRjXpybravL>F%s^SPd1Z>jK+3th_V{qo75~XvLtQfnUebs7|%cfKyQZ zgZAeoFA)yXixe$tn_g6ROv`Zdf+wGa{-+Go>=Y@e+#QF?5}{;TfPfKSf{>V&s7xcK zcnl=GCWSRQtVOWah~{ISf}Q&gojc__!gsvTV>$Bf)}5wvJ0ANF;AJJ zGg`4~n-uCs$l{aFVJGT=s zC0e0DF!KnpoSLmSR40g|f|sb-Raaxxs_VsSm6B%GEH6Ey8yD)e!0A)@7z0Eh4DDqZ zBv~ZANOC|t$*Q=l51ZWF0j%xn5Ue=JLNA?cWpY=hTIuYS)2+V2Tk@d)2I}nWp#30u zfP*j`xr1OuN4)q;llS*X-JuK?mP=VtH>f&8XZ4E~gdw`6Yb6!DY+1jgK_tSe*U&kR zmvgAjj9Ja3Gu1jQH?8bI!0qUiVHhuW%2t0MN--pR;qRdDe18n?S(o>;b`Rf@hkak5 z&KL4U7%bcu$sj}~0Z`_TzCgi52P^`ZQXN1L0I@E#;An-I6ogH|lX1a#vcI8o<6 z*%$D8fSwq$MB}d&70Mais%*{#Do-7d40Ko3OC?52x{Ha#wmTECu~2yz zoSf|>A6%t#Iy0fuGE9^;gSn@H$*D#ePeK^mW$MmWO+O*oRUNYm@5boPNotW*%jzq~ z&zx3^mW|m1N9;v#{v9tCgoP*8El3&}lNR;5?P{=)0InNeEXX-VkaWT0gkW8Q|J?$R zCDJ74;kmV(54IHD{BtBP%s=t$1C49&hy`4RCng4-MGAS5VYD&a3B3);&7pSD!EvaJ zV-oV`d;Li%Y-ANobPgidWzvq4Z2z0{ug%}ijICwHR^wwlOut0Kp87n7ot-*`k#dnT z2e%<7&rYIk2VjT{!S)Dv}a!14Vt!4JD#`k^MhJCK-!i89`Z4E^}UAR5i zHeJY^?+@(qUk-BZ&WQqf1rSmoumAFr^a`014B!~zLUNAeQ~GIeJqKU};nRTGThayx zYkdQ*3reVcoRysMu!Zl3;WDnC1vFRPDnEU2tOtzgI_+RgZS?d4kF718`7sOID96=d zBc#q*&?9CQYKEz*s|($NgVE;UOu$E`s_q1Aq|9$IR%PSrq<qY$FX8A#s-gLwRHCkbGT<9uzS|qY-f?e~PkWMKJM1y2dqUBZaqHzNJh^JTo(eLo z--^|(fE^@O-r6!MFX{`qB@Tu)&URRM#`6*wDjE(p#Luf4onT4C!0hZdFAuHmIi*DkFj zu0OuMtFV}Ae% z+Yyq;wh5@-QAoCLdEk2NooC;V-O4<-8h`HID2#eFJ@VgAc7p=nZ@)2kA|d^5_vncM z`S*#)iCk=I6L8{0ao}8p$`=PtH-yG!2oOzx&zsVX0C3@QMaEwTH1I-VD=ai*9~SZv zxTfgL={fx(8gSPFNWp=oW0fb&O~9ry7j7!;>YV1d4S6+#F+3jR zCH7Th<8oqr^M;;<{`{Daxm7>nI~$^)T>~Wt58M#%{hQ)Fm3?#awaK3qmtXv;y>k4$ zeIKO0$)o;udSop<@|lDA<&8(5mfnjT7h?H=9S?r|Rsc`K{>hgE0HVGp+W=xd+#n!$ z@nC=6;M-JxKgV}JxGR854ZKbA_w4=&`#APFfn>`Gdmr+UpfjA`0C|<%kt30KJ4I51 z>+(P=J+v+lwc>Et{%~afJMOKKW0y}YzWCNx*3yrx%g6o^N#7C0$iBN0iT1Ry){a9B zd1E1OEadGEdHX}&p^$e7dA)6MZ)8$f8Eq4MUO)H=f1*+#YCkI^!HFwzN00FOv$u@6hxj+_nwF!_wfXyB*kc5W- z=7fg=0X9>CAcKNTD98kYGlFnjXcPVjgg)yBB9T*z^M981w983~Vnl8L literal 2907 zcmcH*OKcm*b#`ZVNiIK3cS%cb zPX)Rp-psuB=Dm5}nGdsB1Ht#A?kp8_gg%#x{-?#0t@mMa4N*iD7gd##QdLW86_>Cg zajHwIT1gA%*wsrqQ&e;cQE~}U?WP*ZxEYHoC1R=$)Wm;KlPpD(%aaK4eJWzf!7hUt z=f+|Y+XkAtsYFW!HE|Ocjep2x5sMVH1H0_nzAvOp6SJl^(9JG?x+xS`uhlxYA3hkd}}u2$I3lx03xZlrk*MvipDn{3*%7a?}9F zr75Zqnz=*3Rpqb0S3y6; z=Va8L_$a2N&+`hpLdxnQDyu&pN2pDLRG+y;s3!dzq_<&oiRtURH7KjA>SdFFj5cXu z8pcvZEku(JYT28`6-00^^iR|6GO_IYrtVaDI9o=wdtwd*{r z1+&7vs%e{NXI^{Nl%bk32HZ1z(LXj=NfCp9nv+mf#bc%{a5M1CV2N!P(bp$OV1#a9 zPA+(!Tihv5l4mRC8D3}4n%s7L=9_lSWSo24bSiMHW!5yE+D`E%^?(nG*<|M(Kj_n= zd^W>YG_~W@v6_>%>oapQPS@hIrcEj6b6u$gCh!m7tPAyk`H!9OdYHIR_#x7D%$%+H zfn6&z)2qN49Y4}OP%acc$De|*pX4&FxXk#X`gqQVB_bh+x58?INbic2Z&#S*LZAd* zWI@z^WVaHU10B#WCIv3LK0hc?N<|no*07?G3heW;P?m-Arbs&R(R|szqzB~3cgtNV z8FNc&yD%JCGBv>L1qS~Q09Mh%ag^KLEnc6$n=Pz;_peVM`t8N{F5a*=o<7n!{RpGn zM9)C^iE9_HUR-l-@4Y*AbW6hpfycOJj`HuekZ*NS$^q0)c z=Gf8ulB;8MzrLP4c;7%HWA_mrN#Dy)e3GAB&rjaXAAYFe{l;hc!&gaH=|0~BS-H*| zZ@;pcKm0&aLjFz?v=8SyLyr!?Nj}OJ{`Eiyx&PcUfWsdJ1Kt@uF@pbi=!8lB^sVfP zXL28mXeSOP=619o>DRCYbzt(px1eCqWLksf(1J#E3*U@e7u?xI{`zePZc*}NDK|+} z%0=zMTZ0sux8G!8`j_jRvs&;(^+M_WziL+~jg!#ccMsaT>oqJtXu52!NO%Dp#^oI@ z2(4FFxV-S-H9&mkR>IumUj~dX%^&1lBJiTD%f(q2uV0XyM*u+1<#yy;X86kN+q1u^ zTtD-!yEb#@nU6Csg_-wBwy>TpeBsNgdS~Km_>MNCB#OJZY}H&|(OfoNF4KVLBp8xF zzH4|MfWS^I;QQrLUMPaN&_}{T4a-fW!zNN?!IDS$bCOLSF|5|;zL#ahNBThO@&f?y zFc{6Rp6=oelHc5SVD+``3mfFX=AO~jQ=Rz@GP=2Ue08?tZ;pxThY_@@t2B2!a-*kvtx~ zx?bep09JW}`woB}Rutv$=*T)c@;8+K9Ho0YQqzxART=6b0AQ4w?uAPuBs{0gC_N-$ rObQV{Q4q;=F8mqq`!u`fmp@oNxk-#)%>8Vx`^}F@p{F6TKeYbeZi%CL diff --git a/config.yaml b/config.yaml index a4d5b46..b268e06 100644 --- a/config.yaml +++ b/config.yaml @@ -34,6 +34,14 @@ ui: # Search settings search_debounce_ms: 300 +# Metadata Settings +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: diff --git a/docs/METADATA_IMPLEMENTATION.md b/docs/METADATA_IMPLEMENTATION.md new file mode 100644 index 0000000..5bc2950 --- /dev/null +++ b/docs/METADATA_IMPLEMENTATION.md @@ -0,0 +1,103 @@ +# Metadata System Implementation Summary + +## What Was Implemented + +### 1. Core Metadata Module (`metadata.py`) +- **`load_metadata_file()`**: Loads YAML/JSON metadata files with error handling +- **`load_folder_metadata()`**: Discovers and loads folder-level metadata (meta.yaml/meta.json) +- **`merge_metadata()`**: Merges parent and child metadata with proper override behavior +- **`resolve_metadata_for_plot()`**: Resolves final metadata for individual plots +- **`save_metadata_cache()`**: Saves resolved metadata to cache files for performance + +### 2. Updated Gallery Generator (`generate_gallery.py`) +- **Hierarchical inheritance**: Folder metadata is inherited by subfolders and plots +- **Plot-specific overrides**: Individual plots can have their own metadata files +- **Template integration**: Metadata is passed to HTML templates for rendering +- **Cache generation**: `meta_cache.json` files are created in each output directory + +### 3. Configuration Updates (`config.py` and `config.yaml`) +- Added `MetadataConfig` class with caching and inheritance options +- Updated main `Config` class to include metadata settings +- Added metadata section to `config.yaml` + +### 4. Documentation and Examples +- **`METADATA_USAGE.md`**: Comprehensive documentation on using the metadata system +- **`examples/meta.yaml`**: Example folder metadata file +- **`examples/specific_plot.json`**: Example plot-specific metadata file +- **`validate_metadata.py`**: Utility script for validating metadata files + +## Key Features + +### Hierarchical Metadata Inheritance +``` +root_folder/ +├── meta.yaml # Base metadata for all plots +├── subfolder/ +│ ├── meta.yaml # Inherits from parent, can override +│ ├── plot1.pdf +│ ├── plot1.yaml # Plot-specific metadata +│ └── plot2.pdf # Uses folder metadata +``` + +### Flexible Format Support +- YAML files: `.yaml`, `.yml` +- JSON files: `.json` +- Automatic format detection based on file extension + +### Template Integration +- `folder_metadata`: Available in templates for folder-level metadata +- `item.metadata`: Available for each plot in the items loop +- Clean separation of concerns between data and presentation + +### Performance Optimization +- Metadata caching in `meta_cache.json` files +- Only reload when source files are newer than cache +- Efficient hierarchical resolution + +## Usage Examples + +### Basic Folder Metadata +```yaml +# meta.yaml +title: "Physics Analysis Results" +experiment: "CMS" +author: + name: "Researcher Name" + institution: "University" +tags: ["analysis", "physics"] +``` + +### Plot-specific Metadata +```yaml +# my_plot.yaml (for my_plot.pdf) +title: "Signal Region Analysis" +plot_type: "histogram" +variables: + x_axis: "mass" + y_axis: "events" +highlight: true +``` + +### Template Usage +```html +

{{ folder_metadata.title }}

+{% for item in items %} +
+

{{ item.metadata.title or item.name }}

+ {% if item.metadata.plot_type %} + {{ item.metadata.plot_type }} + {% endif %} +
+{% endfor %} +``` + +## Benefits + +1. **Flexibility**: Support any metadata structure using YAML/JSON +2. **Inheritance**: Avoid repetition by inheriting from parent folders +3. **Override capability**: Fine-tune metadata for specific plots +4. **Performance**: Caching system for efficient repeated builds +5. **Validation**: Built-in error handling and validation utilities +6. **Documentation**: Comprehensive usage documentation and examples + +The metadata system is now fully integrated and ready for use in your scientific plot gallery generator! diff --git a/docs/METADATA_USAGE.md b/docs/METADATA_USAGE.md new file mode 100644 index 0000000..86a55f3 --- /dev/null +++ b/docs/METADATA_USAGE.md @@ -0,0 +1,114 @@ +# Metadata System Documentation + +## Overview + +The metadata system allows you to add flexible metadata to your plots and folders using YAML or JSON files. Metadata is inherited hierarchically from parent folders and can be overridden at any level. + +## File Structure + +### Folder Metadata +- **File names**: `meta.yaml`, `meta.yml`, or `meta.json` +- **Location**: Place in any folder containing plots +- **Scope**: Applies to all plots in the folder and subfolders (unless overridden) + +### Plot-specific Metadata +- **File names**: `{plot_name}.yaml`, `{plot_name}.yml`, or `{plot_name}.json` +- **Location**: Place in the same folder as the plot PDF file +- **Scope**: Applies only to the specific plot with the same name + +## Hierarchy and Inheritance + +1. **Root folder**: Start with folder metadata in your source directory +2. **Subfolders**: Each subfolder can have its own `meta.yaml` that merges with parent metadata +3. **Plot-specific**: Individual plots can have their own metadata files that override folder metadata + +## Example Usage + +### Folder Structure +``` +analysis_results/ +├── meta.yaml # Root folder metadata +├── signal/ +│ ├── meta.yaml # Signal-specific metadata +│ ├── mass_plot.pdf +│ └── mass_plot.yaml # Plot-specific metadata +└── background/ + ├── meta.yaml # Background-specific metadata + └── qcd_plot.pdf +``` + +### Example Metadata Fields + +**Common fields for folder metadata:** +- `title`: Folder title +- `description`: Folder description +- `experiment`: Experiment name (CMS, ATLAS, etc.) +- `dataset`: Dataset identifier +- `analysis_type`: Type of analysis +- `author`: Author information +- `parameters`: Analysis parameters +- `tags`: Categorization tags + +**Common fields for plot metadata:** +- `plot_type`: Type of plot (histogram, scatter, etc.) +- `variables`: Variable information (x_axis, y_axis, units) +- `selection`: Selection criteria +- `statistics`: Statistical information +- `display`: Display options (highlight, featured, order_priority) + +## Configuration + +The metadata system can be configured in `config.yaml`: + +```yaml +metadata: + cache_enabled: true # Enable metadata caching + inherit_from_parent: true # Enable hierarchical inheritance +``` + +## Output + +### HTML Template +Metadata is available in the HTML template as: +- `folder_metadata`: Current folder's resolved metadata +- `item.metadata`: Individual plot metadata (in items loop) + +### Cache Files +- `meta_cache.json`: Generated in each web directory +- Contains resolved metadata for all plots in that directory +- Used for performance optimization and debugging + +## Usage Tips + +1. **Start simple**: Begin with basic folder metadata and add complexity as needed +2. **Use inheritance**: Put common metadata in parent folders to avoid repetition +3. **Override selectively**: Use plot-specific metadata only when needed +4. **Consistent naming**: Use consistent field names across your metadata files +5. **Validate format**: Ensure YAML/JSON files are valid before running the generator + +## Integration with Templates + +In your HTML templates, you can access metadata like: + +```html + +

{{ folder_metadata.title }}

+

{{ folder_metadata.description }}

+ + +{% for item in items %} +
+

{{ item.name }}

+ {% if item.metadata.plot_type %} + {{ item.metadata.plot_type }} + {% endif %} + {% if item.metadata.tags %} +
+ {% for tag in item.metadata.tags %} + {{ tag }} + {% endfor %} +
+ {% endif %} +
+{% endfor %} +``` diff --git a/examples/meta.yaml b/examples/meta.yaml new file mode 100644 index 0000000..fec7514 --- /dev/null +++ b/examples/meta.yaml @@ -0,0 +1,34 @@ +# Example folder metadata file +# This file should be named "meta.yaml" in a folder containing plots + +# Folder-level metadata that applies to all plots in this folder +title: "Analysis Results" +description: "Comprehensive analysis of ttbar events in Run 2 data" +experiment: "CMS" +dataset: "Run2_2016_nano_v9" +analysis_type: "ttbar_analysis" + +# Author information +author: + name: "Klaus Schmidt" + email: "kschmidt@example.com" + institution: "Example University" + +# Analysis parameters that apply to all plots in this folder +parameters: + luminosity: "35.9 fb^-1" + center_of_mass_energy: "13 TeV" + selection: "baseline" + +# Tags for categorization +tags: + - "ttbar" + - "run2" + - "cms" + - "analysis" + +# Custom styling or display options +display: + highlight: true + category: "primary_results" + order_priority: 1 diff --git a/examples/specific_plot.json b/examples/specific_plot.json new file mode 100644 index 0000000..4257fa9 --- /dev/null +++ b/examples/specific_plot.json @@ -0,0 +1,22 @@ +{ + "title": "Specific Plot Analysis", + "description": "This metadata overrides folder metadata for this specific plot", + "plot_type": "histogram", + "variables": { + "x_axis": "invariant_mass", + "y_axis": "events", + "units": "GeV" + }, + "selection": "signal_region", + "statistics": { + "entries": 125000, + "mean": 125.3, + "std_dev": 2.1 + }, + "tags": ["signal", "mass_peak", "important"], + "display": { + "highlight": true, + "featured": true, + "order_priority": 0 + } +} diff --git a/generate_gallery.py b/generate_gallery.py index 6b8dee4..cd640de 100644 --- a/generate_gallery.py +++ b/generate_gallery.py @@ -16,14 +16,22 @@ Features: import subprocess import shutil from pathlib import Path +from typing import Dict, Any, Optional from jinja2 import Environment, FileSystemLoader -from config import Config + +from orchestration.config import Config +from orchestration.metadata import ( + load_folder_metadata, + merge_metadata, + resolve_metadata_for_plot, + save_metadata_cache +) CONFIG = Config.from_yaml("config.yaml") env = Environment(loader=FileSystemLoader(".")) -template = env.get_template("template.html") +template = env.get_template("templates/gallery.html") def convert_pdf_to_png(pdf_path: Path) -> None: @@ -80,26 +88,36 @@ def needs_update(source_file: Path, target_file: Path) -> bool: def build_gallery(source_dir: Path, web_dir: Path, - relative_path: Path = None) -> 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. + navigation and thumbnails. Now includes metadata support. Args: 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 relative_path is None: relative_path = Path(".") + + if inherited_metadata is None: + inherited_metadata = {} + + # Load folder-level metadata and merge with inherited metadata + folder_metadata = load_folder_metadata(source_dir) + current_metadata = merge_metadata(inherited_metadata, folder_metadata) pdf_files = list(source_dir.glob("*.pdf")) subdirs = [d for d in source_dir.iterdir() if d.is_dir()] items = [] + plot_metadata_cache = {} for pdf_file in pdf_files: png_file = pdf_file.with_suffix(".png") @@ -122,18 +140,27 @@ def build_gallery(source_dir: Path, web_dir: Path, else: print(f"Skipping {png_file.name} (up to date)") + # Resolve metadata for this specific plot + plot_metadata = resolve_metadata_for_plot(pdf_file, current_metadata) + plot_metadata_cache[pdf_file.stem] = plot_metadata + items.append({ "name": pdf_file.stem, "pdf_href": pdf_file.name, - "png_href": png_file.name + "png_href": png_file.name, + "metadata": plot_metadata }) + # Save metadata cache for this directory + save_metadata_cache(web_dir, plot_metadata_cache) + 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(subdir, subdir_web, subdir_relative) + # Pass current metadata to subdirectories + build_gallery(subdir, subdir_web, subdir_relative, current_metadata) subdir_names.append(subdir.name) output_html = web_dir / "index.html" @@ -152,6 +179,14 @@ def build_gallery(source_dir: Path, web_dir: Path, "total_size_bytes": current_stats["total_size"] } + # Calculate relative path to assets based on directory depth + if relative_path == Path("."): + assets_path = "../assets" + else: + # Count the number of directory levels to go back + depth = len(relative_path.parts) + assets_path = "../" * (depth + 1) + "assets" + with output_html.open("w") as f: f.write(template.render( title=title, @@ -159,7 +194,9 @@ def build_gallery(source_dir: Path, web_dir: Path, subdirs=subdir_names, relpath=str(relative_path), ui=CONFIG.ui, - stats=stats + stats=stats, + folder_metadata=current_metadata, + assets_path=assets_path )) print(f"Generated {output_html}") @@ -241,6 +278,20 @@ def main() -> None: gallery_root.mkdir(parents=True, exist_ok=True) + # Copy assets folder to the gallery root + assets_src = Path("assets") + assets_dst = gallery_root.parent / "assets" + + if assets_src.exists(): + if assets_dst.exists(): + shutil.rmtree(assets_dst) + shutil.copytree(assets_src, assets_dst) + print(f"Copied assets from {assets_src} to {assets_dst}") + else: + print(f"Warning: Assets directory {assets_src} not found") + + plot_metadata_cache = {} + for source in CONFIG.sources: source_path = Path(source.path) @@ -270,12 +321,21 @@ def main() -> None: else: print(f"Skipping {source_png_path.name} (up to date)") + # Resolve metadata for this single plot + plot_metadata = resolve_metadata_for_plot(source_path, {}) + plot_metadata_cache[source_path.stem] = plot_metadata + items = [{ "name": source_path.stem, "pdf_href": pdf_name, - "png_href": png_name + "png_href": png_name, + "metadata": plot_metadata }] + # Save metadata cache for this directory + plot_cache = {source_path.stem: plot_metadata} + save_metadata_cache(source_web_dir, plot_cache) + # Calculate statistics for single file current_stats = calculate_directory_stats(source_web_dir) stats = { @@ -293,7 +353,9 @@ def main() -> None: subdirs=[], relpath=source.name, ui=CONFIG.ui, - stats=stats + stats=stats, + folder_metadata={}, + assets_path="../../assets" )) print(f"Generated {output_html}") diff --git a/backup.py b/orchestration/backup.py similarity index 100% rename from backup.py rename to orchestration/backup.py diff --git a/config.py b/orchestration/config.py similarity index 85% rename from config.py rename to orchestration/config.py index fc798e4..9672315 100644 --- a/config.py +++ b/orchestration/config.py @@ -35,6 +35,16 @@ class UIConfig: search_debounce_ms: int +@dataclass +class MetadataConfig: + """Configuration for metadata handling.""" + cache_enabled: bool = True + inherit_from_parent: bool = True + supported_formats: list[str] = field( + default_factory=lambda: ['.yaml', '.yml', '.json'] + ) + + @dataclass class GalleryItem: """Represents a single data source for the gallery.""" @@ -53,6 +63,7 @@ class Config: paths: PathConfig gallery: GalleryConfig ui: UIConfig + metadata: MetadataConfig sources: list[GalleryItem] = field(default_factory=list) @property @@ -96,18 +107,26 @@ class Config: paths_data = data.get('paths', {}) gallery_data = data.get('gallery', {}) ui_data = data.get('ui', {}) + metadata_data = data.get('metadata', {}) sources_data = data.get('sources', []) paths = PathConfig(**paths_data) gallery = GalleryConfig(**gallery_data) ui = UIConfig(**ui_data) + metadata = MetadataConfig(**metadata_data) sources = [ GalleryItem(name=source["name"], path=Path(source["path"])) for source in sources_data ] - return cls(paths=paths, gallery=gallery, ui=ui, sources=sources) + return cls( + paths=paths, + gallery=gallery, + ui=ui, + metadata=metadata, + sources=sources + ) def to_yaml(self, yaml_file: str) -> None: """ diff --git a/orchestration/metadata.py b/orchestration/metadata.py new file mode 100644 index 0000000..a8b64d3 --- /dev/null +++ b/orchestration/metadata.py @@ -0,0 +1,132 @@ +""" +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}") + return {} + + +def load_folder_metadata(folder_path: Path) -> Dict[str, Any]: + """ + Load folder-level metadata from meta.yaml or meta.json. + + Args: + folder_path: Path to the folder to check for metadata + + Returns: + Dictionary containing the folder metadata + """ + # Try YAML first, then JSON + for filename in ['meta.yaml', 'meta.yml', 'meta.json']: + metadata_path = folder_path / filename + if metadata_path.exists(): + return load_metadata_file(metadata_path) + + return {} + + +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 + values. + + 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 by merging 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) + print(f"Saved metadata cache: {cache_path}") + except IOError as e: + print(f"Warning: Could not save metadata cache {cache_path}: {e}") diff --git a/template.html b/template.html deleted file mode 100644 index 75a9ef5..0000000 --- a/template.html +++ /dev/null @@ -1,2005 +0,0 @@ - - - - - {{ title }} - - - - -

{{ title }}

- - -
- - 🔍 -
-
- - - - - - - - -
- - -
- {% for item in items %} -
- - {{ item.name }} - -
{{ item.name }}
-
- {% endfor %} -
- - - {% if subdirs %} -

Subdirectories

- - {% endif %} - - - - - - -
- - - - -
- - -
-

Keyboard Shortcuts

-
- Search - Ctrl+K -
-
- Recent plots - Ctrl+R -
-
- Compare plots - Ctrl+C -
-
- Refresh - F5 -
-
- Toggle theme - Ctrl+T -
-
- Help - ? -
-
- - - - - -
-
-
-

Plot Comparison

- -
-
-
-
- Plot A - -
-
-
-
- 📊 Click to select first plot -
-
-
-
-
-
- Plot B - -
-
-
-
- 📊 Click to select second plot -
-
-
-
-
-
-
- - - - diff --git a/templates/gallery.html b/templates/gallery.html index 7e9bf17..227b1d5 100644 --- a/templates/gallery.html +++ b/templates/gallery.html @@ -3,7 +3,7 @@ {{ title }} - + @@ -184,6 +184,6 @@ - + diff --git a/tests/validate_metadata.py b/tests/validate_metadata.py new file mode 100644 index 0000000..3f21ebe --- /dev/null +++ b/tests/validate_metadata.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Metadata Validation Utility + +This script validates metadata files in the gallery source directories, +checking for proper YAML/JSON syntax and common field validation. +""" + +import sys +import json +import yaml +from pathlib import Path +from typing import Dict, Any, List + + +def validate_metadata_file(file_path: Path) -> tuple[bool, List[str]]: + """ + Validate a single metadata file. + + Args: + file_path: Path to the metadata file + + Returns: + Tuple of (is_valid, error_messages) + """ + errors = [] + + if not file_path.exists(): + errors.append(f"File does not exist: {file_path}") + return False, errors + + try: + with file_path.open('r', encoding='utf-8') as f: + suffix_lower = file_path.suffix.lower() + if suffix_lower in ['.yaml', '.yml']: + data = yaml.safe_load(f) + elif suffix_lower == '.json': + data = json.load(f) + else: + errors.append(f"Unsupported file format: {file_path}") + return False, errors + + if data is None: + errors.append(f"Empty metadata file: {file_path}") + return False, errors + + # Basic validation + if not isinstance(data, dict): + errors.append(f"Metadata must be a dictionary: {file_path}") + return False, errors + + # Check for common issues + if 'title' in data and not isinstance(data['title'], str): + errors.append(f"Title must be a string: {file_path}") + + if 'tags' in data and not isinstance(data['tags'], list): + errors.append(f"Tags must be a list: {file_path}") + + if 'author' in data and not isinstance(data['author'], dict): + errors.append(f"Author must be a dictionary: {file_path}") + + except (yaml.YAMLError, json.JSONDecodeError) as e: + errors.append(f"Parse error in {file_path}: {e}") + return False, errors + except Exception as e: + errors.append(f"Unexpected error reading {file_path}: {e}") + return False, errors + + return len(errors) == 0, errors + + +def find_metadata_files(root_dir: Path) -> List[Path]: + """ + Find all metadata files in a directory tree. + + Args: + root_dir: Root directory to search + + Returns: + List of metadata file paths + """ + metadata_files = [] + + for pattern in ['**/*.yaml', '**/*.yml', '**/*.json']: + for file_path in root_dir.glob(pattern): + if file_path.name.startswith('meta.') or file_path.stem != file_path.name: + metadata_files.append(file_path) + + return metadata_files + + +def main(): + """Main validation function.""" + if len(sys.argv) != 2: + print("Usage: python validate_metadata.py ") + sys.exit(1) + + root_dir = Path(sys.argv[1]) + + if not root_dir.exists(): + print(f"Error: Directory does not exist: {root_dir}") + sys.exit(1) + + if not root_dir.is_dir(): + print(f"Error: Not a directory: {root_dir}") + sys.exit(1) + + print(f"Validating metadata files in: {root_dir}") + print("-" * 50) + + metadata_files = find_metadata_files(root_dir) + + if not metadata_files: + print("No metadata files found.") + return + + total_files = len(metadata_files) + valid_files = 0 + + for file_path in metadata_files: + is_valid, errors = validate_metadata_file(file_path) + + if is_valid: + print(f"✓ {file_path.relative_to(root_dir)}") + valid_files += 1 + else: + print(f"✗ {file_path.relative_to(root_dir)}") + for error in errors: + print(f" - {error}") + + print("-" * 50) + print(f"Summary: {valid_files}/{total_files} files valid") + + if valid_files != total_files: + sys.exit(1) + + +if __name__ == "__main__": + main() -- 2.39.5 From 51a74b23f8ee7d53edb16c2595db61866d5e9dce Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Tue, 8 Jul 2025 13:19:54 +0200 Subject: [PATCH 07/35] Add metadata button (WIP) --- assets/css/main.css | 1 + assets/css/metadata.css | 180 ++++++++++++++++++++++++++++++ assets/js/metadata-popup.js | 212 ++++++++++++++++++++++++++++++++++++ templates/gallery.html | 6 + 4 files changed, 399 insertions(+) create mode 100644 assets/css/metadata.css create mode 100644 assets/js/metadata-popup.js diff --git a/assets/css/main.css b/assets/css/main.css index 8162397..f68a4c9 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -15,6 +15,7 @@ @import url('./floating-elements.css'); @import url('./stats.css'); @import url('./comparison.css'); +@import url('./metadata.css'); /* Responsive design */ @import url('./responsive.css'); diff --git a/assets/css/metadata.css b/assets/css/metadata.css new file mode 100644 index 0000000..872fef6 --- /dev/null +++ b/assets/css/metadata.css @@ -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; + } +} diff --git a/assets/js/metadata-popup.js b/assets/js/metadata-popup.js new file mode 100644 index 0000000..085c3a9 --- /dev/null +++ b/assets/js/metadata-popup.js @@ -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 ` + + + `; + } + + let html = ` + + '; + 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 = 'null'; + } else if (typeof value === 'object') { + if (Array.isArray(value)) { + if (value.length <= 3) { + formattedValue = value.map(item => ``).join(' '); + } else { + formattedValue = `${value.slice(0, 3).map(item => ``).join(' ')} `; + } + } 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 = '' + JSON.stringify(value) + ''; + } else { + formattedValue = `Object with ${keys.length} properties`; + } + } + } else { + // Truncate long strings + const str = String(value); + formattedValue = str.length > 50 ? str.substring(0, 47) + '...' : str; + } + + return ` + + `; + } +} + +// Global instance +window.metadataPopup = new MetadataPopup(); + +// Global function for template usage +window.showMetadataPopup = function(button, plotName, metadata) { + window.metadataPopup.showPopup(button, plotName, metadata); +}; diff --git a/templates/gallery.html b/templates/gallery.html index 227b1d5..a0ee24c 100644 --- a/templates/gallery.html +++ b/templates/gallery.html @@ -41,6 +41,9 @@ {{ item.name }} +
{{ item.name }}
{% endfor %} @@ -183,6 +186,9 @@ }; + + + -- 2.39.5 From 8173d7618ab6f67b4972136d11984640ab43b097 Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Tue, 8 Jul 2025 13:54:13 +0200 Subject: [PATCH 08/35] Add metadata (WIP) --- examples/meta.yaml | 8 +------- orchestration/metadata.py | 28 ++++++++++++++++------------ templates/gallery.html | 2 +- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/examples/meta.yaml b/examples/meta.yaml index fec7514..1ecf494 100644 --- a/examples/meta.yaml +++ b/examples/meta.yaml @@ -3,17 +3,11 @@ # Folder-level metadata that applies to all plots in this folder title: "Analysis Results" -description: "Comprehensive analysis of ttbar events in Run 2 data" +description: "ttbar" experiment: "CMS" dataset: "Run2_2016_nano_v9" analysis_type: "ttbar_analysis" -# Author information -author: - name: "Klaus Schmidt" - email: "kschmidt@example.com" - institution: "Example University" - # Analysis parameters that apply to all plots in this folder parameters: luminosity: "35.9 fb^-1" diff --git a/orchestration/metadata.py b/orchestration/metadata.py index a8b64d3..d7c993d 100644 --- a/orchestration/metadata.py +++ b/orchestration/metadata.py @@ -31,7 +31,7 @@ def load_metadata_file(metadata_path: Path) -> Dict[str, Any]: """ if not metadata_path.exists(): return {} - + try: with metadata_path.open('r', encoding='utf-8') as f: suffix_lower = metadata_path.suffix.lower() @@ -63,12 +63,14 @@ def load_folder_metadata(folder_path: Path) -> Dict[str, Any]: metadata_path = folder_path / filename if metadata_path.exists(): return load_metadata_file(metadata_path) - + return {} -def merge_metadata(parent_metadata: Dict[str, Any], - child_metadata: Dict[str, Any]) -> Dict[str, Any]: +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 values. @@ -85,9 +87,10 @@ def merge_metadata(parent_metadata: Dict[str, Any], return merged -def resolve_metadata_for_plot(plot_path: Path, - inherited_metadata: Dict[str, Any] - ) -> Dict[str, Any]: +def resolve_metadata_for_plot( + plot_path: Path, + inherited_metadata: Dict[str, Any] +) -> Dict[str, Any]: """ Resolve metadata for a specific plot by merging inherited metadata with plot-specific metadata. @@ -101,21 +104,22 @@ def resolve_metadata_for_plot(plot_path: Path, """ 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: +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. diff --git a/templates/gallery.html b/templates/gallery.html index a0ee24c..8ee0e5e 100644 --- a/templates/gallery.html +++ b/templates/gallery.html @@ -41,7 +41,7 @@ {{ item.name }} -
{{ item.name }}
-- 2.39.5 From 2d98ed0e7ae66341639feed1689015499e570910 Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Tue, 8 Jul 2025 17:04:55 +0200 Subject: [PATCH 09/35] Add export to latex button (i think) --- assets/css/export.css | 289 +++++++++++++++++++++++++++++++ assets/css/main.css | 1 + assets/js/export-manager.js | 320 +++++++++++++++++++++++++++++++++++ assets/js/gallery-app.js | 3 + docs/EXPORT_FUNCTIONALITY.md | 101 +++++++++++ export_api.py | 94 ++++++++++ export_plots.py | 107 ++++++++++++ generate_gallery.py | 8 +- orchestration/pdf_export.py | 205 ++++++++++++++++++++++ templates/gallery.html | 4 + test.ipynb | 2 +- 11 files changed, 1131 insertions(+), 3 deletions(-) create mode 100644 assets/css/export.css create mode 100644 assets/js/export-manager.js create mode 100644 docs/EXPORT_FUNCTIONALITY.md create mode 100644 export_api.py create mode 100644 export_plots.py create mode 100644 orchestration/pdf_export.py diff --git a/assets/css/export.css b/assets/css/export.css new file mode 100644 index 0000000..80fcc6e --- /dev/null +++ b/assets/css/export.css @@ -0,0 +1,289 @@ +/* ======================================== + 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-commands code { + background: var(--background-color); + padding: 4px 8px; + border-radius: 3px; + font-family: 'Courier New', monospace; + border: 1px solid var(--border-color); + display: inline-block; + margin-top: 8px; +} + +.export-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 20px; +} + +.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); +} +[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; +} diff --git a/assets/css/main.css b/assets/css/main.css index f68a4c9..32a44c7 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -16,6 +16,7 @@ @import url('./stats.css'); @import url('./comparison.css'); @import url('./metadata.css'); +@import url('./export.css'); /* Responsive design */ @import url('./responsive.css'); diff --git a/assets/js/export-manager.js b/assets/js/export-manager.js new file mode 100644 index 0000000..ad9daeb --- /dev/null +++ b/assets/js/export-manager.js @@ -0,0 +1,320 @@ +/** + * 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.clearSelection(); + } + }); + + // 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 = ` +
+ +
+ `; + 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() { + document.body.classList.remove('selection-mode'); + document.getElementById('exportBtn').style.display = 'none'; + document.getElementById('selectionCounter').style.display = 'none'; + this.clearSelection(); + } + + /** + * 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 payload = { + plots: actualPaths, + layout: this.calculateLayout(actualPaths.length), + output_name: `merged_plots_${new Date().toISOString().split('T')[0]}.pdf` + }; + + // Save the request to a JSON file that can be picked up by a Python script + const requestData = JSON.stringify(payload, null, 2); + + // Since we can't directly call Python from the browser, we'll show instructions + this.showExportInstructions(requestData); + } + + /** + * 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) { + const instructions = ` +
+

Export Instructions

+

To export your selected plots, save the following data to a file called export_request.json and run the export script:

+
+ +
+
+

Command to run:

+ cd /work/kschmidt/web && python export_plots.py export_request.json +
+
+ + +
+
+ `; + + const overlay = document.createElement('div'); + overlay.className = 'export-overlay'; + overlay.innerHTML = instructions; + document.body.appendChild(overlay); + } + + /** + * 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); + } +} diff --git a/assets/js/gallery-app.js b/assets/js/gallery-app.js index 01eb39e..935294e 100644 --- a/assets/js/gallery-app.js +++ b/assets/js/gallery-app.js @@ -9,6 +9,7 @@ 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 { ExportManager } from './export-manager.js'; import { Utils } from './utils.js'; /** @@ -28,6 +29,7 @@ export class GalleryApp { this.recentPlotsManager = new RecentPlotsManager(this.MAX_RECENT_PLOTS); this.comparisonManager = new ComparisonManager(); this.statsManager = new StatsManager(); + this.exportManager = new ExportManager(); this.keyboardManager = new KeyboardManager(this); this.utils = Utils; @@ -36,6 +38,7 @@ export class GalleryApp { window.searchManager = this.searchManager; window.recentPlotsManager = this.recentPlotsManager; window.comparisonManager = this.comparisonManager; + window.exportManager = this.exportManager; window.utils = this.utils; this.init(); diff --git a/docs/EXPORT_FUNCTIONALITY.md b/docs/EXPORT_FUNCTIONALITY.md new file mode 100644 index 0000000..96669e7 --- /dev/null +++ b/docs/EXPORT_FUNCTIONALITY.md @@ -0,0 +1,101 @@ +# PDF Export Functionality + +The gallery now includes the ability to export multiple plots to a merged PDF with grid layout. + +## Features + +- Select up to 4 plots from any gallery page +- Automatic grid layout (1x1, 1x2, 2x2) +- Export to PDF with proper scaling +- Keyboard shortcuts for easy access + +## Usage + +### Selecting Plots + +1. Press **Ctrl+E** to enter selection mode +2. Click on up to 4 plot thumbnails to select them +3. Selected plots will show a checkmark overlay +4. A counter shows how many plots are selected (e.g., "2/4 selected") + +### Exporting + +1. After selecting plots, click the **📄** export button (appears in floating buttons) +2. The system will show export instructions with: + - JSON data for the export request + - Command to run the export script +3. Copy the JSON data and save it as `export_request.json` +4. Run the export command in your terminal + +### Keyboard Shortcuts + +- **Ctrl+E**: Toggle selection mode +- **Escape**: Clear all selections and exit selection mode + +## Export Methods + +The system supports two PDF merging methods: + +### Method 1: pdfjam (Recommended) +```bash +# Install on Ubuntu/Debian +sudo apt-get install texlive-extra-utils + +# Check if available +python export_plots.py --check-deps +``` + +### Method 2: Python libraries +```bash +# Install Python dependencies +pip install PyPDF2 reportlab + +# Check if available +python export_plots.py --check-deps +``` + +## Export Script Usage + +```bash +# Basic usage +python export_plots.py export_request.json + +# Specify output file +python export_plots.py export_request.json --output my_plots.pdf + +# Check available dependencies +python export_plots.py --check-deps +``` + +## JSON Request Format + +```json +{ + "plots": [ + "/path/to/plot1.pdf", + "/path/to/plot2.pdf" + ], + "layout": { + "rows": 1, + "cols": 2 + }, + "output_name": "merged_plots.pdf" +} +``` + +## Layout Options + +- **1 plot**: 1x1 grid +- **2 plots**: 1x2 grid (horizontal) +- **3 plots**: 2x2 grid (one empty slot) +- **4 plots**: 2x2 grid (full) + +## Technical Details + +The export functionality consists of: + +- **Frontend**: JavaScript selection UI and export manager +- **Backend**: Python script for PDF merging +- **CSS**: Styling for selection mode and overlays + +The system is designed to work without requiring a web server, using file-based communication between the browser and Python script. diff --git a/export_api.py b/export_api.py new file mode 100644 index 0000000..c2385f7 --- /dev/null +++ b/export_api.py @@ -0,0 +1,94 @@ +""" +Simple Flask API for PDF Export + +This provides a web API endpoint for the gallery export functionality. +""" + +from flask import Flask, request, jsonify, send_file +from pathlib import Path +import json +import tempfile +import os +from orchestration.pdf_export import PDFExporter, check_dependencies + +app = Flask(__name__) +exporter = PDFExporter() + + +@app.route('/api/export-pdf', methods=['POST']) +def export_pdf(): + """Export selected plots to merged PDF""" + try: + data = request.get_json() + + if not data or 'plots' not in data: + return jsonify({'error': 'No plots specified'}), 400 + + plot_urls = data['plots'] + layout = data.get('layout', {'rows': 2, 'cols': 2}) + + # Convert URLs to file paths + plot_paths = [] + for url in plot_urls: + # Extract path from file:// URL or relative path + if url.startswith('file://'): + path = url[7:] # Remove 'file://' prefix + elif url.startswith('http'): + return jsonify({'error': 'Remote URLs not supported'}), 400 + else: + # Assume relative path from web root + path = url + + # Resolve to absolute path + plot_path = Path(path) + if not plot_path.exists(): + return jsonify({'error': f'Plot not found: {path}'}), 404 + + plot_paths.append(str(plot_path)) + + if not plot_paths: + return jsonify({'error': 'No valid plots found'}), 400 + + # Create merged PDF + pdf_bytes = exporter.merge_plots(plot_paths, layout) + + # Create temporary file to serve + with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp_file: + tmp_file.write(pdf_bytes) + tmp_path = tmp_file.name + + def cleanup_temp_file(): + """Clean up temporary file after sending""" + try: + os.unlink(tmp_path) + except OSError: + pass + + return send_file( + tmp_path, + as_attachment=True, + download_name='merged_plots.pdf', + mimetype='application/pdf' + ) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/export-status', methods=['GET']) +def export_status(): + """Check export capabilities""" + deps = check_dependencies() + return jsonify({ + 'available': any(deps.values()), + 'dependencies': deps, + 'methods': { + 'pypdf2': deps['pypdf2'], + 'pdfjam': deps['pdfjam'] + } + }) + + +if __name__ == '__main__': + # For development only + app.run(debug=True, port=5000) diff --git a/export_plots.py b/export_plots.py new file mode 100644 index 0000000..4c4fb0a --- /dev/null +++ b/export_plots.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Standalone PDF Export Script + +This script processes export requests from the gallery and creates merged PDFs. +Usage: python export_plots.py [request_file.json] +""" + +import sys +import json +import argparse +from pathlib import Path +from orchestration.pdf_export import PDFExporter, check_dependencies + + +def main(): + parser = argparse.ArgumentParser(description='Export gallery plots to merged PDF') + parser.add_argument('request_file', nargs='?', default='export_request.json', + help='JSON file containing export request') + parser.add_argument('--output', '-o', help='Output PDF file path') + parser.add_argument('--check-deps', action='store_true', + help='Check available dependencies') + + args = parser.parse_args() + + if args.check_deps: + deps = check_dependencies() + print("Available dependencies:") + for dep, available in deps.items(): + status = "✓" if available else "✗" + print(f" {status} {dep}") + + if not any(deps.values()): + print("\nNo PDF merging tools available!") + print("Install one of the following:") + print(" - PyPDF2 and reportlab: pip install PyPDF2 reportlab") + print(" - pdfjam: apt-get install texlive-extra-utils (on Ubuntu/Debian)") + return + + request_file = Path(args.request_file) + if not request_file.exists(): + print(f"Error: Request file '{request_file}' not found") + print("Create a JSON file with the following structure:") + print(json.dumps({ + "plots": ["/path/to/plot1.pdf", "/path/to/plot2.pdf"], + "layout": {"rows": 1, "cols": 2}, + "output_name": "merged_plots.pdf" + }, indent=2)) + return 1 + + try: + with open(request_file, 'r') as f: + request_data = json.load(f) + except json.JSONDecodeError as e: + print(f"Error: Invalid JSON in '{request_file}': {e}") + return 1 + + # Validate request data + if 'plots' not in request_data: + print("Error: Missing 'plots' field in request") + return 1 + + plot_paths = request_data['plots'] + layout = request_data.get('layout', {'rows': 2, 'cols': 2}) + output_name = args.output or request_data.get('output_name', 'merged_plots.pdf') + + # Validate plot files exist + missing_files = [] + for plot_path in plot_paths: + if not Path(plot_path).exists(): + missing_files.append(plot_path) + + if missing_files: + print("Error: The following plot files were not found:") + for missing in missing_files: + print(f" - {missing}") + return 1 + + print(f"Exporting {len(plot_paths)} plots to '{output_name}'...") + print(f"Layout: {layout['rows']}x{layout['cols']}") + + # Create exporter and merge plots + exporter = PDFExporter() + + try: + pdf_bytes = exporter.merge_plots(plot_paths, layout, output_name) + + if not args.output and 'output_name' not in request_data: + # Write to file if not already done by exporter + with open(output_name, 'wb') as f: + f.write(pdf_bytes) + + print(f"✓ Successfully created '{output_name}'") + print(f" File size: {len(pdf_bytes) / 1024:.1f} KB") + + # Clean up request file if export was successful + request_file.unlink(missing_ok=True) + + except Exception as e: + print(f"Error during export: {e}") + return 1 + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/generate_gallery.py b/generate_gallery.py index cd640de..e0ff44b 100644 --- a/generate_gallery.py +++ b/generate_gallery.py @@ -183,7 +183,7 @@ def build_gallery(source_dir: Path, web_dir: Path, if relative_path == Path("."): assets_path = "../assets" else: - # Count the number of directory levels to go back + # Count directory levels to go back to gallery, then to public_html depth = len(relative_path.parts) assets_path = "../" * (depth + 1) + "assets" @@ -345,6 +345,10 @@ def main() -> None: "total_size_bytes": current_stats["total_size"] } + # Calculate relative path to assets for single file + # Single files are at depth 1 (gallery_root/source.name/index.html) + assets_path = "../assets" + output_html = source_web_dir / "index.html" with output_html.open("w") as f: f.write(template.render( @@ -355,7 +359,7 @@ def main() -> None: ui=CONFIG.ui, stats=stats, folder_metadata={}, - assets_path="../../assets" + assets_path=assets_path )) print(f"Generated {output_html}") diff --git a/orchestration/pdf_export.py b/orchestration/pdf_export.py new file mode 100644 index 0000000..51df501 --- /dev/null +++ b/orchestration/pdf_export.py @@ -0,0 +1,205 @@ +""" +PDF Export Module for Scientific Gallery Generator + +This module handles exporting and merging multiple plots into a single PDF +using PyPDF2 and reportlab for layout. +""" + +import io +import tempfile +from pathlib import Path +from typing import List, Dict, Any +import subprocess + +try: + from PyPDF2 import PdfReader, PdfWriter + from reportlab.pdfgen import canvas + from reportlab.lib.pagesizes import letter, A4 + from reportlab.lib.utils import ImageReader + HAS_PDF_LIBS = True +except ImportError: + HAS_PDF_LIBS = False + + +class PDFExporter: + """Handles exporting multiple plots to a merged PDF""" + + def __init__(self): + self.page_size = A4 + self.margin = 50 + + def merge_plots(self, plot_paths: List[str], layout: Dict[str, int], + output_path: str = None) -> bytes: + """ + Merge multiple PDF plots into a single PDF with grid layout. + + Args: + plot_paths: List of paths to PDF files + layout: Dictionary with 'rows' and 'cols' keys + output_path: Optional output file path + + Returns: + PDF bytes + """ + if not HAS_PDF_LIBS: + return self._merge_with_pdfjam(plot_paths, layout, output_path) + + return self._merge_with_pypdf(plot_paths, layout, output_path) + + def _merge_with_pypdf(self, plot_paths: List[str], layout: Dict[str, int], + output_path: str = None) -> bytes: + """Merge PDFs using PyPDF2 and reportlab""" + from reportlab.pdfgen import canvas + from reportlab.lib.pagesizes import A4 + + # Create a new PDF with the layout + buffer = io.BytesIO() + c = canvas.Canvas(buffer, pagesize=A4) + page_width, page_height = A4 + + rows = layout['rows'] + cols = layout['cols'] + + # Calculate dimensions for each plot + plot_width = (page_width - 2 * self.margin) / cols + plot_height = (page_height - 2 * self.margin) / rows + + # Place each plot in the grid + for i, plot_path in enumerate(plot_paths): + if i >= rows * cols: + break + + row = i // cols + col = i % cols + + # Calculate position + x = self.margin + col * plot_width + y = page_height - self.margin - (row + 1) * plot_height + + try: + # Read the source PDF + with open(plot_path, 'rb') as f: + reader = PdfReader(f) + if len(reader.pages) > 0: + page = reader.pages[0] + + # Convert PDF page to image and place it + # This is a simplified approach - in practice you'd want + # to properly scale and position the PDF content + self._draw_pdf_placeholder(c, x, y, plot_width, plot_height, + Path(plot_path).stem) + + except Exception as e: + print(f"Error processing {plot_path}: {e}") + self._draw_error_placeholder(c, x, y, plot_width, plot_height) + + c.save() + + if output_path: + with open(output_path, 'wb') as f: + f.write(buffer.getvalue()) + + return buffer.getvalue() + + def _merge_with_pdfjam(self, plot_paths: List[str], layout: Dict[str, int], + output_path: str = None) -> bytes: + """Merge PDFs using pdfjam (requires pdfpages LaTeX package)""" + rows = layout['rows'] + cols = layout['cols'] + + # Create temporary output file + with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp_file: + temp_output = tmp_file.name + + try: + # Build pdfjam command + cmd = [ + 'pdfjam', + '--nup', f'{cols}x{rows}', + '--landscape' if cols > rows else '--no-landscape', + '--frame', 'true', + '--delta', '10pt 10pt', + '--offset', '0pt 0pt', + '--outfile', temp_output + ] + + # Add input files + cmd.extend(plot_paths[:rows * cols]) + + # Run pdfjam + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + raise Exception(f"pdfjam failed: {result.stderr}") + + # Read the output file + with open(temp_output, 'rb') as f: + pdf_bytes = f.read() + + if output_path: + with open(output_path, 'wb') as f: + f.write(pdf_bytes) + + return pdf_bytes + + finally: + # Clean up temporary file + Path(temp_output).unlink(missing_ok=True) + + def _draw_pdf_placeholder(self, canvas, x: float, y: float, width: float, + height: float, plot_name: str): + """Draw a placeholder for a PDF plot""" + # Draw border + canvas.setStrokeColorRGB(0.5, 0.5, 0.5) + canvas.setLineWidth(1) + canvas.rect(x, y, width, height) + + # Draw plot name + canvas.setFillColorRGB(0, 0, 0) + canvas.setFont("Helvetica", 10) + text_width = canvas.stringWidth(plot_name, "Helvetica", 10) + text_x = x + (width - text_width) / 2 + text_y = y + height / 2 + canvas.drawString(text_x, text_y, plot_name) + + def _draw_error_placeholder(self, canvas, x: float, y: float, width: float, + height: float): + """Draw an error placeholder""" + # Draw red border + canvas.setStrokeColorRGB(1, 0, 0) + canvas.setLineWidth(2) + canvas.rect(x, y, width, height) + + # Draw error text + canvas.setFillColorRGB(1, 0, 0) + canvas.setFont("Helvetica-Bold", 12) + error_text = "Error loading plot" + text_width = canvas.stringWidth(error_text, "Helvetica-Bold", 12) + text_x = x + (width - text_width) / 2 + text_y = y + height / 2 + canvas.drawString(text_x, text_y, error_text) + + +def check_dependencies() -> Dict[str, bool]: + """Check if required dependencies are available""" + deps = { + 'pypdf2': HAS_PDF_LIBS, + 'pdfjam': False + } + + # Check for pdfjam + try: + result = subprocess.run(['pdfjam', '--version'], + capture_output=True, text=True) + deps['pdfjam'] = result.returncode == 0 + except FileNotFoundError: + pass + + return deps + + +if __name__ == "__main__": + # Test the exporter + exporter = PDFExporter() + deps = check_dependencies() + print("Available dependencies:", deps) diff --git a/templates/gallery.html b/templates/gallery.html index 8ee0e5e..e345b2c 100644 --- a/templates/gallery.html +++ b/templates/gallery.html @@ -104,6 +104,10 @@ Compare plots Ctrl+C +
+ Export plots + Ctrl+E +
Refresh F5 diff --git a/test.ipynb b/test.ipynb index d5d9ae8..10fe056 100644 --- a/test.ipynb +++ b/test.ipynb @@ -227,7 +227,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.9" + "version": "3.12.1" } }, "nbformat": 4, -- 2.39.5 From dbd67f60395e49fa7bcf83a84a270d3e23df5f16 Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Sun, 20 Jul 2025 08:59:10 +0200 Subject: [PATCH 10/35] Fix template rendering issue and improve export functionality - Fix template rendering by storing rendered HTML in variable before writing This resolves subdirectories not appearing in navigation despite being correctly detected and included in template variables - Add missing template variables (paths.work_dir) to gallery config - Add CGI refresh handler for web-based gallery regeneration - Improve PDF export functionality with better error handling - Add ESC key shortcut documentation for exiting selection mode - Enhanced export manager with proper dependency checking Fixes issue where new subdirectories were detected but not displayed in browser navigation due to incomplete template rendering. --- assets/css/export.css | 171 +++++++++++++++++++++++++++++++++--- assets/js/export-manager.js | 158 +++++++++++++++++++++++++++++---- assets/js/utils.js | 10 ++- generate_gallery.py | 32 ++++++- templates/gallery.html | 5 ++ 5 files changed, 347 insertions(+), 29 deletions(-) diff --git a/assets/css/export.css b/assets/css/export.css index 80fcc6e..84d7b5f 100644 --- a/assets/css/export.css +++ b/assets/css/export.css @@ -214,21 +214,37 @@ border: 1px solid var(--border-color); } -.export-commands code { - background: var(--background-color); - padding: 4px 8px; - border-radius: 3px; - font-family: 'Courier New', monospace; +.export-command-container { + margin: 20px 0; border: 1px solid var(--border-color); - display: inline-block; - margin-top: 8px; + 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: 12px; - justify-content: flex-end; - margin-top: 20px; + gap: 8px; + padding: 12px 16px; + background: var(--card-background); } .export-actions button { @@ -254,6 +270,124 @@ .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); @@ -287,3 +421,20 @@ 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); +} diff --git a/assets/js/export-manager.js b/assets/js/export-manager.js index ad9daeb..b60967b 100644 --- a/assets/js/export-manager.js +++ b/assets/js/export-manager.js @@ -52,7 +52,7 @@ export class ExportManager { this.toggleSelectionMode(); } if (e.key === 'Escape') { - this.clearSelection(); + this.exitSelectionMode(); } }); @@ -118,10 +118,17 @@ export class ExportManager { * 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'); + } } /** @@ -229,17 +236,26 @@ export class ExportManager { 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: `merged_plots_${new Date().toISOString().split('T')[0]}.pdf` + output_name: outputName }; + // Generate a unique temporary filename + const tempFileName = `export_request_${Date.now()}.json`; + + // Get work directory from config or fallback + const workDir = window.galleryConfig?.workDir || '/work/kschmidt/web'; + // Save the request to a JSON file that can be picked up by a Python script const requestData = JSON.stringify(payload, null, 2); - // Since we can't directly call Python from the browser, we'll show instructions - this.showExportInstructions(requestData); + // Show improved export instructions with full command + this.showExportInstructions(requestData, tempFileName, workDir); } /** @@ -258,21 +274,49 @@ export class ExportManager { /** * Show export instructions to user */ - showExportInstructions(requestData) { + showExportInstructions(requestData, tempFileName, workDir) { + const tempFilePath = `/tmp/${tempFileName}`; + const fullCommand = `echo '${requestData.replace(/'/g, "'\\''")}' > ${tempFilePath} && cd ${workDir} && python export_plots.py ${tempFilePath}`; + const instructions = `
-

Export Instructions

-

To export your selected plots, save the following data to a file called export_request.json and run the export script:

-
- +

🚀 Export Selected Plots

+

Run the following command in your terminal to export the selected plots:

+ +
+
+ ${fullCommand} +
+
+ + + +
-
-

Command to run:

- cd /work/kschmidt/web && python export_plots.py export_request.json + +
+

📋 Command Breakdown:

+
    +
  • Creates temporary file: ${tempFilePath}
  • +
  • Changes to work directory: ${workDir}
  • +
  • Runs export script: python export_plots.py
  • +
  • Output file: Will be saved in the work directory
  • +
-
- - + +
+

💡 Tips:

+
    +
  • The temporary JSON file will be automatically cleaned up after successful export
  • +
  • Use Esc to exit selection mode
  • +
  • Press Ctrl+E to toggle selection mode
  • +
`; @@ -280,7 +324,59 @@ export class ExportManager { 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); + } + }); } /** @@ -318,3 +414,35 @@ export class ExportManager { }, 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(); + }); + } +}); diff --git a/assets/js/utils.js b/assets/js/utils.js index 78fb7e5..f9817d4 100644 --- a/assets/js/utils.js +++ b/assets/js/utils.js @@ -105,8 +105,16 @@ export class Utils { refreshBtn.disabled = true; refreshBtn.textContent = '⏳'; + // Use the CGI script path from config if available + let cgiPath = '/cgi-bin/refresh_gallery.py'; + if (window.galleryConfig && window.galleryConfig.paths && window.galleryConfig.paths.cgi_script) { + cgiPath = window.galleryConfig.paths.cgi_script; + // Ensure it starts with a slash for fetch + if (!cgiPath.startsWith('/')) cgiPath = '/' + cgiPath; + } + try { - const response = await fetch('/cgi-bin/refresh_gallery.py', { + const response = await fetch(cgiPath, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/generate_gallery.py b/generate_gallery.py index e0ff44b..e878a78 100644 --- a/generate_gallery.py +++ b/generate_gallery.py @@ -15,6 +15,8 @@ Features: import subprocess import shutil +import os +import sys from pathlib import Path from typing import Dict, Any, Optional from jinja2 import Environment, FileSystemLoader @@ -114,6 +116,7 @@ def build_gallery(source_dir: Path, web_dir: Path, current_metadata = merge_metadata(inherited_metadata, folder_metadata) pdf_files = list(source_dir.glob("*.pdf")) + subdirs = [d for d in source_dir.iterdir() if d.is_dir()] items = [] @@ -188,16 +191,18 @@ def build_gallery(source_dir: Path, web_dir: Path, assets_path = "../" * (depth + 1) + "assets" with output_html.open("w") as f: - f.write(template.render( + rendered_html = template.render( title=title, items=items, subdirs=subdir_names, relpath=str(relative_path), + paths=CONFIG.paths, ui=CONFIG.ui, stats=stats, folder_metadata=current_metadata, assets_path=assets_path - )) + ) + f.write(rendered_html) print(f"Generated {output_html}") @@ -262,6 +267,21 @@ def format_file_size(size_bytes: int) -> str: return f"{size:.1f} {size_names[i]}" +def refresh_gallery_cgi(): + """ + CGI handler to refresh the gallery from a web request. + Outputs a minimal HTTP response and triggers gallery regeneration. + """ + import traceback + print("Content-Type: text/plain\n") + try: + main() + print("Gallery refreshed successfully.") + except Exception as e: + print(f"Error refreshing gallery: {e}") + traceback.print_exc(file=sys.stdout) + + def main() -> None: """ Main entry point for gallery generation. @@ -356,6 +376,7 @@ def main() -> None: items=items, subdirs=[], relpath=source.name, + paths=CONFIG.paths, ui=CONFIG.ui, stats=stats, folder_metadata={}, @@ -375,7 +396,12 @@ def main() -> None: f"directory nor a PDF file") print("Done") + # No copying of this script to CGI location if __name__ == "__main__": - main() + # If run as CGI, call the CGI handler + if 'GATEWAY_INTERFACE' in os.environ: + refresh_gallery_cgi() + else: + main() diff --git a/templates/gallery.html b/templates/gallery.html index e345b2c..6de9970 100644 --- a/templates/gallery.html +++ b/templates/gallery.html @@ -108,6 +108,10 @@ Export plots Ctrl+E
+
+ Exit selection mode + Esc +
Refresh F5 @@ -186,6 +190,7 @@ window.galleryConfig = { searchDebounceMs: {{ ui.search_debounce_ms|default(300) }}, maxRecentPlots: {{ ui.max_recent_plots|default(20) }}, + workDir: "{{ paths.work_dir }}", stats: {% if stats %}{{ stats|tojson }}{% else %}null{% endif %} }; -- 2.39.5 From 7a9275819a896c92cf517b01f5f377c131127f17 Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Mon, 21 Jul 2025 09:57:35 +0200 Subject: [PATCH 11/35] Fix index not being reloaded if a subfolder was added --- debug_template.py | 49 ++++++++++++++++++++++++++++++++++++++ generate_gallery.py | 58 +++++++++++++++++++-------------------------- refresh_gallery.py | 51 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 33 deletions(-) create mode 100644 debug_template.py create mode 100755 refresh_gallery.py diff --git a/debug_template.py b/debug_template.py new file mode 100644 index 0000000..89e9c8a --- /dev/null +++ b/debug_template.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +from pathlib import Path +from jinja2 import Environment, FileSystemLoader + +# Test template rendering +env = Environment(loader=FileSystemLoader(".")) +template = env.get_template("templates/gallery.html") + +# Mock data +test_items = [{ + "name": "test_plot", + "pdf_href": "test_plot.pdf", + "png_href": "test_plot.png", + "metadata": {"key1": "value1", "key2": "value2"} +}] + +test_config = { + "paths": {"work_dir": "/work/kschmidt/web"}, + "ui": {"search_debounce_ms": 300, "max_recent_plots": 20} +} + +rendered = template.render( + title="Test Gallery", + items=test_items, + subdirs=["test_subdir"], + relpath="test/path", + paths=test_config["paths"], + ui=test_config["ui"], + stats={"file_count": 1, "folder_count": 1, "total_size": "100 KB", "total_size_bytes": 100000}, + folder_metadata={}, + assets_path="../assets" +) + +# Extract just the metadata button part +lines = rendered.split('\n') +for i, line in enumerate(lines): + if 'metadata-btn' in line: + print(f"Line {i}: {line.strip()}") + if i > 0: + print(f"Line {i-1}: {lines[i-1].strip()}") + if i < len(lines)-1: + print(f"Line {i+1}: {lines[i+1].strip()}") + break + +print("\n--- Assets path in template ---") +for i, line in enumerate(lines): + if 'assets_path' in line: + print(f"Line {i}: {line.strip()}") diff --git a/generate_gallery.py b/generate_gallery.py index e878a78..88fb3a5 100644 --- a/generate_gallery.py +++ b/generate_gallery.py @@ -15,8 +15,6 @@ Features: import subprocess import shutil -import os -import sys from pathlib import Path from typing import Dict, Any, Optional from jinja2 import Environment, FileSystemLoader @@ -97,7 +95,8 @@ def build_gallery(source_dir: Path, web_dir: Path, 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. Now includes metadata support. + navigation and thumbnails. HTML files are always regenerated to ensure + that new subdirectories appear in navigation immediately. Args: source_dir: Source directory containing PDF files @@ -267,46 +266,38 @@ def format_file_size(size_bytes: int) -> str: return f"{size:.1f} {size_names[i]}" -def refresh_gallery_cgi(): - """ - CGI handler to refresh the gallery from a web request. - Outputs a minimal HTTP response and triggers gallery regeneration. - """ - import traceback - print("Content-Type: text/plain\n") - try: - main() - print("Gallery refreshed successfully.") - except Exception as e: - print(f"Error refreshing gallery: {e}") - traceback.print_exc(file=sys.stdout) - - -def main() -> None: +def main(clean_first: bool = False) -> None: """ Main entry point for gallery generation. + Args: + clean_first: If True, removes and recreates the gallery directory + Processes all configured sources and generates the complete gallery - structure in the web directory. Cleans up the gallery directory - on first run to ensure consistency. + structure in the web directory. Ensures assets are available. """ gallery_root = Path(CONFIG.web_folder) / CONFIG.plot_root - if gallery_root.exists(): - print(f"Gallery directory {gallery_root} exists, cleaning up...") + if clean_first and gallery_root.exists(): + print(f"Cleaning gallery directory {gallery_root}...") shutil.rmtree(gallery_root) + # Ensure gallery root exists gallery_root.mkdir(parents=True, exist_ok=True) - # Copy assets folder to the gallery root + # Always ensure assets are up to date assets_src = Path("assets") assets_dst = gallery_root.parent / "assets" if assets_src.exists(): - if assets_dst.exists(): - shutil.rmtree(assets_dst) - shutil.copytree(assets_src, assets_dst) - print(f"Copied assets from {assets_src} to {assets_dst}") + # Update assets if they don't exist or are outdated + main_css_src = assets_src / "css" / "main.css" + main_css_dst = assets_dst / "css" / "main.css" + if not assets_dst.exists() or needs_update(main_css_src, main_css_dst): + if assets_dst.exists(): + shutil.rmtree(assets_dst) + shutil.copytree(assets_src, assets_dst) + print(f"Updated assets from {assets_src} to {assets_dst}") else: print(f"Warning: Assets directory {assets_src} not found") @@ -400,8 +391,9 @@ def main() -> None: if __name__ == "__main__": - # If run as CGI, call the CGI handler - if 'GATEWAY_INTERFACE' in os.environ: - refresh_gallery_cgi() - else: - main() + import argparse + parser = argparse.ArgumentParser(description='Generate gallery') + parser.add_argument('--clean', action='store_true', + help='Clean gallery directory before generation') + args = parser.parse_args() + main(clean_first=args.clean) diff --git a/refresh_gallery.py b/refresh_gallery.py new file mode 100755 index 0000000..546af3b --- /dev/null +++ b/refresh_gallery.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +Simple CGI script to refresh the gallery. +Just calls the main generate_gallery.py script. +""" + +import subprocess + + +def main(): + # Output HTTP headers + print("Content-Type: text/plain") + print("Cache-Control: no-cache") + print() # Empty line to end headers + + try: + # Call the gallery generation script + result = subprocess.run([ + "python3", + "/work/kschmidt/web/generate_gallery.py" + ], + capture_output=True, + text=True, + timeout=300) # 5 minute timeout + + if result.returncode == 0: + print("Gallery refresh successful!") + print("\nOutput:") + print(result.stdout) + if result.stderr: + print("\nWarnings:") + print(result.stderr) + else: + print(f"Gallery refresh failed with return code " + f"{result.returncode}") + print("\nError output:") + print(result.stderr) + if result.stdout: + print("\nStandard output:") + print(result.stdout) + + except subprocess.TimeoutExpired: + print("Gallery refresh timed out after 5 minutes") + except Exception as e: + print(f"Error running gallery refresh: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + main() -- 2.39.5 From 465f9b6dc6e654fc41ea44aa36eec8e27ac90f70 Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Mon, 21 Jul 2025 15:33:33 +0200 Subject: [PATCH 12/35] Try to fix metadata stuff again (does not work) --- assets/css/metadata.css | 2 +- config.yaml | 2 ++ debug_template.py | 49 --------------------------------------- generate_gallery.py | 5 ++++ orchestration/metadata.py | 2 +- refresh_gallery.py | 22 ++++++++++-------- templates/gallery.html | 6 ++++- 7 files changed, 26 insertions(+), 62 deletions(-) delete mode 100644 debug_template.py diff --git a/assets/css/metadata.css b/assets/css/metadata.css index 872fef6..d6ab001 100644 --- a/assets/css/metadata.css +++ b/assets/css/metadata.css @@ -47,7 +47,7 @@ /* Metadata popup */ .metadata-popup { - background: var(--card-background); + background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); diff --git a/config.yaml b/config.yaml index b268e06..14ef13b 100644 --- a/config.yaml +++ b/config.yaml @@ -47,3 +47,5 @@ metadata: 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" diff --git a/debug_template.py b/debug_template.py deleted file mode 100644 index 89e9c8a..0000000 --- a/debug_template.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 - -from pathlib import Path -from jinja2 import Environment, FileSystemLoader - -# Test template rendering -env = Environment(loader=FileSystemLoader(".")) -template = env.get_template("templates/gallery.html") - -# Mock data -test_items = [{ - "name": "test_plot", - "pdf_href": "test_plot.pdf", - "png_href": "test_plot.png", - "metadata": {"key1": "value1", "key2": "value2"} -}] - -test_config = { - "paths": {"work_dir": "/work/kschmidt/web"}, - "ui": {"search_debounce_ms": 300, "max_recent_plots": 20} -} - -rendered = template.render( - title="Test Gallery", - items=test_items, - subdirs=["test_subdir"], - relpath="test/path", - paths=test_config["paths"], - ui=test_config["ui"], - stats={"file_count": 1, "folder_count": 1, "total_size": "100 KB", "total_size_bytes": 100000}, - folder_metadata={}, - assets_path="../assets" -) - -# Extract just the metadata button part -lines = rendered.split('\n') -for i, line in enumerate(lines): - if 'metadata-btn' in line: - print(f"Line {i}: {line.strip()}") - if i > 0: - print(f"Line {i-1}: {lines[i-1].strip()}") - if i < len(lines)-1: - print(f"Line {i+1}: {lines[i+1].strip()}") - break - -print("\n--- Assets path in template ---") -for i, line in enumerate(lines): - if 'assets_path' in line: - print(f"Line {i}: {line.strip()}") diff --git a/generate_gallery.py b/generate_gallery.py index 88fb3a5..0217a0f 100644 --- a/generate_gallery.py +++ b/generate_gallery.py @@ -144,6 +144,11 @@ def build_gallery(source_dir: Path, web_dir: Path, # Resolve metadata for this specific plot plot_metadata = resolve_metadata_for_plot(pdf_file, current_metadata) + if not plot_metadata: + plot_metadata = {} + for k, v in plot_metadata.items(): + if isinstance(v, str): + plot_metadata[k] = v.replace('<', '').replace('>', '').replace('&', '').replace('"', "'") plot_metadata_cache[pdf_file.stem] = plot_metadata items.append({ diff --git a/orchestration/metadata.py b/orchestration/metadata.py index d7c993d..4c1d9b8 100644 --- a/orchestration/metadata.py +++ b/orchestration/metadata.py @@ -45,7 +45,7 @@ def load_metadata_file(metadata_path: Path) -> Dict[str, Any]: return {} except (yaml.YAMLError, json.JSONDecodeError, IOError) as e: print(f"Warning: Could not parse metadata file {metadata_path}: {e}") - return {} + raise e def load_folder_metadata(folder_path: Path) -> Dict[str, Any]: diff --git a/refresh_gallery.py b/refresh_gallery.py index 546af3b..6e978c2 100755 --- a/refresh_gallery.py +++ b/refresh_gallery.py @@ -12,17 +12,19 @@ def main(): print("Content-Type: text/plain") print("Cache-Control: no-cache") print() # Empty line to end headers - + try: # Call the gallery generation script - result = subprocess.run([ - "python3", - "/work/kschmidt/web/generate_gallery.py" - ], - capture_output=True, - text=True, - timeout=300) # 5 minute timeout - + result = subprocess.run( + [ + "python3", + "/work/kschmidt/web/generate_gallery.py" + ], + capture_output=True, + text=True, + timeout=300, + ) + if result.returncode == 0: print("Gallery refresh successful!") print("\nOutput:") @@ -38,7 +40,7 @@ def main(): if result.stdout: print("\nStandard output:") print(result.stdout) - + except subprocess.TimeoutExpired: print("Gallery refresh timed out after 5 minutes") except Exception as e: diff --git a/templates/gallery.html b/templates/gallery.html index 6de9970..db63f8a 100644 --- a/templates/gallery.html +++ b/templates/gallery.html @@ -41,7 +41,11 @@ {{ item.name }} -
{{ item.name }}
-- 2.39.5 From 491a4a9fd2cbbf2205bac94c9c6412d225bc43d6 Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Mon, 21 Jul 2025 15:54:17 +0200 Subject: [PATCH 13/35] Add gallery index.html to website --- generate_gallery.py | 163 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 2 deletions(-) diff --git a/generate_gallery.py b/generate_gallery.py index 0217a0f..ea93ba1 100644 --- a/generate_gallery.py +++ b/generate_gallery.py @@ -115,12 +115,15 @@ def build_gallery(source_dir: Path, web_dir: Path, current_metadata = merge_metadata(inherited_metadata, folder_metadata) pdf_files = list(source_dir.glob("*.pdf")) + png_files = list(source_dir.glob("*.png")) subdirs = [d for d in source_dir.iterdir() if d.is_dir()] items = [] plot_metadata_cache = {} + processed_stems = set() + # First, process PDF files (with PNG conversion) for pdf_file in pdf_files: png_file = pdf_file.with_suffix(".png") @@ -148,7 +151,8 @@ def build_gallery(source_dir: Path, web_dir: Path, plot_metadata = {} for k, v in plot_metadata.items(): if isinstance(v, str): - plot_metadata[k] = v.replace('<', '').replace('>', '').replace('&', '').replace('"', "'") + plot_metadata[k] = (v.replace('<', '').replace('>', '') + .replace('&', '').replace('"', "'")) plot_metadata_cache[pdf_file.stem] = plot_metadata items.append({ @@ -157,6 +161,40 @@ def build_gallery(source_dir: Path, web_dir: Path, "png_href": png_file.name, "metadata": plot_metadata }) + processed_stems.add(pdf_file.stem) + + # Second, process standalone PNG files (no corresponding PDF) + for png_file in png_files: + # Skip if we already processed this PNG via its PDF + if png_file.stem in processed_stems: + continue + + web_png = web_dir / png_file.name + + if needs_update(png_file, web_png): + print(f"Copying {png_file} to {web_png}") + shutil.copy2(png_file, web_png) + else: + print(f"Skipping {png_file.name} (up to date)") + + # Resolve metadata for this standalone PNG plot + plot_metadata = resolve_metadata_for_plot(png_file, current_metadata) + if not plot_metadata: + plot_metadata = {} + for k, v in plot_metadata.items(): + if isinstance(v, str): + plot_metadata[k] = (v.replace('<', '').replace('>', '') + .replace('&', '').replace('"', "'")) + plot_metadata_cache[png_file.stem] = plot_metadata + + # For standalone PNGs, link to the PNG for both thumbnail and main view + items.append({ + "name": png_file.stem, + "pdf_href": png_file.name, # Link to PNG instead of PDF + "png_href": png_file.name, # Thumbnail is also the PNG + "metadata": plot_metadata + }) + processed_stems.add(png_file.stem) # Save metadata cache for this directory save_metadata_cache(web_dir, plot_metadata_cache) @@ -271,6 +309,64 @@ def format_file_size(size_bytes: int) -> str: return f"{size:.1f} {size_names[i]}" +def generate_root_index(gallery_root: Path) -> None: + """ + Generate the main index.html file for the gallery root. + + This serves as the entry point to navigate to all configured sources. + + Args: + gallery_root: Path to the gallery root directory + """ + # Collect information about all source directories + subdirs = [] + total_plots = 0 + total_size = 0 + + for source in CONFIG.sources: + source_web_dir = gallery_root / source.name + if source_web_dir.exists(): + subdirs.append(source.name) + + # Count plots and calculate size for this source + stats = calculate_directory_stats(source_web_dir) + total_plots += stats.get("file_count", 0) + total_size += stats.get("total_size", 0) + + # Calculate overall statistics + stats = { + "file_count": total_plots, + "folder_count": len(subdirs), + "total_size": format_file_size(total_size), + "total_size_bytes": total_size + } + + # Assets are at the same level as gallery for root + assets_path = "../assets" + + output_html = gallery_root / "index.html" + with output_html.open("w") as f: + rendered_html = template.render( + title="Scientific Gallery", + items=[], # No individual plots at root level + subdirs=subdirs, + relpath=".", + paths=CONFIG.paths, + ui=CONFIG.ui, + stats=stats, + folder_metadata={ + "description": ("Main gallery containing scientific plots " + "and analyses"), + "sources": [{"name": s.name, "path": s.path} + for s in CONFIG.sources] + }, + assets_path=assets_path + ) + f.write(rendered_html) + + print(f"Generated root gallery index: {output_html}") + + def main(clean_first: bool = False) -> None: """ Main entry point for gallery generation. @@ -382,6 +478,65 @@ def main(clean_first: bool = False) -> None: print(f"Generated {output_html}") print(f"Processed {source.name}: {source.path}") + elif source_path.is_file() and source_path.suffix == '.png': + # Handle standalone PNG files + source_web_dir = gallery_root / source.name + source_web_dir.mkdir(parents=True, exist_ok=True) + + png_name = source_path.name + web_png_path = source_web_dir / png_name + + if needs_update(source_path, web_png_path): + print(f"Copying {source_path} to {web_png_path}") + shutil.copy2(source_path, web_png_path) + else: + print(f"Skipping {source_path.name} (up to date)") + + # Resolve metadata for this single PNG plot + plot_metadata = resolve_metadata_for_plot(source_path, {}) + plot_metadata_cache[source_path.stem] = plot_metadata + + items = [{ + "name": source_path.stem, + "pdf_href": png_name, # Link to PNG instead of PDF + "png_href": png_name, # Thumbnail is the PNG + "metadata": plot_metadata + }] + + # Save metadata cache for this directory + plot_cache = {source_path.stem: plot_metadata} + save_metadata_cache(source_web_dir, plot_cache) + + # Calculate statistics for single file + current_stats = calculate_directory_stats(source_web_dir) + stats = { + "file_count": 1, + "folder_count": 0, + "total_size": format_file_size(current_stats["total_size"]), + "total_size_bytes": current_stats["total_size"] + } + + # Calculate relative path to assets for single file + # Single files are at depth 1 (gallery_root/source.name/index.html) + assets_path = "../assets" + + output_html = source_web_dir / "index.html" + with output_html.open("w") as f: + f.write(template.render( + title=source.name, + items=items, + subdirs=[], + relpath=source.name, + paths=CONFIG.paths, + ui=CONFIG.ui, + stats=stats, + folder_metadata={}, + assets_path=assets_path + )) + + print(f"Generated {output_html}") + print(f"Processed {source.name}: {source.path}") + elif source_path.is_dir(): source_web_dir = gallery_root / source.name source_web_dir.mkdir(parents=True, exist_ok=True) @@ -389,9 +544,13 @@ def main(clean_first: bool = False) -> None: print(f"Processed {source.name}: {source.path}") else: print(f"Warning: Source {source.path} is neither a " - f"directory nor a PDF file") + f"directory nor a PDF/PNG file") print("Done") + + # Generate root index.html for gallery navigation + generate_root_index(gallery_root) + # No copying of this script to CGI location -- 2.39.5 From 6eeacdc9b7f7adf7a16317afc8d30491b7a8cdd4 Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Tue, 22 Jul 2025 09:49:43 +0200 Subject: [PATCH 14/35] Add pyproject.toml --- .gitlab-ci.yml | 31 ------- config.yaml | 2 +- debug_template.py | 49 +++++++++++ generate_gallery.py | 209 +++++++++----------------------------------- pyproject.toml | 25 ++++++ 5 files changed, 114 insertions(+), 202 deletions(-) delete mode 100644 .gitlab-ci.yml create mode 100644 debug_template.py create mode 100644 pyproject.toml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 994d98c..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,31 +0,0 @@ -# You can override the included template(s) by including variable overrides -# SAST customization: https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings -# Secret Detection customization: https://docs.gitlab.com/user/application_security/secret_detection/pipeline/configure -# Dependency Scanning customization: https://docs.gitlab.com/ee/user/application_security/dependency_scanning/#customizing-the-dependency-scanning-settings -# Container Scanning customization: https://docs.gitlab.com/ee/user/application_security/container_scanning/#customizing-the-container-scanning-settings -# Note that environment variables can be set in several places -# See https://docs.gitlab.com/ee/ci/variables/#cicd-variable-precedence -stages: -- build -- test -- deploy -- review -- dast -- staging -- canary -- production -- incremental rollout 10% -- incremental rollout 25% -- incremental rollout 50% -- incremental rollout 100% -- performance -- cleanup -- secret-detection -sast: - stage: test -include: -- template: Auto-DevOps.gitlab-ci.yml -variables: - SECRET_DETECTION_ENABLED: 'true' -secret_detection: - stage: secret-detection diff --git a/config.yaml b/config.yaml index 14ef13b..e6c4910 100644 --- a/config.yaml +++ b/config.yaml @@ -47,5 +47,5 @@ metadata: sources: - name: "ttbar_analysis" path: "/work/kschmidt/NEEDLE/test_analysis/data/cf_store/test_analysis/cf.PlotVariables1D/run2_2016_nano_v9/" - - name: "NEEDLE_benchmarks" + - name: "needle_benchmarks" path: "/work/kschmidt/NEEDLE/orchestrator/ml/benchmarks/plots" diff --git a/debug_template.py b/debug_template.py new file mode 100644 index 0000000..89e9c8a --- /dev/null +++ b/debug_template.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +from pathlib import Path +from jinja2 import Environment, FileSystemLoader + +# Test template rendering +env = Environment(loader=FileSystemLoader(".")) +template = env.get_template("templates/gallery.html") + +# Mock data +test_items = [{ + "name": "test_plot", + "pdf_href": "test_plot.pdf", + "png_href": "test_plot.png", + "metadata": {"key1": "value1", "key2": "value2"} +}] + +test_config = { + "paths": {"work_dir": "/work/kschmidt/web"}, + "ui": {"search_debounce_ms": 300, "max_recent_plots": 20} +} + +rendered = template.render( + title="Test Gallery", + items=test_items, + subdirs=["test_subdir"], + relpath="test/path", + paths=test_config["paths"], + ui=test_config["ui"], + stats={"file_count": 1, "folder_count": 1, "total_size": "100 KB", "total_size_bytes": 100000}, + folder_metadata={}, + assets_path="../assets" +) + +# Extract just the metadata button part +lines = rendered.split('\n') +for i, line in enumerate(lines): + if 'metadata-btn' in line: + print(f"Line {i}: {line.strip()}") + if i > 0: + print(f"Line {i-1}: {lines[i-1].strip()}") + if i < len(lines)-1: + print(f"Line {i+1}: {lines[i+1].strip()}") + break + +print("\n--- Assets path in template ---") +for i, line in enumerate(lines): + if 'assets_path' in line: + print(f"Line {i}: {line.strip()}") diff --git a/generate_gallery.py b/generate_gallery.py index ea93ba1..e47e60f 100644 --- a/generate_gallery.py +++ b/generate_gallery.py @@ -15,6 +15,8 @@ Features: import subprocess import shutil +import os +import sys from pathlib import Path from typing import Dict, Any, Optional from jinja2 import Environment, FileSystemLoader @@ -95,8 +97,7 @@ def build_gallery(source_dir: Path, web_dir: Path, 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. HTML files are always regenerated to ensure - that new subdirectories appear in navigation immediately. + navigation and thumbnails. Now includes metadata support. Args: source_dir: Source directory containing PDF files @@ -115,15 +116,12 @@ def build_gallery(source_dir: Path, web_dir: Path, current_metadata = merge_metadata(inherited_metadata, folder_metadata) pdf_files = list(source_dir.glob("*.pdf")) - png_files = list(source_dir.glob("*.png")) subdirs = [d for d in source_dir.iterdir() if d.is_dir()] items = [] plot_metadata_cache = {} - processed_stems = set() - # First, process PDF files (with PNG conversion) for pdf_file in pdf_files: png_file = pdf_file.with_suffix(".png") @@ -147,12 +145,6 @@ def build_gallery(source_dir: Path, web_dir: Path, # Resolve metadata for this specific plot plot_metadata = resolve_metadata_for_plot(pdf_file, current_metadata) - if not plot_metadata: - plot_metadata = {} - for k, v in plot_metadata.items(): - if isinstance(v, str): - plot_metadata[k] = (v.replace('<', '').replace('>', '') - .replace('&', '').replace('"', "'")) plot_metadata_cache[pdf_file.stem] = plot_metadata items.append({ @@ -161,40 +153,6 @@ def build_gallery(source_dir: Path, web_dir: Path, "png_href": png_file.name, "metadata": plot_metadata }) - processed_stems.add(pdf_file.stem) - - # Second, process standalone PNG files (no corresponding PDF) - for png_file in png_files: - # Skip if we already processed this PNG via its PDF - if png_file.stem in processed_stems: - continue - - web_png = web_dir / png_file.name - - if needs_update(png_file, web_png): - print(f"Copying {png_file} to {web_png}") - shutil.copy2(png_file, web_png) - else: - print(f"Skipping {png_file.name} (up to date)") - - # Resolve metadata for this standalone PNG plot - plot_metadata = resolve_metadata_for_plot(png_file, current_metadata) - if not plot_metadata: - plot_metadata = {} - for k, v in plot_metadata.items(): - if isinstance(v, str): - plot_metadata[k] = (v.replace('<', '').replace('>', '') - .replace('&', '').replace('"', "'")) - plot_metadata_cache[png_file.stem] = plot_metadata - - # For standalone PNGs, link to the PNG for both thumbnail and main view - items.append({ - "name": png_file.stem, - "pdf_href": png_file.name, # Link to PNG instead of PDF - "png_href": png_file.name, # Thumbnail is also the PNG - "metadata": plot_metadata - }) - processed_stems.add(png_file.stem) # Save metadata cache for this directory save_metadata_cache(web_dir, plot_metadata_cache) @@ -209,6 +167,17 @@ def build_gallery(source_dir: Path, web_dir: Path, subdir_names.append(subdir.name) output_html = web_dir / "index.html" + + # Always regenerate HTML to ensure subdirectory changes are reflected + # This ensures new subdirectories appear in navigation + force_regeneration = True + if output_html.exists() and not force_regeneration: + html_mtime = output_html.stat().st_mtime + # Check if any subdirectory is newer than the HTML file + for subdir in subdirs: + if subdir.stat().st_mtime > html_mtime: + print(f"Subdirectory {subdir.name} is newer, forcing regeneration") + break if relative_path == Path("."): title = "Gallery" @@ -309,62 +278,19 @@ def format_file_size(size_bytes: int) -> str: return f"{size:.1f} {size_names[i]}" -def generate_root_index(gallery_root: Path) -> None: +def refresh_gallery_cgi(): """ - Generate the main index.html file for the gallery root. - - This serves as the entry point to navigate to all configured sources. - - Args: - gallery_root: Path to the gallery root directory + CGI handler to refresh the gallery from a web request. + Outputs a minimal HTTP response and triggers gallery regeneration. """ - # Collect information about all source directories - subdirs = [] - total_plots = 0 - total_size = 0 - - for source in CONFIG.sources: - source_web_dir = gallery_root / source.name - if source_web_dir.exists(): - subdirs.append(source.name) - - # Count plots and calculate size for this source - stats = calculate_directory_stats(source_web_dir) - total_plots += stats.get("file_count", 0) - total_size += stats.get("total_size", 0) - - # Calculate overall statistics - stats = { - "file_count": total_plots, - "folder_count": len(subdirs), - "total_size": format_file_size(total_size), - "total_size_bytes": total_size - } - - # Assets are at the same level as gallery for root - assets_path = "../assets" - - output_html = gallery_root / "index.html" - with output_html.open("w") as f: - rendered_html = template.render( - title="Scientific Gallery", - items=[], # No individual plots at root level - subdirs=subdirs, - relpath=".", - paths=CONFIG.paths, - ui=CONFIG.ui, - stats=stats, - folder_metadata={ - "description": ("Main gallery containing scientific plots " - "and analyses"), - "sources": [{"name": s.name, "path": s.path} - for s in CONFIG.sources] - }, - assets_path=assets_path - ) - f.write(rendered_html) - - print(f"Generated root gallery index: {output_html}") + import traceback + print("Content-Type: text/plain\n") + try: + main() + print("Gallery refreshed successfully.") + except Exception as e: + print(f"Error refreshing gallery: {e}") + traceback.print_exc(file=sys.stdout) def main(clean_first: bool = False) -> None: @@ -478,65 +404,6 @@ def main(clean_first: bool = False) -> None: print(f"Generated {output_html}") print(f"Processed {source.name}: {source.path}") - elif source_path.is_file() and source_path.suffix == '.png': - # Handle standalone PNG files - source_web_dir = gallery_root / source.name - source_web_dir.mkdir(parents=True, exist_ok=True) - - png_name = source_path.name - web_png_path = source_web_dir / png_name - - if needs_update(source_path, web_png_path): - print(f"Copying {source_path} to {web_png_path}") - shutil.copy2(source_path, web_png_path) - else: - print(f"Skipping {source_path.name} (up to date)") - - # Resolve metadata for this single PNG plot - plot_metadata = resolve_metadata_for_plot(source_path, {}) - plot_metadata_cache[source_path.stem] = plot_metadata - - items = [{ - "name": source_path.stem, - "pdf_href": png_name, # Link to PNG instead of PDF - "png_href": png_name, # Thumbnail is the PNG - "metadata": plot_metadata - }] - - # Save metadata cache for this directory - plot_cache = {source_path.stem: plot_metadata} - save_metadata_cache(source_web_dir, plot_cache) - - # Calculate statistics for single file - current_stats = calculate_directory_stats(source_web_dir) - stats = { - "file_count": 1, - "folder_count": 0, - "total_size": format_file_size(current_stats["total_size"]), - "total_size_bytes": current_stats["total_size"] - } - - # Calculate relative path to assets for single file - # Single files are at depth 1 (gallery_root/source.name/index.html) - assets_path = "../assets" - - output_html = source_web_dir / "index.html" - with output_html.open("w") as f: - f.write(template.render( - title=source.name, - items=items, - subdirs=[], - relpath=source.name, - paths=CONFIG.paths, - ui=CONFIG.ui, - stats=stats, - folder_metadata={}, - assets_path=assets_path - )) - - print(f"Generated {output_html}") - print(f"Processed {source.name}: {source.path}") - elif source_path.is_dir(): source_web_dir = gallery_root / source.name source_web_dir.mkdir(parents=True, exist_ok=True) @@ -544,20 +411,22 @@ def main(clean_first: bool = False) -> None: print(f"Processed {source.name}: {source.path}") else: print(f"Warning: Source {source.path} is neither a " - f"directory nor a PDF/PNG file") + f"directory nor a PDF file") print("Done") - - # Generate root index.html for gallery navigation - generate_root_index(gallery_root) - - # No copying of this script to CGI location if __name__ == "__main__": - import argparse - parser = argparse.ArgumentParser(description='Generate gallery') - parser.add_argument('--clean', action='store_true', - help='Clean gallery directory before generation') - args = parser.parse_args() - main(clean_first=args.clean) + if 'GATEWAY_INTERFACE' in os.environ: + refresh_gallery_cgi() + else: + import argparse + + parser = argparse.ArgumentParser(description='Generate gallery') + parser.add_argument( + '--clean', + action='store_true', + help='Clean gallery directory before generation' + ) + args = parser.parse_args() + main(clean_first=args.clean) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b519773 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "plot-gallery" +version = "0.1.0" +description = "Host your plots on a personal website" +authors = [ + { name = "K. Schmidt" } +] +readme = "README.md" +requires-python = ">=3.9" + +[tool.black] +line-length = 120 +target-version = ['py39'] + +[tool.isort] +profile = "black" +line_length = 120 + +[tool.flake8] +max-line-length = 120 +extend-ignore = ["E203", "W503"] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" \ No newline at end of file -- 2.39.5 From dced82a56fa28348e36081d3dd00ace9035712a0 Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Tue, 22 Jul 2025 12:37:33 +0200 Subject: [PATCH 15/35] Remove features that were not working: Metadata, export button and reloading the website from the website itself --- assets/css/metadata.css | 2 +- generate_gallery.py | 13 +--------- refresh_gallery.py | 53 ----------------------------------------- templates/gallery.html | 6 +---- test_metadata.html | 0 5 files changed, 3 insertions(+), 71 deletions(-) create mode 100644 test_metadata.html diff --git a/assets/css/metadata.css b/assets/css/metadata.css index d6ab001..872fef6 100644 --- a/assets/css/metadata.css +++ b/assets/css/metadata.css @@ -47,7 +47,7 @@ /* Metadata popup */ .metadata-popup { - background: var(--card-bg); + background: var(--card-background); border: 1px solid var(--border-color); border-radius: 8px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); diff --git a/generate_gallery.py b/generate_gallery.py index e47e60f..65b622c 100644 --- a/generate_gallery.py +++ b/generate_gallery.py @@ -328,8 +328,6 @@ def main(clean_first: bool = False) -> None: else: print(f"Warning: Assets directory {assets_src} not found") - plot_metadata_cache = {} - for source in CONFIG.sources: source_path = Path(source.path) @@ -359,21 +357,12 @@ def main(clean_first: bool = False) -> None: else: print(f"Skipping {source_png_path.name} (up to date)") - # Resolve metadata for this single plot - plot_metadata = resolve_metadata_for_plot(source_path, {}) - plot_metadata_cache[source_path.stem] = plot_metadata - items = [{ "name": source_path.stem, "pdf_href": pdf_name, - "png_href": png_name, - "metadata": plot_metadata + "png_href": png_name }] - # Save metadata cache for this directory - plot_cache = {source_path.stem: plot_metadata} - save_metadata_cache(source_web_dir, plot_cache) - # Calculate statistics for single file current_stats = calculate_directory_stats(source_web_dir) stats = { diff --git a/refresh_gallery.py b/refresh_gallery.py index 6e978c2..e69de29 100755 --- a/refresh_gallery.py +++ b/refresh_gallery.py @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple CGI script to refresh the gallery. -Just calls the main generate_gallery.py script. -""" - -import subprocess - - -def main(): - # Output HTTP headers - print("Content-Type: text/plain") - print("Cache-Control: no-cache") - print() # Empty line to end headers - - try: - # Call the gallery generation script - result = subprocess.run( - [ - "python3", - "/work/kschmidt/web/generate_gallery.py" - ], - capture_output=True, - text=True, - timeout=300, - ) - - if result.returncode == 0: - print("Gallery refresh successful!") - print("\nOutput:") - print(result.stdout) - if result.stderr: - print("\nWarnings:") - print(result.stderr) - else: - print(f"Gallery refresh failed with return code " - f"{result.returncode}") - print("\nError output:") - print(result.stderr) - if result.stdout: - print("\nStandard output:") - print(result.stdout) - - except subprocess.TimeoutExpired: - print("Gallery refresh timed out after 5 minutes") - except Exception as e: - print(f"Error running gallery refresh: {e}") - import traceback - traceback.print_exc() - - -if __name__ == "__main__": - main() diff --git a/templates/gallery.html b/templates/gallery.html index db63f8a..6de9970 100644 --- a/templates/gallery.html +++ b/templates/gallery.html @@ -41,11 +41,7 @@ {{ item.name }} -
{{ item.name }}
diff --git a/test_metadata.html b/test_metadata.html new file mode 100644 index 0000000..e69de29 -- 2.39.5 From 3e2aaa4be8ab2315278af243adfb2717b850d6b7 Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Tue, 22 Jul 2025 13:19:53 +0200 Subject: [PATCH 16/35] Add toggle for grid, list and compact list views --- assets/css/main.css | 8 +- assets/css/responsive.css | 17 ++ assets/css/view-controls.css | 310 ++++++++++++++++++++++++++++++++++ assets/css/view-override.css | 54 ++++++ assets/js/gallery-app.js | 6 +- assets/js/keyboard-manager.js | 7 + assets/js/view-manager.js | 203 ++++++++++++++++++++++ templates/gallery.html | 56 +++++- 8 files changed, 648 insertions(+), 13 deletions(-) create mode 100644 assets/css/view-controls.css create mode 100644 assets/css/view-override.css create mode 100644 assets/js/view-manager.js diff --git a/assets/css/main.css b/assets/css/main.css index 32a44c7..771752c 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -18,5 +18,11 @@ @import url('./metadata.css'); @import url('./export.css'); -/* Responsive design */ +/* View controls - must come after grid.css to override */ +@import url('./view-controls.css'); + +/* View override - force grid layout to work */ +@import url('./view-override.css'); + +/* Responsive design - last to override everything */ @import url('./responsive.css'); diff --git a/assets/css/responsive.css b/assets/css/responsive.css index 7b2a7a8..04f7bda 100644 --- a/assets/css/responsive.css +++ b/assets/css/responsive.css @@ -28,4 +28,21 @@ .search-container { max-width: 100%; } + + /* View controls responsive */ + .view-controls { + margin-right: 0.5rem !important; + padding: 8px 12px !important; + } + + .view-btn { + min-width: 40px !important; + min-height: 40px !important; + padding: 8px 12px !important; + } + + .view-btn svg { + width: 16px !important; + height: 16px !important; + } } diff --git a/assets/css/view-controls.css b/assets/css/view-controls.css new file mode 100644 index 0000000..9ceece9 --- /dev/null +++ b/assets/css/view-controls.css @@ -0,0 +1,310 @@ +/* ======================================== + VIEW CONTROLS AND LAYOUT MODES + ======================================== */ + +/* View Controls */ +.view-controls { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 12px; + margin: 1rem 0; + padding: 12px 16px; + background: var(--tree-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + width: fit-content; + margin-left: auto; + margin-right: 2rem; + 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; + 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); +} + +/* 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; +} + +.plot-container.list-large-view .plot-name { + font-size: 1rem !important; + line-height: 1.4 !important; + max-height: none !important; + overflow: visible !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; +} + +.plot-container.list-compact-view .plot-link { + flex: 1 !important; + display: flex !important; + align-items: center !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; +} + +.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; +} + +/* 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; + } +} diff --git a/assets/css/view-override.css b/assets/css/view-override.css new file mode 100644 index 0000000..315fe0f --- /dev/null +++ b/assets/css/view-override.css @@ -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(180px, 1fr)) !important; + gap: 1rem !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; +} diff --git a/assets/js/gallery-app.js b/assets/js/gallery-app.js index 935294e..b24eec6 100644 --- a/assets/js/gallery-app.js +++ b/assets/js/gallery-app.js @@ -9,7 +9,7 @@ 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 { ExportManager } from './export-manager.js'; +import { ViewManager } from './view-manager.js'; import { Utils } from './utils.js'; /** @@ -29,7 +29,7 @@ export class GalleryApp { this.recentPlotsManager = new RecentPlotsManager(this.MAX_RECENT_PLOTS); this.comparisonManager = new ComparisonManager(); this.statsManager = new StatsManager(); - this.exportManager = new ExportManager(); + this.viewManager = new ViewManager(); this.keyboardManager = new KeyboardManager(this); this.utils = Utils; @@ -38,7 +38,7 @@ export class GalleryApp { window.searchManager = this.searchManager; window.recentPlotsManager = this.recentPlotsManager; window.comparisonManager = this.comparisonManager; - window.exportManager = this.exportManager; + window.viewManager = this.viewManager; window.utils = this.utils; this.init(); diff --git a/assets/js/keyboard-manager.js b/assets/js/keyboard-manager.js index 5936d62..cc7ad00 100644 --- a/assets/js/keyboard-manager.js +++ b/assets/js/keyboard-manager.js @@ -39,6 +39,13 @@ export class KeyboardManager { } } + if (e.ctrlKey && e.key === 'v') { + e.preventDefault(); + if (this.app.viewManager) { + this.app.viewManager.cycleView(); + } + } + if (e.key === 'F5') { e.preventDefault(); if (this.app.utils && this.app.utils.refreshGallery) { diff --git a/assets/js/view-manager.js b/assets/js/view-manager.js new file mode 100644 index 0000000..c35381a --- /dev/null +++ b/assets/js/view-manager.js @@ -0,0 +1,203 @@ +/** + * 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 viewControls = document.querySelector('.view-controls'); + + if (!plotContainer || !viewControls) return; + + const hasPlots = plotContainer.children.length > 0; + viewControls.style.display = hasPlots ? 'flex' : 'none'; + } + + /** + * Setup view toggle buttons + */ + setupViewButtons() { + const viewButtons = document.querySelectorAll('.view-btn'); + console.log('ViewManager: Found', viewButtons.length, 'view buttons'); + + viewButtons.forEach(button => { + button.addEventListener('click', (e) => { + const newView = button.getAttribute('data-view'); + console.log('ViewManager: Switching to view:', newView); + this.switchView(newView); + }); + }); + } + + /** + * Switch to a different view mode + */ + switchView(viewMode) { + console.log('ViewManager: switchView called with:', viewMode, 'current:', this.currentView); + + if (viewMode === this.currentView) return; + + const plotContainer = document.getElementById('plotContainer'); + const viewButtons = document.querySelectorAll('.view-btn'); + + if (!plotContainer) { + console.error('ViewManager: plotContainer not found'); + return; + } + + console.log('ViewManager: Plot container found, classes before:', plotContainer.className); + + // 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'; + } + + console.log('ViewManager: Plot container classes after:', plotContainer.className); + + // 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]); + } +} diff --git a/templates/gallery.html b/templates/gallery.html index 6de9970..927df73 100644 --- a/templates/gallery.html +++ b/templates/gallery.html @@ -34,17 +34,51 @@
- -
+ + {% if items %} +
+ + + +
+ {% endif %} + + +
{% for item in items %} -
- - {{ item.name }} +
+ + {{ item.name }} - -
{{ item.name }}
+
+
{{ item.name }}
+
{% endfor %}
@@ -120,6 +154,10 @@ Toggle theme Ctrl+T
+
+ Toggle view + Ctrl+V +
Help ? -- 2.39.5 From f3adbe49fac8ba7648453e6c5485f0f62248c39d Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Tue, 29 Jul 2025 09:38:28 +0200 Subject: [PATCH 17/35] Added so much stuff I cant keep up. I think some sorting capability and a way to add metadata to each folder (WIP) --- .vscode/settings.json | 6 + assets/css/floating-elements.css | 15 -- assets/css/folder-metadata.css | 203 ++++++++++++++++++ assets/css/main.css | 4 + assets/css/responsive.css | 11 + assets/css/sort-controls.css | 88 ++++++++ .../css/sort-debug.css | 0 assets/css/view-controls.css | 101 ++++++++- assets/css/view-override.css | 4 +- assets/js/folder-metadata.js | 69 ++++++ assets/js/gallery-app.js | 4 +- assets/js/keyboard-manager.js | 20 +- assets/js/main.js | 2 - assets/js/sort-manager.js | 197 +++++++++++++++++ assets/js/utils.js | 44 ---- assets/js/view-manager.js | 19 +- debug_template.py | 49 ----- docs/EXPORT_FUNCTIONALITY.md | 101 --------- docs/METADATA_IMPLEMENTATION.md | 103 --------- docs/METADATA_USAGE.md | 114 ---------- docs/SORT_IMPLEMENTATION.md | 136 ++++++++++++ examples/metadata.json | 19 ++ export_api.py | 94 -------- export_plots.py | 107 --------- generate_gallery.py | 26 ++- orchestration/metadata.py | 6 +- python/add_creation_time.py | 92 ++++++++ python/add_metadata.py | 45 ++++ templates/gallery.html | 152 +++++++++---- test_metadata.html | 0 30 files changed, 1134 insertions(+), 697 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 assets/css/folder-metadata.css create mode 100644 assets/css/sort-controls.css rename refresh_gallery.py => assets/css/sort-debug.css (100%) mode change 100755 => 100644 create mode 100644 assets/js/folder-metadata.js create mode 100644 assets/js/sort-manager.js delete mode 100644 debug_template.py delete mode 100644 docs/EXPORT_FUNCTIONALITY.md delete mode 100644 docs/METADATA_IMPLEMENTATION.md delete mode 100644 docs/METADATA_USAGE.md create mode 100644 docs/SORT_IMPLEMENTATION.md create mode 100644 examples/metadata.json delete mode 100644 export_api.py delete mode 100644 export_plots.py create mode 100644 python/add_creation_time.py create mode 100644 python/add_metadata.py delete mode 100644 test_metadata.html diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..47b059e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "flake8.args": [ + "--max-line-length=120", + "--ignore=W293,E123,W503", + ], +} \ No newline at end of file diff --git a/assets/css/floating-elements.css b/assets/css/floating-elements.css index 1c5bb2f..ae6a70a 100644 --- a/assets/css/floating-elements.css +++ b/assets/css/floating-elements.css @@ -39,21 +39,6 @@ color: white; } -.refresh-btn { - background: var(--success-color); - color: white; -} - -.refresh-btn:hover { - background: var(--success-hover); -} - -.refresh-btn:disabled { - background: var(--disabled-color); - cursor: not-allowed; - transform: none; -} - /* ======================================== KEYBOARD SHORTCUTS HELP ======================================== */ diff --git a/assets/css/folder-metadata.css b/assets/css/folder-metadata.css new file mode 100644 index 0000000..b41a242 --- /dev/null +++ b/assets/css/folder-metadata.css @@ -0,0 +1,203 @@ +/* ======================================== + FOLDER METADATA DISPLAY STYLES + ======================================== */ + +.folder-metadata-container { + margin: 2rem 0; + padding: 1.5rem; + background: var(--card-background); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.folder-metadata-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 2px solid var(--border-color); +} + +.folder-metadata-title { + margin: 0; + color: var(--text-color); + font-size: 1.25rem; + font-weight: 600; +} + +.folder-metadata-edit-btn { + background: var(--accent-color); + color: white; + border: none; + border-radius: 6px; + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.folder-metadata-edit-btn:hover { + background: var(--accent-color-dark); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.folder-metadata-edit-btn:active { + transform: translateY(0); +} + +.folder-metadata-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1rem; +} + +.folder-metadata-item { + display: flex; + flex-direction: column; + background: var(--background); + border: 1px solid var(--border-color-light); + border-radius: 6px; + padding: 1rem; + transition: all 0.2s ease; +} + +.folder-metadata-item:hover { + background: var(--hover-background); + border-color: var(--accent-color); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.folder-metadata-key { + font-weight: 600; + color: var(--accent-color); + margin-bottom: 0.5rem; + font-size: 0.95rem; + text-transform: capitalize; + border-bottom: 1px solid var(--border-color-light); + padding-bottom: 0.25rem; +} + +.folder-metadata-value { + color: var(--text-color); + line-height: 1.5; + word-break: break-word; +} + +.folder-metadata-value a { + color: var(--accent-color); + text-decoration: none; + border-bottom: 1px dotted var(--accent-color); + transition: all 0.2s ease; +} + +.folder-metadata-value a:hover { + color: var(--accent-color-dark); + border-bottom-style: solid; +} + +.folder-metadata-list { + margin: 0; + padding-left: 1.5rem; +} + +.folder-metadata-list li { + margin-bottom: 0.25rem; +} + +.folder-metadata-nested { + background: var(--background-dark); + padding: 0.75rem; + border-radius: 4px; + border-left: 3px solid var(--accent-color); +} + +.folder-metadata-nested-item { + margin-bottom: 0.5rem; +} + +.folder-metadata-nested-item:last-child { + margin-bottom: 0; +} + +.folder-metadata-nested-item strong { + color: var(--accent-color); +} + +.folder-metadata-long-text { + display: inline; +} + +.folder-metadata-expand { + background: none; + border: none; + color: var(--accent-color); + cursor: pointer; + text-decoration: underline; + font-size: 0.9rem; + margin-left: 0.5rem; + padding: 0; +} + +.folder-metadata-expand:hover { + color: var(--accent-color-dark); +} + +.folder-metadata-full-text { + display: none; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .folder-metadata-container { + margin: 1rem 0; + padding: 1rem; + } + + .folder-metadata-grid { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + .folder-metadata-header { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .folder-metadata-edit-btn { + align-self: flex-end; + } +} + +/* Dark theme adjustments */ +@media (prefers-color-scheme: dark) { + .folder-metadata-container { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } + + .folder-metadata-item:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + } +} + +/* Animation for expanding text */ +.folder-metadata-full-text.show { + display: inline; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/assets/css/main.css b/assets/css/main.css index 771752c..3713837 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -16,11 +16,15 @@ @import url('./stats.css'); @import url('./comparison.css'); @import url('./metadata.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'); diff --git a/assets/css/responsive.css b/assets/css/responsive.css index 04f7bda..1ca81f5 100644 --- a/assets/css/responsive.css +++ b/assets/css/responsive.css @@ -30,9 +30,20 @@ } /* 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 { diff --git a/assets/css/sort-controls.css b/assets/css/sort-controls.css new file mode 100644 index 0000000..3df2439 --- /dev/null +++ b/assets/css/sort-controls.css @@ -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; +} diff --git a/refresh_gallery.py b/assets/css/sort-debug.css old mode 100755 new mode 100644 similarity index 100% rename from refresh_gallery.py rename to assets/css/sort-debug.css diff --git a/assets/css/view-controls.css b/assets/css/view-controls.css index 9ceece9..b608505 100644 --- a/assets/css/view-controls.css +++ b/assets/css/view-controls.css @@ -2,20 +2,80 @@ 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; - margin: 1rem 0; padding: 12px 16px; background: var(--tree-bg); border: 1px solid var(--border-color); border-radius: 8px; - width: fit-content; - margin-left: auto; - margin-right: 2rem; position: relative; z-index: 10; } @@ -103,6 +163,13 @@ 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; @@ -199,6 +266,9 @@ 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 { @@ -206,6 +276,16 @@ 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 */ @@ -237,6 +317,9 @@ 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 { @@ -246,6 +329,16 @@ 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 */ diff --git a/assets/css/view-override.css b/assets/css/view-override.css index 315fe0f..e9bd47c 100644 --- a/assets/css/view-override.css +++ b/assets/css/view-override.css @@ -5,8 +5,8 @@ /* Force grid layout when grid-view class is present */ body .plot-container.grid-view { display: grid !important; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)) !important; - gap: 1rem !important; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)) !important; + gap: 1.2rem !important; padding: 1rem 0 !important; } diff --git a/assets/js/folder-metadata.js b/assets/js/folder-metadata.js new file mode 100644 index 0000000..6be3ce4 --- /dev/null +++ b/assets/js/folder-metadata.js @@ -0,0 +1,69 @@ +/** + * Folder Metadata functionality for Gallery + * + * Handles interactions with folder-level metadata display + */ + +// Function to toggle expansion of long metadata text +function toggleMetadataText(button) { + const longText = button.previousElementSibling; + const fullText = button.nextElementSibling; + + if (fullText.style.display === 'none') { + // Show full text + longText.style.display = 'none'; + fullText.style.display = 'inline'; + fullText.classList.add('show'); + button.textContent = 'Show less'; + } else { + // Show truncated text + longText.style.display = 'inline'; + fullText.style.display = 'none'; + fullText.classList.remove('show'); + button.textContent = 'Show more'; + } +} + +// Function to handle folder metadata editing (placeholder for future implementation) +function editFolderMetadata() { + // For now, just show an alert that this feature is coming soon + alert('Folder metadata editing functionality is coming soon!'); + + // Future implementation will: + // 1. Open an edit modal with form fields for each metadata key + // 2. Allow adding/removing metadata fields + // 3. Validate the input + // 4. Send updates to the server + // 5. Refresh the page or update the display dynamically +} + +// Initialize folder metadata functionality when DOM is loaded +document.addEventListener('DOMContentLoaded', function() { + console.log('Folder metadata functionality initialized'); + + // Add keyboard shortcuts for metadata editing (future feature) + document.addEventListener('keydown', function(e) { + // Ctrl+M for metadata editing + if (e.ctrlKey && e.key === 'm') { + e.preventDefault(); + const editBtn = document.querySelector('.folder-metadata-edit-btn'); + if (editBtn) { + editFolderMetadata(); + } + } + }); + + // Add accessibility improvements + const metadataItems = document.querySelectorAll('.folder-metadata-item'); + metadataItems.forEach(item => { + item.setAttribute('tabindex', '0'); + item.setAttribute('role', 'listitem'); + }); + + // Add ARIA labels for better accessibility + const metadataContainer = document.querySelector('.folder-metadata-container'); + if (metadataContainer) { + metadataContainer.setAttribute('role', 'region'); + metadataContainer.setAttribute('aria-label', 'Folder metadata information'); + } +}); diff --git a/assets/js/gallery-app.js b/assets/js/gallery-app.js index b24eec6..849286e 100644 --- a/assets/js/gallery-app.js +++ b/assets/js/gallery-app.js @@ -10,6 +10,7 @@ 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'; /** @@ -30,6 +31,7 @@ export class GalleryApp { this.comparisonManager = new ComparisonManager(); this.statsManager = new StatsManager(); this.viewManager = new ViewManager(); + this.sortManager = new SortManager(); this.keyboardManager = new KeyboardManager(this); this.utils = Utils; @@ -39,6 +41,7 @@ export class GalleryApp { window.recentPlotsManager = this.recentPlotsManager; window.comparisonManager = this.comparisonManager; window.viewManager = this.viewManager; + window.sortManager = this.sortManager; window.utils = this.utils; this.init(); @@ -63,7 +66,6 @@ export class GalleryApp { // Backward compatibility methods toggleTheme() { this.themeManager.toggle(); } toggleSidebar() { this.recentPlotsManager.toggleSidebar(); } - refreshGallery() { Utils.refreshGallery(); } toggleCompareMode() { this.comparisonManager.toggleCompareMode(); } closeComparison() { this.comparisonManager.closeComparison(); } selectPlotForComparison(slot) { this.comparisonManager.selectPlotForComparison(slot); } diff --git a/assets/js/keyboard-manager.js b/assets/js/keyboard-manager.js index cc7ad00..c535a9c 100644 --- a/assets/js/keyboard-manager.js +++ b/assets/js/keyboard-manager.js @@ -46,10 +46,24 @@ export class KeyboardManager { } } - if (e.key === 'F5') { + if (e.ctrlKey && e.key === 'n') { e.preventDefault(); - if (this.app.utils && this.app.utils.refreshGallery) { - this.app.utils.refreshGallery(); + 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(); } } diff --git a/assets/js/main.js b/assets/js/main.js index adcfd02..8f924e6 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -10,12 +10,10 @@ let app; // Global functions for onclick handlers (backward compatibility) function toggleTheme() { app.toggleTheme(); } function toggleSidebar() { app.toggleSidebar(); } -function refreshGallery() { app.refreshGallery(); } // Make functions globally available window.toggleTheme = toggleTheme; window.toggleSidebar = toggleSidebar; -window.refreshGallery = refreshGallery; // Initialize application when DOM is ready document.addEventListener('DOMContentLoaded', function() { diff --git a/assets/js/sort-manager.js b/assets/js/sort-manager.js new file mode 100644 index 0000000..8ec9a85 --- /dev/null +++ b/assets/js/sort-manager.js @@ -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(); + } +} diff --git a/assets/js/utils.js b/assets/js/utils.js index f9817d4..3c5b432 100644 --- a/assets/js/utils.js +++ b/assets/js/utils.js @@ -95,50 +95,6 @@ export class Utils { } } - /** - * Refresh gallery by calling CGI script - */ - static async refreshGallery() { - const refreshBtn = document.getElementById('refreshBtn'); - if (!refreshBtn) return; - - refreshBtn.disabled = true; - refreshBtn.textContent = '⏳'; - - // Use the CGI script path from config if available - let cgiPath = '/cgi-bin/refresh_gallery.py'; - if (window.galleryConfig && window.galleryConfig.paths && window.galleryConfig.paths.cgi_script) { - cgiPath = window.galleryConfig.paths.cgi_script; - // Ensure it starts with a slash for fetch - if (!cgiPath.startsWith('/')) cgiPath = '/' + cgiPath; - } - - try { - const response = await fetch(cgiPath, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - } - }); - - if (response.ok) { - refreshBtn.textContent = '✅'; - setTimeout(() => { - window.location.reload(); - }, 1000); - } else { - throw new Error('Refresh failed'); - } - } catch (error) { - console.error('Error refreshing gallery:', error); - refreshBtn.textContent = '❌'; - setTimeout(() => { - refreshBtn.disabled = false; - refreshBtn.textContent = '🔄'; - }, 3000); - } - } - /** * Toggle keyboard shortcuts help display */ diff --git a/assets/js/view-manager.js b/assets/js/view-manager.js index c35381a..0d51208 100644 --- a/assets/js/view-manager.js +++ b/assets/js/view-manager.js @@ -21,12 +21,12 @@ export class ViewManager { */ updateControlsVisibility() { const plotContainer = document.getElementById('plotContainer'); - const viewControls = document.querySelector('.view-controls'); + const controlsContainer = document.querySelector('.controls-container'); - if (!plotContainer || !viewControls) return; + if (!plotContainer || !controlsContainer) return; const hasPlots = plotContainer.children.length > 0; - viewControls.style.display = hasPlots ? 'flex' : 'none'; + controlsContainer.style.display = hasPlots ? 'flex' : 'none'; } /** @@ -34,12 +34,10 @@ export class ViewManager { */ setupViewButtons() { const viewButtons = document.querySelectorAll('.view-btn'); - console.log('ViewManager: Found', viewButtons.length, 'view buttons'); viewButtons.forEach(button => { button.addEventListener('click', (e) => { const newView = button.getAttribute('data-view'); - console.log('ViewManager: Switching to view:', newView); this.switchView(newView); }); }); @@ -49,19 +47,12 @@ export class ViewManager { * Switch to a different view mode */ switchView(viewMode) { - console.log('ViewManager: switchView called with:', viewMode, 'current:', this.currentView); - if (viewMode === this.currentView) return; const plotContainer = document.getElementById('plotContainer'); const viewButtons = document.querySelectorAll('.view-btn'); - if (!plotContainer) { - console.error('ViewManager: plotContainer not found'); - return; - } - - console.log('ViewManager: Plot container found, classes before:', plotContainer.className); + if (!plotContainer) return; // Remove current view class plotContainer.classList.remove( @@ -86,8 +77,6 @@ export class ViewManager { viewMode = 'grid'; } - console.log('ViewManager: Plot container classes after:', plotContainer.className); - // Update button states viewButtons.forEach(btn => { if (btn.getAttribute('data-view') === viewMode) { diff --git a/debug_template.py b/debug_template.py deleted file mode 100644 index 89e9c8a..0000000 --- a/debug_template.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 - -from pathlib import Path -from jinja2 import Environment, FileSystemLoader - -# Test template rendering -env = Environment(loader=FileSystemLoader(".")) -template = env.get_template("templates/gallery.html") - -# Mock data -test_items = [{ - "name": "test_plot", - "pdf_href": "test_plot.pdf", - "png_href": "test_plot.png", - "metadata": {"key1": "value1", "key2": "value2"} -}] - -test_config = { - "paths": {"work_dir": "/work/kschmidt/web"}, - "ui": {"search_debounce_ms": 300, "max_recent_plots": 20} -} - -rendered = template.render( - title="Test Gallery", - items=test_items, - subdirs=["test_subdir"], - relpath="test/path", - paths=test_config["paths"], - ui=test_config["ui"], - stats={"file_count": 1, "folder_count": 1, "total_size": "100 KB", "total_size_bytes": 100000}, - folder_metadata={}, - assets_path="../assets" -) - -# Extract just the metadata button part -lines = rendered.split('\n') -for i, line in enumerate(lines): - if 'metadata-btn' in line: - print(f"Line {i}: {line.strip()}") - if i > 0: - print(f"Line {i-1}: {lines[i-1].strip()}") - if i < len(lines)-1: - print(f"Line {i+1}: {lines[i+1].strip()}") - break - -print("\n--- Assets path in template ---") -for i, line in enumerate(lines): - if 'assets_path' in line: - print(f"Line {i}: {line.strip()}") diff --git a/docs/EXPORT_FUNCTIONALITY.md b/docs/EXPORT_FUNCTIONALITY.md deleted file mode 100644 index 96669e7..0000000 --- a/docs/EXPORT_FUNCTIONALITY.md +++ /dev/null @@ -1,101 +0,0 @@ -# PDF Export Functionality - -The gallery now includes the ability to export multiple plots to a merged PDF with grid layout. - -## Features - -- Select up to 4 plots from any gallery page -- Automatic grid layout (1x1, 1x2, 2x2) -- Export to PDF with proper scaling -- Keyboard shortcuts for easy access - -## Usage - -### Selecting Plots - -1. Press **Ctrl+E** to enter selection mode -2. Click on up to 4 plot thumbnails to select them -3. Selected plots will show a checkmark overlay -4. A counter shows how many plots are selected (e.g., "2/4 selected") - -### Exporting - -1. After selecting plots, click the **📄** export button (appears in floating buttons) -2. The system will show export instructions with: - - JSON data for the export request - - Command to run the export script -3. Copy the JSON data and save it as `export_request.json` -4. Run the export command in your terminal - -### Keyboard Shortcuts - -- **Ctrl+E**: Toggle selection mode -- **Escape**: Clear all selections and exit selection mode - -## Export Methods - -The system supports two PDF merging methods: - -### Method 1: pdfjam (Recommended) -```bash -# Install on Ubuntu/Debian -sudo apt-get install texlive-extra-utils - -# Check if available -python export_plots.py --check-deps -``` - -### Method 2: Python libraries -```bash -# Install Python dependencies -pip install PyPDF2 reportlab - -# Check if available -python export_plots.py --check-deps -``` - -## Export Script Usage - -```bash -# Basic usage -python export_plots.py export_request.json - -# Specify output file -python export_plots.py export_request.json --output my_plots.pdf - -# Check available dependencies -python export_plots.py --check-deps -``` - -## JSON Request Format - -```json -{ - "plots": [ - "/path/to/plot1.pdf", - "/path/to/plot2.pdf" - ], - "layout": { - "rows": 1, - "cols": 2 - }, - "output_name": "merged_plots.pdf" -} -``` - -## Layout Options - -- **1 plot**: 1x1 grid -- **2 plots**: 1x2 grid (horizontal) -- **3 plots**: 2x2 grid (one empty slot) -- **4 plots**: 2x2 grid (full) - -## Technical Details - -The export functionality consists of: - -- **Frontend**: JavaScript selection UI and export manager -- **Backend**: Python script for PDF merging -- **CSS**: Styling for selection mode and overlays - -The system is designed to work without requiring a web server, using file-based communication between the browser and Python script. diff --git a/docs/METADATA_IMPLEMENTATION.md b/docs/METADATA_IMPLEMENTATION.md deleted file mode 100644 index 5bc2950..0000000 --- a/docs/METADATA_IMPLEMENTATION.md +++ /dev/null @@ -1,103 +0,0 @@ -# Metadata System Implementation Summary - -## What Was Implemented - -### 1. Core Metadata Module (`metadata.py`) -- **`load_metadata_file()`**: Loads YAML/JSON metadata files with error handling -- **`load_folder_metadata()`**: Discovers and loads folder-level metadata (meta.yaml/meta.json) -- **`merge_metadata()`**: Merges parent and child metadata with proper override behavior -- **`resolve_metadata_for_plot()`**: Resolves final metadata for individual plots -- **`save_metadata_cache()`**: Saves resolved metadata to cache files for performance - -### 2. Updated Gallery Generator (`generate_gallery.py`) -- **Hierarchical inheritance**: Folder metadata is inherited by subfolders and plots -- **Plot-specific overrides**: Individual plots can have their own metadata files -- **Template integration**: Metadata is passed to HTML templates for rendering -- **Cache generation**: `meta_cache.json` files are created in each output directory - -### 3. Configuration Updates (`config.py` and `config.yaml`) -- Added `MetadataConfig` class with caching and inheritance options -- Updated main `Config` class to include metadata settings -- Added metadata section to `config.yaml` - -### 4. Documentation and Examples -- **`METADATA_USAGE.md`**: Comprehensive documentation on using the metadata system -- **`examples/meta.yaml`**: Example folder metadata file -- **`examples/specific_plot.json`**: Example plot-specific metadata file -- **`validate_metadata.py`**: Utility script for validating metadata files - -## Key Features - -### Hierarchical Metadata Inheritance -``` -root_folder/ -├── meta.yaml # Base metadata for all plots -├── subfolder/ -│ ├── meta.yaml # Inherits from parent, can override -│ ├── plot1.pdf -│ ├── plot1.yaml # Plot-specific metadata -│ └── plot2.pdf # Uses folder metadata -``` - -### Flexible Format Support -- YAML files: `.yaml`, `.yml` -- JSON files: `.json` -- Automatic format detection based on file extension - -### Template Integration -- `folder_metadata`: Available in templates for folder-level metadata -- `item.metadata`: Available for each plot in the items loop -- Clean separation of concerns between data and presentation - -### Performance Optimization -- Metadata caching in `meta_cache.json` files -- Only reload when source files are newer than cache -- Efficient hierarchical resolution - -## Usage Examples - -### Basic Folder Metadata -```yaml -# meta.yaml -title: "Physics Analysis Results" -experiment: "CMS" -author: - name: "Researcher Name" - institution: "University" -tags: ["analysis", "physics"] -``` - -### Plot-specific Metadata -```yaml -# my_plot.yaml (for my_plot.pdf) -title: "Signal Region Analysis" -plot_type: "histogram" -variables: - x_axis: "mass" - y_axis: "events" -highlight: true -``` - -### Template Usage -```html -

{{ folder_metadata.title }}

-{% for item in items %} -
-

{{ item.metadata.title or item.name }}

- {% if item.metadata.plot_type %} - {{ item.metadata.plot_type }} - {% endif %} -
-{% endfor %} -``` - -## Benefits - -1. **Flexibility**: Support any metadata structure using YAML/JSON -2. **Inheritance**: Avoid repetition by inheriting from parent folders -3. **Override capability**: Fine-tune metadata for specific plots -4. **Performance**: Caching system for efficient repeated builds -5. **Validation**: Built-in error handling and validation utilities -6. **Documentation**: Comprehensive usage documentation and examples - -The metadata system is now fully integrated and ready for use in your scientific plot gallery generator! diff --git a/docs/METADATA_USAGE.md b/docs/METADATA_USAGE.md deleted file mode 100644 index 86a55f3..0000000 --- a/docs/METADATA_USAGE.md +++ /dev/null @@ -1,114 +0,0 @@ -# Metadata System Documentation - -## Overview - -The metadata system allows you to add flexible metadata to your plots and folders using YAML or JSON files. Metadata is inherited hierarchically from parent folders and can be overridden at any level. - -## File Structure - -### Folder Metadata -- **File names**: `meta.yaml`, `meta.yml`, or `meta.json` -- **Location**: Place in any folder containing plots -- **Scope**: Applies to all plots in the folder and subfolders (unless overridden) - -### Plot-specific Metadata -- **File names**: `{plot_name}.yaml`, `{plot_name}.yml`, or `{plot_name}.json` -- **Location**: Place in the same folder as the plot PDF file -- **Scope**: Applies only to the specific plot with the same name - -## Hierarchy and Inheritance - -1. **Root folder**: Start with folder metadata in your source directory -2. **Subfolders**: Each subfolder can have its own `meta.yaml` that merges with parent metadata -3. **Plot-specific**: Individual plots can have their own metadata files that override folder metadata - -## Example Usage - -### Folder Structure -``` -analysis_results/ -├── meta.yaml # Root folder metadata -├── signal/ -│ ├── meta.yaml # Signal-specific metadata -│ ├── mass_plot.pdf -│ └── mass_plot.yaml # Plot-specific metadata -└── background/ - ├── meta.yaml # Background-specific metadata - └── qcd_plot.pdf -``` - -### Example Metadata Fields - -**Common fields for folder metadata:** -- `title`: Folder title -- `description`: Folder description -- `experiment`: Experiment name (CMS, ATLAS, etc.) -- `dataset`: Dataset identifier -- `analysis_type`: Type of analysis -- `author`: Author information -- `parameters`: Analysis parameters -- `tags`: Categorization tags - -**Common fields for plot metadata:** -- `plot_type`: Type of plot (histogram, scatter, etc.) -- `variables`: Variable information (x_axis, y_axis, units) -- `selection`: Selection criteria -- `statistics`: Statistical information -- `display`: Display options (highlight, featured, order_priority) - -## Configuration - -The metadata system can be configured in `config.yaml`: - -```yaml -metadata: - cache_enabled: true # Enable metadata caching - inherit_from_parent: true # Enable hierarchical inheritance -``` - -## Output - -### HTML Template -Metadata is available in the HTML template as: -- `folder_metadata`: Current folder's resolved metadata -- `item.metadata`: Individual plot metadata (in items loop) - -### Cache Files -- `meta_cache.json`: Generated in each web directory -- Contains resolved metadata for all plots in that directory -- Used for performance optimization and debugging - -## Usage Tips - -1. **Start simple**: Begin with basic folder metadata and add complexity as needed -2. **Use inheritance**: Put common metadata in parent folders to avoid repetition -3. **Override selectively**: Use plot-specific metadata only when needed -4. **Consistent naming**: Use consistent field names across your metadata files -5. **Validate format**: Ensure YAML/JSON files are valid before running the generator - -## Integration with Templates - -In your HTML templates, you can access metadata like: - -```html - -

{{ folder_metadata.title }}

-

{{ folder_metadata.description }}

- - -{% for item in items %} -
-

{{ item.name }}

- {% if item.metadata.plot_type %} - {{ item.metadata.plot_type }} - {% endif %} - {% if item.metadata.tags %} -
- {% for tag in item.metadata.tags %} - {{ tag }} - {% endfor %} -
- {% endif %} -
-{% endfor %} -``` diff --git a/docs/SORT_IMPLEMENTATION.md b/docs/SORT_IMPLEMENTATION.md new file mode 100644 index 0000000..b0dae4a --- /dev/null +++ b/docs/SORT_IMPLEMENTATION.md @@ -0,0 +1,136 @@ +# Gallery Sort Functionality Implementation Guide + +## Overview +This guide explains how to integrate the new sorting functionality that allows users to sort plots by name and creation time. + +## Frontend Implementation (Complete ✅) + +The frontend implementation is complete and includes: + +### 1. Sort Controls UI +- **Name/Time buttons**: Toggle between sorting by filename and creation time +- **Order button**: Toggle between ascending (↑) and descending (↓) order +- **Positioned**: Left side of the controls container, next to view toggle buttons +- **Responsive**: Adapts to mobile layouts + +### 2. Keyboard Shortcuts +- `Ctrl+N`: Sort by name +- `Ctrl+M`: Sort by time (modification/creation time) +- `Ctrl+O`: Toggle sort order (ascending/descending) + +### 3. Persistence +- Sort preferences are saved to localStorage +- Settings persist across page reloads and navigation + +### 4. Tile Sizing +- Grid view now shows ~6 plots per row on desktop (240px minimum width) +- Responsive design maintains usability on mobile devices + +## Backend Integration (Required) + +To enable time-based sorting, you need to modify your Python gallery generation code: + +### 1. Add Creation Time to Plot Items + +```python +from pathlib import Path + +def add_creation_time_to_items(items, base_path): + """Add creation time to plot items for sorting functionality.""" + for item in items: + try: + # Get creation time from PNG or PDF file + png_path = None + pdf_path = None + + if 'png_href' in item: + png_rel_path = item['png_href'].replace('../', '').replace('./', '') + png_path = Path(base_path) / png_rel_path + + if 'pdf_href' in item: + pdf_rel_path = item['pdf_href'].replace('../', '').replace('./', '') + pdf_path = Path(base_path) / pdf_rel_path + + # Use PNG creation time if available, otherwise PDF + creation_time = 0 + if png_path and png_path.exists(): + creation_time = int(png_path.stat().st_ctime) + elif pdf_path and pdf_path.exists(): + creation_time = int(pdf_path.stat().st_ctime) + + item['creation_time'] = creation_time + + except Exception as e: + print(f"Warning: Could not get creation time for {item.get('name', 'unknown')}: {e}") + item['creation_time'] = 0 + + return items +``` + +### 2. Integrate into Your Gallery Generation + +In your existing gallery generation code, call this function before rendering the template: + +```python +# Your existing code that creates the items list +items = generate_plot_items() # Your existing function + +# Add creation times +items = add_creation_time_to_items(items, gallery_base_path) + +# Pass to template +template.render(items=items, ...) +``` + +### 3. Template Data Structure + +The template now expects each item to have a `creation_time` field: + +```python +item = { + 'name': 'plot_name.png', + 'png_href': './plot_name.png', + 'pdf_href': './plot_name.pdf', + 'creation_time': 1642723200 # Unix timestamp +} +``` + +## File Locations + +### Frontend Files (Ready to use) +- `templates/gallery.html` - Updated with sort controls and data attributes +- `assets/css/view-controls.css` - Styling for sort and view controls +- `assets/css/view-override.css` - Grid layout with larger tiles +- `assets/js/sort-manager.js` - Sort functionality implementation +- `assets/js/gallery-app.js` - Integration of SortManager +- `assets/js/keyboard-manager.js` - Keyboard shortcuts for sorting + +### Backend Integration +- `python/add_creation_time.py` - Example implementation for adding creation times + +## Features Summary + +### ✅ Completed Features +1. **Larger Grid Tiles**: ~6 plots per row instead of 8 +2. **Sort Controls**: Name and time sorting with visual feedback +3. **Sort Order Toggle**: Ascending/descending with visual indicator +4. **Keyboard Shortcuts**: Quick access to all sort functions +5. **Persistence**: Settings saved across sessions +6. **Responsive Design**: Works on all screen sizes +7. **Template Integration**: Data attributes ready for backend + +### 🔄 Next Steps (Backend Integration) +1. Modify your Python gallery generation code to include `creation_time` +2. Use the provided `add_creation_time_to_items()` function +3. Test with real plot files to ensure timestamps are correct + +## Testing + +After backend integration: +1. Navigate to a gallery with multiple plots +2. Click the sort buttons to verify functionality +3. Use keyboard shortcuts to test responsiveness +4. Check that sort order toggles correctly +5. Verify settings persist after page reload + +The frontend is fully functional and will work immediately once the backend provides the `creation_time` data. diff --git a/examples/metadata.json b/examples/metadata.json new file mode 100644 index 0000000..61751f9 --- /dev/null +++ b/examples/metadata.json @@ -0,0 +1,19 @@ +{ + "title": "Sample Research Project", + "description": "This folder contains plots and analysis results for our sample research project studying data visualization techniques.", + "author": "Research Team", + "created": "2025-01-15", + "project_id": "PROJ-2025-001", + "status": "Active", + "tags": ["data-science", "visualization", "research"], + "collaborators": ["Alice Smith", "Bob Johnson", "Carol Davis"], + "funding": { + "agency": "National Science Foundation", + "grant_number": "NSF-2025-12345", + "amount": "$150,000" + }, + "contact": "research-team@example.com", + "repository": "https://github.com/example/sample-project", + "documentation": "https://docs.example.com/sample-project", + "notes": "This is a long note that demonstrates how the system handles lengthy text content. It should be truncated in the display and allow users to expand it to see the full content. This helps keep the interface clean while still providing access to detailed information when needed." +} diff --git a/export_api.py b/export_api.py deleted file mode 100644 index c2385f7..0000000 --- a/export_api.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Simple Flask API for PDF Export - -This provides a web API endpoint for the gallery export functionality. -""" - -from flask import Flask, request, jsonify, send_file -from pathlib import Path -import json -import tempfile -import os -from orchestration.pdf_export import PDFExporter, check_dependencies - -app = Flask(__name__) -exporter = PDFExporter() - - -@app.route('/api/export-pdf', methods=['POST']) -def export_pdf(): - """Export selected plots to merged PDF""" - try: - data = request.get_json() - - if not data or 'plots' not in data: - return jsonify({'error': 'No plots specified'}), 400 - - plot_urls = data['plots'] - layout = data.get('layout', {'rows': 2, 'cols': 2}) - - # Convert URLs to file paths - plot_paths = [] - for url in plot_urls: - # Extract path from file:// URL or relative path - if url.startswith('file://'): - path = url[7:] # Remove 'file://' prefix - elif url.startswith('http'): - return jsonify({'error': 'Remote URLs not supported'}), 400 - else: - # Assume relative path from web root - path = url - - # Resolve to absolute path - plot_path = Path(path) - if not plot_path.exists(): - return jsonify({'error': f'Plot not found: {path}'}), 404 - - plot_paths.append(str(plot_path)) - - if not plot_paths: - return jsonify({'error': 'No valid plots found'}), 400 - - # Create merged PDF - pdf_bytes = exporter.merge_plots(plot_paths, layout) - - # Create temporary file to serve - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp_file: - tmp_file.write(pdf_bytes) - tmp_path = tmp_file.name - - def cleanup_temp_file(): - """Clean up temporary file after sending""" - try: - os.unlink(tmp_path) - except OSError: - pass - - return send_file( - tmp_path, - as_attachment=True, - download_name='merged_plots.pdf', - mimetype='application/pdf' - ) - - except Exception as e: - return jsonify({'error': str(e)}), 500 - - -@app.route('/api/export-status', methods=['GET']) -def export_status(): - """Check export capabilities""" - deps = check_dependencies() - return jsonify({ - 'available': any(deps.values()), - 'dependencies': deps, - 'methods': { - 'pypdf2': deps['pypdf2'], - 'pdfjam': deps['pdfjam'] - } - }) - - -if __name__ == '__main__': - # For development only - app.run(debug=True, port=5000) diff --git a/export_plots.py b/export_plots.py deleted file mode 100644 index 4c4fb0a..0000000 --- a/export_plots.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -""" -Standalone PDF Export Script - -This script processes export requests from the gallery and creates merged PDFs. -Usage: python export_plots.py [request_file.json] -""" - -import sys -import json -import argparse -from pathlib import Path -from orchestration.pdf_export import PDFExporter, check_dependencies - - -def main(): - parser = argparse.ArgumentParser(description='Export gallery plots to merged PDF') - parser.add_argument('request_file', nargs='?', default='export_request.json', - help='JSON file containing export request') - parser.add_argument('--output', '-o', help='Output PDF file path') - parser.add_argument('--check-deps', action='store_true', - help='Check available dependencies') - - args = parser.parse_args() - - if args.check_deps: - deps = check_dependencies() - print("Available dependencies:") - for dep, available in deps.items(): - status = "✓" if available else "✗" - print(f" {status} {dep}") - - if not any(deps.values()): - print("\nNo PDF merging tools available!") - print("Install one of the following:") - print(" - PyPDF2 and reportlab: pip install PyPDF2 reportlab") - print(" - pdfjam: apt-get install texlive-extra-utils (on Ubuntu/Debian)") - return - - request_file = Path(args.request_file) - if not request_file.exists(): - print(f"Error: Request file '{request_file}' not found") - print("Create a JSON file with the following structure:") - print(json.dumps({ - "plots": ["/path/to/plot1.pdf", "/path/to/plot2.pdf"], - "layout": {"rows": 1, "cols": 2}, - "output_name": "merged_plots.pdf" - }, indent=2)) - return 1 - - try: - with open(request_file, 'r') as f: - request_data = json.load(f) - except json.JSONDecodeError as e: - print(f"Error: Invalid JSON in '{request_file}': {e}") - return 1 - - # Validate request data - if 'plots' not in request_data: - print("Error: Missing 'plots' field in request") - return 1 - - plot_paths = request_data['plots'] - layout = request_data.get('layout', {'rows': 2, 'cols': 2}) - output_name = args.output or request_data.get('output_name', 'merged_plots.pdf') - - # Validate plot files exist - missing_files = [] - for plot_path in plot_paths: - if not Path(plot_path).exists(): - missing_files.append(plot_path) - - if missing_files: - print("Error: The following plot files were not found:") - for missing in missing_files: - print(f" - {missing}") - return 1 - - print(f"Exporting {len(plot_paths)} plots to '{output_name}'...") - print(f"Layout: {layout['rows']}x{layout['cols']}") - - # Create exporter and merge plots - exporter = PDFExporter() - - try: - pdf_bytes = exporter.merge_plots(plot_paths, layout, output_name) - - if not args.output and 'output_name' not in request_data: - # Write to file if not already done by exporter - with open(output_name, 'wb') as f: - f.write(pdf_bytes) - - print(f"✓ Successfully created '{output_name}'") - print(f" File size: {len(pdf_bytes) / 1024:.1f} KB") - - # Clean up request file if export was successful - request_file.unlink(missing_ok=True) - - except Exception as e: - print(f"Error during export: {e}") - return 1 - - return 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/generate_gallery.py b/generate_gallery.py index 65b622c..3eae8b5 100644 --- a/generate_gallery.py +++ b/generate_gallery.py @@ -19,6 +19,7 @@ import os import sys from pathlib import Path from typing import Dict, Any, Optional +from datetime import datetime from jinja2 import Environment, FileSystemLoader from orchestration.config import Config @@ -32,7 +33,20 @@ from orchestration.metadata import ( CONFIG = Config.from_yaml("config.yaml") + +def datetime_from_timestamp(timestamp): + """Convert a Unix timestamp to a datetime object.""" + return datetime.fromtimestamp(timestamp) + + +def strftime_filter(dt, fmt): + """Format a datetime object using strftime.""" + return dt.strftime(fmt) + + env = Environment(loader=FileSystemLoader(".")) +env.filters['datetime_from_timestamp'] = datetime_from_timestamp +env.filters['strftime'] = strftime_filter template = env.get_template("templates/gallery.html") @@ -147,11 +161,15 @@ def build_gallery(source_dir: Path, web_dir: Path, plot_metadata = resolve_metadata_for_plot(pdf_file, current_metadata) plot_metadata_cache[pdf_file.stem] = plot_metadata + # Get source file creation time (in seconds since epoch) + source_creation_time = int(pdf_file.stat().st_ctime) + items.append({ "name": pdf_file.stem, "pdf_href": pdf_file.name, "png_href": png_file.name, - "metadata": plot_metadata + "metadata": plot_metadata, + "creation_time": source_creation_time }) # Save metadata cache for this directory @@ -357,10 +375,14 @@ def main(clean_first: bool = False) -> None: else: print(f"Skipping {source_png_path.name} (up to date)") + # Get source file creation time for single file + source_creation_time = int(source_path.stat().st_ctime) + items = [{ "name": source_path.stem, "pdf_href": pdf_name, - "png_href": png_name + "png_href": png_name, + "creation_time": source_creation_time }] # Calculate statistics for single file diff --git a/orchestration/metadata.py b/orchestration/metadata.py index 4c1d9b8..a6bfb84 100644 --- a/orchestration/metadata.py +++ b/orchestration/metadata.py @@ -50,7 +50,7 @@ def load_metadata_file(metadata_path: Path) -> Dict[str, Any]: def load_folder_metadata(folder_path: Path) -> Dict[str, Any]: """ - Load folder-level metadata from meta.yaml or meta.json. + Load folder-level metadata from meta.yaml, meta.json, or metadata.json. Args: folder_path: Path to the folder to check for metadata @@ -58,8 +58,8 @@ def load_folder_metadata(folder_path: Path) -> Dict[str, Any]: Returns: Dictionary containing the folder metadata """ - # Try YAML first, then JSON - for filename in ['meta.yaml', 'meta.yml', 'meta.json']: + # Try YAML first, then JSON (including metadata.json for backwards compatibility) + for filename in ['meta.yaml', 'meta.yml', 'meta.json', 'metadata.json']: metadata_path = folder_path / filename if metadata_path.exists(): return load_metadata_file(metadata_path) diff --git a/python/add_creation_time.py b/python/add_creation_time.py new file mode 100644 index 0000000..135d807 --- /dev/null +++ b/python/add_creation_time.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +""" +Gallery Creation Time Integration +Example function to add creation time metadata to plot items. +This should be integrated into your existing Python gallery generation code. +""" + +from pathlib import Path + + +def add_creation_time_to_items(items, base_path): + """ + Add creation time to plot items for sorting functionality. + + Args: + items: List of plot item dictionaries + base_path: Base path where plot files are located + + Returns: + Updated items list with creation_time field + """ + for item in items: + try: + # Try to get creation time from PNG file first, then PDF + png_path = None + pdf_path = None + + # Extract relative path from href + if 'png_href' in item: + png_rel_path = item['png_href'].replace('../', '').replace( + './', '') + png_path = Path(base_path) / png_rel_path + + if 'pdf_href' in item: + pdf_rel_path = item['pdf_href'].replace('../', '').replace( + './', '') + pdf_path = Path(base_path) / pdf_rel_path + + # Use PNG creation time if available, otherwise PDF + creation_time = 0 + if png_path and png_path.exists(): + creation_time = int(png_path.stat().st_ctime) + elif pdf_path and pdf_path.exists(): + creation_time = int(pdf_path.stat().st_ctime) + + # Add creation time as timestamp (JavaScript can handle this) + item['creation_time'] = creation_time + + except Exception as e: + # Fallback to 0 if there's any error + name = item.get('name', 'unknown') + print(f"Warning: Could not get creation time for {name}: {e}") + item['creation_time'] = 0 + + return items + + +def example_integration(): + """ + Example of how to integrate this into your existing gallery generation. + """ + # This would be part of your existing gallery generation code + items = [ + { + 'name': 'plot1.png', + 'png_href': './plot1.png', + 'pdf_href': './plot1.pdf' + }, + { + 'name': 'plot2.png', + 'png_href': './plot2.png', + 'pdf_href': './plot2.pdf' + } + ] + + base_path = "/path/to/your/gallery/directory" + + # Add creation times + items_with_time = add_creation_time_to_items(items, base_path) + + # Now items_with_time can be passed to your Jinja2 template + # The template will have access to item.creation_time for each item + + return items_with_time + + +if __name__ == "__main__": + # Test the function + items = example_integration() + for item in items: + created = item['creation_time'] + print(f"Plot: {item['name']}, Created: {created}") diff --git a/python/add_metadata.py b/python/add_metadata.py new file mode 100644 index 0000000..cb2d5ef --- /dev/null +++ b/python/add_metadata.py @@ -0,0 +1,45 @@ +from typing import Dict, Any +import json +from pathlib import Path + + +def open_metadata(path: str, filename: str = "metadata.json") -> Dict[str, Any]: + """ + Open metadata file and return its contents as a dictionary. + + Args: + path: The directory path where the metadata file is located. + filename: The name of the metadata file (default: "metadata.json"). + + Returns: + A dictionary containing the metadata. + """ + with open(Path(path) / filename, 'r', encoding='utf-8') as f: + try: + return json.load(f) + except json.JSONDecodeError as e: + raise ValueError(f"Error decoding JSON from {filename}: {e}") + except FileNotFoundError: + raise FileNotFoundError(f"Metadata file {filename} not found in {path}") + except Exception as e: + raise RuntimeError(f"Unexpected error reading metadata: {e}") + + +def merge_metadata( + base_metadata: Dict[str, Any], + additional_metadata: Dict[str, Any] +) -> Dict[str, Any]: + """ + Merge two metadata dictionaries. + + Args: + base_metadata: The base metadata dictionary. + additional_metadata: The additional metadata dictionary to merge. + + Returns: + A new dictionary containing the merged metadata. + """ + merged = base_metadata.copy() + for key, value in additional_metadata.items(): + merged[key] = value + return merged diff --git a/templates/gallery.html b/templates/gallery.html index 927df73..a7d3d07 100644 --- a/templates/gallery.html +++ b/templates/gallery.html @@ -34,50 +34,118 @@
+ + {% if folder_metadata %} + + {% endif %} + {% if items %} -
- - - +
+
+ + + + +
+
+ + + +
{% endif %}
{% for item in items %} -
+
{{ item.name }}
{{ item.name }}
+
+ {% if item.creation_time and item.creation_time|int > 0 %} + {{ item.creation_time|int|datetime_from_timestamp|strftime('%Y-%m-%d') }} + {% else %} + Unknown + {% endif %} +
{% endfor %} @@ -118,9 +186,6 @@ -
@@ -146,10 +211,6 @@ Exit selection mode Esc
-
- Refresh - F5 -
Toggle theme Ctrl+T @@ -158,6 +219,18 @@ Toggle view Ctrl+V
+
+ Sort by name + Ctrl+N +
+
+ Sort by time + Ctrl+M +
+
+ Toggle sort order + Ctrl+O +
Help ? @@ -236,6 +309,9 @@ + + + diff --git a/test_metadata.html b/test_metadata.html deleted file mode 100644 index e69de29..0000000 -- 2.39.5 From bcb083bbb99e0bcdf73f90cf877b7a19fc3e4dea Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Tue, 29 Jul 2025 10:31:35 +0200 Subject: [PATCH 18/35] Add an expandable metadata grid --- assets/css/folder-metadata.css | 244 ++++++++++++++------------------ assets/css/grid.css | 4 +- assets/css/main.css | 1 + assets/css/metadata-section.css | 163 +++++++++++++++++++++ assets/js/folder-metadata.js | 104 ++++++++------ assets/js/metadata-section.js | 74 ++++++++++ examples/meta.yaml | 28 ---- examples/metadata.json | 19 --- examples/specific_plot.json | 22 --- templates/gallery.html | 40 +++--- 10 files changed, 430 insertions(+), 269 deletions(-) create mode 100644 assets/css/metadata-section.css create mode 100644 assets/js/metadata-section.js delete mode 100644 examples/meta.yaml delete mode 100644 examples/metadata.json delete mode 100644 examples/specific_plot.json diff --git a/assets/css/folder-metadata.css b/assets/css/folder-metadata.css index b41a242..8508cf3 100644 --- a/assets/css/folder-metadata.css +++ b/assets/css/folder-metadata.css @@ -1,203 +1,179 @@ /* ======================================== - FOLDER METADATA DISPLAY STYLES + FOLDER METADATA STYLES ======================================== */ +/* Folder Metadata Container */ .folder-metadata-container { - margin: 2rem 0; - padding: 1.5rem; - background: var(--card-background); - border: 1px solid var(--border-color); + margin: 1rem 0; border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + background: var(--card-bg); + border: 1px solid var(--border-color); + overflow: hidden; } -.folder-metadata-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; - padding-bottom: 0.75rem; - border-bottom: 2px solid var(--border-color); -} - -.folder-metadata-title { - margin: 0; - color: var(--text-color); - font-size: 1.25rem; - font-weight: 600; -} - -.folder-metadata-edit-btn { - background: var(--accent-color); - color: white; +/* Folder Metadata Toggle Button */ +.folder-metadata-toggle { + width: 100%; + padding: 0.75rem 1rem; + background: var(--card-bg); border: none; - border-radius: 6px; - padding: 0.5rem 1rem; cursor: pointer; - font-size: 0.9rem; - transition: all 0.2s ease; display: flex; align-items: center; - gap: 0.5rem; + 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-edit-btn:hover { - background: var(--accent-color-dark); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +.folder-metadata-toggle:hover { + background: var(--button-hover); + color: white; } -.folder-metadata-edit-btn:active { - transform: translateY(0); +.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(300px, 1fr)); - gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.5rem 1rem; } +/* Folder Metadata Items */ .folder-metadata-item { display: flex; - flex-direction: column; - background: var(--background); - border: 1px solid var(--border-color-light); - border-radius: 6px; - padding: 1rem; - transition: all 0.2s ease; -} - -.folder-metadata-item:hover { - background: var(--hover-background); - border-color: var(--accent-color); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + align-items: flex-start; + gap: 0.5rem; + padding: 0.25rem 0; } .folder-metadata-key { font-weight: 600; - color: var(--accent-color); - margin-bottom: 0.5rem; - font-size: 0.95rem; - text-transform: capitalize; - border-bottom: 1px solid var(--border-color-light); - padding-bottom: 0.25rem; + color: var(--link-color); + white-space: nowrap; + min-width: fit-content; } .folder-metadata-value { color: var(--text-color); - line-height: 1.5; word-break: break-word; + flex: 1; } -.folder-metadata-value a { - color: var(--accent-color); - text-decoration: none; - border-bottom: 1px dotted var(--accent-color); - transition: all 0.2s ease; -} - -.folder-metadata-value a:hover { - color: var(--accent-color-dark); - border-bottom-style: solid; -} - +/* Tags for list items */ .folder-metadata-list { - margin: 0; - padding-left: 1.5rem; + display: flex; + flex-wrap: wrap; + gap: 0.25rem; } -.folder-metadata-list li { - margin-bottom: 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(--background-dark); - padding: 0.75rem; + background: var(--card-bg); + padding: 0.5rem; border-radius: 4px; - border-left: 3px solid var(--accent-color); + border-left: 3px solid var(--link-color); } .folder-metadata-nested-item { - margin-bottom: 0.5rem; -} - -.folder-metadata-nested-item:last-child { - margin-bottom: 0; -} - -.folder-metadata-nested-item strong { - color: var(--accent-color); -} - -.folder-metadata-long-text { - display: inline; + margin: 0.25rem 0; + font-size: 0.9rem; } +/* Long text handling */ .folder-metadata-expand { background: none; border: none; - color: var(--accent-color); + color: var(--link-color); cursor: pointer; text-decoration: underline; - font-size: 0.9rem; - margin-left: 0.5rem; padding: 0; + margin-left: 0.5rem; + font-size: 0.85rem; } .folder-metadata-expand:hover { - color: var(--accent-color-dark); + color: var(--link-hover); } -.folder-metadata-full-text { - display: none; +/* Links */ +.folder-metadata-value a { + color: var(--link-color); + text-decoration: none; } -/* Responsive adjustments */ +.folder-metadata-value a:hover { + color: var(--link-hover); + text-decoration: underline; +} + +/* Responsive design */ @media (max-width: 768px) { - .folder-metadata-container { - margin: 1rem 0; - padding: 1rem; - } - .folder-metadata-grid { grid-template-columns: 1fr; - gap: 0.75rem; + gap: 0.5rem; } - .folder-metadata-header { + .folder-metadata-item { flex-direction: column; - align-items: flex-start; - gap: 0.75rem; + gap: 0.25rem; } - .folder-metadata-edit-btn { - align-self: flex-end; - } -} - -/* Dark theme adjustments */ -@media (prefers-color-scheme: dark) { - .folder-metadata-container { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - } - - .folder-metadata-item:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); - } -} - -/* Animation for expanding text */ -.folder-metadata-full-text.show { - display: inline; - animation: fadeIn 0.3s ease; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; + .folder-metadata-key { + white-space: normal; } } diff --git a/assets/css/grid.css b/assets/css/grid.css index 5f633a2..8ad9828 100644 --- a/assets/css/grid.css +++ b/assets/css/grid.css @@ -3,8 +3,8 @@ ======================================== */ .grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 1.2rem; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.8rem; padding: 1rem 0; } diff --git a/assets/css/main.css b/assets/css/main.css index 3713837..c6019d7 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -16,6 +16,7 @@ @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'); diff --git a/assets/css/metadata-section.css b/assets/css/metadata-section.css new file mode 100644 index 0000000..5824a9c --- /dev/null +++ b/assets/css/metadata-section.css @@ -0,0 +1,163 @@ +/* ======================================== + 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 */ +} + +/* Grid Layout */ +.metadata-grid { + padding: 1rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.5rem 1rem; +} + +/* Metadata Items */ +.metadata-item { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.25rem 0; +} + +.metadata-key { + font-weight: 600; + color: var(--link-color); + white-space: nowrap; + min-width: fit-content; +} + +.metadata-value { + color: var(--text-color); + word-break: break-word; + flex: 1; +} + +/* Tags for list items */ +.metadata-list { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.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 */ +.metadata-nested { + background: var(--card-bg); + padding: 0.5rem; + border-radius: 4px; + border-left: 3px solid var(--link-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; + } +} diff --git a/assets/js/folder-metadata.js b/assets/js/folder-metadata.js index 6be3ce4..86f9db4 100644 --- a/assets/js/folder-metadata.js +++ b/assets/js/folder-metadata.js @@ -1,69 +1,81 @@ /** * Folder Metadata functionality for Gallery * - * Handles interactions with folder-level metadata display + * Handles folder metadata dropdown display and interaction */ -// Function to toggle expansion of long metadata text -function toggleMetadataText(button) { +// 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') { - // Show full text longText.style.display = 'none'; fullText.style.display = 'inline'; - fullText.classList.add('show'); button.textContent = 'Show less'; } else { - // Show truncated text longText.style.display = 'inline'; fullText.style.display = 'none'; - fullText.classList.remove('show'); button.textContent = 'Show more'; } -} +}; -// Function to handle folder metadata editing (placeholder for future implementation) -function editFolderMetadata() { - // For now, just show an alert that this feature is coming soon - alert('Folder metadata editing functionality is coming soon!'); - - // Future implementation will: - // 1. Open an edit modal with form fields for each metadata key - // 2. Allow adding/removing metadata fields - // 3. Validate the input - // 4. Send updates to the server - // 5. Refresh the page or update the display dynamically -} - -// Initialize folder metadata functionality when DOM is loaded +// Initialize folder metadata on page load document.addEventListener('DOMContentLoaded', function() { - console.log('Folder metadata functionality initialized'); + console.log('Folder metadata script loaded'); - // Add keyboard shortcuts for metadata editing (future feature) - document.addEventListener('keydown', function(e) { - // Ctrl+M for metadata editing - if (e.ctrlKey && e.key === 'm') { - e.preventDefault(); - const editBtn = document.querySelector('.folder-metadata-edit-btn'); - if (editBtn) { - editFolderMetadata(); - } + // 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'); } }); - - // Add accessibility improvements - const metadataItems = document.querySelectorAll('.folder-metadata-item'); - metadataItems.forEach(item => { - item.setAttribute('tabindex', '0'); - item.setAttribute('role', 'listitem'); - }); - - // Add ARIA labels for better accessibility - const metadataContainer = document.querySelector('.folder-metadata-container'); - if (metadataContainer) { - metadataContainer.setAttribute('role', 'region'); - metadataContainer.setAttribute('aria-label', 'Folder metadata information'); - } }); diff --git a/assets/js/metadata-section.js b/assets/js/metadata-section.js new file mode 100644 index 0000000..70e52ac --- /dev/null +++ b/assets/js/metadata-section.js @@ -0,0 +1,74 @@ +/** + * 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'); + } + + // 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'; + } +} + +// 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); + } +}); diff --git a/examples/meta.yaml b/examples/meta.yaml deleted file mode 100644 index 1ecf494..0000000 --- a/examples/meta.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# Example folder metadata file -# This file should be named "meta.yaml" in a folder containing plots - -# Folder-level metadata that applies to all plots in this folder -title: "Analysis Results" -description: "ttbar" -experiment: "CMS" -dataset: "Run2_2016_nano_v9" -analysis_type: "ttbar_analysis" - -# Analysis parameters that apply to all plots in this folder -parameters: - luminosity: "35.9 fb^-1" - center_of_mass_energy: "13 TeV" - selection: "baseline" - -# Tags for categorization -tags: - - "ttbar" - - "run2" - - "cms" - - "analysis" - -# Custom styling or display options -display: - highlight: true - category: "primary_results" - order_priority: 1 diff --git a/examples/metadata.json b/examples/metadata.json deleted file mode 100644 index 61751f9..0000000 --- a/examples/metadata.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "title": "Sample Research Project", - "description": "This folder contains plots and analysis results for our sample research project studying data visualization techniques.", - "author": "Research Team", - "created": "2025-01-15", - "project_id": "PROJ-2025-001", - "status": "Active", - "tags": ["data-science", "visualization", "research"], - "collaborators": ["Alice Smith", "Bob Johnson", "Carol Davis"], - "funding": { - "agency": "National Science Foundation", - "grant_number": "NSF-2025-12345", - "amount": "$150,000" - }, - "contact": "research-team@example.com", - "repository": "https://github.com/example/sample-project", - "documentation": "https://docs.example.com/sample-project", - "notes": "This is a long note that demonstrates how the system handles lengthy text content. It should be truncated in the display and allow users to expand it to see the full content. This helps keep the interface clean while still providing access to detailed information when needed." -} diff --git a/examples/specific_plot.json b/examples/specific_plot.json deleted file mode 100644 index 4257fa9..0000000 --- a/examples/specific_plot.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "title": "Specific Plot Analysis", - "description": "This metadata overrides folder metadata for this specific plot", - "plot_type": "histogram", - "variables": { - "x_axis": "invariant_mass", - "y_axis": "events", - "units": "GeV" - }, - "selection": "signal_region", - "statistics": { - "entries": 125000, - "mean": 125.3, - "std_dev": 2.1 - }, - "tags": ["signal", "mass_peak", "important"], - "display": { - "highlight": true, - "featured": true, - "order_priority": 0 - } -} diff --git a/templates/gallery.html b/templates/gallery.html index a7d3d07..7a5d2f4 100644 --- a/templates/gallery.html +++ b/templates/gallery.html @@ -34,36 +34,37 @@
- + {% if folder_metadata %} -