heisbuba commited on
Commit
93e2e1d
·
verified ·
1 Parent(s): 79049a3

Update src/services/chart_browser_engine.py

Browse files
Files changed (1) hide show
  1. 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
- BROWSER_SCOPES = ['https://www.googleapis.com/auth/drive.readonly']
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 # smaller originals are served as-is
50
- PREVIEW_TAG = "~p" # '~' can never appear in a Drive id
51
 
52
- # Drive's orderBy for each sort. Both date sorts fetch newest-first so that,
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 & {BROWSER_SCOPES[0], FULL_DRIVE_SCOPE})
 
 
 
 
 
 
 
 
 
 
 
 
 
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 list_images(service, folder_id, sort=DEFAULT_SORT):
252
- mime_clause = " or ".join(f"mimeType='{m}'" for m in IMAGE_MIME_EXT)
253
- q = f"'{folder_id}' in parents and trashed=false and ({mime_clause})"
254
- fields = ("id,name,mimeType,size,modifiedTime,webViewLink,"
255
- "imageMediaMetadata(width,height)")
256
- raw, truncated = ChartBrowserEngine._list_all(
257
- service, q, fields, SORT_ORDERS.get(sort, SORT_ORDERS[DEFAULT_SORT]), LIST_PAGE_SIZE, MAX_IMAGES
258
- )
259
- images = []
260
- for f in raw:
261
- meta = f.get('imageMediaMetadata') or {}
262
- try:
263
- size = int(f.get('size') or 0)
264
- except (TypeError, ValueError):
265
- size = 0
266
- images.append({
267
- "id": f['id'],
268
- "name": f.get('name', f['id']),
269
- "mime": f.get('mimeType'),
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, truncated
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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