Spaces:
Running
Running
v0.2.20 - Add Challenge Mode v1.0
Browse files- README.md +15 -4
- app.py +1 -1
- battlewords/__init__.py +2 -2
- battlewords/assets/audio/effects/incorrect_guess.mp3 +2 -2
- battlewords/game_storage.py +474 -0
- battlewords/generator.py +28 -10
- battlewords/{storage.py → local_storage.py} +12 -1
- battlewords/modules/__init__.py +80 -0
- battlewords/modules/constants.py +60 -0
- battlewords/modules/file_utils.py +204 -0
- battlewords/modules/storage.md +227 -0
- battlewords/modules/storage.py +708 -0
- battlewords/ui.py +245 -3
- claude.md +37 -4
- requirements.txt +4 -1
- specs/requirements.md +8 -0
- specs/specs.md +6 -0
- tests/test_download_game_settings.py +52 -0
README.md
CHANGED
|
@@ -23,7 +23,7 @@ BattleWords is a vocabulary learning game inspired by classic Battleship mechani
|
|
| 23 |
|
| 24 |
## Features
|
| 25 |
|
| 26 |
-
-12x12 grid with six hidden words (2x4-letter,2x5-letter,2x6-letter)
|
| 27 |
- Words placed horizontally or vertically
|
| 28 |
- Radar visualization to help locate word boundaries
|
| 29 |
- Reveal grid cells and guess words for points
|
|
@@ -38,6 +38,11 @@ BattleWords is a vocabulary learning game inspired by classic Battleship mechani
|
|
| 38 |
- **Dockerfile-based deployment supported for Hugging Face Spaces and other container platforms**
|
| 39 |
- **Game ends when all words are guessed or all word letters are revealed**
|
| 40 |
- **Incorrect guess history with tooltip and optional display (enabled by default)**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
## Installation
|
| 43 |
1. Clone the repository:
|
|
@@ -93,7 +98,9 @@ docker run -p8501:8501 battlewords
|
|
| 93 |
- `generator.py` – word placement logic
|
| 94 |
- `logic.py` – game mechanics (reveal, guess, scoring)
|
| 95 |
- `ui.py` – Streamlit UI composition
|
| 96 |
-
- `
|
|
|
|
|
|
|
| 97 |
- `words/wordlist.txt` – candidate words
|
| 98 |
- `specs/` – documentation (`specs.md`, `requirements.md`)
|
| 99 |
- `tests/` – unit tests
|
|
@@ -104,6 +111,7 @@ docker run -p8501:8501 battlewords
|
|
| 104 |
2. After revealing a letter, enter a guess for a word in the text box.
|
| 105 |
3. Earn points for correct guesses and bonus points for unrevealed letters.
|
| 106 |
4. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.**
|
|
|
|
| 107 |
|
| 108 |
## Changelog
|
| 109 |
|
|
@@ -111,6 +119,7 @@ docker run -p8501:8501 battlewords
|
|
| 111 |
- Game Sharing: share unique game URLs with friends to challenge them on the same word set.
|
| 112 |
- High Scores: local leaderboard tracking top scores by wordlist and game mode.
|
| 113 |
- Persistent Storage: all game results saved locally for personal statistics without accounts.
|
|
|
|
| 114 |
|
| 115 |
-0.2.20 (development)
|
| 116 |
- Remote Storage game_id:
|
|
@@ -133,7 +142,7 @@ docker run -p8501:8501 battlewords
|
|
| 133 |
- highscores/highscores.json
|
| 134 |
|
| 135 |
Note
|
| 136 |
-
- `battlewords/storage.py` remains local-only storage; a separate HF integration wrapper
|
| 137 |
|
| 138 |
-0.2.19
|
| 139 |
- Fix music and sound effect volume issues
|
|
@@ -431,7 +440,9 @@ Happy gaming and sound designing!
|
|
| 431 |
- `generator.py` – word placement logic
|
| 432 |
- `logic.py` – game mechanics (reveal, guess, scoring)
|
| 433 |
- `ui.py` – Streamlit UI composition
|
| 434 |
-
- `
|
|
|
|
|
|
|
| 435 |
- `words/wordlist.txt` – candidate words
|
| 436 |
- `specs/` – documentation (`specs.md`, `requirements.md`)
|
| 437 |
- `tests/` – unit tests
|
|
|
|
| 23 |
|
| 24 |
## Features
|
| 25 |
|
| 26 |
+
- 12x12 grid with six hidden words (2x4-letter, 2x5-letter, 2x6-letter)
|
| 27 |
- Words placed horizontally or vertically
|
| 28 |
- Radar visualization to help locate word boundaries
|
| 29 |
- Reveal grid cells and guess words for points
|
|
|
|
| 38 |
- **Dockerfile-based deployment supported for Hugging Face Spaces and other container platforms**
|
| 39 |
- **Game ends when all words are guessed or all word letters are revealed**
|
| 40 |
- **Incorrect guess history with tooltip and optional display (enabled by default)**
|
| 41 |
+
- **Challenge Mode:** Shareable game links with leaderboard, remote storage, and multi-user results
|
| 42 |
+
|
| 43 |
+
## Challenge Mode & Leaderboard
|
| 44 |
+
|
| 45 |
+
When playing a shared challenge (via a `game_id` link), the leaderboard displays all submitted results for that challenge. The leaderboard is **sorted by highest score (descending), then by fastest time (ascending)**. This means players with the most points appear at the top, and ties are broken by the shortest completion time.
|
| 46 |
|
| 47 |
## Installation
|
| 48 |
1. Clone the repository:
|
|
|
|
| 98 |
- `generator.py` – word placement logic
|
| 99 |
- `logic.py` – game mechanics (reveal, guess, scoring)
|
| 100 |
- `ui.py` – Streamlit UI composition
|
| 101 |
+
- `game_storage.py` – Hugging Face remote storage integration and challenge sharing
|
| 102 |
+
- `local_storage.py` – local JSON storage for results and high scores
|
| 103 |
+
- `storage.py` – (legacy) local storage and high scores
|
| 104 |
- `words/wordlist.txt` – candidate words
|
| 105 |
- `specs/` – documentation (`specs.md`, `requirements.md`)
|
| 106 |
- `tests/` – unit tests
|
|
|
|
| 111 |
2. After revealing a letter, enter a guess for a word in the text box.
|
| 112 |
3. Earn points for correct guesses and bonus points for unrevealed letters.
|
| 113 |
4. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.**
|
| 114 |
+
5. **To play a shared challenge, use a link with `?game_id=<sid>`. Your result will be added to the challenge leaderboard.**
|
| 115 |
|
| 116 |
## Changelog
|
| 117 |
|
|
|
|
| 119 |
- Game Sharing: share unique game URLs with friends to challenge them on the same word set.
|
| 120 |
- High Scores: local leaderboard tracking top scores by wordlist and game mode.
|
| 121 |
- Persistent Storage: all game results saved locally for personal statistics without accounts.
|
| 122 |
+
- Challenge Mode: remote storage of challenge results, multi-user leaderboard, and shareable links.
|
| 123 |
|
| 124 |
-0.2.20 (development)
|
| 125 |
- Remote Storage game_id:
|
|
|
|
| 142 |
- highscores/highscores.json
|
| 143 |
|
| 144 |
Note
|
| 145 |
+
- `battlewords/storage.py` remains local-only storage; a separate HF integration wrapper is provided as `game_storage.py` for remote challenge mode.
|
| 146 |
|
| 147 |
-0.2.19
|
| 148 |
- Fix music and sound effect volume issues
|
|
|
|
| 440 |
- `generator.py` – word placement logic
|
| 441 |
- `logic.py` – game mechanics (reveal, guess, scoring)
|
| 442 |
- `ui.py` – Streamlit UI composition
|
| 443 |
+
- `game_storage.py` – Hugging Face remote storage integration and challenge sharing
|
| 444 |
+
- `local_storage.py` – local JSON storage for results and high scores
|
| 445 |
+
- `storage.py` – (legacy) local storage and high scores
|
| 446 |
- `words/wordlist.txt` – candidate words
|
| 447 |
- `specs/` – documentation (`specs.md`, `requirements.md`)
|
| 448 |
- `tests/` – unit tests
|
app.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import streamlit as st
|
| 2 |
|
| 3 |
-
from battlewords.ui import run_app
|
| 4 |
|
| 5 |
|
| 6 |
def _new_game() -> None:
|
|
|
|
| 1 |
import streamlit as st
|
| 2 |
|
| 3 |
+
from battlewords.ui import run_app, _init_session
|
| 4 |
|
| 5 |
|
| 6 |
def _new_game() -> None:
|
battlewords/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
| 1 |
-
__version__ = "0.2.
|
| 2 |
-
__all__ = ["models", "generator", "logic", "ui"]
|
|
|
|
| 1 |
+
__version__ = "0.2.20"
|
| 2 |
+
__all__ = ["models", "generator", "logic", "ui", "game_storage"]
|
battlewords/assets/audio/effects/incorrect_guess.mp3
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:07f70499881c735fc284e9a6b17f0f6d383b35b6e06f0c90aa672597110b916c
|
| 3 |
+
size 23449
|
battlewords/game_storage.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file: battlewords/game_storage.py
|
| 2 |
+
"""
|
| 3 |
+
BattleWords-specific storage wrapper for HuggingFace storage operations.
|
| 4 |
+
|
| 5 |
+
This module provides high-level functions for saving and loading BattleWords games
|
| 6 |
+
using the shared storage module from battlewords.modules.
|
| 7 |
+
"""
|
| 8 |
+
__version__ = "0.1.1"
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import tempfile
|
| 12 |
+
import os
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
+
from typing import Dict, Any, List, Optional, Tuple
|
| 15 |
+
import logging
|
| 16 |
+
|
| 17 |
+
from battlewords.modules import (
|
| 18 |
+
upload_files_to_repo,
|
| 19 |
+
gen_full_url,
|
| 20 |
+
HF_REPO_ID,
|
| 21 |
+
SHORTENER_JSON_FILE,
|
| 22 |
+
SPACE_NAME
|
| 23 |
+
)
|
| 24 |
+
from battlewords.modules.storage import _get_json_from_repo
|
| 25 |
+
from battlewords.local_storage import save_json_to_file
|
| 26 |
+
|
| 27 |
+
# Configure logging
|
| 28 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def generate_uid() -> str:
|
| 33 |
+
"""
|
| 34 |
+
Generate a unique identifier for a game.
|
| 35 |
+
|
| 36 |
+
Format: YYYYMMDDTHHMMSSZ-RANDOM
|
| 37 |
+
Example: 20250123T153045Z-A7B9C2
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
str: Unique game identifier
|
| 41 |
+
"""
|
| 42 |
+
import random
|
| 43 |
+
import string
|
| 44 |
+
|
| 45 |
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
| 46 |
+
random_suffix = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
| 47 |
+
return f"{timestamp}-{random_suffix}"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def serialize_game_settings(
|
| 51 |
+
word_list: List[str],
|
| 52 |
+
username: str,
|
| 53 |
+
score: int,
|
| 54 |
+
time_seconds: int,
|
| 55 |
+
game_mode: str,
|
| 56 |
+
grid_size: int = 12,
|
| 57 |
+
spacer: int = 1,
|
| 58 |
+
may_overlap: bool = False,
|
| 59 |
+
wordlist_source: Optional[str] = None,
|
| 60 |
+
challenge_id: Optional[str] = None
|
| 61 |
+
) -> Dict[str, Any]:
|
| 62 |
+
"""
|
| 63 |
+
Serialize game settings into a JSON-compatible dictionary.
|
| 64 |
+
Creates initial structure with one user's result.
|
| 65 |
+
Each user has their own uid and word_list.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
word_list: List of words used in THIS user's game
|
| 69 |
+
username: Player's name
|
| 70 |
+
score: Final score achieved
|
| 71 |
+
time_seconds: Time taken to complete (in seconds)
|
| 72 |
+
game_mode: Game mode ("classic" or "too_easy")
|
| 73 |
+
grid_size: Grid size (default: 12)
|
| 74 |
+
spacer: Word spacing configuration (0-2, default: 1)
|
| 75 |
+
may_overlap: Whether words can overlap (default: False)
|
| 76 |
+
wordlist_source: Source file name (e.g., "classic.txt")
|
| 77 |
+
challenge_id: Optional challenge ID (generated if not provided)
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
dict: Serialized game settings with users array
|
| 81 |
+
"""
|
| 82 |
+
if challenge_id is None:
|
| 83 |
+
challenge_id = generate_uid()
|
| 84 |
+
|
| 85 |
+
# Create user result entry with their own uid and word_list
|
| 86 |
+
user_result = {
|
| 87 |
+
"uid": generate_uid(), # Unique ID for this user's game
|
| 88 |
+
"username": username,
|
| 89 |
+
"word_list": word_list, # Words THIS user played
|
| 90 |
+
"score": score,
|
| 91 |
+
"time": time_seconds,
|
| 92 |
+
"timestamp": datetime.now(timezone.utc).isoformat()
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
settings = {
|
| 96 |
+
"challenge_id": challenge_id, # ID for the challenge itself
|
| 97 |
+
"game_mode": game_mode,
|
| 98 |
+
"grid_size": grid_size,
|
| 99 |
+
"puzzle_options": {
|
| 100 |
+
"spacer": spacer,
|
| 101 |
+
"may_overlap": may_overlap
|
| 102 |
+
},
|
| 103 |
+
"users": [user_result], # Array of user results
|
| 104 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 105 |
+
"version": __version__
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
# Add wordlist_source if provided
|
| 109 |
+
if wordlist_source:
|
| 110 |
+
settings["wordlist_source"] = wordlist_source
|
| 111 |
+
|
| 112 |
+
return settings
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def add_user_result_to_game(
|
| 116 |
+
sid: str,
|
| 117 |
+
username: str,
|
| 118 |
+
word_list: List[str],
|
| 119 |
+
score: int,
|
| 120 |
+
time_seconds: int,
|
| 121 |
+
repo_id: Optional[str] = None
|
| 122 |
+
) -> bool:
|
| 123 |
+
"""
|
| 124 |
+
Add a user's result to an existing shared challenge.
|
| 125 |
+
Each user gets their own uid and word_list.
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
sid: Short ID of the existing challenge
|
| 129 |
+
username: Player's name
|
| 130 |
+
word_list: List of words THIS user played
|
| 131 |
+
score: Score achieved
|
| 132 |
+
time_seconds: Time taken (seconds)
|
| 133 |
+
repo_id: HF repository ID (uses HF_REPO_ID from env if None)
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
bool: True if successfully added, False otherwise
|
| 137 |
+
"""
|
| 138 |
+
if repo_id is None:
|
| 139 |
+
repo_id = HF_REPO_ID
|
| 140 |
+
|
| 141 |
+
logger.info(f"➕ Adding user result to challenge {sid}")
|
| 142 |
+
|
| 143 |
+
try:
|
| 144 |
+
# Load existing game settings
|
| 145 |
+
settings = load_game_from_sid(sid, repo_id)
|
| 146 |
+
if not settings:
|
| 147 |
+
logger.error(f"❌ Challenge not found: {sid}")
|
| 148 |
+
return False
|
| 149 |
+
|
| 150 |
+
# Create new user result with their own uid and word_list
|
| 151 |
+
user_result = {
|
| 152 |
+
"uid": generate_uid(), # Unique ID for this user's game
|
| 153 |
+
"username": username,
|
| 154 |
+
"word_list": word_list, # Words THIS user played
|
| 155 |
+
"score": score,
|
| 156 |
+
"time": time_seconds,
|
| 157 |
+
"timestamp": datetime.now(timezone.utc).isoformat()
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
# Add to users array
|
| 161 |
+
if "users" not in settings:
|
| 162 |
+
settings["users"] = []
|
| 163 |
+
settings["users"].append(user_result)
|
| 164 |
+
|
| 165 |
+
logger.info(f"👥 Now {len(settings['users'])} users in game")
|
| 166 |
+
|
| 167 |
+
# Get the file path from the sid
|
| 168 |
+
status, full_url = gen_full_url(
|
| 169 |
+
short_url=sid,
|
| 170 |
+
repo_id=repo_id,
|
| 171 |
+
json_file=SHORTENER_JSON_FILE
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
if status != "success_retrieved_full" or not full_url:
|
| 175 |
+
logger.error(f"❌ Could not resolve sid: {sid}")
|
| 176 |
+
return False
|
| 177 |
+
|
| 178 |
+
# Extract challenge_id from URL
|
| 179 |
+
url_parts = full_url.split("/resolve/main/")
|
| 180 |
+
if len(url_parts) != 2:
|
| 181 |
+
logger.error(f"❌ Invalid URL format: {full_url}")
|
| 182 |
+
return False
|
| 183 |
+
|
| 184 |
+
file_path = url_parts[1] # e.g., "games/{challenge_id}/settings.json"
|
| 185 |
+
challenge_id = file_path.split("/")[1] # Extract challenge_id
|
| 186 |
+
folder_name = f"games/{challenge_id}"
|
| 187 |
+
|
| 188 |
+
# Save updated settings back to HF
|
| 189 |
+
try:
|
| 190 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 191 |
+
settings_path = save_json_to_file(settings, tmpdir, "settings.json")
|
| 192 |
+
logger.info(f"📤 Updating {folder_name}/settings.json")
|
| 193 |
+
|
| 194 |
+
response = upload_files_to_repo(
|
| 195 |
+
files=[settings_path],
|
| 196 |
+
repo_id=repo_id,
|
| 197 |
+
folder_name=folder_name,
|
| 198 |
+
repo_type="dataset"
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
logger.info(f"✅ User result added for {username}")
|
| 202 |
+
return True
|
| 203 |
+
|
| 204 |
+
except Exception as e:
|
| 205 |
+
logger.error(f"❌ Failed to upload updated settings: {e}")
|
| 206 |
+
return False
|
| 207 |
+
|
| 208 |
+
except Exception as e:
|
| 209 |
+
logger.error(f"❌ Failed to add user result: {e}")
|
| 210 |
+
return False
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def save_game_to_hf(
|
| 214 |
+
word_list: List[str],
|
| 215 |
+
username: str,
|
| 216 |
+
score: int,
|
| 217 |
+
time_seconds: int,
|
| 218 |
+
game_mode: str,
|
| 219 |
+
grid_size: int = 12,
|
| 220 |
+
spacer: int = 1,
|
| 221 |
+
may_overlap: bool = False,
|
| 222 |
+
repo_id: Optional[str] = None,
|
| 223 |
+
wordlist_source: Optional[str] = None
|
| 224 |
+
) -> Tuple[str, Optional[str], Optional[str]]:
|
| 225 |
+
"""
|
| 226 |
+
Save game settings to HuggingFace repository and generate shareable URL.
|
| 227 |
+
Creates a new game entry with the first user's result.
|
| 228 |
+
|
| 229 |
+
This function:
|
| 230 |
+
1. Generates a unique UID for the game
|
| 231 |
+
2. Serializes game settings to JSON with first user
|
| 232 |
+
3. Uploads settings.json to HF repo under games/{uid}/
|
| 233 |
+
4. Creates a shortened URL (sid) for sharing
|
| 234 |
+
5. Returns the full URL and short ID
|
| 235 |
+
|
| 236 |
+
Args:
|
| 237 |
+
word_list: List of words used in the game
|
| 238 |
+
username: Player's name
|
| 239 |
+
score: Final score achieved
|
| 240 |
+
time_seconds: Time taken to complete (in seconds)
|
| 241 |
+
game_mode: Game mode ("classic" or "too_easy")
|
| 242 |
+
grid_size: Grid size (default: 12)
|
| 243 |
+
spacer: Word spacing configuration (0-2, default: 1)
|
| 244 |
+
may_overlap: Whether words can overlap (default: False)
|
| 245 |
+
repo_id: HF repository ID (uses HF_REPO_ID from env if None)
|
| 246 |
+
wordlist_source: Source wordlist file name (e.g., "classic.txt")
|
| 247 |
+
|
| 248 |
+
Returns:
|
| 249 |
+
tuple: (challenge_id, full_url, sid) where:
|
| 250 |
+
- challenge_id: Unique challenge identifier
|
| 251 |
+
- full_url: Full URL to settings.json
|
| 252 |
+
- sid: Shortened ID for sharing (8 characters)
|
| 253 |
+
|
| 254 |
+
Raises:
|
| 255 |
+
Exception: If upload or URL shortening fails
|
| 256 |
+
|
| 257 |
+
Example:
|
| 258 |
+
>>> uid, full_url, sid = save_game_to_hf(
|
| 259 |
+
... word_list=["WORD", "TEST", "GAME", "PLAY", "SCORE", "FINAL"],
|
| 260 |
+
... username="Alice",
|
| 261 |
+
... score=42,
|
| 262 |
+
... time_seconds=180,
|
| 263 |
+
... game_mode="classic",
|
| 264 |
+
... wordlist_source="classic.txt"
|
| 265 |
+
... )
|
| 266 |
+
>>> print(f"Share: https://{SPACE_NAME}/?game_id={sid}")
|
| 267 |
+
"""
|
| 268 |
+
if repo_id is None:
|
| 269 |
+
repo_id = HF_REPO_ID
|
| 270 |
+
|
| 271 |
+
logger.info(f"💾 Saving game to HuggingFace repo: {repo_id}")
|
| 272 |
+
|
| 273 |
+
# Generate challenge ID and serialize settings
|
| 274 |
+
challenge_id = generate_uid()
|
| 275 |
+
settings = serialize_game_settings(
|
| 276 |
+
word_list=word_list,
|
| 277 |
+
username=username,
|
| 278 |
+
score=score,
|
| 279 |
+
time_seconds=time_seconds,
|
| 280 |
+
game_mode=game_mode,
|
| 281 |
+
grid_size=grid_size,
|
| 282 |
+
spacer=spacer,
|
| 283 |
+
may_overlap=may_overlap,
|
| 284 |
+
challenge_id=challenge_id,
|
| 285 |
+
wordlist_source=wordlist_source
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
logger.debug(f"🆔 Generated Challenge ID: {challenge_id}")
|
| 289 |
+
|
| 290 |
+
# Write settings to a temp directory using a fixed filename 'settings.json'
|
| 291 |
+
folder_name = f"games/{challenge_id}"
|
| 292 |
+
try:
|
| 293 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 294 |
+
settings_path = save_json_to_file(settings, tmpdir, "settings.json")
|
| 295 |
+
logger.info(f"📤 Uploading to {folder_name}/settings.json")
|
| 296 |
+
# Upload to HF repo under games/{uid}/settings.json
|
| 297 |
+
response = upload_files_to_repo(
|
| 298 |
+
files=[settings_path],
|
| 299 |
+
repo_id=repo_id,
|
| 300 |
+
folder_name=folder_name,
|
| 301 |
+
repo_type="dataset"
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
# Construct full URL to settings.json
|
| 305 |
+
full_url = f"https://huggingface.co/datasets/{repo_id}/resolve/main/{folder_name}/settings.json"
|
| 306 |
+
logger.info(f"✅ Uploaded: {full_url}")
|
| 307 |
+
|
| 308 |
+
# Generate short URL
|
| 309 |
+
logger.info("🔗 Creating short URL...")
|
| 310 |
+
status, sid = gen_full_url(
|
| 311 |
+
full_url=full_url,
|
| 312 |
+
repo_id=repo_id,
|
| 313 |
+
json_file=SHORTENER_JSON_FILE
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
if status in ["created_short", "success_retrieved_short", "exists_match"]:
|
| 317 |
+
logger.info(f"✅ Short ID created: {sid}")
|
| 318 |
+
share_url = f"https://{SPACE_NAME}/?game_id={sid}"
|
| 319 |
+
logger.info(f"🎮 Share URL: {share_url}")
|
| 320 |
+
return challenge_id, full_url, sid
|
| 321 |
+
else:
|
| 322 |
+
logger.warning(f"⚠️ URL shortening failed: {status}")
|
| 323 |
+
return challenge_id, full_url, None
|
| 324 |
+
|
| 325 |
+
except Exception as e:
|
| 326 |
+
logger.error(f"❌ Failed to save game: {e}")
|
| 327 |
+
raise
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def load_game_from_sid(
|
| 331 |
+
sid: str,
|
| 332 |
+
repo_id: Optional[str] = None
|
| 333 |
+
) -> Optional[Dict[str, Any]]:
|
| 334 |
+
"""
|
| 335 |
+
Load game settings from a short ID (sid).
|
| 336 |
+
If settings.json cannot be found, return None and allow normal game loading.
|
| 337 |
+
|
| 338 |
+
Args:
|
| 339 |
+
sid: Short ID (8 characters) from shareable URL
|
| 340 |
+
repo_id: HF repository ID (uses HF_REPO_ID from env if None)
|
| 341 |
+
|
| 342 |
+
Returns:
|
| 343 |
+
dict | None: Game settings or None if not found
|
| 344 |
+
|
| 345 |
+
dict: Challenge settings containing:
|
| 346 |
+
- challenge_id: Unique challenge identifier
|
| 347 |
+
- wordlist_source: Source wordlist file (e.g., "classic.txt")
|
| 348 |
+
- game_mode: Game mode
|
| 349 |
+
- grid_size: Grid size
|
| 350 |
+
- puzzle_options: Puzzle configuration (spacer, may_overlap)
|
| 351 |
+
- users: Array of user results, each with:
|
| 352 |
+
- uid: Unique user game identifier
|
| 353 |
+
- username: Player name
|
| 354 |
+
- word_list: Words THIS user played
|
| 355 |
+
- score: Score achieved
|
| 356 |
+
- time: Time taken (seconds)
|
| 357 |
+
- timestamp: When result was recorded
|
| 358 |
+
- created_at: When challenge was created
|
| 359 |
+
- version: Storage version
|
| 360 |
+
|
| 361 |
+
Returns None if sid not found or download fails
|
| 362 |
+
|
| 363 |
+
Example:
|
| 364 |
+
>>> settings = load_game_from_sid("abc12345")
|
| 365 |
+
>>> if settings:
|
| 366 |
+
... print(f"Challenge ID: {settings['challenge_id']}")
|
| 367 |
+
... print(f"Wordlist: {settings['wordlist_source']}")
|
| 368 |
+
... for user in settings['users']:
|
| 369 |
+
... print(f"{user['username']}: {user['score']} pts")
|
| 370 |
+
"""
|
| 371 |
+
if repo_id is None:
|
| 372 |
+
repo_id = HF_REPO_ID
|
| 373 |
+
|
| 374 |
+
logger.info(f"🔍 Loading game from sid: {sid}")
|
| 375 |
+
|
| 376 |
+
try:
|
| 377 |
+
# Resolve sid to full URL
|
| 378 |
+
status, full_url = gen_full_url(
|
| 379 |
+
short_url=sid,
|
| 380 |
+
repo_id=repo_id,
|
| 381 |
+
json_file=SHORTENER_JSON_FILE
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
if status != "success_retrieved_full" or not full_url:
|
| 385 |
+
logger.warning(f"⚠️ Could not resolve sid: {sid} (status: {status})")
|
| 386 |
+
return None
|
| 387 |
+
|
| 388 |
+
logger.info(f"✅ Resolved to: {full_url}")
|
| 389 |
+
|
| 390 |
+
# Extract the file path from the full URL
|
| 391 |
+
# URL format: https://huggingface.co/datasets/{repo_id}/resolve/main/{path}
|
| 392 |
+
# We need just the path part: games/{uid}/settings.json
|
| 393 |
+
try:
|
| 394 |
+
url_parts = full_url.split("/resolve/main/")
|
| 395 |
+
if len(url_parts) != 2:
|
| 396 |
+
logger.error(f"❌ Invalid URL format: {full_url}")
|
| 397 |
+
return None
|
| 398 |
+
|
| 399 |
+
file_path = url_parts[1]
|
| 400 |
+
logger.info(f"📥 Downloading {file_path} using authenticated API...")
|
| 401 |
+
|
| 402 |
+
settings = _get_json_from_repo(repo_id, file_path, repo_type="dataset")
|
| 403 |
+
if not settings:
|
| 404 |
+
logger.error(f"❌ settings.json not found for sid: {sid}. Loading normal game.")
|
| 405 |
+
return None
|
| 406 |
+
|
| 407 |
+
logger.info(f"✅ Loaded challenge: {settings.get('challenge_id', 'unknown')}")
|
| 408 |
+
users = settings.get('users', [])
|
| 409 |
+
logger.debug(f"Users in challenge: {len(users)}")
|
| 410 |
+
|
| 411 |
+
return settings
|
| 412 |
+
|
| 413 |
+
except Exception as e:
|
| 414 |
+
logger.error(f"❌ Failed to parse URL or download: {e}")
|
| 415 |
+
return None
|
| 416 |
+
|
| 417 |
+
except Exception as e:
|
| 418 |
+
logger.error(f"❌ Unexpected error loading game: {e}")
|
| 419 |
+
return None
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def get_shareable_url(sid: str) -> str:
|
| 423 |
+
"""
|
| 424 |
+
Generate a shareable URL from a short ID.
|
| 425 |
+
|
| 426 |
+
Args:
|
| 427 |
+
sid: Short ID (8 characters)
|
| 428 |
+
|
| 429 |
+
Returns:
|
| 430 |
+
str: Full shareable URL
|
| 431 |
+
|
| 432 |
+
Example:
|
| 433 |
+
>>> url = get_shareable_url("abc12345")
|
| 434 |
+
>>> print(url)
|
| 435 |
+
https://Surn/BattleWords/?game_id=abc12345
|
| 436 |
+
"""
|
| 437 |
+
return f"https://{SPACE_NAME}/?game_id={sid}"
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
if __name__ == "__main__":
|
| 441 |
+
# Example usage
|
| 442 |
+
print("BattleWords Game Storage Module")
|
| 443 |
+
print(f"Version: {__version__}")
|
| 444 |
+
print(f"Target Repository: {HF_REPO_ID}")
|
| 445 |
+
print(f"Space Name: {SPACE_NAME}")
|
| 446 |
+
|
| 447 |
+
# Example: Save a game
|
| 448 |
+
print("\n--- Example: Save Game ---")
|
| 449 |
+
try:
|
| 450 |
+
challenge_id, full_url, sid = save_game_to_hf(
|
| 451 |
+
word_list=["WORD", "TEST", "GAME", "PLAY", "SCORE", "FINAL"],
|
| 452 |
+
username="Alice",
|
| 453 |
+
score=42,
|
| 454 |
+
time_seconds=180,
|
| 455 |
+
game_mode="classic"
|
| 456 |
+
)
|
| 457 |
+
print(f"Challenge ID: {challenge_id}")
|
| 458 |
+
print(f"Full URL: {full_url}")
|
| 459 |
+
print(f"Short ID: {sid}")
|
| 460 |
+
print(f"Share: {get_shareable_url(sid)}")
|
| 461 |
+
except Exception as e:
|
| 462 |
+
print(f"Error: {e}")
|
| 463 |
+
|
| 464 |
+
# Example: Load a game
|
| 465 |
+
print("\n--- Example: Load Game ---")
|
| 466 |
+
if sid:
|
| 467 |
+
settings = load_game_from_sid(sid)
|
| 468 |
+
if settings:
|
| 469 |
+
print(f"Loaded Challenge: {settings['challenge_id']}")
|
| 470 |
+
print(f"Wordlist Source: {settings.get('wordlist_source', 'N/A')}")
|
| 471 |
+
users = settings.get('users', [])
|
| 472 |
+
print(f"Users: {len(users)}")
|
| 473 |
+
for user in users:
|
| 474 |
+
print(f" - {user['username']}: {user['score']} pts in {user['time']}s")
|
battlewords/generator.py
CHANGED
|
@@ -39,6 +39,8 @@ def generate_puzzle(
|
|
| 39 |
spacer: int = 1,
|
| 40 |
puzzle_id: Optional[str] = None, # NEW
|
| 41 |
_retry: int = 0, # NEW internal for deterministic retries
|
|
|
|
|
|
|
| 42 |
) -> Puzzle:
|
| 43 |
"""
|
| 44 |
Place exactly six words: 2x4, 2x5, 2x6, horizontal or vertical,
|
|
@@ -54,6 +56,8 @@ def generate_puzzle(
|
|
| 54 |
- 0: words may touch
|
| 55 |
- 1: at least 1 blank tile between words (default)
|
| 56 |
- 2: at least 2 blank tiles between words
|
|
|
|
|
|
|
| 57 |
|
| 58 |
Determinism:
|
| 59 |
- If puzzle_id is provided, it's used to derive the RNG seed. Retries use f"{puzzle_id}:{_retry}".
|
|
@@ -71,20 +75,34 @@ def generate_puzzle(
|
|
| 71 |
seed_val = None
|
| 72 |
|
| 73 |
rng = random.Random(seed_val) if seed_val is not None else random.Random()
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
used: set[Coord] = set()
|
| 78 |
used_texts: set[str] = set()
|
| 79 |
placed: List[Word] = []
|
| 80 |
|
| 81 |
-
# Pre-shuffle the word pools for variety but deterministic with seed.
|
| 82 |
-
pools: Dict[int, List[str]] = {}
|
| 83 |
-
for L in (4, 5, 6):
|
| 84 |
-
unique_words = list(dict.fromkeys(words_by_len.get(L, [])))
|
| 85 |
-
rng.shuffle(unique_words)
|
| 86 |
-
pools[L] = unique_words
|
| 87 |
-
|
| 88 |
attempts = 0
|
| 89 |
for L in target_lengths:
|
| 90 |
placed_ok = False
|
|
@@ -140,7 +158,7 @@ def generate_puzzle(
|
|
| 140 |
spacer=spacer,
|
| 141 |
)
|
| 142 |
|
| 143 |
-
puzzle = Puzzle(words=placed, spacer=spacer)
|
| 144 |
try:
|
| 145 |
validate_puzzle(puzzle, grid_size=grid_size)
|
| 146 |
except AssertionError:
|
|
|
|
| 39 |
spacer: int = 1,
|
| 40 |
puzzle_id: Optional[str] = None, # NEW
|
| 41 |
_retry: int = 0, # NEW internal for deterministic retries
|
| 42 |
+
target_words: Optional[List[str]] = None, # NEW: for loading shared games
|
| 43 |
+
may_overlap: bool = False, # NEW: for future crossword-style gameplay
|
| 44 |
) -> Puzzle:
|
| 45 |
"""
|
| 46 |
Place exactly six words: 2x4, 2x5, 2x6, horizontal or vertical,
|
|
|
|
| 56 |
- 0: words may touch
|
| 57 |
- 1: at least 1 blank tile between words (default)
|
| 58 |
- 2: at least 2 blank tiles between words
|
| 59 |
+
- target_words: optional list of exactly 6 words to use (for shared games)
|
| 60 |
+
- may_overlap: whether words can overlap (default False, for future use)
|
| 61 |
|
| 62 |
Determinism:
|
| 63 |
- If puzzle_id is provided, it's used to derive the RNG seed. Retries use f"{puzzle_id}:{_retry}".
|
|
|
|
| 75 |
seed_val = None
|
| 76 |
|
| 77 |
rng = random.Random(seed_val) if seed_val is not None else random.Random()
|
| 78 |
+
|
| 79 |
+
# If target_words is provided, use those specific words
|
| 80 |
+
if target_words:
|
| 81 |
+
if len(target_words) != 6:
|
| 82 |
+
raise ValueError(f"target_words must contain exactly 6 words, got {len(target_words)}")
|
| 83 |
+
# Group target words by length
|
| 84 |
+
pools: Dict[int, List[str]] = {}
|
| 85 |
+
for word in target_words:
|
| 86 |
+
L = len(word)
|
| 87 |
+
if L not in pools:
|
| 88 |
+
pools[L] = []
|
| 89 |
+
pools[L].append(word.upper())
|
| 90 |
+
target_lengths = sorted([len(w) for w in target_words])
|
| 91 |
+
else:
|
| 92 |
+
# Normal random word selection
|
| 93 |
+
words_by_len = words_by_len or load_word_list()
|
| 94 |
+
target_lengths = [4, 4, 5, 5, 6, 6]
|
| 95 |
+
# Pre-shuffle the word pools for variety but deterministic with seed.
|
| 96 |
+
pools: Dict[int, List[str]] = {}
|
| 97 |
+
for L in (4, 5, 6):
|
| 98 |
+
unique_words = list(dict.fromkeys(words_by_len.get(L, [])))
|
| 99 |
+
rng.shuffle(unique_words)
|
| 100 |
+
pools[L] = unique_words
|
| 101 |
|
| 102 |
used: set[Coord] = set()
|
| 103 |
used_texts: set[str] = set()
|
| 104 |
placed: List[Word] = []
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
attempts = 0
|
| 107 |
for L in target_lengths:
|
| 108 |
placed_ok = False
|
|
|
|
| 158 |
spacer=spacer,
|
| 159 |
)
|
| 160 |
|
| 161 |
+
puzzle = Puzzle(words=placed, spacer=spacer, may_overlap=may_overlap)
|
| 162 |
try:
|
| 163 |
validate_puzzle(puzzle, grid_size=grid_size)
|
| 164 |
except AssertionError:
|
battlewords/{storage.py → local_storage.py}
RENAMED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# file: battlewords/
|
| 2 |
"""
|
| 3 |
Storage module for BattleWords game.
|
| 4 |
|
|
@@ -161,6 +161,17 @@ class GameStorage:
|
|
| 161 |
"fastest_time": min(r.elapsed_seconds for r in player_results)
|
| 162 |
}
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
def generate_game_id_from_words(words: List[str]) -> str:
|
| 165 |
import hashlib
|
| 166 |
sorted_words = sorted([w.upper() for w in words])
|
|
|
|
| 1 |
+
# file: battlewords/local_storage.py
|
| 2 |
"""
|
| 3 |
Storage module for BattleWords game.
|
| 4 |
|
|
|
|
| 161 |
"fastest_time": min(r.elapsed_seconds for r in player_results)
|
| 162 |
}
|
| 163 |
|
| 164 |
+
def save_json_to_file(data: dict, directory: str, filename: str = "settings.json") -> str:
|
| 165 |
+
"""
|
| 166 |
+
Save a dictionary as a JSON file with a specified filename in the given directory.
|
| 167 |
+
Returns the full path to the saved file.
|
| 168 |
+
"""
|
| 169 |
+
os.makedirs(directory, exist_ok=True)
|
| 170 |
+
file_path = os.path.join(directory, filename)
|
| 171 |
+
with open(file_path, "w", encoding="utf-8") as f:
|
| 172 |
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
| 173 |
+
return file_path
|
| 174 |
+
|
| 175 |
def generate_game_id_from_words(words: List[str]) -> str:
|
| 176 |
import hashlib
|
| 177 |
sorted_words = sorted([w.upper() for w in words])
|
battlewords/modules/__init__.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# battlewords/modules/__init__.py
|
| 2 |
+
"""
|
| 3 |
+
Shared utility modules for BattleWords.
|
| 4 |
+
|
| 5 |
+
These modules are imported from the OpenBadge project and provide
|
| 6 |
+
reusable functionality for storage, constants, and file utilities.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from .storage import (
|
| 10 |
+
upload_files_to_repo,
|
| 11 |
+
gen_full_url,
|
| 12 |
+
generate_permalink,
|
| 13 |
+
generate_permalink_from_urls,
|
| 14 |
+
store_issuer_keypair,
|
| 15 |
+
get_issuer_keypair,
|
| 16 |
+
get_verification_methods_registry,
|
| 17 |
+
list_issuer_ids
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
from .constants import (
|
| 21 |
+
HF_API_TOKEN,
|
| 22 |
+
HF_REPO_ID,
|
| 23 |
+
SHORTENER_JSON_FILE,
|
| 24 |
+
SPACE_NAME,
|
| 25 |
+
TMPDIR,
|
| 26 |
+
upload_file_types,
|
| 27 |
+
model_extensions,
|
| 28 |
+
image_extensions,
|
| 29 |
+
audio_extensions,
|
| 30 |
+
video_extensions,
|
| 31 |
+
doc_extensions
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
from .file_utils import (
|
| 35 |
+
get_file_parts,
|
| 36 |
+
rename_file_to_lowercase_extension,
|
| 37 |
+
get_filename,
|
| 38 |
+
convert_title_to_filename,
|
| 39 |
+
get_filename_from_filepath,
|
| 40 |
+
delete_file,
|
| 41 |
+
get_unique_file_path,
|
| 42 |
+
download_and_save_image,
|
| 43 |
+
download_and_save_file
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
__all__ = [
|
| 47 |
+
# storage.py
|
| 48 |
+
'upload_files_to_repo',
|
| 49 |
+
'gen_full_url',
|
| 50 |
+
'generate_permalink',
|
| 51 |
+
'generate_permalink_from_urls',
|
| 52 |
+
'store_issuer_keypair',
|
| 53 |
+
'get_issuer_keypair',
|
| 54 |
+
'get_verification_methods_registry',
|
| 55 |
+
'list_issuer_ids',
|
| 56 |
+
|
| 57 |
+
# constants.py
|
| 58 |
+
'HF_API_TOKEN',
|
| 59 |
+
'HF_REPO_ID',
|
| 60 |
+
'SHORTENER_JSON_FILE',
|
| 61 |
+
'SPACE_NAME',
|
| 62 |
+
'TMPDIR',
|
| 63 |
+
'upload_file_types',
|
| 64 |
+
'model_extensions',
|
| 65 |
+
'image_extensions',
|
| 66 |
+
'audio_extensions',
|
| 67 |
+
'video_extensions',
|
| 68 |
+
'doc_extensions',
|
| 69 |
+
|
| 70 |
+
# file_utils.py
|
| 71 |
+
'get_file_parts',
|
| 72 |
+
'rename_file_to_lowercase_extension',
|
| 73 |
+
'get_filename',
|
| 74 |
+
'convert_title_to_filename',
|
| 75 |
+
'get_filename_from_filepath',
|
| 76 |
+
'delete_file',
|
| 77 |
+
'get_unique_file_path',
|
| 78 |
+
'download_and_save_image',
|
| 79 |
+
'download_and_save_file'
|
| 80 |
+
]
|
battlewords/modules/constants.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# battlewords/modules/constants.py
|
| 2 |
+
"""
|
| 3 |
+
Storage-related constants for BattleWords.
|
| 4 |
+
Trimmed version of OpenBadge constants - only includes what's needed for storage.py
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import tempfile
|
| 8 |
+
import logging
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
|
| 12 |
+
# Load environment variables from .env file
|
| 13 |
+
dotenv_path = Path(__file__).parent.parent.parent / '.env'
|
| 14 |
+
load_dotenv(dotenv_path)
|
| 15 |
+
|
| 16 |
+
# Hugging Face Configuration
|
| 17 |
+
HF_API_TOKEN = os.getenv("HF_TOKEN", os.getenv("HF_API_TOKEN", None))
|
| 18 |
+
CRYPTO_PK = os.getenv("CRYPTO_PK", None)
|
| 19 |
+
|
| 20 |
+
# Repository Configuration
|
| 21 |
+
HF_REPO_ID = os.getenv("HF_REPO_ID", "Surn/Storage")
|
| 22 |
+
SPACE_NAME = os.getenv('SPACE_NAME', 'Surn/BattleWords')
|
| 23 |
+
SHORTENER_JSON_FILE = "shortener.json"
|
| 24 |
+
|
| 25 |
+
# Temporary Directory Configuration
|
| 26 |
+
try:
|
| 27 |
+
if os.environ.get('TMPDIR'):
|
| 28 |
+
TMPDIR = os.environ['TMPDIR']
|
| 29 |
+
else:
|
| 30 |
+
TMPDIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tmp')
|
| 31 |
+
except:
|
| 32 |
+
TMPDIR = tempfile.gettempdir()
|
| 33 |
+
|
| 34 |
+
os.makedirs(TMPDIR, exist_ok=True)
|
| 35 |
+
|
| 36 |
+
# File Extension Sets (for storage.py compatibility)
|
| 37 |
+
model_extensions = {".glb", ".gltf", ".obj", ".ply"}
|
| 38 |
+
model_extensions_list = list(model_extensions)
|
| 39 |
+
|
| 40 |
+
image_extensions = {".png", ".jpg", ".jpeg", ".webp"}
|
| 41 |
+
image_extensions_list = list(image_extensions)
|
| 42 |
+
|
| 43 |
+
audio_extensions = {".mp3", ".wav", ".ogg", ".flac"}
|
| 44 |
+
audio_extensions_list = list(audio_extensions)
|
| 45 |
+
|
| 46 |
+
video_extensions = {".mp4"}
|
| 47 |
+
video_extensions_list = list(video_extensions)
|
| 48 |
+
|
| 49 |
+
doc_extensions = {".json"}
|
| 50 |
+
doc_extensions_list = list(doc_extensions)
|
| 51 |
+
|
| 52 |
+
upload_file_types = (
|
| 53 |
+
model_extensions_list +
|
| 54 |
+
image_extensions_list +
|
| 55 |
+
audio_extensions_list +
|
| 56 |
+
video_extensions_list +
|
| 57 |
+
doc_extensions_list
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
logging.getLogger("matplotlib").setLevel(logging.WARNING)
|
battlewords/modules/file_utils.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file_utils
|
| 2 |
+
import os
|
| 3 |
+
import shutil
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import requests
|
| 6 |
+
from PIL import Image
|
| 7 |
+
from io import BytesIO
|
| 8 |
+
from urllib.parse import urlparse
|
| 9 |
+
|
| 10 |
+
def get_file_parts(file_path: str):
|
| 11 |
+
# Split the path into directory and filename
|
| 12 |
+
directory, filename = os.path.split(file_path)
|
| 13 |
+
|
| 14 |
+
# Split the filename into name and extension
|
| 15 |
+
name, ext = os.path.splitext(filename)
|
| 16 |
+
|
| 17 |
+
# Convert the extension to lowercase
|
| 18 |
+
new_ext = ext.lower()
|
| 19 |
+
return directory, filename, name, ext, new_ext
|
| 20 |
+
|
| 21 |
+
def rename_file_to_lowercase_extension(file_path: str) -> str:
|
| 22 |
+
"""
|
| 23 |
+
Renames a file's extension to lowercase in place.
|
| 24 |
+
|
| 25 |
+
Parameters:
|
| 26 |
+
file_path (str): The original file path.
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
str: The new file path with the lowercase extension.
|
| 30 |
+
|
| 31 |
+
Raises:
|
| 32 |
+
OSError: If there is an error renaming the file (e.g., file not found, permissions issue).
|
| 33 |
+
"""
|
| 34 |
+
directory, filename, name, ext, new_ext = get_file_parts(file_path)
|
| 35 |
+
# If the extension changes, rename the file
|
| 36 |
+
if ext != new_ext:
|
| 37 |
+
new_filename = name + new_ext
|
| 38 |
+
new_file_path = os.path.join(directory, new_filename)
|
| 39 |
+
try:
|
| 40 |
+
os.rename(file_path, new_file_path)
|
| 41 |
+
print(f"Rename {file_path} to {new_file_path}\n")
|
| 42 |
+
except Exception as e:
|
| 43 |
+
print(f"os.rename failed: {e}. Falling back to binary copy operation.")
|
| 44 |
+
try:
|
| 45 |
+
# Read the file in binary mode and write it to new_file_path
|
| 46 |
+
with open(file_path, 'rb') as f:
|
| 47 |
+
data = f.read()
|
| 48 |
+
with open(new_file_path, 'wb') as f:
|
| 49 |
+
f.write(data)
|
| 50 |
+
print(f"Copied {file_path} to {new_file_path}\n")
|
| 51 |
+
# Optionally, remove the original file after copying
|
| 52 |
+
#os.remove(file_path)
|
| 53 |
+
except Exception as inner_e:
|
| 54 |
+
print(f"Failed to copy file from {file_path} to {new_file_path}: {inner_e}")
|
| 55 |
+
raise inner_e
|
| 56 |
+
return new_file_path
|
| 57 |
+
else:
|
| 58 |
+
return file_path
|
| 59 |
+
|
| 60 |
+
def get_filename(file):
|
| 61 |
+
# extract filename from file object
|
| 62 |
+
filename = None
|
| 63 |
+
if file is not None:
|
| 64 |
+
filename = file.name
|
| 65 |
+
return filename
|
| 66 |
+
|
| 67 |
+
def convert_title_to_filename(title):
|
| 68 |
+
# convert title to filename
|
| 69 |
+
filename = title.lower().replace(" ", "_").replace("/", "_")
|
| 70 |
+
return filename
|
| 71 |
+
|
| 72 |
+
def get_filename_from_filepath(filepath):
|
| 73 |
+
file_name = os.path.basename(filepath)
|
| 74 |
+
file_base, file_extension = os.path.splitext(file_name)
|
| 75 |
+
return file_base, file_extension
|
| 76 |
+
|
| 77 |
+
def delete_file(file_path: str) -> None:
|
| 78 |
+
"""
|
| 79 |
+
Deletes the specified file.
|
| 80 |
+
|
| 81 |
+
Parameters:
|
| 82 |
+
file_path (str): The path to thefile to delete.
|
| 83 |
+
|
| 84 |
+
Raises:
|
| 85 |
+
FileNotFoundError: If the file does not exist.
|
| 86 |
+
Exception: If there is an error deleting the file.
|
| 87 |
+
"""
|
| 88 |
+
try:
|
| 89 |
+
path = Path(file_path)
|
| 90 |
+
path.unlink()
|
| 91 |
+
print(f"Deleted original file: {file_path}")
|
| 92 |
+
except FileNotFoundError:
|
| 93 |
+
print(f"File not found: {file_path}")
|
| 94 |
+
except Exception as e:
|
| 95 |
+
print(f"Error deleting file: {e}")
|
| 96 |
+
|
| 97 |
+
def get_unique_file_path(directory, filename, file_ext, counter=0):
|
| 98 |
+
"""
|
| 99 |
+
Recursively increments the filename until a unique path is found.
|
| 100 |
+
|
| 101 |
+
Parameters:
|
| 102 |
+
directory (str): The directory for the file.
|
| 103 |
+
filename (str): The base filename.
|
| 104 |
+
file_ext (str): The file extension including the leading dot.
|
| 105 |
+
counter (int): The current counter value to append.
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
str: A unique file path that does not exist.
|
| 109 |
+
"""
|
| 110 |
+
if counter == 0:
|
| 111 |
+
filepath = os.path.join(directory, f"{filename}{file_ext}")
|
| 112 |
+
else:
|
| 113 |
+
filepath = os.path.join(directory, f"{filename}{counter}{file_ext}")
|
| 114 |
+
|
| 115 |
+
if not os.path.exists(filepath):
|
| 116 |
+
return filepath
|
| 117 |
+
else:
|
| 118 |
+
return get_unique_file_path(directory, filename, file_ext, counter + 1)
|
| 119 |
+
|
| 120 |
+
# Example usage:
|
| 121 |
+
# new_file_path = get_unique_file_path(video_dir, title_file_name, video_new_ext)
|
| 122 |
+
|
| 123 |
+
def download_and_save_image(url: str, dst_folder: Path, token: str = None) -> Path:
|
| 124 |
+
"""
|
| 125 |
+
Downloads an image from a URL with authentication if a token is provided,
|
| 126 |
+
verifies it with PIL, and saves it in dst_folder with a unique filename.
|
| 127 |
+
|
| 128 |
+
Args:
|
| 129 |
+
url (str): The image URL.
|
| 130 |
+
dst_folder (Path): The destination folder for the image.
|
| 131 |
+
token (str, optional): A valid Bearer token. If not provided, the HF_API_TOKEN
|
| 132 |
+
environment variable is used if available.
|
| 133 |
+
|
| 134 |
+
Returns:
|
| 135 |
+
Path: The saved image's file path.
|
| 136 |
+
"""
|
| 137 |
+
headers = {}
|
| 138 |
+
# Use provided token; otherwise, fall back to environment variable.
|
| 139 |
+
api_token = token
|
| 140 |
+
if api_token:
|
| 141 |
+
headers["Authorization"] = f"Bearer {api_token}"
|
| 142 |
+
|
| 143 |
+
response = requests.get(url, headers=headers)
|
| 144 |
+
response.raise_for_status()
|
| 145 |
+
pil_image = Image.open(BytesIO(response.content))
|
| 146 |
+
|
| 147 |
+
parsed_url = urlparse(url)
|
| 148 |
+
original_filename = os.path.basename(parsed_url.path) # e.g., "background.png"
|
| 149 |
+
base, ext = os.path.splitext(original_filename)
|
| 150 |
+
|
| 151 |
+
# Use get_unique_file_path from file_utils.py to generate a unique file path.
|
| 152 |
+
unique_filepath_str = get_unique_file_path(str(dst_folder), base, ext)
|
| 153 |
+
dst = Path(unique_filepath_str)
|
| 154 |
+
dst_folder.mkdir(parents=True, exist_ok=True)
|
| 155 |
+
pil_image.save(dst)
|
| 156 |
+
return dst
|
| 157 |
+
|
| 158 |
+
def download_and_save_file(url: str, dst_folder: Path, token: str = None) -> Path:
|
| 159 |
+
"""
|
| 160 |
+
Downloads a binary file (e.g., audio or video) from a URL with authentication if a token is provided,
|
| 161 |
+
and saves it in dst_folder with a unique filename.
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
url (str): The file URL.
|
| 165 |
+
dst_folder (Path): The destination folder for the file.
|
| 166 |
+
token (str, optional): A valid Bearer token.
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
Path: The saved file's path.
|
| 170 |
+
"""
|
| 171 |
+
headers = {}
|
| 172 |
+
if token:
|
| 173 |
+
headers["Authorization"] = f"Bearer {token}"
|
| 174 |
+
|
| 175 |
+
response = requests.get(url, headers=headers)
|
| 176 |
+
response.raise_for_status()
|
| 177 |
+
|
| 178 |
+
parsed_url = urlparse(url)
|
| 179 |
+
original_filename = os.path.basename(parsed_url.path)
|
| 180 |
+
base, ext = os.path.splitext(original_filename)
|
| 181 |
+
|
| 182 |
+
unique_filepath_str = get_unique_file_path(str(dst_folder), base, ext)
|
| 183 |
+
dst = Path(unique_filepath_str)
|
| 184 |
+
dst_folder.mkdir(parents=True, exist_ok=True)
|
| 185 |
+
|
| 186 |
+
with open(dst, "wb") as f:
|
| 187 |
+
f.write(response.content)
|
| 188 |
+
|
| 189 |
+
return dst
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
if __name__ == "__main__":
|
| 193 |
+
# Example usage
|
| 194 |
+
url = "https://example.com/image.png"
|
| 195 |
+
dst_folder = Path("downloads")
|
| 196 |
+
download_and_save_image(url, dst_folder)
|
| 197 |
+
# Example usage for file download
|
| 198 |
+
file_url = "https://example.com/file.mp3"
|
| 199 |
+
downloaded_file = download_and_save_file(file_url, dst_folder)
|
| 200 |
+
print(f"File downloaded to: {downloaded_file}")
|
| 201 |
+
# Example usage for renaming file extension
|
| 202 |
+
file_path = "example.TXT"
|
| 203 |
+
new_file_path = rename_file_to_lowercase_extension(file_path)
|
| 204 |
+
print(f"Renamed file to: {new_file_path}")
|
battlewords/modules/storage.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
- **🔑 Cryptographic key management for Open Badge 3.0 issuers.**
|
| 10 |
+
|
| 11 |
+
## Key Functions
|
| 12 |
+
|
| 13 |
+
### 1. `generate_permalink(valid_files, base_url_external, permalink_viewer_url="surn-3d-viewer.hf.space")`
|
| 14 |
+
- **Purpose:**
|
| 15 |
+
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.
|
| 16 |
+
- **Usage Example:**from modules.storage import generate_permalink
|
| 17 |
+
|
| 18 |
+
valid_files = [
|
| 19 |
+
"models/3d_model.glb",
|
| 20 |
+
"images/model_texture.png",
|
| 21 |
+
"images/model_depth.png"
|
| 22 |
+
]
|
| 23 |
+
base_url_external = "https://huggingface.co/datasets/Surn/Storage/resolve/main/saved_models/my_model"
|
| 24 |
+
permalink = generate_permalink(valid_files, base_url_external)
|
| 25 |
+
if permalink:
|
| 26 |
+
print("Permalink:", permalink)
|
| 27 |
+
### 2. `generate_permalink_from_urls(model_url, hm_url, img_url, permalink_viewer_url="surn-3d-viewer.hf.space")`
|
| 28 |
+
- **Purpose:**
|
| 29 |
+
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.
|
| 30 |
+
- **Usage Example:**from modules.storage import generate_permalink_from_urls
|
| 31 |
+
|
| 32 |
+
model_url = "https://example.com/model.glb"
|
| 33 |
+
hm_url = "https://example.com/heightmap.png"
|
| 34 |
+
img_url = "https://example.com/source.png"
|
| 35 |
+
|
| 36 |
+
permalink = generate_permalink_from_urls(model_url, hm_url, img_url)
|
| 37 |
+
print("Generated Permalink:", permalink)
|
| 38 |
+
### 3. `upload_files_to_repo(files, repo_id, folder_name, create_permalink=False, repo_type="dataset", permalink_viewer_url="surn-3d-viewer.hf.space")`
|
| 39 |
+
- **Purpose:**
|
| 40 |
+
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.
|
| 41 |
+
The function's return type is `Union[Dict[str, Any], List[Tuple[Any, str]]]`.
|
| 42 |
+
- When `create_permalink` is `True` and exactly three valid files (one model and two images) are provided, the function returns a dictionary:{
|
| 43 |
+
"response": <upload_folder_response>,
|
| 44 |
+
"permalink": "<full_permalink_url>",
|
| 45 |
+
"short_permalink": "<shortened_permalink_url_with_sid>"
|
| 46 |
+
} - 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)`.
|
| 47 |
+
- 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).
|
| 48 |
+
- **Usage Example:**
|
| 49 |
+
|
| 50 |
+
**a. Uploading with permalink creation:**from modules.storage import upload_files_to_repo
|
| 51 |
+
|
| 52 |
+
files_for_permalink = [
|
| 53 |
+
"local/path/to/model.glb",
|
| 54 |
+
"local/path/to/heightmap.png",
|
| 55 |
+
"local/path/to/image.png"
|
| 56 |
+
]
|
| 57 |
+
repo_id = "Surn/Storage" # Make sure this is defined, e.g., from constants or environment variables
|
| 58 |
+
folder_name = "my_new_model_with_permalink"
|
| 59 |
+
|
| 60 |
+
upload_result = upload_files_to_repo(
|
| 61 |
+
files_for_permalink,
|
| 62 |
+
repo_id,
|
| 63 |
+
folder_name,
|
| 64 |
+
create_permalink=True
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
if isinstance(upload_result, dict):
|
| 68 |
+
print("Upload Response:", upload_result.get("response"))
|
| 69 |
+
print("Full Permalink:", upload_result.get("permalink"))
|
| 70 |
+
print("Short Permalink:", upload_result.get("short_permalink"))
|
| 71 |
+
elif upload_result: # Check if list is not empty
|
| 72 |
+
print("Upload Response for individual files:")
|
| 73 |
+
for res, link in upload_result:
|
| 74 |
+
print(f" Response: {res}, Link: {link}")
|
| 75 |
+
else:
|
| 76 |
+
print("No files uploaded or error occurred.")
|
| 77 |
+
**b. Uploading without permalink creation (or if conditions for permalink are not met):**from modules.storage import upload_files_to_repo
|
| 78 |
+
|
| 79 |
+
files_individual = [
|
| 80 |
+
"local/path/to/another_model.obj",
|
| 81 |
+
"local/path/to/texture.jpg"
|
| 82 |
+
]
|
| 83 |
+
repo_id = "Surn/Storage"
|
| 84 |
+
folder_name = "my_other_uploads"
|
| 85 |
+
|
| 86 |
+
upload_results_list = upload_files_to_repo(
|
| 87 |
+
files_individual,
|
| 88 |
+
repo_id,
|
| 89 |
+
folder_name,
|
| 90 |
+
create_permalink=False # Or if create_permalink=True but not 1 model & 2 images
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
if upload_results_list: # Will be a list of tuples
|
| 94 |
+
print("Upload results for individual files:")
|
| 95 |
+
for res, link in upload_results_list:
|
| 96 |
+
print(f" Upload Response: {res}, File Link: {link}")
|
| 97 |
+
else:
|
| 98 |
+
print("No files uploaded or error occurred.")
|
| 99 |
+
### 4. URL Shortening Functions: `gen_full_url(...)` and Helpers
|
| 100 |
+
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:
|
| 101 |
+
- **Read:** Look up the full URL using a provided short URL ID.
|
| 102 |
+
- **Create:** Generate a new short URL ID for a full URL if no existing mapping exists.
|
| 103 |
+
- **Update/Conflict Handling:**
|
| 104 |
+
If both short URL ID and full URL are provided, it checks consistency and either confirms or reports a conflict.
|
| 105 |
+
|
| 106 |
+
#### `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")`
|
| 107 |
+
- **Purpose:**
|
| 108 |
+
Based on which parameter is provided, it retrieves or creates a mapping between a short URL ID and a full URL.
|
| 109 |
+
- If only `short_url` (the ID) is given, it returns the corresponding `full_url`.
|
| 110 |
+
- If only `full_url` is given, it looks up an existing `short_url` ID or generates and stores a new one.
|
| 111 |
+
- If both are given, it validates and returns the mapping or an error status.
|
| 112 |
+
- **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).
|
| 113 |
+
- **Usage Examples:**
|
| 114 |
+
|
| 115 |
+
**a. Convert a full URL into a short URL ID:**from modules.storage import gen_full_url
|
| 116 |
+
from modules.constants import HF_REPO_ID, SHORTENER_JSON_FILE # Assuming these are defined
|
| 117 |
+
|
| 118 |
+
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"
|
| 119 |
+
|
| 120 |
+
status, short_id = gen_full_url(
|
| 121 |
+
full_url=full_permalink,
|
| 122 |
+
repo_id=HF_REPO_ID,
|
| 123 |
+
json_file=SHORTENER_JSON_FILE
|
| 124 |
+
)
|
| 125 |
+
print("Status:", status)
|
| 126 |
+
if status == "created_short" or status == "success_retrieved_short":
|
| 127 |
+
print("Shortened URL ID:", short_id)
|
| 128 |
+
# Construct the full short URL for sharing:
|
| 129 |
+
# permalink_viewer_url = "surn-3d-viewer.hf.space" # Or from constants
|
| 130 |
+
# shareable_short_url = f"https://{permalink_viewer_url}/?sid={short_id}"
|
| 131 |
+
# print("Shareable Short URL:", shareable_short_url)
|
| 132 |
+
**b. Retrieve the full URL from a short URL ID:**from modules.storage import gen_full_url
|
| 133 |
+
from modules.constants import HF_REPO_ID, SHORTENER_JSON_FILE # Assuming these are defined
|
| 134 |
+
|
| 135 |
+
short_id_to_lookup = "aBcDeFg1" # Example short URL ID
|
| 136 |
+
|
| 137 |
+
status, retrieved_full_url = gen_full_url(
|
| 138 |
+
short_url=short_id_to_lookup,
|
| 139 |
+
repo_id=HF_REPO_ID,
|
| 140 |
+
json_file=SHORTENER_JSON_FILE
|
| 141 |
+
)
|
| 142 |
+
print("Status:", status)
|
| 143 |
+
if status == "success_retrieved_full":
|
| 144 |
+
print("Retrieved Full URL:", retrieved_full_url)
|
| 145 |
+
## 🔑 Cryptographic Key Management Functions
|
| 146 |
+
|
| 147 |
+
### 5. `store_issuer_keypair(issuer_id, public_key, private_key, repo_id=None)`
|
| 148 |
+
- **Purpose:**
|
| 149 |
+
Securely store cryptographic keys for an issuer in a private Hugging Face repository. Private keys are encrypted before storage.
|
| 150 |
+
- **⚠️ IMPORTANT:** This function requires a PRIVATE Hugging Face repository to ensure the security of stored private keys. Never use this with public repositories.
|
| 151 |
+
- **Storage Structure:**keys/issuers/{issuer_id}/
|
| 152 |
+
├── private_key.json (encrypted)
|
| 153 |
+
└── public_key.json- **Returns:** `bool` - True if keys were stored successfully, False otherwise.
|
| 154 |
+
- **Usage Example:**from modules.storage import store_issuer_keypair
|
| 155 |
+
|
| 156 |
+
# Example Ed25519 keys (multibase encoded)
|
| 157 |
+
issuer_id = "https://example.edu/issuers/565049"
|
| 158 |
+
public_key = "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
|
| 159 |
+
private_key = "z3u2MQhLnQw7nvJRGJCdKdqfXHV4N7BLKuEGFWnJqsVSdgYv"
|
| 160 |
+
|
| 161 |
+
success = store_issuer_keypair(issuer_id, public_key, private_key)
|
| 162 |
+
if success:
|
| 163 |
+
print("Keys stored successfully")
|
| 164 |
+
else:
|
| 165 |
+
print("Failed to store keys")
|
| 166 |
+
### 6. `get_issuer_keypair(issuer_id, repo_id=None)`
|
| 167 |
+
- **Purpose:**
|
| 168 |
+
Retrieve and decrypt stored cryptographic keys for an issuer from the private Hugging Face repository.
|
| 169 |
+
- **⚠️ IMPORTANT:** This function accesses a PRIVATE Hugging Face repository containing encrypted private keys. Ensure proper access control and security measures.
|
| 170 |
+
- **Returns:** `Tuple[Optional[str], Optional[str]]` - (public_key, private_key) or (None, None) if not found.
|
| 171 |
+
- **Usage Example:**from modules.storage import get_issuer_keypair
|
| 172 |
+
|
| 173 |
+
issuer_id = "https://example.edu/issuers/565049"
|
| 174 |
+
public_key, private_key = get_issuer_keypair(issuer_id)
|
| 175 |
+
|
| 176 |
+
if public_key and private_key:
|
| 177 |
+
print("Keys retrieved successfully")
|
| 178 |
+
print(f"Public key: {public_key}")
|
| 179 |
+
# Use private_key for signing operations
|
| 180 |
+
else:
|
| 181 |
+
print("Keys not found or error occurred")
|
| 182 |
+
### 7. `get_verification_methods_registry(repo_id=None)`
|
| 183 |
+
- **Purpose:**
|
| 184 |
+
Retrieve the global verification methods registry containing all registered issuer public keys.
|
| 185 |
+
- **Returns:** `Dict[str, Any]` - Registry data containing all verification methods.
|
| 186 |
+
- **Usage Example:**from modules.storage import get_verification_methods_registry
|
| 187 |
+
|
| 188 |
+
registry = get_verification_methods_registry()
|
| 189 |
+
methods = registry.get("verification_methods", [])
|
| 190 |
+
|
| 191 |
+
for method in methods:
|
| 192 |
+
print(f"Issuer: {method['issuer_id']}")
|
| 193 |
+
print(f"Public Key: {method['public_key']}")
|
| 194 |
+
print(f"Key Type: {method['key_type']}")
|
| 195 |
+
print("---")
|
| 196 |
+
### 8. `list_issuer_ids(repo_id=None)`
|
| 197 |
+
- **Purpose:**
|
| 198 |
+
List all issuer IDs that have stored keys in the repository.
|
| 199 |
+
- **Returns:** `List[str]` - List of issuer IDs.
|
| 200 |
+
- **Usage Example:**from modules.storage import list_issuer_ids
|
| 201 |
+
|
| 202 |
+
issuer_ids = list_issuer_ids()
|
| 203 |
+
print("Registered issuers:")
|
| 204 |
+
for issuer_id in issuer_ids:
|
| 205 |
+
print(f" - {issuer_id}")
|
| 206 |
+
## Notes
|
| 207 |
+
- **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.
|
| 208 |
+
- **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.
|
| 209 |
+
- **🔐 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.
|
| 210 |
+
- **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`.
|
| 211 |
+
- **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.
|
| 212 |
+
- **Temporary Directory:** `upload_files_to_repo` temporarily copies files to a local directory (configured by `TMPDIR` in `modules/constants.py`) before uploading.
|
| 213 |
+
- **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.
|
| 214 |
+
- **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.
|
| 215 |
+
|
| 216 |
+
## 🔒 Security Considerations for Key Management
|
| 217 |
+
|
| 218 |
+
1. **Private Repository Only:** Always use private repositories for key storage to protect cryptographic material.
|
| 219 |
+
2. **Key Sanitization:** Issuer IDs are sanitized for file system compatibility (replacing special characters with underscores).
|
| 220 |
+
3. **Encryption:** Private keys are encrypted before storage. Upgrade to Fernet encryption in production.
|
| 221 |
+
4. **Access Control:** Implement proper authentication and authorization for key access.
|
| 222 |
+
5. **Key Rotation:** Consider implementing key rotation mechanisms for enhanced security.
|
| 223 |
+
6. **Audit Logging:** Monitor key access and usage patterns for security auditing.
|
| 224 |
+
|
| 225 |
+
---
|
| 226 |
+
|
| 227 |
+
This guide provides the essential usage examples for interacting with the storage, URL-shortening, 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,708 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# modules/storage.py
|
| 2 |
+
__version__ = "0.1.5"
|
| 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 |
+
|
| 695 |
+
if __name__ == "__main__":
|
| 696 |
+
issuer_id = "https://example.edu/issuers/565049"
|
| 697 |
+
# Example usage
|
| 698 |
+
public_key, private_key = get_issuer_keypair(issuer_id)
|
| 699 |
+
print(f"Public Key: {public_key}")
|
| 700 |
+
print(f"Private Key: {private_key}")
|
| 701 |
+
|
| 702 |
+
# Example to store keys
|
| 703 |
+
store_issuer_keypair(issuer_id, public_key, private_key)
|
| 704 |
+
|
| 705 |
+
# Example to list issuer IDs
|
| 706 |
+
issuer_ids = list_issuer_ids()
|
| 707 |
+
|
| 708 |
+
print(f"Issuer IDs: {issuer_ids}")
|
battlewords/ui.py
CHANGED
|
@@ -27,6 +27,7 @@ from .audio import (
|
|
| 27 |
_inject_audio_control_sync,
|
| 28 |
play_sound_effect,
|
| 29 |
)
|
|
|
|
| 30 |
|
| 31 |
st.set_page_config(initial_sidebar_state="collapsed")
|
| 32 |
|
|
@@ -363,6 +364,9 @@ def _init_session() -> None:
|
|
| 363 |
return
|
| 364 |
# --- Preserve music settings ---
|
| 365 |
|
|
|
|
|
|
|
|
|
|
| 366 |
# Ensure a default selection exists before creating the puzzle
|
| 367 |
files = get_wordlist_files()
|
| 368 |
if "selected_wordlist" not in st.session_state and files:
|
|
@@ -370,8 +374,34 @@ def _init_session() -> None:
|
|
| 370 |
if "game_mode" not in st.session_state:
|
| 371 |
st.session_state.game_mode = "classic"
|
| 372 |
|
| 373 |
-
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
|
| 376 |
st.session_state.puzzle = puzzle
|
| 377 |
st.session_state.grid_size = 12
|
|
@@ -459,6 +489,76 @@ def _sync_back(state: GameState) -> None:
|
|
| 459 |
|
| 460 |
def _render_header():
|
| 461 |
st.title(f"Battlewords v{version}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
st.subheader("Reveal letters in cells, then guess the words!")
|
| 463 |
# st.markdown(
|
| 464 |
# "- Grid is 12×12 with 6 words (two 4-letter, two 5-letter, two 6-letter).\n"
|
|
@@ -1372,6 +1472,120 @@ def _game_over_content(state: GameState) -> None:
|
|
| 1372 |
height=0,
|
| 1373 |
)
|
| 1374 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1375 |
# Dialog actions
|
| 1376 |
if st.button("Close", key="close_game_over"):
|
| 1377 |
st.session_state["show_gameover_overlay"] = False
|
|
@@ -1428,11 +1642,13 @@ def _render_game_over(state: GameState):
|
|
| 1428 |
_mount_background_audio(False, None, 0.0)
|
| 1429 |
|
| 1430 |
def run_app():
|
| 1431 |
-
# Handle
|
| 1432 |
try:
|
| 1433 |
params = st.query_params
|
| 1434 |
except Exception:
|
| 1435 |
params = {}
|
|
|
|
|
|
|
| 1436 |
if params.get("overlay") == "0":
|
| 1437 |
# Clear param and remember to hide overlay this session
|
| 1438 |
try:
|
|
@@ -1441,6 +1657,32 @@ def run_app():
|
|
| 1441 |
pass
|
| 1442 |
st.session_state["hide_gameover_overlay"] = True
|
| 1443 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1444 |
_init_session()
|
| 1445 |
st.markdown(ocean_background_css, unsafe_allow_html=True)
|
| 1446 |
inject_ocean_layers() # <-- add the animated layers
|
|
|
|
| 27 |
_inject_audio_control_sync,
|
| 28 |
play_sound_effect,
|
| 29 |
)
|
| 30 |
+
from .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url, add_user_result_to_game
|
| 31 |
|
| 32 |
st.set_page_config(initial_sidebar_state="collapsed")
|
| 33 |
|
|
|
|
| 364 |
return
|
| 365 |
# --- Preserve music settings ---
|
| 366 |
|
| 367 |
+
# Check if we're loading a shared game
|
| 368 |
+
shared_settings = st.session_state.get("shared_game_settings")
|
| 369 |
+
|
| 370 |
# Ensure a default selection exists before creating the puzzle
|
| 371 |
files = get_wordlist_files()
|
| 372 |
if "selected_wordlist" not in st.session_state and files:
|
|
|
|
| 374 |
if "game_mode" not in st.session_state:
|
| 375 |
st.session_state.game_mode = "classic"
|
| 376 |
|
| 377 |
+
# Generate puzzle with shared game settings if available
|
| 378 |
+
if shared_settings:
|
| 379 |
+
# Each user gets different random words from the same wordlist source
|
| 380 |
+
wordlist_source = shared_settings.get("wordlist_source", "classic.txt")
|
| 381 |
+
spacer = shared_settings["puzzle_options"].get("spacer", 1)
|
| 382 |
+
may_overlap = shared_settings["puzzle_options"].get("may_overlap", False)
|
| 383 |
+
game_mode = shared_settings.get("game_mode", "classic")
|
| 384 |
+
|
| 385 |
+
# Override selected wordlist to match challenge
|
| 386 |
+
st.session_state.selected_wordlist = wordlist_source
|
| 387 |
+
|
| 388 |
+
# Generate puzzle with random words from the challenge's wordlist
|
| 389 |
+
words = load_word_list(wordlist_source)
|
| 390 |
+
puzzle = generate_puzzle(
|
| 391 |
+
grid_size=12,
|
| 392 |
+
words_by_len=words,
|
| 393 |
+
spacer=spacer,
|
| 394 |
+
may_overlap=may_overlap
|
| 395 |
+
)
|
| 396 |
+
st.session_state.game_mode = game_mode
|
| 397 |
+
st.session_state.spacer = spacer
|
| 398 |
+
|
| 399 |
+
# Users will see leaderboard showing all players' results
|
| 400 |
+
# Each player has their own uid and word_list in the users array
|
| 401 |
+
else:
|
| 402 |
+
# Normal game generation
|
| 403 |
+
words = load_word_list(st.session_state.get("selected_wordlist"))
|
| 404 |
+
puzzle = generate_puzzle(grid_size=12, words_by_len=words)
|
| 405 |
|
| 406 |
st.session_state.puzzle = puzzle
|
| 407 |
st.session_state.grid_size = 12
|
|
|
|
| 489 |
|
| 490 |
def _render_header():
|
| 491 |
st.title(f"Battlewords v{version}")
|
| 492 |
+
|
| 493 |
+
# Show Challenge Mode banner if loading a shared game
|
| 494 |
+
shared_settings = st.session_state.get("shared_game_settings")
|
| 495 |
+
if shared_settings:
|
| 496 |
+
users = shared_settings.get("users", [])
|
| 497 |
+
|
| 498 |
+
if users:
|
| 499 |
+
# Sort users by score (descending), then by time (ascending)
|
| 500 |
+
sorted_users = sorted(users, key=lambda u: (-u["score"], u["time"]))
|
| 501 |
+
best_user = sorted_users[0]
|
| 502 |
+
best_score = best_user["score"]
|
| 503 |
+
best_time = best_user["time"]
|
| 504 |
+
mins, secs = divmod(best_time, 60)
|
| 505 |
+
best_time_str = f"{mins:02d}:{secs:02d}"
|
| 506 |
+
|
| 507 |
+
# Build leaderboard HTML
|
| 508 |
+
leaderboard_rows = []
|
| 509 |
+
for i, user in enumerate(sorted_users[:5], 1): # Top 5
|
| 510 |
+
u_mins, u_secs = divmod(user["time"], 60)
|
| 511 |
+
u_time_str = f"{u_mins:02d}:{u_secs:02d}"
|
| 512 |
+
medal = ["🥇", "🥈", "🥉"][i-1] if i <= 3 else f"{i}."
|
| 513 |
+
leaderboard_rows.append(
|
| 514 |
+
f"<div style='padding:0.25rem; font-size:0.85rem;'>{medal} {user['username']}: {user['score']} pts in {u_time_str}</div>"
|
| 515 |
+
)
|
| 516 |
+
leaderboard_html = "".join(leaderboard_rows)
|
| 517 |
+
|
| 518 |
+
st.markdown(
|
| 519 |
+
f"""
|
| 520 |
+
<div style="
|
| 521 |
+
background: linear-gradient(90deg, #1d64c8 0%, #165ba8 100%);
|
| 522 |
+
color: white;
|
| 523 |
+
padding: 1rem;
|
| 524 |
+
border-radius: 0.5rem;
|
| 525 |
+
margin-bottom: 1rem;
|
| 526 |
+
text-align: center;
|
| 527 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
| 528 |
+
">
|
| 529 |
+
🎯 <strong>CHALLENGE MODE</strong> 🎯<br/>
|
| 530 |
+
<span style="font-size: 0.9rem;">
|
| 531 |
+
Beat the best: <strong>{best_score} points</strong> in <strong>{best_time_str}</strong> by <strong>{best_user['username']}</strong>
|
| 532 |
+
</span>
|
| 533 |
+
<div style="margin-top:0.75rem; border-top: 1px solid rgba(255,255,255,0.3); padding-top:0.5rem;">
|
| 534 |
+
<strong style="font-size:0.9rem;">🏆 Leaderboard</strong>
|
| 535 |
+
{leaderboard_html}
|
| 536 |
+
</div>
|
| 537 |
+
</div>
|
| 538 |
+
""",
|
| 539 |
+
unsafe_allow_html=True
|
| 540 |
+
)
|
| 541 |
+
else:
|
| 542 |
+
st.markdown(
|
| 543 |
+
"""
|
| 544 |
+
<div style="
|
| 545 |
+
background: linear-gradient(90deg, #1d64c8 0%, #165ba8 100%);
|
| 546 |
+
color: white;
|
| 547 |
+
padding: 1rem;
|
| 548 |
+
border-radius: 0.5rem;
|
| 549 |
+
margin-bottom: 1rem;
|
| 550 |
+
text-align: center;
|
| 551 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
| 552 |
+
">
|
| 553 |
+
🎯 <strong>CHALLENGE MODE</strong> 🎯<br/>
|
| 554 |
+
<span style="font-size: 0.9rem;">
|
| 555 |
+
Be the first to complete this challenge!
|
| 556 |
+
</span>
|
| 557 |
+
</div>
|
| 558 |
+
""",
|
| 559 |
+
unsafe_allow_html=True
|
| 560 |
+
)
|
| 561 |
+
|
| 562 |
st.subheader("Reveal letters in cells, then guess the words!")
|
| 563 |
# st.markdown(
|
| 564 |
# "- Grid is 12×12 with 6 words (two 4-letter, two 5-letter, two 6-letter).\n"
|
|
|
|
| 1472 |
height=0,
|
| 1473 |
)
|
| 1474 |
|
| 1475 |
+
# Share Challenge Button
|
| 1476 |
+
st.markdown("---")
|
| 1477 |
+
st.markdown("### 🎮 Share Your Challenge")
|
| 1478 |
+
|
| 1479 |
+
# Check if this is a shared game being completed
|
| 1480 |
+
is_shared_game = st.session_state.get("loaded_game_sid") is not None
|
| 1481 |
+
existing_sid = st.session_state.get("loaded_game_sid")
|
| 1482 |
+
|
| 1483 |
+
# Username input
|
| 1484 |
+
if "player_username" not in st.session_state:
|
| 1485 |
+
st.session_state["player_username"] = ""
|
| 1486 |
+
|
| 1487 |
+
username = st.text_input(
|
| 1488 |
+
"Enter your name (optional)",
|
| 1489 |
+
value=st.session_state.get("player_username", ""),
|
| 1490 |
+
key="username_input",
|
| 1491 |
+
placeholder="Anonymous"
|
| 1492 |
+
)
|
| 1493 |
+
if username:
|
| 1494 |
+
st.session_state["player_username"] = username
|
| 1495 |
+
else:
|
| 1496 |
+
username = "Anonymous"
|
| 1497 |
+
|
| 1498 |
+
# Check if share URL already generated
|
| 1499 |
+
if "share_url" not in st.session_state or st.session_state.get("share_url") is None:
|
| 1500 |
+
button_text = "📊 Submit Your Result" if is_shared_game else "🔗 Generate Share Link"
|
| 1501 |
+
|
| 1502 |
+
if st.button(button_text, key="generate_share_link", use_container_width=True):
|
| 1503 |
+
try:
|
| 1504 |
+
# Extract game data
|
| 1505 |
+
word_list = [w.text for w in state.puzzle.words]
|
| 1506 |
+
spacer = state.puzzle.spacer
|
| 1507 |
+
may_overlap = state.puzzle.may_overlap
|
| 1508 |
+
wordlist_source = st.session_state.get("selected_wordlist", "unknown")
|
| 1509 |
+
|
| 1510 |
+
if is_shared_game and existing_sid:
|
| 1511 |
+
# Add result to existing game
|
| 1512 |
+
success = add_user_result_to_game(
|
| 1513 |
+
sid=existing_sid,
|
| 1514 |
+
username=username,
|
| 1515 |
+
word_list=word_list, # Each user gets different words
|
| 1516 |
+
score=state.score,
|
| 1517 |
+
time_seconds=elapsed_seconds
|
| 1518 |
+
)
|
| 1519 |
+
|
| 1520 |
+
if success:
|
| 1521 |
+
share_url = get_shareable_url(existing_sid)
|
| 1522 |
+
st.session_state["share_url"] = share_url
|
| 1523 |
+
st.session_state["share_sid"] = existing_sid
|
| 1524 |
+
st.success(f"✅ Result submitted for {username}!")
|
| 1525 |
+
st.rerun()
|
| 1526 |
+
else:
|
| 1527 |
+
st.error("Failed to submit result")
|
| 1528 |
+
else:
|
| 1529 |
+
# Create new game
|
| 1530 |
+
challenge_id, full_url, sid = save_game_to_hf(
|
| 1531 |
+
word_list=word_list,
|
| 1532 |
+
username=username,
|
| 1533 |
+
score=state.score,
|
| 1534 |
+
time_seconds=elapsed_seconds,
|
| 1535 |
+
game_mode=state.game_mode,
|
| 1536 |
+
grid_size=state.grid_size,
|
| 1537 |
+
spacer=spacer,
|
| 1538 |
+
may_overlap=may_overlap,
|
| 1539 |
+
wordlist_source=wordlist_source
|
| 1540 |
+
)
|
| 1541 |
+
|
| 1542 |
+
if sid:
|
| 1543 |
+
share_url = get_shareable_url(sid)
|
| 1544 |
+
st.session_state["share_url"] = share_url
|
| 1545 |
+
st.session_state["share_sid"] = sid
|
| 1546 |
+
st.rerun()
|
| 1547 |
+
else:
|
| 1548 |
+
st.error("Failed to generate short URL")
|
| 1549 |
+
|
| 1550 |
+
except Exception as e:
|
| 1551 |
+
st.error(f"Failed to save game: {e}")
|
| 1552 |
+
else:
|
| 1553 |
+
# Display generated share URL
|
| 1554 |
+
share_url = st.session_state["share_url"]
|
| 1555 |
+
st.success("✅ Share link generated!")
|
| 1556 |
+
st.code(share_url, language=None)
|
| 1557 |
+
|
| 1558 |
+
# Copy to clipboard button
|
| 1559 |
+
components.html(
|
| 1560 |
+
f"""
|
| 1561 |
+
<script>
|
| 1562 |
+
function copyToClipboard() {{
|
| 1563 |
+
navigator.clipboard.writeText("{share_url}").then(function() {{
|
| 1564 |
+
alert("Share link copied to clipboard!");
|
| 1565 |
+
}}, function(err) {{
|
| 1566 |
+
console.error('Could not copy text: ', err);
|
| 1567 |
+
}});
|
| 1568 |
+
}}
|
| 1569 |
+
</script>
|
| 1570 |
+
<button onclick="copyToClipboard()" style="
|
| 1571 |
+
width: 100%;
|
| 1572 |
+
padding: 0.5rem 1rem;
|
| 1573 |
+
background: #1d64c8;
|
| 1574 |
+
color: white;
|
| 1575 |
+
border: none;
|
| 1576 |
+
border-radius: 0.5rem;
|
| 1577 |
+
cursor: pointer;
|
| 1578 |
+
font-size: 1rem;
|
| 1579 |
+
font-weight: bold;
|
| 1580 |
+
">
|
| 1581 |
+
📋 Copy Link
|
| 1582 |
+
</button>
|
| 1583 |
+
""",
|
| 1584 |
+
height=60
|
| 1585 |
+
)
|
| 1586 |
+
|
| 1587 |
+
st.markdown("---")
|
| 1588 |
+
|
| 1589 |
# Dialog actions
|
| 1590 |
if st.button("Close", key="close_game_over"):
|
| 1591 |
st.session_state["show_gameover_overlay"] = False
|
|
|
|
| 1642 |
_mount_background_audio(False, None, 0.0)
|
| 1643 |
|
| 1644 |
def run_app():
|
| 1645 |
+
# Handle query params using new API
|
| 1646 |
try:
|
| 1647 |
params = st.query_params
|
| 1648 |
except Exception:
|
| 1649 |
params = {}
|
| 1650 |
+
|
| 1651 |
+
# Handle overlay dismissal
|
| 1652 |
if params.get("overlay") == "0":
|
| 1653 |
# Clear param and remember to hide overlay this session
|
| 1654 |
try:
|
|
|
|
| 1657 |
pass
|
| 1658 |
st.session_state["hide_gameover_overlay"] = True
|
| 1659 |
|
| 1660 |
+
# Handle game_id for loading shared games
|
| 1661 |
+
if "game_id" in params and "shared_game_loaded" not in st.session_state:
|
| 1662 |
+
sid = params.get("game_id")
|
| 1663 |
+
try:
|
| 1664 |
+
settings = load_game_from_sid(sid)
|
| 1665 |
+
if settings:
|
| 1666 |
+
# Store loaded settings and sid for initialization
|
| 1667 |
+
st.session_state["shared_game_settings"] = settings
|
| 1668 |
+
st.session_state["loaded_game_sid"] = sid # Store sid for adding results later
|
| 1669 |
+
st.session_state["shared_game_loaded"] = True
|
| 1670 |
+
|
| 1671 |
+
# Get best score and time from users array
|
| 1672 |
+
users = settings.get("users", [])
|
| 1673 |
+
if users:
|
| 1674 |
+
best_score = max(u["score"] for u in users)
|
| 1675 |
+
best_time = min(u["time"] for u in users)
|
| 1676 |
+
st.info(f"🎯 Loading shared challenge (Best: {best_score} pts in {best_time}s by {len(users)} player(s))")
|
| 1677 |
+
else:
|
| 1678 |
+
st.info(f"🎯 Loading shared challenge")
|
| 1679 |
+
else:
|
| 1680 |
+
st.warning(f"No shared game found for ID: {sid}. Starting a normal game.")
|
| 1681 |
+
st.session_state["shared_game_loaded"] = True # Prevent repeated attempts
|
| 1682 |
+
except Exception as e:
|
| 1683 |
+
st.error(f"❌ Error loading shared game: {e}")
|
| 1684 |
+
st.session_state["shared_game_loaded"] = True # Prevent repeated attempts
|
| 1685 |
+
|
| 1686 |
_init_session()
|
| 1687 |
st.markdown(ocean_background_css, unsafe_allow_html=True)
|
| 1688 |
inject_ocean_layers() # <-- add the animated layers
|
claude.md
CHANGED
|
@@ -52,8 +52,14 @@ battlewords/
|
|
| 52 |
│ ├── audio.py # Background music system
|
| 53 |
│ ├── sounds.py # Sound effects management
|
| 54 |
│ ├── generate_sounds.py # Sound generation utilities
|
| 55 |
-
│ ├──
|
| 56 |
│ ├── version_info.py # Version display
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
│ └── words/ # Word list files
|
| 58 |
│ ├── classic.txt # Default word list
|
| 59 |
│ ├── fourth_grade.txt # Elementary word list
|
|
@@ -154,9 +160,29 @@ battlewords/
|
|
| 154 |
- Maintains 2% margin for tick visibility while ensuring consistent layer alignment
|
| 155 |
|
| 156 |
**In Progress (v0.3.0 on cc-01):**
|
| 157 |
-
-
|
| 158 |
-
-
|
| 159 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
## Data Models
|
| 162 |
|
|
@@ -402,3 +428,10 @@ This file (CLAUDE.md) serves as a **living context document** for AI-assisted de
|
|
| 402 |
|
| 403 |
**Synchronization:**
|
| 404 |
Changes to game mechanics should update specs.md → requirements.md → CLAUDE.md → README.md in that order
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
│ ├── audio.py # Background music system
|
| 53 |
│ ├── sounds.py # Sound effects management
|
| 54 |
│ ├── generate_sounds.py # Sound generation utilities
|
| 55 |
+
│ ├── game_storage.py # HF game storage wrapper (v0.1.0)
|
| 56 |
│ ├── version_info.py # Version display
|
| 57 |
+
│ ├── modules/ # Shared utility modules (from OpenBadge)
|
| 58 |
+
│ │ ├── __init__.py # Module exports
|
| 59 |
+
│ │ ├── storage.py # HuggingFace storage & URL shortener (v0.1.5)
|
| 60 |
+
│ │ ├── storage.md # Storage module documentation
|
| 61 |
+
│ │ ├── constants.py # Storage-related constants (trimmed)
|
| 62 |
+
│ │ └── file_utils.py # File utility functions
|
| 63 |
│ └── words/ # Word list files
|
| 64 |
│ ├── classic.txt # Default word list
|
| 65 |
│ ├── fourth_grade.txt # Elementary word list
|
|
|
|
| 160 |
- Maintains 2% margin for tick visibility while ensuring consistent layer alignment
|
| 161 |
|
| 162 |
**In Progress (v0.3.0 on cc-01):**
|
| 163 |
+
- ✅ Imported storage modules from OpenBadge project:
|
| 164 |
+
- `battlewords/modules/storage.py` (v0.1.5) - HuggingFace storage & URL shortener
|
| 165 |
+
- `battlewords/modules/constants.py` (trimmed) - Storage-related constants
|
| 166 |
+
- `battlewords/modules/file_utils.py` - File utility functions
|
| 167 |
+
- `battlewords/modules/storage.md` - Documentation
|
| 168 |
+
- ✅ Created `battlewords/game_storage.py` (v0.1.0) - BattleWords storage wrapper:
|
| 169 |
+
- `save_game_to_hf()` - Save game to HF repo and generate short URL
|
| 170 |
+
- `load_game_from_sid()` - Load game from short ID
|
| 171 |
+
- `generate_uid()` - Generate unique game identifiers
|
| 172 |
+
- `serialize_game_settings()` - Convert game data to JSON
|
| 173 |
+
- `get_shareable_url()` - Generate shareable URLs
|
| 174 |
+
- ✅ UI integration complete (`battlewords/ui.py`):
|
| 175 |
+
- Query parameter parsing for `?game_id=<sid>` on app load
|
| 176 |
+
- Load shared game settings into session state
|
| 177 |
+
- Challenge Mode banner showing target score and time
|
| 178 |
+
- Share button in game over dialog with "Generate Share Link"
|
| 179 |
+
- Copy-to-clipboard functionality for share URLs
|
| 180 |
+
- Automatic save to HuggingFace on game completion
|
| 181 |
+
- ✅ Generator updates (`battlewords/generator.py`):
|
| 182 |
+
- Added `target_words` parameter for loading specific words
|
| 183 |
+
- Added `may_overlap` parameter (for future crossword mode)
|
| 184 |
+
- Support for shared game replay with same words, different positions
|
| 185 |
+
- ⏳ High score tracking infrastructure (backend ready, UI pending)
|
| 186 |
|
| 187 |
## Data Models
|
| 188 |
|
|
|
|
| 428 |
|
| 429 |
**Synchronization:**
|
| 430 |
Changes to game mechanics should update specs.md → requirements.md → CLAUDE.md → README.md in that order
|
| 431 |
+
|
| 432 |
+
## Challenge Mode & Remote Storage
|
| 433 |
+
|
| 434 |
+
- The app supports a Challenge Mode where games can be shared via a short link (`?game_id=<sid>`).
|
| 435 |
+
- Results are stored in a Hugging Face dataset repo using `game_storage.py`.
|
| 436 |
+
- The leaderboard for a challenge is sorted by highest score (descending), then by fastest time (ascending).
|
| 437 |
+
- Each user result is appended to the challenge's `users` array in the remote JSON.
|
requirements.txt
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
altair
|
| 2 |
pandas
|
|
|
|
|
|
|
| 3 |
streamlit
|
| 4 |
matplotlib
|
| 5 |
numpy
|
|
@@ -9,4 +11,5 @@ flake8
|
|
| 9 |
mypy
|
| 10 |
requests
|
| 11 |
huggingface_hub
|
| 12 |
-
python-dotenv
|
|
|
|
|
|
| 1 |
altair
|
| 2 |
pandas
|
| 3 |
+
typing
|
| 4 |
+
pathlib
|
| 5 |
streamlit
|
| 6 |
matplotlib
|
| 7 |
numpy
|
|
|
|
| 11 |
mypy
|
| 12 |
requests
|
| 13 |
huggingface_hub
|
| 14 |
+
python-dotenv
|
| 15 |
+
google-api-core
|
specs/requirements.md
CHANGED
|
@@ -247,3 +247,11 @@ E) Acceptance
|
|
| 247 |
F) Implementation Notes
|
| 248 |
- Do not replace `battlewords/storage.py` now; introduce a separate integration wrapper (e.g., `battlewords/hf_storage.py`) in the next PR
|
| 249 |
- Consider a private repo for write access; shortener JSON and settings JSONs can be public read
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
F) Implementation Notes
|
| 248 |
- Do not replace `battlewords/storage.py` now; introduce a separate integration wrapper (e.g., `battlewords/hf_storage.py`) in the next PR
|
| 249 |
- Consider a private repo for write access; shortener JSON and settings JSONs can be public read
|
| 250 |
+
|
| 251 |
+
## Challenge Mode & Remote Storage
|
| 252 |
+
|
| 253 |
+
- The app must support remote storage of challenge results using Hugging Face datasets.
|
| 254 |
+
- The leaderboard for a shared challenge (`game_id`) must display all user results.
|
| 255 |
+
- **Sorting:** Results are sorted by highest score (descending), then by fastest time (ascending).
|
| 256 |
+
- Each user result is appended to the challenge's `users` array in the remote JSON.
|
| 257 |
+
- The UI must allow submitting results to an existing challenge or creating a new one.
|
specs/specs.md
CHANGED
|
@@ -113,3 +113,9 @@ UI/UX
|
|
| 113 |
Security/Privacy
|
| 114 |
- Only game configuration and scores are stored; no personal data is required
|
| 115 |
- `game_id` is a short reference; full URL is stored in a repo JSON shortener index
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
Security/Privacy
|
| 114 |
- Only game configuration and scores are stored; no personal data is required
|
| 115 |
- `game_id` is a short reference; full URL is stored in a repo JSON shortener index
|
| 116 |
+
|
| 117 |
+
## Challenge Mode & Leaderboard
|
| 118 |
+
|
| 119 |
+
- When loading a shared challenge via `game_id`, the leaderboard displays all user results for that challenge.
|
| 120 |
+
- **Sorting:** The leaderboard is sorted by highest score (descending), then by fastest time (ascending).
|
| 121 |
+
- Results are stored remotely in a Hugging Face dataset repo and updated via the app.
|
tests/test_download_game_settings.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file: tests/test_download_game_settings.py
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
# Ensure the modules path is available
|
| 7 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 8 |
+
|
| 9 |
+
from battlewords.modules.storage import gen_full_url, _get_json_from_repo, HF_REPO_ID, SHORTENER_JSON_FILE
|
| 10 |
+
|
| 11 |
+
def test_download_settings_by_short_id_handles_both(capsys):
|
| 12 |
+
# Use a fixed short id for testing
|
| 13 |
+
short_id = "hDjsB_dl1"
|
| 14 |
+
|
| 15 |
+
# Step 1: Resolve short ID to full URL
|
| 16 |
+
status, full_url = gen_full_url(
|
| 17 |
+
short_url=short_id,
|
| 18 |
+
repo_id=HF_REPO_ID,
|
| 19 |
+
json_file=SHORTENER_JSON_FILE
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
# Failure branch: provide a helpful message and assert expected failure shape
|
| 23 |
+
if status != "success_retrieved_full" or not full_url:
|
| 24 |
+
print(
|
| 25 |
+
f"Could not resolve short id '{short_id}'. "
|
| 26 |
+
f"Status: {status}. "
|
| 27 |
+
f"Check repo '{HF_REPO_ID}' and mapping file '{SHORTENER_JSON_FILE}'."
|
| 28 |
+
)
|
| 29 |
+
captured = capsys.readouterr()
|
| 30 |
+
assert "Could not resolve short id" in captured.out
|
| 31 |
+
# Ensure failure shape is consistent
|
| 32 |
+
assert not full_url, "full_url should be empty/None on failure"
|
| 33 |
+
return
|
| 34 |
+
|
| 35 |
+
# Success branch
|
| 36 |
+
assert status == "success_retrieved_full", f"Failed to resolve short ID: {status}"
|
| 37 |
+
assert full_url, "No full URL returned"
|
| 38 |
+
|
| 39 |
+
# Step 2: Extract file path from full URL
|
| 40 |
+
url_parts = full_url.split("/resolve/main/")
|
| 41 |
+
assert len(url_parts) == 2, f"Invalid full URL format: {full_url}"
|
| 42 |
+
file_path = url_parts[1]
|
| 43 |
+
|
| 44 |
+
# Step 3: Download and parse settings.json
|
| 45 |
+
settings = _get_json_from_repo(HF_REPO_ID, file_path, repo_type="dataset")
|
| 46 |
+
assert settings, "Failed to download or parse settings.json"
|
| 47 |
+
|
| 48 |
+
print("Downloaded settings.json:", settings)
|
| 49 |
+
# Optionally, add more assertions about the settings structure
|
| 50 |
+
assert "uid" in settings
|
| 51 |
+
assert "word_list" in settings
|
| 52 |
+
assert "score" in settings
|