Spaces:
Running
Running
Update src/services/chart_browser_engine.py
Browse files- src/services/chart_browser_engine.py +209 -34
src/services/chart_browser_engine.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import json
|
| 2 |
import os
|
| 3 |
import re
|
|
@@ -11,15 +12,16 @@ from google.auth.transport.requests import Request as GoogleAuthRequest
|
|
| 11 |
from google.oauth2.credentials import Credentials
|
| 12 |
from googleapiclient.discovery import build
|
| 13 |
from googleapiclient.errors import HttpError
|
| 14 |
-
from googleapiclient.http import MediaIoBaseDownload
|
| 15 |
from PIL import Image, ImageOps
|
| 16 |
|
| 17 |
from ..config import update_user_keys, get_user_keys, firestore
|
| 18 |
from ..state import get_user_temp_dir
|
| 19 |
from .journal_engine import FOLDER_MIME
|
| 20 |
|
| 21 |
-
|
| 22 |
FULL_DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive'
|
|
|
|
| 23 |
|
| 24 |
TOKEN_FIELD = "google_browser_token_json"
|
| 25 |
LAST_FOLDER_FIELD = "chart_browser_last_folder"
|
|
@@ -40,18 +42,39 @@ ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
|
| 40 |
|
| 41 |
MAX_IMAGES = 1000
|
| 42 |
MAX_FILE_BYTES = 30 * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
MAX_CACHE_BYTES = 300 * 1024 * 1024
|
| 44 |
CACHE_TTL_SECONDS = 48 * 60 * 60
|
| 45 |
DOWNLOAD_CHUNK = 4 * 1024 * 1024
|
| 46 |
|
| 47 |
PREVIEW_MAX_PX = 1600
|
| 48 |
PREVIEW_QUALITY = 78
|
| 49 |
-
PREVIEW_MIN_BYTES = 250 * 1024
|
| 50 |
-
PREVIEW_TAG = "~p"
|
| 51 |
|
| 52 |
-
# Drive's orderBy for each sort.
|
| 53 |
-
# in a folder over MAX_IMAGES, the cut-off drops the oldest files and never
|
| 54 |
-
# the latest ones. The final order is applied in list_images().
|
| 55 |
SORT_ORDERS = {
|
| 56 |
"name": "name_natural",
|
| 57 |
"newest": "modifiedTime desc,name_natural desc",
|
|
@@ -85,6 +108,10 @@ class TooLarge(Exception):
|
|
| 85 |
pass
|
| 86 |
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
def valid_id(value) -> bool:
|
| 89 |
return isinstance(value, str) and bool(ID_RE.match(value))
|
| 90 |
|
|
@@ -107,7 +134,20 @@ class ChartBrowserEngine:
|
|
| 107 |
if not creds:
|
| 108 |
return False
|
| 109 |
scopes = set(getattr(creds, 'scopes', None) or [])
|
| 110 |
-
return bool(scopes & {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
@staticmethod
|
| 113 |
def get_creds(uid, user_data=None):
|
|
@@ -248,37 +288,172 @@ class ChartBrowserEngine:
|
|
| 248 |
return folders
|
| 249 |
|
| 250 |
@staticmethod
|
| 251 |
-
def
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
"size": size,
|
| 271 |
-
"modified": f.get('modifiedTime'),
|
| 272 |
-
"width": meta.get('width'),
|
| 273 |
-
"height": meta.get('height'),
|
| 274 |
-
"link": f.get('webViewLink'),
|
| 275 |
-
})
|
| 276 |
if sort in ("oldest", "newest"):
|
| 277 |
# Sort here as well so the order never depends on Drive's tie
|
| 278 |
# handling: by modified time, then name for identical timestamps.
|
| 279 |
images.sort(key=lambda i: (i.get("modified") or "", i["name"].lower()),
|
| 280 |
reverse=(sort == "newest"))
|
| 281 |
-
return images
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
# --- Temp cache ---
|
| 284 |
@staticmethod
|
|
|
|
| 1 |
+
import io
|
| 2 |
import json
|
| 3 |
import os
|
| 4 |
import re
|
|
|
|
| 12 |
from google.oauth2.credentials import Credentials
|
| 13 |
from googleapiclient.discovery import build
|
| 14 |
from googleapiclient.errors import HttpError
|
| 15 |
+
from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload
|
| 16 |
from PIL import Image, ImageOps
|
| 17 |
|
| 18 |
from ..config import update_user_keys, get_user_keys, firestore
|
| 19 |
from ..state import get_user_temp_dir
|
| 20 |
from .journal_engine import FOLDER_MIME
|
| 21 |
|
| 22 |
+
READONLY_SCOPE = 'https://www.googleapis.com/auth/drive.readonly'
|
| 23 |
FULL_DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive'
|
| 24 |
+
BROWSER_SCOPES = [FULL_DRIVE_SCOPE]
|
| 25 |
|
| 26 |
TOKEN_FIELD = "google_browser_token_json"
|
| 27 |
LAST_FOLDER_FIELD = "chart_browser_last_folder"
|
|
|
|
| 42 |
|
| 43 |
MAX_IMAGES = 1000
|
| 44 |
MAX_FILE_BYTES = 30 * 1024 * 1024
|
| 45 |
+
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
| 46 |
+
MAX_NAME_CHARS = 120
|
| 47 |
+
RESUMABLE_ABOVE = 5 * 1024 * 1024
|
| 48 |
+
|
| 49 |
+
# PIL format name -> stored mime.
|
| 50 |
+
PIL_FORMAT_MIME = {
|
| 51 |
+
"JPEG": "image/jpeg",
|
| 52 |
+
"PNG": "image/png",
|
| 53 |
+
"WEBP": "image/webp",
|
| 54 |
+
"GIF": "image/gif",
|
| 55 |
+
"BMP": "image/bmp",
|
| 56 |
+
"AVIF": "image/avif",
|
| 57 |
+
}
|
| 58 |
+
MIME_EXTS = {
|
| 59 |
+
"image/jpeg": (".jpg", ".jpeg"),
|
| 60 |
+
"image/png": (".png",),
|
| 61 |
+
"image/webp": (".webp",),
|
| 62 |
+
"image/gif": (".gif",),
|
| 63 |
+
"image/bmp": (".bmp",),
|
| 64 |
+
"image/avif": (".avif",),
|
| 65 |
+
}
|
| 66 |
+
IMAGE_FIELDS = ("id,name,mimeType,size,modifiedTime,webViewLink,"
|
| 67 |
+
"imageMediaMetadata(width,height)")
|
| 68 |
MAX_CACHE_BYTES = 300 * 1024 * 1024
|
| 69 |
CACHE_TTL_SECONDS = 48 * 60 * 60
|
| 70 |
DOWNLOAD_CHUNK = 4 * 1024 * 1024
|
| 71 |
|
| 72 |
PREVIEW_MAX_PX = 1600
|
| 73 |
PREVIEW_QUALITY = 78
|
| 74 |
+
PREVIEW_MIN_BYTES = 250 * 1024
|
| 75 |
+
PREVIEW_TAG = "~p"
|
| 76 |
|
| 77 |
+
# Drive's orderBy for each sort.
|
|
|
|
|
|
|
| 78 |
SORT_ORDERS = {
|
| 79 |
"name": "name_natural",
|
| 80 |
"newest": "modifiedTime desc,name_natural desc",
|
|
|
|
| 108 |
pass
|
| 109 |
|
| 110 |
|
| 111 |
+
class InvalidUpload(Exception):
|
| 112 |
+
pass
|
| 113 |
+
|
| 114 |
+
|
| 115 |
def valid_id(value) -> bool:
|
| 116 |
return isinstance(value, str) and bool(ID_RE.match(value))
|
| 117 |
|
|
|
|
| 134 |
if not creds:
|
| 135 |
return False
|
| 136 |
scopes = set(getattr(creds, 'scopes', None) or [])
|
| 137 |
+
return bool(scopes & {READONLY_SCOPE, FULL_DRIVE_SCOPE})
|
| 138 |
+
|
| 139 |
+
@staticmethod
|
| 140 |
+
def has_write_scope(creds) -> bool:
|
| 141 |
+
return FULL_DRIVE_SCOPE in set(getattr(creds, 'scopes', None) or [])
|
| 142 |
+
|
| 143 |
+
@staticmethod
|
| 144 |
+
def token_can_write(token_json) -> bool:
|
| 145 |
+
"""Cheap check on the stored token (no refresh, no network) for the page."""
|
| 146 |
+
try:
|
| 147 |
+
scopes = json.loads(token_json).get('scopes') or []
|
| 148 |
+
except Exception:
|
| 149 |
+
return False
|
| 150 |
+
return FULL_DRIVE_SCOPE in scopes
|
| 151 |
|
| 152 |
@staticmethod
|
| 153 |
def get_creds(uid, user_data=None):
|
|
|
|
| 288 |
return folders
|
| 289 |
|
| 290 |
@staticmethod
|
| 291 |
+
def _entry(f) -> dict:
|
| 292 |
+
meta = f.get('imageMediaMetadata') or {}
|
| 293 |
+
try:
|
| 294 |
+
size = int(f.get('size') or 0)
|
| 295 |
+
except (TypeError, ValueError):
|
| 296 |
+
size = 0
|
| 297 |
+
return {
|
| 298 |
+
"id": f['id'],
|
| 299 |
+
"name": f.get('name', f['id']),
|
| 300 |
+
"mime": f.get('mimeType'),
|
| 301 |
+
"size": size,
|
| 302 |
+
"modified": f.get('modifiedTime'),
|
| 303 |
+
"width": meta.get('width'),
|
| 304 |
+
"height": meta.get('height'),
|
| 305 |
+
"link": f.get('webViewLink'),
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
@staticmethod
|
| 309 |
+
def _order(images, sort):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
if sort in ("oldest", "newest"):
|
| 311 |
# Sort here as well so the order never depends on Drive's tie
|
| 312 |
# handling: by modified time, then name for identical timestamps.
|
| 313 |
images.sort(key=lambda i: (i.get("modified") or "", i["name"].lower()),
|
| 314 |
reverse=(sort == "newest"))
|
| 315 |
+
return images
|
| 316 |
+
|
| 317 |
+
@staticmethod
|
| 318 |
+
def list_images(service, folder_id, sort=DEFAULT_SORT):
|
| 319 |
+
mime_clause = " or ".join(f"mimeType='{m}'" for m in IMAGE_MIME_EXT)
|
| 320 |
+
q = f"'{folder_id}' in parents and trashed=false and ({mime_clause})"
|
| 321 |
+
raw, truncated = ChartBrowserEngine._list_all(
|
| 322 |
+
service, q, IMAGE_FIELDS, SORT_ORDERS.get(sort, SORT_ORDERS[DEFAULT_SORT]), LIST_PAGE_SIZE, MAX_IMAGES
|
| 323 |
+
)
|
| 324 |
+
images = [ChartBrowserEngine._entry(f) for f in raw]
|
| 325 |
+
return ChartBrowserEngine._order(images, sort), truncated
|
| 326 |
+
|
| 327 |
+
# --- Upload / delete ---
|
| 328 |
+
@staticmethod
|
| 329 |
+
def _clean_name(raw, mime) -> str:
|
| 330 |
+
name = (raw or "").replace("\\", "/").split("/")[-1]
|
| 331 |
+
name = re.sub(r"[\x00-\x1f\x7f]", "", name).strip().strip(".")
|
| 332 |
+
stem, dot, ext = name.rpartition(".")
|
| 333 |
+
if not dot:
|
| 334 |
+
stem, ext = name, ""
|
| 335 |
+
allowed = MIME_EXTS[mime]
|
| 336 |
+
if ("." + ext.lower()) not in allowed:
|
| 337 |
+
stem, ext = name, allowed[0].lstrip(".")
|
| 338 |
+
stem = stem.strip() or "chart"
|
| 339 |
+
keep = max(1, MAX_NAME_CHARS - len(ext) - 1)
|
| 340 |
+
return f"{stem[:keep]}.{ext}"
|
| 341 |
+
|
| 342 |
+
@staticmethod
|
| 343 |
+
def _read_image(stream):
|
| 344 |
+
"""Read the upload (bounded) and identify it from its bytes."""
|
| 345 |
+
data = stream.read(MAX_UPLOAD_BYTES + 1)
|
| 346 |
+
if not data:
|
| 347 |
+
raise InvalidUpload("The file is empty.")
|
| 348 |
+
if len(data) > MAX_UPLOAD_BYTES:
|
| 349 |
+
raise InvalidUpload(f"File is too large (max {MAX_UPLOAD_BYTES // (1024 * 1024)} MB).")
|
| 350 |
+
try:
|
| 351 |
+
with Image.open(io.BytesIO(data)) as im:
|
| 352 |
+
fmt = im.format
|
| 353 |
+
im.verify()
|
| 354 |
+
except Exception:
|
| 355 |
+
raise InvalidUpload("That file is not a valid image.")
|
| 356 |
+
mime = PIL_FORMAT_MIME.get(fmt)
|
| 357 |
+
if not mime:
|
| 358 |
+
raise InvalidUpload("Unsupported image type. Use PNG, JPG, WebP, GIF, BMP or AVIF.")
|
| 359 |
+
return data, mime
|
| 360 |
+
|
| 361 |
+
@staticmethod
|
| 362 |
+
def _refresh_listing(uid, service, folder_id, sort, add=None, remove_id=None):
|
| 363 |
+
"""Re-list Drive after a change and save the index. add/remove_id patch
|
| 364 |
+
the result in case Drive's listing lags behind our own write."""
|
| 365 |
+
idx = ChartBrowserEngine.load_index(uid)
|
| 366 |
+
if (idx.get("folder") or {}).get("id") == folder_id and "parent" in idx:
|
| 367 |
+
folder = {"id": folder_id, "name": idx["folder"]["name"], "parent": idx["parent"]}
|
| 368 |
+
else:
|
| 369 |
+
folder = ChartBrowserEngine.folder_info(service, folder_id, uid)
|
| 370 |
+
|
| 371 |
+
images, truncated = ChartBrowserEngine.list_images(service, folder_id, sort)
|
| 372 |
+
if remove_id:
|
| 373 |
+
images = [i for i in images if i["id"] != remove_id]
|
| 374 |
+
if add and not truncated and all(i["id"] != add["id"] for i in images):
|
| 375 |
+
images.append(add)
|
| 376 |
+
ChartBrowserEngine._order(images, sort)
|
| 377 |
+
ChartBrowserEngine.save_index(
|
| 378 |
+
uid, {"id": folder["id"], "name": folder["name"]}, images,
|
| 379 |
+
sort=sort, parent=folder["parent"], truncated=truncated)
|
| 380 |
+
return folder, images, truncated
|
| 381 |
+
|
| 382 |
+
@staticmethod
|
| 383 |
+
def upload_image(uid, service, folder_id, stream, filename, sort=DEFAULT_SORT):
|
| 384 |
+
if folder_id != 'root' and not valid_id(folder_id):
|
| 385 |
+
raise NotFound("Bad folder")
|
| 386 |
+
data, mime = ChartBrowserEngine._read_image(stream)
|
| 387 |
+
name = ChartBrowserEngine._clean_name(filename, mime)
|
| 388 |
+
|
| 389 |
+
media = MediaIoBaseUpload(io.BytesIO(data), mimetype=mime,
|
| 390 |
+
resumable=len(data) > RESUMABLE_ABOVE)
|
| 391 |
+
try:
|
| 392 |
+
created = service.files().create(
|
| 393 |
+
body={'name': name, 'parents': [folder_id]},
|
| 394 |
+
media_body=media,
|
| 395 |
+
fields=IMAGE_FIELDS,
|
| 396 |
+
supportsAllDrives=True,
|
| 397 |
+
).execute()
|
| 398 |
+
except HttpError as e:
|
| 399 |
+
if e.resp.status == 404:
|
| 400 |
+
raise NotFound("Folder not found")
|
| 401 |
+
raise
|
| 402 |
+
entry = ChartBrowserEngine._entry(created)
|
| 403 |
+
|
| 404 |
+
folder, images, truncated = ChartBrowserEngine._refresh_listing(
|
| 405 |
+
uid, service, folder_id, sort, add=entry)
|
| 406 |
+
|
| 407 |
+
d = ChartBrowserEngine.cache_dir(uid)
|
| 408 |
+
final = d / f"{entry['id']}{IMAGE_MIME_EXT[mime]}"
|
| 409 |
+
part = d / f"{entry['id']}.part"
|
| 410 |
+
try:
|
| 411 |
+
with open(part, "wb") as fh:
|
| 412 |
+
fh.write(data)
|
| 413 |
+
os.replace(part, final)
|
| 414 |
+
ChartBrowserEngine._enforce_cap(d, final.name)
|
| 415 |
+
except OSError:
|
| 416 |
+
pass
|
| 417 |
+
finally:
|
| 418 |
+
if part.exists():
|
| 419 |
+
try:
|
| 420 |
+
part.unlink()
|
| 421 |
+
except OSError:
|
| 422 |
+
pass
|
| 423 |
+
return entry, folder, images, truncated
|
| 424 |
+
|
| 425 |
+
@staticmethod
|
| 426 |
+
def delete_image(uid, service, file_id):
|
| 427 |
+
"""Move an image to the Drive trash. Only files in the folder that is
|
| 428 |
+
currently open (the saved index) can be deleted."""
|
| 429 |
+
if not valid_id(file_id):
|
| 430 |
+
raise NotFound("Bad id")
|
| 431 |
+
idx = ChartBrowserEngine.load_index(uid)
|
| 432 |
+
if not any(i["id"] == file_id for i in idx.get("images", [])):
|
| 433 |
+
raise NotFound("Image is not in the open folder")
|
| 434 |
+
folder_id = idx["folder"]["id"]
|
| 435 |
+
sort = idx.get("sort") or DEFAULT_SORT
|
| 436 |
+
|
| 437 |
+
try:
|
| 438 |
+
service.files().update(
|
| 439 |
+
fileId=file_id, body={'trashed': True},
|
| 440 |
+
fields='id', supportsAllDrives=True,
|
| 441 |
+
).execute()
|
| 442 |
+
except HttpError as e:
|
| 443 |
+
if e.resp.status != 404: # already gone counts as deleted
|
| 444 |
+
raise
|
| 445 |
+
|
| 446 |
+
try:
|
| 447 |
+
return ChartBrowserEngine._refresh_listing(
|
| 448 |
+
uid, service, folder_id, sort, remove_id=file_id)
|
| 449 |
+
except Exception as e:
|
| 450 |
+
print(f"Chart Browser relist after delete failed: {e}")
|
| 451 |
+
images = [i for i in idx["images"] if i["id"] != file_id]
|
| 452 |
+
folder = {"id": folder_id, "name": idx["folder"]["name"], "parent": idx.get("parent")}
|
| 453 |
+
ChartBrowserEngine.save_index(
|
| 454 |
+
uid, {"id": folder_id, "name": folder["name"]}, images,
|
| 455 |
+
sort=sort, parent=folder["parent"], truncated=bool(idx.get("truncated")))
|
| 456 |
+
return folder, images, bool(idx.get("truncated"))
|
| 457 |
|
| 458 |
# --- Temp cache ---
|
| 459 |
@staticmethod
|