Spaces:
Sleeping
Sleeping
Hall of Legends Pre set up
Browse files- .github/copilot-instructions.md +13 -12
- README.md +43 -2
- battlewords/modules/constants.py +10 -0
- battlewords/modules/storage.md +268 -0
- battlewords/modules/storage.py +799 -0
- battlewords/ui.py +107 -3
- specs/basic.mdx +5 -3
- specs/requirements.mdx +15 -2
- specs/specs.mdx +36 -0
.github/copilot-instructions.md
CHANGED
|
@@ -1,17 +1,18 @@
|
|
| 1 |
# Copilot Instructions
|
| 2 |
-
## General Guidelines
|
| 3 |
-
minimal changes to existing code
|
| 4 |
-
preserve functionality when possible
|
| 5 |
-
clear and concise comments
|
| 6 |
-
no plan unless specified
|
| 7 |
-
no compile unless specified
|
| 8 |
-
no test unless specified
|
| 9 |
-
if testing is specified:
|
| 10 |
-
MSTest framework
|
| 11 |
-
UV is used
|
| 12 |
-
avoid New Dependencies
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
## Project-Specific Rules
|
| 16 |
- Remove AI generation of wordlists and audio system from the basic codebase
|
| 17 |
-
- Keep challenge mode/HF storage and PWA support in the basic codebase
|
|
|
|
|
|
| 1 |
# Copilot Instructions
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
## General Guidelines
|
| 4 |
+
- Minimal changes to existing code
|
| 5 |
+
- Preserve functionality when possible
|
| 6 |
+
- Clear and concise comments
|
| 7 |
+
- No plan unless specified
|
| 8 |
+
- No compile unless specified
|
| 9 |
+
- No test unless specified
|
| 10 |
+
- If testing is specified:
|
| 11 |
+
- MSTest framework
|
| 12 |
+
- UV is used
|
| 13 |
+
- Avoid new dependencies
|
| 14 |
|
| 15 |
## Project-Specific Rules
|
| 16 |
- Remove AI generation of wordlists and audio system from the basic codebase
|
| 17 |
+
- Keep challenge mode/HF storage and PWA support in the basic codebase
|
| 18 |
+
- Implement Hall functionality using existing HF-backed storage patterns (no new local data file) and route it via `?page=hall` instead of leaderboard naming
|
README.md
CHANGED
|
@@ -61,8 +61,13 @@ BattleWords is a vocabulary learning game inspired by classic Battleship mechani
|
|
| 61 |
- 10 incorrect guess limit per game
|
| 62 |
- Two game modes: Classic (chain guesses) and Too Easy (single guess per reveal)
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
### Visuals
|
| 65 |
-
- Ocean-themed gradient background with wave animations
|
| 66 |
- Responsive UI built with Streamlit
|
| 67 |
|
| 68 |
### Word lists
|
|
@@ -130,7 +135,43 @@ docker run -p8501:8501 battlewords
|
|
| 130 |
```
|
| 131 |
|
| 132 |
### Environment Variables
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
## Folder Structure
|
| 136 |
|
|
|
|
| 61 |
- 10 incorrect guess limit per game
|
| 62 |
- Two game modes: Classic (chain guesses) and Too Easy (single guess per reveal)
|
| 63 |
|
| 64 |
+
### Hall of Legends
|
| 65 |
+
- Legendary runs (`score >= 46`) can be saved to Hall of Legends from the game-over flow.
|
| 66 |
+
- Hall page is available in development and production at `?page=hall`.
|
| 67 |
+
- Hall JSON is compatible with Wrdler leaderboard schema (`challenge_id`, `entry_type`, `users`, etc.) with `entry_type` set to `hall`.
|
| 68 |
+
|
| 69 |
### Visuals
|
| 70 |
+
- Ocean-themed gradient background with wave animations are removed
|
| 71 |
- Responsive UI built with Streamlit
|
| 72 |
|
| 73 |
### Word lists
|
|
|
|
| 135 |
```
|
| 136 |
|
| 137 |
### Environment Variables
|
| 138 |
+
- `HF_API_TOKEN` (required for HF-backed Hall save/load in deployed environments)
|
| 139 |
+
- `HF_REPO_ID` (HF dataset/repo used by storage helpers)
|
| 140 |
+
- `SHORTENER_JSON_FILE` (optional, default: `shortener.json`)
|
| 141 |
+
- `HALL_OF_LEGENDS_FILE` (optional, Hall JSON path in HF repo)
|
| 142 |
+
|
| 143 |
+
### Hall of Legends JSON Example (compatible)
|
| 144 |
+
|
| 145 |
+
```json
|
| 146 |
+
{
|
| 147 |
+
"challenge_id": "hall-of-legends/classic-classic-12x12",
|
| 148 |
+
"entry_type": "hall",
|
| 149 |
+
"game_mode": "classic",
|
| 150 |
+
"grid_size": 12,
|
| 151 |
+
"puzzle_options": {
|
| 152 |
+
"spacer": 1,
|
| 153 |
+
"may_overlap": false
|
| 154 |
+
},
|
| 155 |
+
"users": [
|
| 156 |
+
{
|
| 157 |
+
"uid": "20260325T184512Z-A9K2QW",
|
| 158 |
+
"username": "Charles",
|
| 159 |
+
"word_list": ["BLADE", "SHORE", "MARKET", "TUNNEL", "RIBBON", "CANDLE"],
|
| 160 |
+
"score": 49,
|
| 161 |
+
"time": 132.447291,
|
| 162 |
+
"timestamp": "2026-03-25T18:45:12.902145+00:00",
|
| 163 |
+
"word_list_difficulty": 112.3849012231
|
| 164 |
+
}
|
| 165 |
+
],
|
| 166 |
+
"created_at": "2026-03-25T18:45:13.111942+00:00",
|
| 167 |
+
"version": "0.2.40",
|
| 168 |
+
"show_incorrect_guesses": true,
|
| 169 |
+
"enable_free_letters": false,
|
| 170 |
+
"wordlist_source": "classic.txt",
|
| 171 |
+
"game_title": "Battlewords",
|
| 172 |
+
"max_display_entries": 30
|
| 173 |
+
}
|
| 174 |
+
```
|
| 175 |
|
| 176 |
## Folder Structure
|
| 177 |
|
battlewords/modules/constants.py
CHANGED
|
@@ -68,6 +68,16 @@ def load_settings() -> Dict[str, Any]:
|
|
| 68 |
# Load settings at module level for easy import
|
| 69 |
APP_SETTINGS = load_settings()
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
# ---------------------------------------------------------------------------
|
| 73 |
# Temporary Directory Configuration
|
|
|
|
| 68 |
# Load settings at module level for easy import
|
| 69 |
APP_SETTINGS = load_settings()
|
| 70 |
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
# Hugging Face Storage Configuration
|
| 73 |
+
# ---------------------------------------------------------------------------
|
| 74 |
+
|
| 75 |
+
HF_API_TOKEN = os.getenv("HF_API_TOKEN", "")
|
| 76 |
+
HF_REPO_ID = os.getenv("HF_REPO_ID", "")
|
| 77 |
+
SHORTENER_JSON_FILE = os.getenv("SHORTENER_JSON_FILE", "shortener.json")
|
| 78 |
+
SPACE_NAME = os.getenv("SPACE_NAME", "Surn/BattleWords")
|
| 79 |
+
HALL_OF_LEGENDS_FILE = os.getenv("HALL_OF_LEGENDS_FILE", "games/hall_of_legends.json")
|
| 80 |
+
|
| 81 |
|
| 82 |
# ---------------------------------------------------------------------------
|
| 83 |
# Temporary Directory Configuration
|
battlewords/modules/storage.md
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Storage Module (`modules/storage.py`) Usage Guide
|
| 2 |
+
|
| 3 |
+
The `storage.py` module provides helper functions for:
|
| 4 |
+
- Generating permalinks for 3D viewer projects.
|
| 5 |
+
- Uploading files in batches to a Hugging Face repository.
|
| 6 |
+
- Managing URL shortening by storing (short URL, full URL) pairs in a JSON file on the repository.
|
| 7 |
+
- Retrieving full URLs from short URL IDs and vice versa.
|
| 8 |
+
- Handle specific file types for 3D models, images, video and audio.
|
| 9 |
+
- **📁 Listing folders and files in HuggingFace repositories.**
|
| 10 |
+
- **🔑 Cryptographic key management for Open Badge 3.0 issuers.**
|
| 11 |
+
|
| 12 |
+
## Key Functions
|
| 13 |
+
|
| 14 |
+
### 1. `generate_permalink(valid_files, base_url_external, permalink_viewer_url="surn-3d-viewer.hf.space")`
|
| 15 |
+
- **Purpose:**
|
| 16 |
+
Given a list of file paths, it looks for exactly one model file (with an extension defined in `model_extensions`) and exactly two image files (extensions defined in `image_extensions`). If the criteria are met, it returns a permalink URL built from the base URL and query parameters.
|
| 17 |
+
- **Usage Example:**from modules.storage import generate_permalink
|
| 18 |
+
|
| 19 |
+
valid_files = [
|
| 20 |
+
"models/3d_model.glb",
|
| 21 |
+
"images/model_texture.png",
|
| 22 |
+
"images/model_depth.png"
|
| 23 |
+
]
|
| 24 |
+
base_url_external = "https://huggingface.co/datasets/Surn/Storage/resolve/main/saved_models/my_model"
|
| 25 |
+
permalink = generate_permalink(valid_files, base_url_external)
|
| 26 |
+
if permalink:
|
| 27 |
+
print("Permalink:", permalink)
|
| 28 |
+
### 2. `generate_permalink_from_urls(model_url, hm_url, img_url, permalink_viewer_url="surn-3d-viewer.hf.space")`
|
| 29 |
+
- **Purpose:**
|
| 30 |
+
Constructs a permalink URL by combining individual URLs for a 3D model (`model_url`), height map (`hm_url`), and image (`img_url`) into a single URL with corresponding query parameters.
|
| 31 |
+
- **Usage Example:**from modules.storage import generate_permalink_from_urls
|
| 32 |
+
|
| 33 |
+
model_url = "https://example.com/model.glb"
|
| 34 |
+
hm_url = "https://example.com/heightmap.png"
|
| 35 |
+
img_url = "https://example.com/source.png"
|
| 36 |
+
|
| 37 |
+
permalink = generate_permalink_from_urls(model_url, hm_url, img_url)
|
| 38 |
+
print("Generated Permalink:", permalink)
|
| 39 |
+
### 3. `upload_files_to_repo(files, repo_id, folder_name, create_permalink=False, repo_type="dataset", permalink_viewer_url="surn-3d-viewer.hf.space")`
|
| 40 |
+
- **Purpose:**
|
| 41 |
+
Uploads a batch of files (each file represented as a path string) to a specified Hugging Face repository (e.g. `"Surn/Storage"`) under a given folder.
|
| 42 |
+
The function's return type is `Union[Dict[str, Any], List[Tuple[Any, str]]]`.
|
| 43 |
+
- When `create_permalink` is `True` and exactly three valid files (one model and two images) are provided, the function returns a dictionary:{
|
| 44 |
+
"response": <upload_folder_response>,
|
| 45 |
+
"permalink": "<full_permalink_url>",
|
| 46 |
+
"short_permalink": "<shortened_permalink_url_with_sid>"
|
| 47 |
+
} - Otherwise (or if `create_permalink` is `False` or conditions for permalink creation are not met), it returns a list of tuples, where each tuple is `(upload_folder_response, individual_file_link)`.
|
| 48 |
+
- If no valid files are provided, it returns an empty list `[]` (this case should ideally also return the dictionary with empty/None values for consistency, but currently returns `[]` as per the code).
|
| 49 |
+
- **Usage Example:**
|
| 50 |
+
|
| 51 |
+
**a. Uploading with permalink creation:**from modules.storage import upload_files_to_repo
|
| 52 |
+
|
| 53 |
+
files_for_permalink = [
|
| 54 |
+
"local/path/to/model.glb",
|
| 55 |
+
"local/path/to/heightmap.png",
|
| 56 |
+
"local/path/to/image.png"
|
| 57 |
+
]
|
| 58 |
+
repo_id = "Surn/Storage" # Make sure this is defined, e.g., from constants or environment variables
|
| 59 |
+
folder_name = "my_new_model_with_permalink"
|
| 60 |
+
|
| 61 |
+
upload_result = upload_files_to_repo(
|
| 62 |
+
files_for_permalink,
|
| 63 |
+
repo_id,
|
| 64 |
+
folder_name,
|
| 65 |
+
create_permalink=True
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
if isinstance(upload_result, dict):
|
| 69 |
+
print("Upload Response:", upload_result.get("response"))
|
| 70 |
+
print("Full Permalink:", upload_result.get("permalink"))
|
| 71 |
+
print("Short Permalink:", upload_result.get("short_permalink"))
|
| 72 |
+
elif upload_result: # Check if list is not empty
|
| 73 |
+
print("Upload Response for individual files:")
|
| 74 |
+
for res, link in upload_result:
|
| 75 |
+
print(f" Response: {res}, Link: {link}")
|
| 76 |
+
else:
|
| 77 |
+
print("No files uploaded or error occurred.")
|
| 78 |
+
**b. Uploading without permalink creation (or if conditions for permalink are not met):**from modules.storage import upload_files_to_repo
|
| 79 |
+
|
| 80 |
+
files_individual = [
|
| 81 |
+
"local/path/to/another_model.obj",
|
| 82 |
+
"local/path/to/texture.jpg"
|
| 83 |
+
]
|
| 84 |
+
repo_id = "Surn/Storage"
|
| 85 |
+
folder_name = "my_other_uploads"
|
| 86 |
+
|
| 87 |
+
upload_results_list = upload_files_to_repo(
|
| 88 |
+
files_individual,
|
| 89 |
+
repo_id,
|
| 90 |
+
folder_name,
|
| 91 |
+
create_permalink=False # Or if create_permalink=True but not 1 model & 2 images
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
if upload_results_list: # Will be a list of tuples
|
| 95 |
+
print("Upload results for individual files:")
|
| 96 |
+
for res, link in upload_results_list:
|
| 97 |
+
print(f" Upload Response: {res}, File Link: {link}")
|
| 98 |
+
else:
|
| 99 |
+
print("No files uploaded or error occurred.")
|
| 100 |
+
### 4. URL Shortening Functions: `gen_full_url(...)` and Helpers
|
| 101 |
+
The module also enables URL shortening by managing a JSON file (e.g. `shortener.json`) in a Hugging Face repository. It supports CRUD-like operations:
|
| 102 |
+
- **Read:** Look up the full URL using a provided short URL ID.
|
| 103 |
+
- **Create:** Generate a new short URL ID for a full URL if no existing mapping exists.
|
| 104 |
+
- **Update/Conflict Handling:**
|
| 105 |
+
If both short URL ID and full URL are provided, it checks consistency and either confirms or reports a conflict.
|
| 106 |
+
|
| 107 |
+
#### `gen_full_url(short_url=None, full_url=None, repo_id=None, repo_type="dataset", permalink_viewer_url="surn-3d-viewer.hf.space", json_file="shortener.json")`
|
| 108 |
+
- **Purpose:**
|
| 109 |
+
Based on which parameter is provided, it retrieves or creates a mapping between a short URL ID and a full URL.
|
| 110 |
+
- If only `short_url` (the ID) is given, it returns the corresponding `full_url`.
|
| 111 |
+
- If only `full_url` is given, it looks up an existing `short_url` ID or generates and stores a new one.
|
| 112 |
+
- If both are given, it validates and returns the mapping or an error status.
|
| 113 |
+
- **Returns:** A tuple `(status_message, result_url)`, where `status_message` indicates the outcome (e.g., `"success_retrieved_full"`, `"created_short"`) and `result_url` is the relevant URL (full or short ID).
|
| 114 |
+
- **Usage Examples:**
|
| 115 |
+
|
| 116 |
+
**a. Convert a full URL into a short URL ID:**from modules.storage import gen_full_url
|
| 117 |
+
from modules.constants import HF_REPO_ID, SHORTENER_JSON_FILE # Assuming these are defined
|
| 118 |
+
|
| 119 |
+
full_permalink = "https://surn-3d-viewer.hf.space/?3d=https%3A%2F%2Fexample.com%2Fmodel.glb&hm=https%3A%2F%2Fexample.com%2Fheightmap.png&image=https%3A%2F%2Fexample.com%2Fsource.png"
|
| 120 |
+
|
| 121 |
+
status, short_id = gen_full_url(
|
| 122 |
+
full_url=full_permalink,
|
| 123 |
+
repo_id=HF_REPO_ID,
|
| 124 |
+
json_file=SHORTENER_JSON_FILE
|
| 125 |
+
)
|
| 126 |
+
print("Status:", status)
|
| 127 |
+
if status == "created_short" or status == "success_retrieved_short":
|
| 128 |
+
print("Shortened URL ID:", short_id)
|
| 129 |
+
# Construct the full short URL for sharing:
|
| 130 |
+
# permalink_viewer_url = "surn-3d-viewer.hf.space" # Or from constants
|
| 131 |
+
# shareable_short_url = f"https://{permalink_viewer_url}/?sid={short_id}"
|
| 132 |
+
# print("Shareable Short URL:", shareable_short_url)
|
| 133 |
+
**b. Retrieve the full URL from a short URL ID:**from modules.storage import gen_full_url
|
| 134 |
+
from modules.constants import HF_REPO_ID, SHORTENER_JSON_FILE # Assuming these are defined
|
| 135 |
+
|
| 136 |
+
short_id_to_lookup = "aBcDeFg1" # Example short URL ID
|
| 137 |
+
|
| 138 |
+
status, retrieved_full_url = gen_full_url(
|
| 139 |
+
short_url=short_id_to_lookup,
|
| 140 |
+
repo_id=HF_REPO_ID,
|
| 141 |
+
json_file=SHORTENER_JSON_FILE
|
| 142 |
+
)
|
| 143 |
+
print("Status:", status)
|
| 144 |
+
if status == "success_retrieved_full":
|
| 145 |
+
print("Retrieved Full URL:", retrieved_full_url)
|
| 146 |
+
## 📁 Repository Folder Listing Functions
|
| 147 |
+
|
| 148 |
+
### 5. `_list_repo_folders(repo_id, path_prefix, repo_type="dataset")`
|
| 149 |
+
- **Purpose:**
|
| 150 |
+
List folder names under a given path in a HuggingFace repository. Enables folder-based discovery without index files.
|
| 151 |
+
- **Parameters:**
|
| 152 |
+
- `repo_id` (str): The repository ID on Hugging Face
|
| 153 |
+
- `path_prefix` (str): The path prefix to list folders under
|
| 154 |
+
- `repo_type` (str): Repository type. Default is `"dataset"`.
|
| 155 |
+
- **Returns:** `List[str]` - List of folder names found under the path_prefix.
|
| 156 |
+
- **Usage Example:**
|
| 157 |
+
```python
|
| 158 |
+
from modules.storage import _list_repo_folders
|
| 159 |
+
|
| 160 |
+
# List all date folders in daily leaderboards
|
| 161 |
+
folders = _list_repo_folders("Surn/Wrdler-Data", "games/leaderboards/daily")
|
| 162 |
+
print("Available dates:", folders)
|
| 163 |
+
# Output: ['2025-01-27', '2025-01-26', '2025-01-25']
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
### 6. `_list_repo_files_in_folder(repo_id, folder_path, repo_type="dataset")`
|
| 167 |
+
- **Purpose:**
|
| 168 |
+
List file names directly under a folder in a HuggingFace repository.
|
| 169 |
+
- **Parameters:**
|
| 170 |
+
- `repo_id` (str): The repository ID on Hugging Face
|
| 171 |
+
- `folder_path` (str): The folder path to list files under
|
| 172 |
+
- `repo_type` (str): Repository type. Default is `"dataset"`.
|
| 173 |
+
- **Returns:** `List[str]` - List of file names found directly in the folder.
|
| 174 |
+
- **Usage Example:**
|
| 175 |
+
```python
|
| 176 |
+
from modules.storage import _list_repo_files_in_folder
|
| 177 |
+
|
| 178 |
+
files = _list_repo_files_in_folder(
|
| 179 |
+
"Surn/Wrdler-Data",
|
| 180 |
+
"games/leaderboards/daily/2025-01-27/classic-classic-0"
|
| 181 |
+
)
|
| 182 |
+
print("Files:", files)
|
| 183 |
+
# Output: ['settings.json']
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
## 🔑 Cryptographic Key Management Functions
|
| 187 |
+
|
| 188 |
+
### 7. `store_issuer_keypair(issuer_id, public_key, private_key, repo_id=None)`
|
| 189 |
+
- **Purpose:**
|
| 190 |
+
Securely store cryptographic keys for an issuer in a private Hugging Face repository. Private keys are encrypted before storage.
|
| 191 |
+
- **⚠️ IMPORTANT:** This function requires a PRIVATE Hugging Face repository to ensure the security of stored private keys. Never use this with public repositories.
|
| 192 |
+
- **Storage Structure:**keys/issuers/{issuer_id}/
|
| 193 |
+
├── private_key.json (encrypted)
|
| 194 |
+
└── public_key.json- **Returns:** `bool` - True if keys were stored successfully, False otherwise.
|
| 195 |
+
- **Usage Example:**from modules.storage import store_issuer_keypair
|
| 196 |
+
|
| 197 |
+
# Example Ed25519 keys (multibase encoded)
|
| 198 |
+
issuer_id = "https://example.edu/issuers/565049"
|
| 199 |
+
public_key = "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
|
| 200 |
+
private_key = "z3u2MQhLnQw7nvJRGJCdKdqfXHV4N7BLKuEGFWnJqsVSdgYv"
|
| 201 |
+
|
| 202 |
+
success = store_issuer_keypair(issuer_id, public_key, private_key)
|
| 203 |
+
if success:
|
| 204 |
+
print("Keys stored successfully")
|
| 205 |
+
else:
|
| 206 |
+
print("Failed to store keys")
|
| 207 |
+
### 8. `get_issuer_keypair(issuer_id, repo_id=None)`
|
| 208 |
+
- **Purpose:**
|
| 209 |
+
Retrieve and decrypt stored cryptographic keys for an issuer from the private Hugging Face repository.
|
| 210 |
+
- **⚠️ IMPORTANT:** This function accesses a PRIVATE Hugging Face repository containing encrypted private keys. Ensure proper access control and security measures.
|
| 211 |
+
- **Returns:** `Tuple[Optional[str], Optional[str]]` - (public_key, private_key) or (None, None) if not found.
|
| 212 |
+
- **Usage Example:**from modules.storage import get_issuer_keypair
|
| 213 |
+
|
| 214 |
+
issuer_id = "https://example.edu/issuers/565049"
|
| 215 |
+
public_key, private_key = get_issuer_keypair(issuer_id)
|
| 216 |
+
|
| 217 |
+
if public_key and private_key:
|
| 218 |
+
print("Keys retrieved successfully")
|
| 219 |
+
print(f"Public key: {public_key}")
|
| 220 |
+
# Use private_key for signing operations
|
| 221 |
+
else:
|
| 222 |
+
print("Keys not found or error occurred")
|
| 223 |
+
### 9. `get_verification_methods_registry(repo_id=None)`
|
| 224 |
+
- **Purpose:**
|
| 225 |
+
Retrieve the global verification methods registry containing all registered issuer public keys.
|
| 226 |
+
- **Returns:** `Dict[str, Any]` - Registry data containing all verification methods.
|
| 227 |
+
- **Usage Example:**from modules.storage import get_verification_methods_registry
|
| 228 |
+
|
| 229 |
+
registry = get_verification_methods_registry()
|
| 230 |
+
methods = registry.get("verification_methods", [])
|
| 231 |
+
|
| 232 |
+
for method in methods:
|
| 233 |
+
print(f"Issuer: {method['issuer_id']}")
|
| 234 |
+
print(f"Public Key: {method['public_key']}")
|
| 235 |
+
print(f"Key Type: {method['key_type']}")
|
| 236 |
+
print("---")
|
| 237 |
+
### 10. `list_issuer_ids(repo_id=None)`
|
| 238 |
+
- **Purpose:**
|
| 239 |
+
List all issuer IDs that have stored keys in the repository.
|
| 240 |
+
- **Returns:** `List[str]` - List of issuer IDs.
|
| 241 |
+
- **Usage Example:**from modules.storage import list_issuer_ids
|
| 242 |
+
|
| 243 |
+
issuer_ids = list_issuer_ids()
|
| 244 |
+
print("Registered issuers:")
|
| 245 |
+
for issuer_id in issuer_ids:
|
| 246 |
+
print(f" - {issuer_id}")
|
| 247 |
+
## Notes
|
| 248 |
+
- **Authentication:** All functions that interact with Hugging Face Hub use the HF API token defined as `HF_API_TOKEN` in `modules/constants.py`. Ensure this environment variable is correctly set.
|
| 249 |
+
- **Constants:** Functions like `gen_full_url` and `upload_files_to_repo` (when creating short links) rely on `HF_REPO_ID` and `SHORTENER_JSON_FILE` from `modules/constants.py` for the URL shortening feature.
|
| 250 |
+
- **🔐 Private Repository Requirement:** Key management functions require a PRIVATE Hugging Face repository to ensure the security of stored encrypted private keys. Never use these functions with public repositories.
|
| 251 |
+
- **File Types:** Only files with extensions included in `upload_file_types` (a combination of `model_extensions` and `image_extensions` from `modules/constants.py`) are processed by `upload_files_to_repo`.
|
| 252 |
+
- **Repository Configuration:** When using URL shortening, file uploads, and key management, ensure that the specified Hugging Face repository (e.g., defined by `HF_REPO_ID`) exists and that you have write permissions.
|
| 253 |
+
- **Temporary Directory:** `upload_files_to_repo` temporarily copies files to a local directory (configured by `TMPDIR` in `modules/constants.py`) before uploading.
|
| 254 |
+
- **Key Encryption:** Private keys are encrypted using basic XOR encryption (demo implementation). In production environments, upgrade to proper encryption like Fernet from the cryptography library.
|
| 255 |
+
- **Error Handling:** Functions include basic error handling (e.g., catching `RepositoryNotFoundError`, `EntryNotFoundError`, JSON decoding errors, or upload issues) and print messages to the console for debugging. Review function return values to handle these cases appropriately in your application.
|
| 256 |
+
|
| 257 |
+
## 🔒 Security Considerations for Key Management
|
| 258 |
+
|
| 259 |
+
1. **Private Repository Only:** Always use private repositories for key storage to protect cryptographic material.
|
| 260 |
+
2. **Key Sanitization:** Issuer IDs are sanitized for file system compatibility (replacing special characters with underscores).
|
| 261 |
+
3. **Encryption:** Private keys are encrypted before storage. Upgrade to Fernet encryption in production.
|
| 262 |
+
4. **Access Control:** Implement proper authentication and authorization for key access.
|
| 263 |
+
5. **Key Rotation:** Consider implementing key rotation mechanisms for enhanced security.
|
| 264 |
+
6. **Audit Logging:** Monitor key access and usage patterns for security auditing.
|
| 265 |
+
|
| 266 |
+
---
|
| 267 |
+
|
| 268 |
+
This guide provides the essential usage examples for interacting with the storage, URL-shortening, folder listing, and cryptographic key management functionality. You can integrate these examples into your application or use them as a reference when extending functionality.
|
battlewords/modules/storage.py
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# modules/storage.py
|
| 2 |
+
__version__ = "0.1.6"
|
| 3 |
+
import os
|
| 4 |
+
import urllib.parse
|
| 5 |
+
import tempfile
|
| 6 |
+
import shutil
|
| 7 |
+
import json
|
| 8 |
+
import base64
|
| 9 |
+
import logging
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
from huggingface_hub import login, upload_folder, hf_hub_download, HfApi
|
| 12 |
+
from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError
|
| 13 |
+
from .constants import HF_API_TOKEN, upload_file_types, model_extensions, image_extensions, audio_extensions, video_extensions, doc_extensions, HF_REPO_ID, SHORTENER_JSON_FILE
|
| 14 |
+
from typing import Any, Dict, List, Tuple, Union, Optional
|
| 15 |
+
|
| 16 |
+
# Configure professional logging
|
| 17 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
# see storage.md for detailed information about the storage module and its functions.
|
| 21 |
+
|
| 22 |
+
def generate_permalink(valid_files, base_url_external, permalink_viewer_url="surn-3d-viewer.hf.space"):
|
| 23 |
+
"""
|
| 24 |
+
Given a list of valid files, checks if they contain exactly 1 model file and 2 image files.
|
| 25 |
+
Constructs and returns a permalink URL with query parameters if the criteria is met.
|
| 26 |
+
Otherwise, returns None.
|
| 27 |
+
"""
|
| 28 |
+
model_link = None
|
| 29 |
+
images_links = []
|
| 30 |
+
audio_links = []
|
| 31 |
+
video_links = []
|
| 32 |
+
doc_links = []
|
| 33 |
+
for f in valid_files:
|
| 34 |
+
filename = os.path.basename(f)
|
| 35 |
+
ext = os.path.splitext(filename)[1].lower()
|
| 36 |
+
if ext in model_extensions:
|
| 37 |
+
if model_link is None:
|
| 38 |
+
model_link = f"{base_url_external}/{filename}"
|
| 39 |
+
elif ext in image_extensions:
|
| 40 |
+
images_links.append(f"{base_url_external}/{filename}")
|
| 41 |
+
elif ext in audio_extensions:
|
| 42 |
+
audio_links.append(f"{base_url_external}/{filename}")
|
| 43 |
+
elif ext in video_extensions:
|
| 44 |
+
video_links.append(f"{base_url_external}/{filename}")
|
| 45 |
+
elif ext in doc_extensions:
|
| 46 |
+
doc_links.append(f"{base_url_external}/{filename}")
|
| 47 |
+
if model_link and len(images_links) == 2:
|
| 48 |
+
# Construct a permalink to the viewer project with query parameters.
|
| 49 |
+
permalink_viewer_url = f"https://{permalink_viewer_url}/"
|
| 50 |
+
params = {"3d": model_link, "hm": images_links[0], "image": images_links[1]}
|
| 51 |
+
query_str = urllib.parse.urlencode(params)
|
| 52 |
+
return f"{permalink_viewer_url}?{query_str}"
|
| 53 |
+
return None
|
| 54 |
+
|
| 55 |
+
def generate_permalink_from_urls(model_url, hm_url, img_url, permalink_viewer_url="surn-3d-viewer.hf.space"):
|
| 56 |
+
"""
|
| 57 |
+
Constructs and returns a permalink URL with query string parameters for the viewer.
|
| 58 |
+
Each parameter is passed separately so that the image positions remain consistent.
|
| 59 |
+
|
| 60 |
+
Parameters:
|
| 61 |
+
model_url (str): Processed URL for the 3D model.
|
| 62 |
+
hm_url (str): Processed URL for the height map image.
|
| 63 |
+
img_url (str): Processed URL for the main image.
|
| 64 |
+
permalink_viewer_url (str): The base viewer URL.
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
str: The generated permalink URL.
|
| 68 |
+
"""
|
| 69 |
+
import urllib.parse
|
| 70 |
+
params = {"3d": model_url, "hm": hm_url, "image": img_url}
|
| 71 |
+
query_str = urllib.parse.urlencode(params)
|
| 72 |
+
return f"https://{permalink_viewer_url}/?{query_str}"
|
| 73 |
+
|
| 74 |
+
def upload_files_to_repo(
|
| 75 |
+
files: List[Any],
|
| 76 |
+
repo_id: str,
|
| 77 |
+
folder_name: str,
|
| 78 |
+
create_permalink: bool = False,
|
| 79 |
+
repo_type: str = "dataset",
|
| 80 |
+
permalink_viewer_url: str = "surn-3d-viewer.hf.space"
|
| 81 |
+
) -> Union[Dict[str, Any], List[Tuple[Any, str]]]:
|
| 82 |
+
"""
|
| 83 |
+
Uploads multiple files to a Hugging Face repository using a batch upload approach via upload_folder.
|
| 84 |
+
|
| 85 |
+
Parameters:
|
| 86 |
+
files (list): A list of file paths (str) to upload.
|
| 87 |
+
repo_id (str): The repository ID on Hugging Face for storage, e.g. "Surn/Storage".
|
| 88 |
+
folder_name (str): The subfolder within the repository where files will be saved.
|
| 89 |
+
create_permalink (bool): If True and if exactly three files are uploaded (1 model and 2 images),
|
| 90 |
+
returns a single permalink to the project with query parameters.
|
| 91 |
+
Otherwise, returns individual permalinks for each file.
|
| 92 |
+
repo_type (str): Repository type ("space", "dataset", etc.). Default is "dataset".
|
| 93 |
+
permalink_viewer_url (str): The base viewer URL.
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
Union[Dict[str, Any], List[Tuple[Any, str]]]:
|
| 97 |
+
If create_permalink is True and files match the criteria:
|
| 98 |
+
dict: {
|
| 99 |
+
"response": <upload response>,
|
| 100 |
+
"permalink": <full_permalink URL>,
|
| 101 |
+
"short_permalink": <shortened permalink URL>
|
| 102 |
+
}
|
| 103 |
+
Otherwise:
|
| 104 |
+
list: A list of tuples (response, permalink) for each file.
|
| 105 |
+
"""
|
| 106 |
+
logger.info(f"📤 Starting batch upload to repository: {repo_id}")
|
| 107 |
+
logger.debug(f"📁 Target folder: {folder_name}")
|
| 108 |
+
logger.debug(f"🔗 Create permalink: {create_permalink}")
|
| 109 |
+
|
| 110 |
+
# Log in using the HF API token.
|
| 111 |
+
try:
|
| 112 |
+
login(token=HF_API_TOKEN)
|
| 113 |
+
logger.debug("🔑 Authenticated with Hugging Face")
|
| 114 |
+
except Exception as e:
|
| 115 |
+
logger.error(f"🚫 Authentication failed: {e}")
|
| 116 |
+
return {"response": "Authentication failed", "permalink": None, "short_permalink": None} if create_permalink else []
|
| 117 |
+
|
| 118 |
+
valid_files = []
|
| 119 |
+
permalink_short = None
|
| 120 |
+
|
| 121 |
+
# Ensure folder_name does not have a trailing slash.
|
| 122 |
+
folder_name = folder_name.rstrip("/")
|
| 123 |
+
|
| 124 |
+
# Filter for valid files based on allowed extensions.
|
| 125 |
+
logger.debug("🔍 Filtering valid files...")
|
| 126 |
+
for f in files:
|
| 127 |
+
file_name = f if isinstance(f, str) else f.name if hasattr(f, "name") else None
|
| 128 |
+
if file_name is None:
|
| 129 |
+
continue
|
| 130 |
+
ext = os.path.splitext(file_name)[1].lower()
|
| 131 |
+
if ext in upload_file_types:
|
| 132 |
+
valid_files.append(f)
|
| 133 |
+
logger.debug(f"✅ Valid file: {os.path.basename(file_name)}")
|
| 134 |
+
else:
|
| 135 |
+
logger.debug(f"⚠️ Skipped file with invalid extension: {os.path.basename(file_name)}")
|
| 136 |
+
|
| 137 |
+
logger.info(f"📊 Found {len(valid_files)} valid files out of {len(files)} total")
|
| 138 |
+
|
| 139 |
+
if not valid_files:
|
| 140 |
+
logger.warning("⚠️ No valid files to upload")
|
| 141 |
+
if create_permalink:
|
| 142 |
+
return {
|
| 143 |
+
"response": "No valid files to upload.",
|
| 144 |
+
"permalink": None,
|
| 145 |
+
"short_permalink": None
|
| 146 |
+
}
|
| 147 |
+
return []
|
| 148 |
+
|
| 149 |
+
# Create a temporary directory and copy valid files
|
| 150 |
+
logger.debug("📁 Creating temporary directory for batch upload...")
|
| 151 |
+
with tempfile.TemporaryDirectory(dir=os.getenv("TMPDIR", "/tmp")) as temp_dir:
|
| 152 |
+
for file_path in valid_files:
|
| 153 |
+
filename = os.path.basename(file_path)
|
| 154 |
+
dest_path = os.path.join(temp_dir, filename)
|
| 155 |
+
shutil.copy(file_path, dest_path)
|
| 156 |
+
logger.debug(f"📄 Copied: {filename}")
|
| 157 |
+
|
| 158 |
+
logger.info("🚀 Starting batch upload to Hugging Face...")
|
| 159 |
+
# Batch upload all files in the temporary folder.
|
| 160 |
+
try:
|
| 161 |
+
response = upload_folder(
|
| 162 |
+
folder_path=temp_dir,
|
| 163 |
+
repo_id=repo_id,
|
| 164 |
+
repo_type=repo_type,
|
| 165 |
+
path_in_repo=folder_name,
|
| 166 |
+
commit_message="Batch upload files"
|
| 167 |
+
)
|
| 168 |
+
logger.info("✅ Batch upload completed successfully")
|
| 169 |
+
except Exception as e:
|
| 170 |
+
logger.error(f"❌ Batch upload failed: {e}")
|
| 171 |
+
return {"response": f"Upload failed: {e}", "permalink": None, "short_permalink": None} if create_permalink else []
|
| 172 |
+
|
| 173 |
+
# Construct external URLs for each uploaded file.
|
| 174 |
+
base_url_external = f"https://huggingface.co/datasets/{repo_id}/resolve/main/{folder_name}"
|
| 175 |
+
individual_links = []
|
| 176 |
+
for file_path in valid_files:
|
| 177 |
+
filename = os.path.basename(file_path)
|
| 178 |
+
link = f"{base_url_external}/{filename}"
|
| 179 |
+
individual_links.append(link)
|
| 180 |
+
logger.debug(f"🔗 Generated link: {link}")
|
| 181 |
+
|
| 182 |
+
# Handle permalink creation if requested
|
| 183 |
+
if create_permalink:
|
| 184 |
+
logger.info("🔗 Attempting to create permalink...")
|
| 185 |
+
permalink = generate_permalink(valid_files, base_url_external, permalink_viewer_url)
|
| 186 |
+
if permalink:
|
| 187 |
+
logger.info(f"✅ Generated permalink: {permalink}")
|
| 188 |
+
logger.debug("🔗 Creating short URL...")
|
| 189 |
+
status, short_id = gen_full_url(
|
| 190 |
+
full_url=permalink,
|
| 191 |
+
repo_id=HF_REPO_ID,
|
| 192 |
+
json_file=SHORTENER_JSON_FILE
|
| 193 |
+
)
|
| 194 |
+
if status in ["created_short", "success_retrieved_short", "exists_match"]:
|
| 195 |
+
permalink_short = f"https://{permalink_viewer_url}/?sid={short_id}"
|
| 196 |
+
logger.info(f"✅ Created short permalink: {permalink_short}")
|
| 197 |
+
else:
|
| 198 |
+
permalink_short = None
|
| 199 |
+
logger.warning(f"⚠️ URL shortening failed: {status} for {permalink}")
|
| 200 |
+
|
| 201 |
+
return {
|
| 202 |
+
"response": response,
|
| 203 |
+
"permalink": permalink,
|
| 204 |
+
"short_permalink": permalink_short
|
| 205 |
+
}
|
| 206 |
+
else:
|
| 207 |
+
logger.warning("⚠️ Permalink generation failed (criteria not met)")
|
| 208 |
+
return {
|
| 209 |
+
"response": response,
|
| 210 |
+
"permalink": None,
|
| 211 |
+
"short_permalink": None
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
# Return individual tuples for each file
|
| 215 |
+
logger.info(f"📋 Returning individual links for {len(individual_links)} files")
|
| 216 |
+
return [(response, link) for link in individual_links]
|
| 217 |
+
|
| 218 |
+
def _generate_short_id(length=8):
|
| 219 |
+
"""Generates a random base64 URL-safe string."""
|
| 220 |
+
return base64.urlsafe_b64encode(os.urandom(length * 2))[:length].decode('utf-8')
|
| 221 |
+
|
| 222 |
+
def _get_json_from_repo(repo_id, json_file_name, repo_type="dataset"):
|
| 223 |
+
"""Downloads and loads the JSON file from the repo. Returns empty list if not found or error."""
|
| 224 |
+
try:
|
| 225 |
+
login(token=HF_API_TOKEN)
|
| 226 |
+
json_path = hf_hub_download(
|
| 227 |
+
repo_id=repo_id,
|
| 228 |
+
filename=json_file_name,
|
| 229 |
+
repo_type=repo_type,
|
| 230 |
+
token=HF_API_TOKEN
|
| 231 |
+
)
|
| 232 |
+
with open(json_path, 'r') as f:
|
| 233 |
+
data = json.load(f)
|
| 234 |
+
os.remove(json_path)
|
| 235 |
+
return data
|
| 236 |
+
except RepositoryNotFoundError:
|
| 237 |
+
logger.warning(f"Repository {repo_id} not found.")
|
| 238 |
+
return []
|
| 239 |
+
except EntryNotFoundError:
|
| 240 |
+
logger.warning(f"JSON file {json_file_name} not found in {repo_id}. Initializing with empty list.")
|
| 241 |
+
return []
|
| 242 |
+
except json.JSONDecodeError:
|
| 243 |
+
logger.error(f"Error decoding JSON from {json_file_name}. Returning empty list.")
|
| 244 |
+
return []
|
| 245 |
+
except Exception as e:
|
| 246 |
+
logger.error(f"An unexpected error occurred while fetching {json_file_name}: {e}")
|
| 247 |
+
return []
|
| 248 |
+
|
| 249 |
+
def _get_files_from_repo(repo_id, file_name, repo_type="dataset"):
|
| 250 |
+
"""Downloads and loads the file from the repo. File must be in upload_file_types. Returns empty list if not found or error."""
|
| 251 |
+
filename = os.path.basename(file_name)
|
| 252 |
+
ext = os.path.splitext(file_name)[1].lower()
|
| 253 |
+
if ext not in upload_file_types:
|
| 254 |
+
logger.error(f"File {filename} with extension {ext} is not allowed for upload.")
|
| 255 |
+
return None
|
| 256 |
+
else:
|
| 257 |
+
try:
|
| 258 |
+
login(token=HF_API_TOKEN)
|
| 259 |
+
file_path = hf_hub_download(
|
| 260 |
+
repo_id=repo_id,
|
| 261 |
+
filename=file_name,
|
| 262 |
+
repo_type=repo_type,
|
| 263 |
+
token=HF_API_TOKEN
|
| 264 |
+
)
|
| 265 |
+
if not file_path:
|
| 266 |
+
return None
|
| 267 |
+
return file_path
|
| 268 |
+
except RepositoryNotFoundError:
|
| 269 |
+
logger.warning(f"Repository {repo_id} not found.")
|
| 270 |
+
return None
|
| 271 |
+
except EntryNotFoundError:
|
| 272 |
+
logger.warning(f"file {file_name} not found in {repo_id}. Initializing with empty list.")
|
| 273 |
+
return None
|
| 274 |
+
except Exception as e:
|
| 275 |
+
logger.error(f"Error fetching {file_name} from {repo_id}: {e}")
|
| 276 |
+
return None
|
| 277 |
+
|
| 278 |
+
def _upload_json_to_repo(data, repo_id, json_file_name, repo_type="dataset"):
|
| 279 |
+
"""Uploads the JSON data to the specified file in the repo."""
|
| 280 |
+
try:
|
| 281 |
+
login(token=HF_API_TOKEN)
|
| 282 |
+
api = HfApi()
|
| 283 |
+
# Use a temporary directory specified by TMPDIR or default to system temp
|
| 284 |
+
temp_dir_for_json = os.getenv("TMPDIR", tempfile.gettempdir())
|
| 285 |
+
os.makedirs(temp_dir_for_json, exist_ok=True)
|
| 286 |
+
|
| 287 |
+
with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".json", dir=temp_dir_for_json) as tmp_file:
|
| 288 |
+
json.dump(data, tmp_file, indent=2)
|
| 289 |
+
tmp_file_path = tmp_file.name
|
| 290 |
+
|
| 291 |
+
logger.info(f"📤 Uploading JSON data to {json_file_name}...")
|
| 292 |
+
api.upload_file(
|
| 293 |
+
path_or_fileobj=tmp_file_path,
|
| 294 |
+
path_in_repo=json_file_name,
|
| 295 |
+
repo_id=repo_id,
|
| 296 |
+
repo_type=repo_type,
|
| 297 |
+
commit_message=f"Update {json_file_name}"
|
| 298 |
+
)
|
| 299 |
+
os.remove(tmp_file_path) # Clean up temporary file
|
| 300 |
+
logger.info("✅ JSON data uploaded successfully")
|
| 301 |
+
return True
|
| 302 |
+
except Exception as e:
|
| 303 |
+
logger.error(f"Failed to upload {json_file_name} to {repo_id}: {e}")
|
| 304 |
+
if 'tmp_file_path' in locals() and os.path.exists(tmp_file_path):
|
| 305 |
+
os.remove(tmp_file_path) # Ensure cleanup on error too
|
| 306 |
+
return False
|
| 307 |
+
|
| 308 |
+
def _find_url_in_json(data, short_url=None, full_url=None):
|
| 309 |
+
"""
|
| 310 |
+
Searches the JSON data.
|
| 311 |
+
If short_url is provided, returns the corresponding full_url or None.
|
| 312 |
+
If full_url is provided, returns the corresponding short_url or None.
|
| 313 |
+
"""
|
| 314 |
+
if not data: # Handles cases where data might be None or empty
|
| 315 |
+
return None
|
| 316 |
+
if short_url:
|
| 317 |
+
for item in data:
|
| 318 |
+
if item.get("short_url") == short_url:
|
| 319 |
+
return item.get("full_url")
|
| 320 |
+
if full_url:
|
| 321 |
+
for item in data:
|
| 322 |
+
if item.get("full_url") == full_url:
|
| 323 |
+
return item.get("short_url")
|
| 324 |
+
return None
|
| 325 |
+
|
| 326 |
+
def _add_url_to_json(data, short_url, full_url):
|
| 327 |
+
"""Adds a new short_url/full_url pair to the data. Returns updated data."""
|
| 328 |
+
if data is None:
|
| 329 |
+
data = []
|
| 330 |
+
data.append({"short_url": short_url, "full_url": full_url})
|
| 331 |
+
return data
|
| 332 |
+
|
| 333 |
+
def gen_full_url(short_url=None, full_url=None, repo_id=None, repo_type="dataset", permalink_viewer_url="surn-3d-viewer.hf.space", json_file="shortener.json"):
|
| 334 |
+
"""
|
| 335 |
+
Manages short URLs and their corresponding full URLs in a JSON file stored in a Hugging Face repository.
|
| 336 |
+
|
| 337 |
+
- If short_url is provided, attempts to retrieve and return the full_url.
|
| 338 |
+
- If full_url is provided, attempts to retrieve an existing short_url or creates a new one, stores it, and returns the short_url.
|
| 339 |
+
- If both are provided, checks for consistency or creates a new entry.
|
| 340 |
+
- If neither is provided, or repo_id is missing, returns an error status.
|
| 341 |
+
|
| 342 |
+
Returns:
|
| 343 |
+
tuple: (status_message, result_url)
|
| 344 |
+
status_message can be "success", "created", "exists", "error", "not_found".
|
| 345 |
+
result_url is the relevant URL (short or full) or None if an error occurs or not found.
|
| 346 |
+
"""
|
| 347 |
+
if not repo_id:
|
| 348 |
+
return "error_repo_id_missing", None
|
| 349 |
+
if not short_url and not full_url:
|
| 350 |
+
return "error_no_input", None
|
| 351 |
+
|
| 352 |
+
login(token=HF_API_TOKEN) # Ensure login at the beginning
|
| 353 |
+
url_data = _get_json_from_repo(repo_id, json_file, repo_type)
|
| 354 |
+
|
| 355 |
+
# Case 1: Only short_url provided (lookup full_url)
|
| 356 |
+
if short_url and not full_url:
|
| 357 |
+
found_full_url = _find_url_in_json(url_data, short_url=short_url)
|
| 358 |
+
return ("success_retrieved_full", found_full_url) if found_full_url else ("not_found_short", None)
|
| 359 |
+
|
| 360 |
+
# Case 2: Only full_url provided (lookup or create short_url)
|
| 361 |
+
if full_url and not short_url:
|
| 362 |
+
existing_short_url = _find_url_in_json(url_data, full_url=full_url)
|
| 363 |
+
if existing_short_url:
|
| 364 |
+
return "success_retrieved_short", existing_short_url
|
| 365 |
+
else:
|
| 366 |
+
# Create new short_url
|
| 367 |
+
new_short_id = _generate_short_id()
|
| 368 |
+
url_data = _add_url_to_json(url_data, new_short_id, full_url)
|
| 369 |
+
if _upload_json_to_repo(url_data, repo_id, json_file, repo_type):
|
| 370 |
+
return "created_short", new_short_id
|
| 371 |
+
else:
|
| 372 |
+
return "error_upload", None
|
| 373 |
+
|
| 374 |
+
# Case 3: Both short_url and full_url provided
|
| 375 |
+
if short_url and full_url:
|
| 376 |
+
found_full_for_short = _find_url_in_json(url_data, short_url=short_url)
|
| 377 |
+
found_short_for_full = _find_url_in_json(url_data, full_url=full_url)
|
| 378 |
+
|
| 379 |
+
if found_full_for_short == full_url:
|
| 380 |
+
return "exists_match", short_url
|
| 381 |
+
if found_full_for_short is not None and found_full_for_short != full_url:
|
| 382 |
+
return "error_conflict_short_exists_different_full", short_url
|
| 383 |
+
if found_short_for_full is not None and found_short_for_full != short_url:
|
| 384 |
+
return "error_conflict_full_exists_different_short", found_short_for_full
|
| 385 |
+
|
| 386 |
+
# If short_url is provided and not found, or full_url is provided and not found,
|
| 387 |
+
# or neither is found, then create a new entry with the provided short_url and full_url.
|
| 388 |
+
# This effectively allows specifying a custom short_url if it's not already taken.
|
| 389 |
+
url_data = _add_url_to_json(url_data, short_url, full_url)
|
| 390 |
+
if _upload_json_to_repo(url_data, repo_id, json_file, repo_type):
|
| 391 |
+
return "created_specific_pair", short_url
|
| 392 |
+
else:
|
| 393 |
+
return "error_upload", None
|
| 394 |
+
|
| 395 |
+
return "error_unhandled_case", None # Should not be reached
|
| 396 |
+
|
| 397 |
+
def _encrypt_private_key(private_key: str, password: str = None) -> str:
|
| 398 |
+
"""
|
| 399 |
+
Basic encryption for private keys. In production, use proper encryption like Fernet.
|
| 400 |
+
|
| 401 |
+
Note: This is a simplified encryption for demonstration. In production environments,
|
| 402 |
+
use proper encryption libraries like cryptography.fernet.Fernet with secure key derivation.
|
| 403 |
+
|
| 404 |
+
Args:
|
| 405 |
+
private_key (str): The private key to encrypt
|
| 406 |
+
password (str, optional): Password for encryption. If None, uses a default method.
|
| 407 |
+
|
| 408 |
+
Returns:
|
| 409 |
+
str: Base64 encoded encrypted private key
|
| 410 |
+
"""
|
| 411 |
+
# WARNING: This is a basic XOR encryption for demo purposes only
|
| 412 |
+
# In production, use proper encryption like Fernet from cryptography library
|
| 413 |
+
if not password:
|
| 414 |
+
password = "default_encryption_key" # In production, use secure key derivation
|
| 415 |
+
|
| 416 |
+
encrypted_bytes = []
|
| 417 |
+
for i, char in enumerate(private_key):
|
| 418 |
+
encrypted_bytes.append(ord(char) ^ ord(password[i % len(password)]))
|
| 419 |
+
|
| 420 |
+
encrypted_data = bytes(encrypted_bytes)
|
| 421 |
+
return base64.b64encode(encrypted_data).decode('utf-8')
|
| 422 |
+
|
| 423 |
+
def _decrypt_private_key(encrypted_private_key: str, password: str = None) -> str:
|
| 424 |
+
"""
|
| 425 |
+
Basic decryption for private keys. In production, use proper decryption like Fernet.
|
| 426 |
+
|
| 427 |
+
Args:
|
| 428 |
+
encrypted_private_key (str): Base64 encoded encrypted private key
|
| 429 |
+
password (str, optional): Password for decryption. If None, uses a default method.
|
| 430 |
+
|
| 431 |
+
Returns:
|
| 432 |
+
str: Decrypted private key
|
| 433 |
+
"""
|
| 434 |
+
# WARNING: This is a basic XOR decryption for demo purposes only
|
| 435 |
+
if not password:
|
| 436 |
+
password = "default_encryption_key" # In production, use secure key derivation
|
| 437 |
+
|
| 438 |
+
encrypted_data = base64.b64decode(encrypted_private_key)
|
| 439 |
+
decrypted_chars = []
|
| 440 |
+
for i, byte in enumerate(encrypted_data):
|
| 441 |
+
decrypted_chars.append(chr(byte ^ ord(password[i % len(password)])))
|
| 442 |
+
|
| 443 |
+
return ''.join(decrypted_chars)
|
| 444 |
+
|
| 445 |
+
def store_issuer_keypair(issuer_id: str, public_key: str, private_key: str, repo_id: str = None) -> bool:
|
| 446 |
+
"""
|
| 447 |
+
Store cryptographic keys for an issuer in the private Hugging Face repository.
|
| 448 |
+
|
| 449 |
+
**IMPORTANT: This function requires a PRIVATE Hugging Face repository to ensure
|
| 450 |
+
the security of stored private keys. Never use this with public repositories.**
|
| 451 |
+
|
| 452 |
+
The keys are stored in the following structure:
|
| 453 |
+
keys/issuers/{issuer_id}/
|
| 454 |
+
├── private_key.json (encrypted)
|
| 455 |
+
└── public_key.json
|
| 456 |
+
|
| 457 |
+
Args:
|
| 458 |
+
issuer_id (str): Unique identifier for the issuer (e.g., "https://example.edu/issuers/565049")
|
| 459 |
+
public_key (str): Multibase-encoded public key
|
| 460 |
+
private_key (str): Multibase-encoded private key (will be encrypted before storage)
|
| 461 |
+
repo_id (str, optional): Repository ID. If None, uses HF_REPO_ID from constants.
|
| 462 |
+
|
| 463 |
+
Returns:
|
| 464 |
+
bool: True if keys were stored successfully, False otherwise
|
| 465 |
+
|
| 466 |
+
Raises:
|
| 467 |
+
ValueError: If issuer_id, public_key, or private_key are empty
|
| 468 |
+
Exception: If repository operations fail
|
| 469 |
+
|
| 470 |
+
Example:
|
| 471 |
+
>>> public_key = "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
|
| 472 |
+
>>> private_key = "z3u2MQhLnQw7nvJRGJCdKdqfXHV4N7BLKuEGFWnJqsVSdgYv"
|
| 473 |
+
>>> success = store_issuer_keypair("https://example.edu/issuers/565049", public_key, private_key)
|
| 474 |
+
>>> print(f"Keys stored: {success}")
|
| 475 |
+
"""
|
| 476 |
+
if not issuer_id or not public_key or not private_key:
|
| 477 |
+
logger.error("❌ Missing required parameters: issuer_id, public_key, and private_key are required")
|
| 478 |
+
raise ValueError("issuer_id, public_key, and private_key are required")
|
| 479 |
+
|
| 480 |
+
if not repo_id:
|
| 481 |
+
repo_id = HF_REPO_ID
|
| 482 |
+
logger.debug(f"🔧 Using default repository: {repo_id}")
|
| 483 |
+
|
| 484 |
+
# Sanitize issuer_id for use as folder name
|
| 485 |
+
safe_issuer_id = issuer_id.replace("https://", "").replace("http://", "").replace("/", "_").replace(":", "_")
|
| 486 |
+
logger.info(f"🔑 Storing keypair for issuer: {issuer_id}")
|
| 487 |
+
logger.debug(f"🗂️ Safe issuer ID: {safe_issuer_id}")
|
| 488 |
+
|
| 489 |
+
try:
|
| 490 |
+
# Encrypt the private key before storage
|
| 491 |
+
encrypted_private_key = _encrypt_private_key(private_key)
|
| 492 |
+
logger.debug("🔐 Private key encrypted successfully")
|
| 493 |
+
|
| 494 |
+
# Prepare key data structures
|
| 495 |
+
private_key_data = {
|
| 496 |
+
"issuer_id": issuer_id,
|
| 497 |
+
"encrypted_private_key": encrypted_private_key,
|
| 498 |
+
"key_type": "Ed25519VerificationKey2020",
|
| 499 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 500 |
+
"encryption_method": "basic_xor" # In production, use proper encryption
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
public_key_data = {
|
| 504 |
+
"issuer_id": issuer_id,
|
| 505 |
+
"public_key": public_key,
|
| 506 |
+
"key_type": "Ed25519VerificationKey2020",
|
| 507 |
+
"created_at": datetime.now(timezone.utc).isoformat()
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
logger.info("📤 Uploading private key...")
|
| 511 |
+
# Store private key
|
| 512 |
+
private_key_path = f"keys/issuers/{safe_issuer_id}/private_key.json"
|
| 513 |
+
private_key_success = _upload_json_to_repo(private_key_data, repo_id, private_key_path, "dataset")
|
| 514 |
+
|
| 515 |
+
logger.info("📤 Uploading public key...")
|
| 516 |
+
# Store public key
|
| 517 |
+
public_key_path = f"keys/issuers/{safe_issuer_id}/public_key.json"
|
| 518 |
+
public_key_success = _upload_json_to_repo(public_key_data, repo_id, public_key_path, "dataset")
|
| 519 |
+
|
| 520 |
+
# Update global verification methods registry
|
| 521 |
+
if private_key_success and public_key_success:
|
| 522 |
+
logger.info("📋 Updating verification methods registry...")
|
| 523 |
+
_update_verification_methods_registry(issuer_id, safe_issuer_id, public_key, repo_id)
|
| 524 |
+
logger.info("✅ Keypair stored successfully and registry updated")
|
| 525 |
+
else:
|
| 526 |
+
logger.error("❌ Failed to store one or both keys")
|
| 527 |
+
|
| 528 |
+
return private_key_success and public_key_success
|
| 529 |
+
|
| 530 |
+
except Exception as e:
|
| 531 |
+
logger.error(f"💥 Error storing issuer keypair for {issuer_id}: {e}")
|
| 532 |
+
return False
|
| 533 |
+
|
| 534 |
+
def get_issuer_keypair(issuer_id: str, repo_id: str = None) -> Tuple[Optional[str], Optional[str]]:
|
| 535 |
+
"""
|
| 536 |
+
Retrieve stored cryptographic keys for an issuer from the private Hugging Face repository.
|
| 537 |
+
|
| 538 |
+
**IMPORTANT: This function accesses a PRIVATE Hugging Face repository containing
|
| 539 |
+
encrypted private keys. Ensure proper access control and security measures.**
|
| 540 |
+
|
| 541 |
+
Args:
|
| 542 |
+
issuer_id (str): Unique identifier for the issuer
|
| 543 |
+
repo_id (str, optional): Repository ID. If None, uses HF_REPO_ID from constants.
|
| 544 |
+
|
| 545 |
+
Returns:
|
| 546 |
+
Tuple[Optional[str], Optional[str]]: (public_key, private_key) or (None, None) if not found
|
| 547 |
+
|
| 548 |
+
Raises:
|
| 549 |
+
ValueError: If issuer_id is empty
|
| 550 |
+
Exception: If repository operations fail or decryption fails
|
| 551 |
+
|
| 552 |
+
Example:
|
| 553 |
+
>>> public_key, private_key = get_issuer_keypair("https://example.edu/issuers/565049")
|
| 554 |
+
>>> if public_key and private_key:
|
| 555 |
+
... print("Keys retrieved successfully")
|
| 556 |
+
... else:
|
| 557 |
+
... print("Keys not found")
|
| 558 |
+
"""
|
| 559 |
+
if not issuer_id:
|
| 560 |
+
logger.error("❌ issuer_id is required")
|
| 561 |
+
raise ValueError("issuer_id is required")
|
| 562 |
+
|
| 563 |
+
if not repo_id:
|
| 564 |
+
repo_id = HF_REPO_ID
|
| 565 |
+
logger.debug(f"🔧 Using default repository: {repo_id}")
|
| 566 |
+
|
| 567 |
+
# Sanitize issuer_id for use as folder name
|
| 568 |
+
safe_issuer_id = issuer_id.replace("https://", "").replace("http://", "").replace("/", "_").replace(":", "_")
|
| 569 |
+
logger.info(f"🔍 Retrieving keypair for issuer: {issuer_id}")
|
| 570 |
+
logger.debug(f"🗂️ Safe issuer ID: {safe_issuer_id}")
|
| 571 |
+
|
| 572 |
+
try:
|
| 573 |
+
logger.debug("📥 Retrieving public key...")
|
| 574 |
+
# Retrieve public key
|
| 575 |
+
public_key_path = f"keys/issuers/{safe_issuer_id}/public_key.json"
|
| 576 |
+
public_key_data = _get_json_from_repo(repo_id, public_key_path, "dataset")
|
| 577 |
+
|
| 578 |
+
logger.debug("📥 Retrieving private key...")
|
| 579 |
+
# Retrieve private key
|
| 580 |
+
private_key_path = f"keys/issuers/{safe_issuer_id}/private_key.json"
|
| 581 |
+
private_key_data = _get_json_from_repo(repo_id, private_key_path, "dataset")
|
| 582 |
+
|
| 583 |
+
if not public_key_data or not private_key_data:
|
| 584 |
+
logger.warning(f"⚠️ Keys not found for issuer {issuer_id}")
|
| 585 |
+
return None, None
|
| 586 |
+
|
| 587 |
+
# Extract and decrypt private key
|
| 588 |
+
encrypted_private_key = private_key_data.get("encrypted_private_key")
|
| 589 |
+
if not encrypted_private_key:
|
| 590 |
+
logger.error(f"❌ No encrypted private key found for issuer {issuer_id}")
|
| 591 |
+
return None, None
|
| 592 |
+
|
| 593 |
+
logger.debug("🔓 Decrypting private key...")
|
| 594 |
+
decrypted_private_key = _decrypt_private_key(encrypted_private_key)
|
| 595 |
+
public_key = public_key_data.get("public_key")
|
| 596 |
+
|
| 597 |
+
logger.info(f"✅ Successfully retrieved keypair for issuer {issuer_id}")
|
| 598 |
+
return public_key, decrypted_private_key
|
| 599 |
+
|
| 600 |
+
except Exception as e:
|
| 601 |
+
logger.error(f"💥 Error retrieving issuer keypair for {issuer_id}: {e}")
|
| 602 |
+
return None, None
|
| 603 |
+
|
| 604 |
+
def _update_verification_methods_registry(issuer_id: str, safe_issuer_id: str, public_key: str, repo_id: str):
|
| 605 |
+
"""
|
| 606 |
+
Update the global verification methods registry with new issuer public key.
|
| 607 |
+
|
| 608 |
+
Args:
|
| 609 |
+
issuer_id (str): Original issuer ID
|
| 610 |
+
safe_issuer_id (str): Sanitized issuer ID for file system
|
| 611 |
+
public_key (str): Public key to register
|
| 612 |
+
repo_id (str): Repository ID
|
| 613 |
+
"""
|
| 614 |
+
try:
|
| 615 |
+
registry_path = "keys/global/verification_methods.json"
|
| 616 |
+
registry_data = _get_json_from_repo(repo_id, registry_path, "dataset")
|
| 617 |
+
|
| 618 |
+
if not registry_data:
|
| 619 |
+
registry_data = {"verification_methods": []}
|
| 620 |
+
|
| 621 |
+
# Check if issuer already exists in registry
|
| 622 |
+
existing_entry = None
|
| 623 |
+
for i, method in enumerate(registry_data.get("verification_methods", [])):
|
| 624 |
+
if method.get("issuer_id") == issuer_id:
|
| 625 |
+
existing_entry = i
|
| 626 |
+
break
|
| 627 |
+
|
| 628 |
+
# Create new verification method entry
|
| 629 |
+
verification_method = {
|
| 630 |
+
"issuer_id": issuer_id,
|
| 631 |
+
"safe_issuer_id": safe_issuer_id,
|
| 632 |
+
"public_key": public_key,
|
| 633 |
+
"key_type": "Ed25519VerificationKey2020",
|
| 634 |
+
"updated_at": datetime.now(timezone.utc).isoformat()
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
if existing_entry is not None:
|
| 638 |
+
# Update existing entry
|
| 639 |
+
registry_data["verification_methods"][existing_entry] = verification_method
|
| 640 |
+
logger.info(f"♻️ Updated verification method for issuer {issuer_id}")
|
| 641 |
+
else:
|
| 642 |
+
# Add new entry
|
| 643 |
+
registry_data["verification_methods"].append(verification_method)
|
| 644 |
+
logger.info(f"➕ Added new verification method for issuer {issuer_id}")
|
| 645 |
+
|
| 646 |
+
# Upload updated registry
|
| 647 |
+
_upload_json_to_repo(registry_data, repo_id, registry_path, "dataset")
|
| 648 |
+
logger.info("✅ Verification methods registry updated successfully")
|
| 649 |
+
|
| 650 |
+
except Exception as e:
|
| 651 |
+
logger.error(f"Error updating verification methods registry: {e}")
|
| 652 |
+
|
| 653 |
+
def get_verification_methods_registry(repo_id: str = None) -> Dict[str, Any]:
|
| 654 |
+
"""
|
| 655 |
+
Retrieve the global verification methods registry.
|
| 656 |
+
|
| 657 |
+
Args:
|
| 658 |
+
repo_id (str, optional): Repository ID. If None, uses HF_REPO_ID from constants.
|
| 659 |
+
|
| 660 |
+
Returns:
|
| 661 |
+
Dict[str, Any]: Registry data containing all verification methods
|
| 662 |
+
"""
|
| 663 |
+
if not repo_id:
|
| 664 |
+
repo_id = HF_REPO_ID
|
| 665 |
+
|
| 666 |
+
try:
|
| 667 |
+
registry_path = "keys/global/verification_methods.json"
|
| 668 |
+
registry_data = _get_json_from_repo(repo_id, registry_path, "dataset")
|
| 669 |
+
return registry_data if registry_data else {"verification_methods": []}
|
| 670 |
+
except Exception as e:
|
| 671 |
+
logger.error(f"Error retrieving verification methods registry: {e}")
|
| 672 |
+
return {"verification_methods": []}
|
| 673 |
+
|
| 674 |
+
def list_issuer_ids(repo_id: str = None) -> List[str]:
|
| 675 |
+
"""
|
| 676 |
+
List all issuer IDs that have stored keys in the repository.
|
| 677 |
+
|
| 678 |
+
Args:
|
| 679 |
+
repo_id (str, optional): Repository ID. If None, uses HF_REPO_ID from constants.
|
| 680 |
+
|
| 681 |
+
Returns:
|
| 682 |
+
List[str]: List of issuer IDs
|
| 683 |
+
"""
|
| 684 |
+
if not repo_id:
|
| 685 |
+
repo_id = HF_REPO_ID
|
| 686 |
+
|
| 687 |
+
try:
|
| 688 |
+
registry = get_verification_methods_registry(repo_id)
|
| 689 |
+
return [method["issuer_id"] for method in registry.get("verification_methods", [])]
|
| 690 |
+
except Exception as e:
|
| 691 |
+
logger.error(f"Error listing issuer IDs: {e}")
|
| 692 |
+
return []
|
| 693 |
+
|
| 694 |
+
def _list_repo_folders(repo_id: str, path_prefix: str, repo_type: str = "dataset") -> List[str]:
|
| 695 |
+
"""
|
| 696 |
+
List folder names under a given path in a HuggingFace repository.
|
| 697 |
+
|
| 698 |
+
Args:
|
| 699 |
+
repo_id: The repository ID on Hugging Face
|
| 700 |
+
path_prefix: The path prefix to list folders under (e.g., "leaderboards/daily/2025-01-27")
|
| 701 |
+
repo_type: Repository type ("dataset", "model", "space"). Default is "dataset".
|
| 702 |
+
|
| 703 |
+
Returns:
|
| 704 |
+
List of folder names (not full paths) found under the path_prefix.
|
| 705 |
+
Returns empty list if path not found or on error.
|
| 706 |
+
"""
|
| 707 |
+
try:
|
| 708 |
+
login(token=HF_API_TOKEN)
|
| 709 |
+
api = HfApi()
|
| 710 |
+
|
| 711 |
+
# List all files in the repo under the prefix
|
| 712 |
+
# The list_repo_files returns file paths, so we extract unique folder names
|
| 713 |
+
all_files = api.list_repo_files(
|
| 714 |
+
repo_id=repo_id,
|
| 715 |
+
repo_type=repo_type,
|
| 716 |
+
token=HF_API_TOKEN
|
| 717 |
+
)
|
| 718 |
+
|
| 719 |
+
# Ensure path_prefix ends with /
|
| 720 |
+
if path_prefix and not path_prefix.endswith("/"):
|
| 721 |
+
path_prefix = path_prefix + "/"
|
| 722 |
+
|
| 723 |
+
folders = set()
|
| 724 |
+
for file_path in all_files:
|
| 725 |
+
if file_path.startswith(path_prefix):
|
| 726 |
+
# Get the relative path after the prefix
|
| 727 |
+
relative_path = file_path[len(path_prefix):]
|
| 728 |
+
# Extract the first folder name (before any /)
|
| 729 |
+
if "/" in relative_path:
|
| 730 |
+
folder_name = relative_path.split("/")[0]
|
| 731 |
+
folders.add(folder_name)
|
| 732 |
+
|
| 733 |
+
return sorted(list(folders))
|
| 734 |
+
|
| 735 |
+
except RepositoryNotFoundError:
|
| 736 |
+
logger.warning(f"Repository {repo_id} not found.")
|
| 737 |
+
return []
|
| 738 |
+
except Exception as e:
|
| 739 |
+
logger.error(f"Error listing folders in {repo_id}/{path_prefix}: {e}")
|
| 740 |
+
return []
|
| 741 |
+
|
| 742 |
+
|
| 743 |
+
def _list_repo_files_in_folder(repo_id: str, folder_path: str, repo_type: str = "dataset") -> List[str]:
|
| 744 |
+
"""
|
| 745 |
+
List file names (not full paths) directly under a folder in a HuggingFace repository.
|
| 746 |
+
|
| 747 |
+
Args:
|
| 748 |
+
repo_id: The repository ID on Hugging Face
|
| 749 |
+
folder_path: The folder path to list files under
|
| 750 |
+
repo_type: Repository type. Default is "dataset".
|
| 751 |
+
|
| 752 |
+
Returns:
|
| 753 |
+
List of file names found directly in the folder.
|
| 754 |
+
"""
|
| 755 |
+
try:
|
| 756 |
+
login(token=HF_API_TOKEN)
|
| 757 |
+
api = HfApi()
|
| 758 |
+
|
| 759 |
+
all_files = api.list_repo_files(
|
| 760 |
+
repo_id=repo_id,
|
| 761 |
+
repo_type=repo_type,
|
| 762 |
+
token=HF_API_TOKEN
|
| 763 |
+
)
|
| 764 |
+
|
| 765 |
+
# Ensure folder_path ends with /
|
| 766 |
+
if folder_path and not folder_path.endswith("/"):
|
| 767 |
+
folder_path = folder_path + "/"
|
| 768 |
+
|
| 769 |
+
files = []
|
| 770 |
+
for file_path in all_files:
|
| 771 |
+
if file_path.startswith(folder_path):
|
| 772 |
+
relative_path = file_path[len(folder_path):]
|
| 773 |
+
# Only include files directly in this folder (no subdirectories)
|
| 774 |
+
if "/" not in relative_path and relative_path:
|
| 775 |
+
files.append(relative_path)
|
| 776 |
+
|
| 777 |
+
return sorted(files)
|
| 778 |
+
|
| 779 |
+
except RepositoryNotFoundError:
|
| 780 |
+
logger.warning(f"Repository {repo_id} not found.")
|
| 781 |
+
return []
|
| 782 |
+
except Exception as e:
|
| 783 |
+
logger.error(f"Error listing files in {repo_id}/{folder_path}: {e}")
|
| 784 |
+
return []
|
| 785 |
+
|
| 786 |
+
if __name__ == "__main__":
|
| 787 |
+
issuer_id = "https://example.edu/issuers/565049"
|
| 788 |
+
# Example usage
|
| 789 |
+
public_key, private_key = get_issuer_keypair(issuer_id)
|
| 790 |
+
print(f"Public Key: {public_key}")
|
| 791 |
+
print(f"Private Key: {private_key}")
|
| 792 |
+
|
| 793 |
+
# Example to store keys
|
| 794 |
+
store_issuer_keypair(issuer_id, public_key, private_key)
|
| 795 |
+
|
| 796 |
+
# Example to list issuer IDs
|
| 797 |
+
issuer_ids = list_issuer_ids()
|
| 798 |
+
|
| 799 |
+
print(f"Issuer IDs: {issuer_ids}")
|
battlewords/ui.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
from . import __version__ as version
|
| 3 |
-
from typing import Iterable, Tuple, Optional
|
| 4 |
import streamlit as st
|
| 5 |
import streamlit.components.v1 as components
|
| 6 |
|
|
@@ -20,6 +20,8 @@ from .models import Coord, GameState, Puzzle
|
|
| 20 |
from .word_loader import get_wordlist_files, load_word_list, compute_word_difficulties
|
| 21 |
from .ui_helpers import inject_styles, fig_to_pil_rgba, ocean_background_css, inject_ocean_layers, show_spinner, fade_out_spinner, start_root_fade_in, finish_root_fade_in
|
| 22 |
from .modules.version_info import versions_html
|
|
|
|
|
|
|
| 23 |
|
| 24 |
# --- Spinner context manager for custom spinner ---
|
| 25 |
class CustomSpinner:
|
|
@@ -471,6 +473,37 @@ def _render_grid(show_grid_ticks: bool = True):
|
|
| 471 |
unsafe_allow_html=True,
|
| 472 |
)
|
| 473 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
grid_container = st.container()
|
| 475 |
with grid_container:
|
| 476 |
for r in range(size):
|
|
@@ -1174,6 +1207,69 @@ def _render_game_over(state: GameState):
|
|
| 1174 |
if visible:
|
| 1175 |
_game_over_dialog(state)
|
| 1176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1177 |
def run_app():
|
| 1178 |
start_root_fade_in(0.1)
|
| 1179 |
|
|
@@ -1183,8 +1279,16 @@ def run_app():
|
|
| 1183 |
except Exception:
|
| 1184 |
params = {}
|
| 1185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1186 |
# Handle overlay dismissal
|
| 1187 |
-
if params.get("overlay") == "0":
|
| 1188 |
# Clear param and remember to hide overlay this session
|
| 1189 |
try:
|
| 1190 |
st.query_params.clear()
|
|
@@ -1198,7 +1302,7 @@ def run_app():
|
|
| 1198 |
with CustomSpinner(spinner_placeholder, "Initial Load..."):
|
| 1199 |
st.session_state["initial_page_loaded"] = True
|
| 1200 |
|
| 1201 |
-
# Basic branch:
|
| 1202 |
|
| 1203 |
if st.session_state.get("needs_initialization", True):
|
| 1204 |
spinner_placeholder = st.empty()
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
from . import __version__ as version
|
| 3 |
+
from typing import Iterable, Tuple, Optional, Any
|
| 4 |
import streamlit as st
|
| 5 |
import streamlit.components.v1 as components
|
| 6 |
|
|
|
|
| 20 |
from .word_loader import get_wordlist_files, load_word_list, compute_word_difficulties
|
| 21 |
from .ui_helpers import inject_styles, fig_to_pil_rgba, ocean_background_css, inject_ocean_layers, show_spinner, fade_out_spinner, start_root_fade_in, finish_root_fade_in
|
| 22 |
from .modules.version_info import versions_html
|
| 23 |
+
from .modules.constants import HF_REPO_ID, HALL_OF_LEGENDS_FILE
|
| 24 |
+
from .modules.storage import _get_json_from_repo, _upload_json_to_repo
|
| 25 |
|
| 26 |
# --- Spinner context manager for custom spinner ---
|
| 27 |
class CustomSpinner:
|
|
|
|
| 473 |
unsafe_allow_html=True,
|
| 474 |
)
|
| 475 |
|
| 476 |
+
# Legendary-only Hall of Legends save prompt
|
| 477 |
+
run_uid = f"{getattr(state.puzzle, 'uid', '')}"
|
| 478 |
+
if state.score >= 46:
|
| 479 |
+
st.markdown("### Hall of Legends")
|
| 480 |
+
st.caption("You reached Legendary. Save this run to the Hall of Legends?")
|
| 481 |
+
|
| 482 |
+
if st.button("Save to Hall of Legends", key="save_hall_of_legends"):
|
| 483 |
+
hall_entry = {
|
| 484 |
+
"uid": run_uid,
|
| 485 |
+
"score": int(state.score),
|
| 486 |
+
"tier": "Legendary",
|
| 487 |
+
"puzzle_words": [w.text for w in state.puzzle.words],
|
| 488 |
+
"points_by_word": dict(st.session_state.get("points_by_word", {})),
|
| 489 |
+
"start_time": (state.start_time.isoformat() if state.start_time else ""),
|
| 490 |
+
"end_time": (state.end_time.isoformat() if state.end_time else ""),
|
| 491 |
+
"elapsed_seconds": elapsed_seconds,
|
| 492 |
+
"game_mode": state.game_mode,
|
| 493 |
+
"wordlist": st.session_state.get("selected_wordlist", ""),
|
| 494 |
+
"metadata": {"app_version": version},
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
ok, message = _save_hall_entry(hall_entry)
|
| 498 |
+
st.session_state["hall_save_message"] = message
|
| 499 |
+
st.session_state["hall_saved_uid"] = run_uid if ok else ""
|
| 500 |
+
|
| 501 |
+
if st.session_state.get("hall_saved_uid") == run_uid:
|
| 502 |
+
st.success(st.session_state.get("hall_save_message", "Saved to Hall of Legends."))
|
| 503 |
+
st.markdown("[View Hall of Legends](?page=hall)")
|
| 504 |
+
elif st.session_state.get("hall_save_message"):
|
| 505 |
+
st.info(st.session_state.get("hall_save_message"))
|
| 506 |
+
|
| 507 |
grid_container = st.container()
|
| 508 |
with grid_container:
|
| 509 |
for r in range(size):
|
|
|
|
| 1207 |
if visible:
|
| 1208 |
_game_over_dialog(state)
|
| 1209 |
|
| 1210 |
+
|
| 1211 |
+
def _normalize_query_value(value: Any) -> str:
|
| 1212 |
+
if isinstance(value, list):
|
| 1213 |
+
return str(value[0]) if value else ""
|
| 1214 |
+
if value is None:
|
| 1215 |
+
return ""
|
| 1216 |
+
return str(value)
|
| 1217 |
+
|
| 1218 |
+
|
| 1219 |
+
def _load_hall_entries() -> list[dict[str, Any]]:
|
| 1220 |
+
if not HF_REPO_ID:
|
| 1221 |
+
return []
|
| 1222 |
+
data = _get_json_from_repo(HF_REPO_ID, HALL_OF_LEGENDS_FILE, "dataset")
|
| 1223 |
+
return data if isinstance(data, list) else []
|
| 1224 |
+
|
| 1225 |
+
|
| 1226 |
+
def _save_hall_entry(entry: dict[str, Any]) -> tuple[bool, str]:
|
| 1227 |
+
if not HF_REPO_ID:
|
| 1228 |
+
return False, "HF_REPO_ID is not configured."
|
| 1229 |
+
|
| 1230 |
+
entries = _load_hall_entries()
|
| 1231 |
+
uid = str(entry.get("uid", "")).strip()
|
| 1232 |
+
|
| 1233 |
+
if uid and any(str(e.get("uid", "")) == uid for e in entries):
|
| 1234 |
+
return True, "Already saved to Hall of Legends."
|
| 1235 |
+
|
| 1236 |
+
entries.append(entry)
|
| 1237 |
+
entries.sort(key=lambda x: (-int(x.get("score", 0)), int(x.get("elapsed_seconds", 999999))))
|
| 1238 |
+
|
| 1239 |
+
if _upload_json_to_repo(entries, HF_REPO_ID, HALL_OF_LEGENDS_FILE, "dataset"):
|
| 1240 |
+
return True, "Saved to Hall of Legends."
|
| 1241 |
+
return False, "Unable to save to Hall of Legends."
|
| 1242 |
+
|
| 1243 |
+
|
| 1244 |
+
def _render_hall_page() -> None:
|
| 1245 |
+
st.title("Hall of Legends")
|
| 1246 |
+
st.caption("Legendary runs only (score 46+) • Local game, HF-backed Hall storage")
|
| 1247 |
+
|
| 1248 |
+
entries = [e for e in _load_hall_entries() if int(e.get("score", 0)) >= 46]
|
| 1249 |
+
entries.sort(key=lambda x: (-int(x.get("score", 0)), int(x.get("elapsed_seconds", 999999))))
|
| 1250 |
+
|
| 1251 |
+
if not entries:
|
| 1252 |
+
st.info("No Hall of Legends entries yet.")
|
| 1253 |
+
return
|
| 1254 |
+
|
| 1255 |
+
table_rows = []
|
| 1256 |
+
for idx, entry in enumerate(entries[:50], 1):
|
| 1257 |
+
table_rows.append(
|
| 1258 |
+
{
|
| 1259 |
+
"Rank": idx,
|
| 1260 |
+
"Score": int(entry.get("score", 0)),
|
| 1261 |
+
"Tier": entry.get("tier", "Legendary"),
|
| 1262 |
+
"Time (s)": int(entry.get("elapsed_seconds", 0)),
|
| 1263 |
+
"Wordlist": entry.get("wordlist", ""),
|
| 1264 |
+
"Saved": entry.get("end_time", ""),
|
| 1265 |
+
"UID": entry.get("uid", ""),
|
| 1266 |
+
}
|
| 1267 |
+
)
|
| 1268 |
+
|
| 1269 |
+
st.dataframe(table_rows, width="stretch", hide_index=True)
|
| 1270 |
+
|
| 1271 |
+
st.markdown("[Return to game](?overlay=0)")
|
| 1272 |
+
|
| 1273 |
def run_app():
|
| 1274 |
start_root_fade_in(0.1)
|
| 1275 |
|
|
|
|
| 1279 |
except Exception:
|
| 1280 |
params = {}
|
| 1281 |
|
| 1282 |
+
page = _normalize_query_value(params.get("page"))
|
| 1283 |
+
|
| 1284 |
+
# Hall of Legends page route
|
| 1285 |
+
if page == "hall":
|
| 1286 |
+
_render_hall_page()
|
| 1287 |
+
finish_root_fade_in(0.35)
|
| 1288 |
+
return
|
| 1289 |
+
|
| 1290 |
# Handle overlay dismissal
|
| 1291 |
+
if _normalize_query_value(params.get("overlay")) == "0":
|
| 1292 |
# Clear param and remember to hide overlay this session
|
| 1293 |
try:
|
| 1294 |
st.query_params.clear()
|
|
|
|
| 1302 |
with CustomSpinner(spinner_placeholder, "Initial Load..."):
|
| 1303 |
st.session_state["initial_page_loaded"] = True
|
| 1304 |
|
| 1305 |
+
# Basic branch: game page
|
| 1306 |
|
| 1307 |
if st.session_state.get("needs_initialization", True):
|
| 1308 |
spinner_placeholder = st.empty()
|
specs/basic.mdx
CHANGED
|
@@ -41,9 +41,11 @@ This document defines the “basic” version scope for Battlewords on the `basi
|
|
| 41 |
- Any settings-related UI should be omitted/disabled in this branch.
|
| 42 |
|
| 43 |
### Leaderboard
|
| 44 |
-
- The app **does not
|
| 45 |
-
-
|
| 46 |
-
-
|
|
|
|
|
|
|
| 47 |
|
| 48 |
### AI generation of wordlists
|
| 49 |
- The basic branch does **not** include AI-generated wordlists or HF-model-backed word generation.
|
|
|
|
| 41 |
- Any settings-related UI should be omitted/disabled in this branch.
|
| 42 |
|
| 43 |
### Leaderboard
|
| 44 |
+
- The app **does not require** a full-featured leaderboard. However, a minimal, local-only "Hall of Legends" view is allowed as a small exception to the "no leaderboard" rule.
|
| 45 |
+
- The Hall of Legends MUST only store runs that achieve the Legendary tier (score >= 46).
|
| 46 |
+
- Hall of Legends storage MUST be local-only (no remote sync) and use the JSON Lines schema described in the requirements document.
|
| 47 |
+
- The Hall of Legends page MUST NOT be linked from the main UI in standard basic builds. It may be accessible via an explicit query parameter for developer inspection only (for example `?page=hall`).
|
| 48 |
+
- No sidebar, no footer navigation, and no authentication for Hall of Legends.
|
| 49 |
|
| 50 |
### AI generation of wordlists
|
| 51 |
- The basic branch does **not** include AI-generated wordlists or HF-model-backed word generation.
|
specs/requirements.mdx
CHANGED
|
@@ -70,6 +70,20 @@ This document captures the implementation requirements for the **basic** branch
|
|
| 70 |
- Triggered when all six words are guessed or when all word letters are revealed.
|
| 71 |
- Shows a summary with score and tier.
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
### Scoring tiers
|
| 74 |
- OK: < 34
|
| 75 |
- Good: 34–37
|
|
@@ -94,7 +108,6 @@ This document captures the implementation requirements for the **basic** branch
|
|
| 94 |
## Out of Scope (Basic)
|
| 95 |
- Settings page
|
| 96 |
- Leaderboards
|
| 97 |
-
-
|
| 98 |
-
- PWA support (removed)
|
| 99 |
- Audio
|
| 100 |
- Tests
|
|
|
|
| 70 |
- Triggered when all six words are guessed or when all word letters are revealed.
|
| 71 |
- Shows a summary with score and tier.
|
| 72 |
|
| 73 |
+
### Hall of Legends (persistent saves)
|
| 74 |
+
- When a player finishes a game with a Legendary score (46 or higher) the app may offer to save the run to the "Hall of Legends".
|
| 75 |
+
- Only games with score >= 46 are eligible to be saved.
|
| 76 |
+
- Hall persistence uses existing HF-backed storage patterns (no new local Hall data file).
|
| 77 |
+
- Hall routing MUST be available in development and production via `?page=hall`.
|
| 78 |
+
- Hall JSON MUST remain compatible with Wrdler leaderboard schema so existing loaders/tools can parse it:
|
| 79 |
+
- top-level required keys: `challenge_id`, `entry_type`, `game_mode`, `grid_size`, `puzzle_options`, `users`, `created_at`, `version`, `show_incorrect_guesses`, `enable_free_letters`, `wordlist_source`, `game_title`, `max_display_entries`
|
| 80 |
+
- `entry_type` MUST be `"hall"`
|
| 81 |
+
- `users[*]` required keys: `uid`, `username`, `word_list`, `score`, `time`, `timestamp`
|
| 82 |
+
- `users[*].score` MUST be `>= 46` for every Hall entry
|
| 83 |
+
- `users[*].word_list_difficulty` is optional
|
| 84 |
+
- Hall read behavior: display top runs sorted by score desc then time asc.
|
| 85 |
+
- The basic branch keeps Hall simple: no authentication and no pagination beyond a capped display list.
|
| 86 |
+
|
| 87 |
### Scoring tiers
|
| 88 |
- OK: < 34
|
| 89 |
- Good: 34–37
|
|
|
|
| 108 |
## Out of Scope (Basic)
|
| 109 |
- Settings page
|
| 110 |
- Leaderboards
|
| 111 |
+
- Full daily/weekly leaderboard systems (Hall-only scope)
|
|
|
|
| 112 |
- Audio
|
| 113 |
- Tests
|
specs/specs.mdx
CHANGED
|
@@ -52,6 +52,42 @@ Battlewords is inspired by the classic Battleship game, but uses words instead o
|
|
| 52 |
- Custom loading spinner/overlay for transitions
|
| 53 |
- No sidebar, no settings page, no leaderboards
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
## Word Lists
|
| 56 |
- Local word list files under `battlewords/words/`.
|
| 57 |
- Loaded via `battlewords.word_loader.load_word_list()`.
|
|
|
|
| 52 |
- Custom loading spinner/overlay for transitions
|
| 53 |
- No sidebar, no settings page, no leaderboards
|
| 54 |
|
| 55 |
+
## Hall of Legends (Simplified Leaderboard)
|
| 56 |
+
|
| 57 |
+
- Purpose: a minimal, read-only listing of saved Legendary runs (score >= 46). The Hall of Legends replaces the previous leaderboard concept with a lightweight, local-only view.
|
| 58 |
+
- Accessibility: Hall of Legends MUST be accessible in both development and production via query-param routing using `?page=hall`.
|
| 59 |
+
- Data source: reads and writes the same HF-backed JSON document used for Hall saves (same structure as existing Wrdler leaderboard JSON format).
|
| 60 |
+
- Display fields: rank (by score, then elapsed time), player-run uid, score, tier, puzzle wordlist name, elapsed time, and timestamp. Points-by-word breakdown is optional and shown in an expandable row.
|
| 61 |
+
- Page constraints: no authentication, no pagination (show latest 50 entries), and client-side sorting is allowed.
|
| 62 |
+
- Export: provide a single CSV export button that streams the currently-displayed rows (client-side only).
|
| 63 |
+
|
| 64 |
+
### Hall of Legends JSON format (compatible)
|
| 65 |
+
|
| 66 |
+
Hall entries MUST use the same top-level schema as Wrdler leaderboard JSON files for compatibility:
|
| 67 |
+
|
| 68 |
+
- `challenge_id` (string): Hall group identifier (example: `hall-of-legends/classic-classic-12x12`)
|
| 69 |
+
- `entry_type` (string): MUST be `"hall"`
|
| 70 |
+
- `game_mode` (string)
|
| 71 |
+
- `grid_size` (integer): `12` for Battlewords
|
| 72 |
+
- `puzzle_options` (object): includes `spacer` and `may_overlap`
|
| 73 |
+
- `users` (array): list of saved runs
|
| 74 |
+
- each user object includes:
|
| 75 |
+
- `uid` (string)
|
| 76 |
+
- `username` (string)
|
| 77 |
+
- `word_list` (array of six words)
|
| 78 |
+
- `score` (number, MUST be `>= 46`)
|
| 79 |
+
- `time` (number of seconds)
|
| 80 |
+
- `timestamp` (ISO-8601 string)
|
| 81 |
+
- `word_list_difficulty` (number, optional)
|
| 82 |
+
- `created_at` (ISO-8601 string)
|
| 83 |
+
- `version` (string)
|
| 84 |
+
- `show_incorrect_guesses` (boolean)
|
| 85 |
+
- `enable_free_letters` (boolean)
|
| 86 |
+
- `wordlist_source` (string)
|
| 87 |
+
- `game_title` (string)
|
| 88 |
+
- `max_display_entries` (integer)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
## Word Lists
|
| 92 |
- Local word list files under `battlewords/words/`.
|
| 93 |
- Loaded via `battlewords.word_loader.load_word_list()`.
|