"""复盘记录:多周期截图上传,存储与读取(三所共用).""" from __future__ import annotations import json import os import re from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence JOURNAL_UPLOAD_TFS: tuple[str, ...] = ("5m", "15m", "1h", "4h") JOURNAL_UPLOAD_ALLOWED_EXT = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}) _JOURNAL_DRAFT_ID_RE = re.compile(r"^[a-f0-9]{32}$") _JOURNAL_SLOT_FILE_RE = re.compile( r"^journal_([a-f0-9]{32})_(5m|15m|1h|4h)\.(png|jpg|jpeg|webp|gif|bmp)$", re.I, ) def journal_upload_field_name(tf: str) -> str: return f"screenshot_{tf}" def uploaded_screenshot_field_name(tf: str) -> str: return f"uploaded_screenshot_{tf}" def normalize_journal_draft_id(raw: Any) -> Optional[str]: s = str(raw or "").strip().lower() if _JOURNAL_DRAFT_ID_RE.match(s): return s return None def _safe_ext(filename: str) -> str: ext = os.path.splitext(str(filename or ""))[1].lower() return ext if ext in JOURNAL_UPLOAD_ALLOWED_EXT else ".png" def build_journal_slot_filename( entry_id: str, tf: str, ext: str, *, secure_filename_fn: Callable[[str], str], ) -> str: ext = ext if ext.startswith(".") else f".{ext}" ext = _safe_ext(f"x{ext}") fname = secure_filename_fn(f"journal_{entry_id}_{tf}{ext}") return fname or "" def is_valid_preuploaded_journal_file(filename: str, entry_id: str, tf: str) -> bool: fn = os.path.basename(str(filename or "").strip()) if not fn or fn != str(filename or "").strip(): return False m = _JOURNAL_SLOT_FILE_RE.match(fn) if not m: return False return m.group(1) == entry_id.lower() and m.group(2) == tf def save_journal_slot_file( file, entry_id: str, tf: str, upload_folder: str, *, secure_filename_fn: Callable[[str], str], ) -> Optional[Dict[str, str]]: if tf not in JOURNAL_UPLOAD_TFS or not entry_id or not upload_folder: return None if not file or not getattr(file, "filename", None): return None ext = _safe_ext(file.filename) fname = build_journal_slot_filename( entry_id, tf, ext, secure_filename_fn=secure_filename_fn ) if not fname: return None os.makedirs(upload_folder, exist_ok=True) path = os.path.join(upload_folder, fname) file.save(path) return {"tf": tf, "file": fname} def collect_journal_slot_images( form, files, entry_id: str, upload_folder: str, *, secure_filename_fn: Callable[[str], str], ) -> List[Dict[str, str]]: """优先使用即时上传 hidden 字段;否则回退到表单 multipart.""" saved: List[Dict[str, str]] = [] if not entry_id or not upload_folder: return saved for tf in JOURNAL_UPLOAD_TFS: pre = "" if form is not None: pre = str(form.get(uploaded_screenshot_field_name(tf)) or "").strip() if pre and is_valid_preuploaded_journal_file(pre, entry_id, tf): path = os.path.join(upload_folder, os.path.basename(pre)) if os.path.isfile(path): saved.append({"tf": tf, "file": os.path.basename(pre)}) continue f = files.get(journal_upload_field_name(tf)) if files else None item = save_journal_slot_file( f, entry_id, tf, upload_folder, secure_filename_fn=secure_filename_fn, ) if item: saved.append(item) return saved def save_journal_slot_uploads( files, entry_id: str, upload_folder: str, *, secure_filename_fn: Callable[[str], str], ) -> List[Dict[str, str]]: """保存四槽位手动截图,返回 [{"tf":"5m","file":"journal_xxx_5m.png"}, ...].""" return collect_journal_slot_images( None, files, entry_id, upload_folder, secure_filename_fn=secure_filename_fn, ) def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]: if not items: return None return json.dumps(list(items), ensure_ascii=False, separators=(",", ":")) def parse_images_json(raw: Any) -> List[Dict[str, str]]: if not raw: return [] if isinstance(raw, list): data = raw else: try: data = json.loads(str(raw)) except (TypeError, ValueError, json.JSONDecodeError): return [] if not isinstance(data, list): return [] out: List[Dict[str, str]] = [] for item in data: if not isinstance(item, dict): continue tf = str(item.get("tf") or "").strip() file = str(item.get("file") or "").strip() if file: out.append({"tf": tf, "file": file}) return out def primary_journal_image( manual_images: Sequence[Mapping[str, str]], *, fallback: Optional[str] = None, ) -> Optional[str]: if manual_images: return str(manual_images[0].get("file") or "").strip() or None return fallback def enrich_journal_api_item(item: Dict[str, Any]) -> Dict[str, Any]: """API 输出:解析 images_json,兼容旧单图 image 字段.""" images = parse_images_json(item.get("images_json")) if not images and item.get("image"): images = [{"tf": "", "file": str(item["image"]).strip()}] item["images"] = images return item def journal_image_paths(row: Any, upload_folder: str) -> List[str]: """删除 / AI 附图:收集本条复盘所有本地图片路径(去重).""" upload_folder = os.path.abspath(upload_folder or "") paths: List[str] = [] seen = set() def _add(name: Optional[str]) -> None: if not name: return p = os.path.abspath(os.path.join(upload_folder, str(name).strip())) if os.path.isfile(p) and p not in seen: seen.add(p) paths.append(p) try: keys = row.keys() if hasattr(row, "keys") else () except Exception: keys = () if "images_json" in keys and row["images_json"]: for img in parse_images_json(row["images_json"]): _add(img.get("file")) if "image" in keys: _add(row["image"]) return paths