|
|
@@ -0,0 +1,57 @@
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import hashlib
|
|
|
+import re
|
|
|
+from typing import Any, Mapping, Optional, 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}")'
|