from __future__ import annotations import hashlib import re from typing import Any, Mapping, Sequence from urllib.parse import unquote, urlparse ARCHIVE_ZIP_ATTACH_COL_MAX = 20 ARCHIVE_ZIP_MAX_FILES = 2000 ARCHIVE_ZIP_MAX_BYTES = 500 * 1024 * 1024 def sanitize_zip_segment(raw: str) -> str: value = re.sub(r'[\\/:*?"<>|]+', "_", str(raw or "").strip()) value = re.sub(r"\s+", " ", value).strip() return value or "未命名" def short_hash(text: str, *, n: int = 6) -> str: return hashlib.sha1(str(text or "").encode("utf-8")).hexdigest()[:n] def guess_ext_from_url(url: str) -> str: path = unquote(urlparse(str(url or "").split("?", 1)[0]).path) name = path.rsplit("/", 1)[-1] if "." not in name: return "bin" ext = name.rsplit(".", 1)[-1].lower() if not ext or len(ext) > 8 or "/" in ext: return "bin" return ext def build_attachment_filename(row_no: int, label: str, url: str) -> str: return ( f"{int(row_no):02d}_{sanitize_zip_segment(label)}_" f"{short_hash(url)}.{guess_ext_from_url(url)}" ) def first_row_no_by_plan_id(rows: Sequence[Mapping[str, Any]]) -> dict[str, int]: out: dict[str, int] = {} for row in rows: plan_id = str(row.get("planId") or "").strip() if not plan_id or plan_id in out: continue try: out[plan_id] = int(row.get("rowNo") or 0) except (TypeError, ValueError): out[plan_id] = 0 return out def build_hyperlink_formula(rel_path: str, display: str) -> str: path = str(rel_path).replace('"', '""') text = sanitize_zip_segment(display).replace('"', '""') return f'=HYPERLINK("{path}","{text}")'