File size: 9,451 Bytes
774fe36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f967ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
774fe36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f967ca
774fe36
 
 
 
 
 
 
 
 
 
 
4f967ca
 
774fe36
 
 
 
4f967ca
774fe36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f967ca
774fe36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f967ca
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
"""
Cloud storage: save images to local remote_images/ and sync with HuggingFace dataset.
"""
import base64
import json
import os
import threading
from datetime import datetime
from pathlib import Path
from typing import Optional

from huggingface_hub import HfApi, snapshot_download

DATA_DIR = Path("remote_images")
INDEX_FILE = DATA_DIR / ".search_index.json"

class CloudStorage:
    def __init__(self):
        self.repo_id = os.environ.get("HF_DATASET_REPO", "")
        self.repo_type = "dataset"
        self.api = HfApi(token=os.environ.get("HF_TOKEN", ""))
        self._counter_date = ""   # current date string
        self._counter_value = 0   # current day's count
        self._lock = threading.Lock()
        DATA_DIR.mkdir(parents=True, exist_ok=True)
        self._init_counter()
        self._search_index = {}  # path -> search_text
        self._load_search_index()

    def safe_path(self, rel_path):
        path = Path(rel_path)
        if path.is_absolute() or len(path.parts) != 2 or ".." in path.parts or path.suffix != ".png":
            raise ValueError("Invalid image path")
        target = (DATA_DIR / path).resolve()
        if not target.is_relative_to(DATA_DIR.resolve()):
            raise ValueError("Invalid image path")
        return target

    def download_user_data(self, destination):
        from huggingface_hub import hf_hub_download
        from huggingface_hub.utils import EntryNotFoundError
        import shutil
        try:
            source = hf_hub_download(self.repo_id, "private/users.dat", repo_type="dataset", token=os.getenv("HF_TOKEN"))
        except EntryNotFoundError:
            return
        shutil.copyfile(source, destination)

    def upload_user_data(self, content):
        self.api.upload_file(path_or_fileobj=content, path_in_repo="private/users.dat", repo_id=self.repo_id, repo_type="dataset")

    def _init_counter(self):
        """Initialize counter from existing files for today."""
        today = datetime.now().strftime("%Y-%m-%d")
        self._counter_date = today
        self._counter_value = self._count_files_for_date(today)

    def _count_files_for_date(self, date_str: str) -> int:
        """Count existing PNG files for a given date, return max index."""
        date_dir = DATA_DIR / date_str
        if not date_dir.exists():
            return 0
        max_idx = 0
        for f in date_dir.glob("*.png"):
            try:
                idx = int(f.stem.split('-')[0])
                max_idx = max(max_idx, idx)
            except (ValueError, IndexError):
                pass
        return max_idx

    def _next_index(self, date_str: str) -> int:
        with self._lock:
            if date_str != self._counter_date:
                # Date changed (past midnight): reset counter
                self._counter_date = date_str
                self._counter_value = self._count_files_for_date(date_str)
            self._counter_value += 1
            return self._counter_value

    def save_image(self, image_b64: str, seed: int) -> Optional[str]:
        """Save image locally and return relative path."""
        if not image_b64:
            return None

        date_str = datetime.now().strftime("%Y-%m-%d")
        date_dir = DATA_DIR / date_str
        date_dir.mkdir(parents=True, exist_ok=True)

        index = self._next_index(date_str)
        filename = f"{index:05d}-{seed}.png"
        filepath = date_dir / filename

        image_bytes = base64.b64decode(image_b64)
        with open(filepath, 'wb') as f:
            f.write(image_bytes)

        rel_path = f"{date_str}/{filename}"
        return rel_path

    def upload_file(self, rel_path: str):
        """Upload a single file to HuggingFace."""
        if not self.repo_id:
            return
        local_path = self.safe_path(rel_path)
        if not local_path.exists():
            return
        try:
            self.api.upload_file(
                path_or_fileobj=str(local_path),
                path_in_repo=rel_path,
                repo_id=self.repo_id,
                repo_type=self.repo_type,
            )
        except Exception as e:
            print(f"[CloudStorage] Upload failed for {rel_path}: {e}")
            return False
        return True

    def delete_file(self, rel_path: str):
        """Delete a file from HuggingFace and locally."""
        # Delete local
        local_path = self.safe_path(rel_path)
        if local_path.exists():
            local_path.unlink()

        # Remove search index entry
        self.remove_search_entry(rel_path)

        # Delete remote
        if not self.repo_id:
            return
        try:
            self.api.delete_file(
                path_in_repo=rel_path,
                repo_id=self.repo_id,
                repo_type=self.repo_type,
            )
        except Exception as e:
            print(f"[CloudStorage] Remote delete failed for {rel_path}: {e}")

    def sync_download(self):
        """Download snapshot from HuggingFace to local."""
        if not self.repo_id:
            return
        try:
            snapshot_download(
                repo_id=self.repo_id,
                repo_type=self.repo_type,
                local_dir=DATA_DIR,
                token=os.environ.get("HF_TOKEN", "")
            )
            # Reinitialize counter from synced files
            self._init_counter()
            self.build_search_index_from_files()
        except Exception as e:
            print(f"[CloudStorage] Sync download failed: {e}")

    def _load_search_index(self):
        if INDEX_FILE.exists():
            try:
                with open(INDEX_FILE) as f:
                    self._search_index = json.load(f)
            except Exception:
                self._search_index = {}

    def _save_search_index(self):
        try:
            with open(INDEX_FILE, 'w') as f:
                json.dump(self._search_index, f)
        except Exception as e:
            print(f"[CloudStorage] Failed to save search index: {e}")

    def set_search_text(self, rel_path: str, search_text: str):
        """Store search text for an image path."""
        self._search_index[rel_path] = search_text.lower()
        self._save_search_index()

    def get_search_text(self, rel_path: str) -> str:
        return self._search_index.get(rel_path, "")

    def search_images(self, query: str, date_filter: str = "") -> list:
        """Search images by query against stored search text."""
        all_images = self.list_images(date_filter)
        if not query.strip():
            return all_images

        terms = query.strip().lower().split()
        results = []
        for img in all_images:
            text = self._search_index.get(img["path"], "")
            if all(t in text for t in terms):
                results.append(img)
        return results

    def remove_search_entry(self, rel_path: str):
        """Remove search index entry for a path."""
        self._search_index.pop(rel_path, None)
        self._save_search_index()

    def build_search_index_from_files(self):
        """Rebuild search index by reading PNG info from all files."""
        from PIL import Image as PILImage

        for date_dir in DATA_DIR.iterdir():
            if not date_dir.is_dir() or date_dir.name.startswith('.'):
                continue
            for f in date_dir.glob("*.png"):
                rel_path = f"{date_dir.name}/{f.name}"
                if rel_path in self._search_index:
                    continue
                try:
                    img = PILImage.open(f)
                    parts = []
                    if hasattr(img, 'text'):
                        for k, v in img.text.items():
                            parts.append(v)
                    self._search_index[rel_path] = " ".join(parts).lower()
                except Exception:
                    pass
        self._save_search_index()

    def list_images(self, date_filter: str = "") -> list:
        """List all images, optionally filtered by date prefix."""
        results = []
        for date_dir in sorted(DATA_DIR.iterdir(), reverse=True):
            if not date_dir.is_dir() or date_dir.name.startswith('.'):
                continue
            if date_filter and date_filter not in date_dir.name:
                continue
            for f in sorted(date_dir.glob("*.png"), reverse=True):
                rel_path = f"{date_dir.name}/{f.name}"
                results.append({
                    "path": rel_path,
                    "name": f.name,
                    "date": date_dir.name,
                    "size": f.stat().st_size,
                    "mtime": f.stat().st_mtime,
                })
        return results

    def get_image_b64(self, rel_path: str) -> Optional[str]:
        """Get base64 of an image by relative path."""
        local_path = self.safe_path(rel_path)
        if not local_path.exists():
            return None
        with open(local_path, 'rb') as f:
            return base64.b64encode(f.read()).decode()

    def list_dates(self) -> list:
        """List all date folders."""
        dates = []
        for d in sorted(DATA_DIR.iterdir(), reverse=True):
            if d.is_dir() and not d.name.startswith('.'):
                count = len(list(d.glob("*.png")))
                if count > 0:
                    dates.append({"date": d.name, "count": count})
        return dates


cloud_storage = CloudStorage()