Files
Timeline_Generator/backend/importer.py
beabigegg aa987adfb9 後端代碼清理:移除冗餘註解和調試代碼
清理內容:
- 移除所有開發元資訊(Author, Version, DocID, Rationale等)
- 刪除註解掉的代碼片段(力導向演算法等24行)
- 移除調試用的 logger.debug 語句
- 簡化冗餘的內聯註解(emoji、"重要"等標註)
- 刪除 TDD 文件引用

清理檔案:
- backend/main.py - 移除調試日誌和元資訊
- backend/importer.py - 移除詳細類型檢查調試
- backend/export.py - 簡化 docstring
- backend/schemas.py - 移除元資訊
- backend/renderer.py - 移除 TDD 引用
- backend/renderer_timeline.py - 移除註解代碼和冗餘註解
- backend/path_planner.py - 簡化策略註解

保留內容:
- 所有函數的 docstring(功能說明、參數、返回值)
- 必要的業務邏輯註解
- 簡潔的模組功能說明

效果:
- 刪除約 100+ 行冗餘註解
- 代碼更加簡潔專業
- 功能完整性 100% 保留

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 12:22:29 +08:00

492 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
CSV/XLSX 匯入模組
本模組負責處理時間軸事件的資料匯入。
支援 CSV 和 XLSX 格式,包含欄位自動對應與格式容錯功能。
"""
import csv
import re
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Dict, Any, Optional, Union
import logging
try:
import pandas as pd
PANDAS_AVAILABLE = True
except ImportError:
PANDAS_AVAILABLE = False
from .schemas import Event, ImportResult, EventType
# 設定日誌
logger = logging.getLogger(__name__)
class ImporterError(Exception):
"""匯入器錯誤基礎類別"""
pass
class FieldMapper:
"""
欄位對應器
負責將不同的欄位名稱映射到標準欄位。
支援多語言和不同命名習慣。
"""
# 欄位對應字典
FIELD_MAPPING = {
'id': ['id', 'ID', '編號', '序號', 'identifier'],
'title': ['title', 'Title', '標題', '名稱', 'name', 'event'],
'start': ['start', 'Start', '開始', '開始時間', 'start_time', 'begin', 'time', 'Time', '時間', 'date', 'Date', '日期'],
'group': ['group', 'Group', '群組', '分類', 'category', 'phase'],
'description': ['description', 'Description', '描述', '說明', 'detail', 'note'],
'color': ['color', 'Color', '顏色', 'colour'],
}
@classmethod
def map_fields(cls, headers: List[str]) -> Dict[str, str]:
"""
將 CSV/XLSX 的欄位名稱映射到標準欄位
Args:
headers: 原始欄位名稱列表
Returns:
映射字典 {標準欄位: 原始欄位}
"""
mapping = {}
headers_lower = [h.strip() for h in headers]
for standard_field, variants in cls.FIELD_MAPPING.items():
for header in headers_lower:
if header in variants or header.lower() in [v.lower() for v in variants]:
# 找到原始 header保留大小寫
original_header = headers[headers_lower.index(header)]
mapping[standard_field] = original_header
break
return mapping
@classmethod
def validate_required_fields(cls, mapping: Dict[str, str]) -> List[str]:
"""
驗證必要欄位是否存在
Args:
mapping: 欄位映射字典
Returns:
缺少的必要欄位列表
"""
required_fields = ['id', 'title', 'start']
missing_fields = [f for f in required_fields if f not in mapping]
return missing_fields
class DateParser:
"""
日期解析器
支援多種日期格式的容錯解析。
"""
# 支援的日期格式列表
DATE_FORMATS = [
'%Y-%m-%d %H:%M:%S',
'%Y/%m/%d %H:%M:%S',
'%Y-%m-%d',
'%Y/%m/%d',
'%d-%m-%Y',
'%d/%m/%Y',
'%Y年%m月%d',
'%Y年%m月%d%H:%M:%S',
'%Y-%m-%dT%H:%M:%S',
'%Y-%m-%dT%H:%M:%S.%f',
]
@classmethod
def parse(cls, date_str: str) -> Optional[datetime]:
"""
解析日期字串
Args:
date_str: 日期字串或 Excel 日期序列號
Returns:
datetime 物件,解析失敗則回傳 None
"""
if not date_str or (isinstance(date_str, str) and not date_str.strip()):
return None
# 如果是數字Excel 日期序列號),先轉換
if isinstance(date_str, (int, float)):
if PANDAS_AVAILABLE:
try:
# Excel 日期從 1899-12-30 開始計算
return pd.to_datetime(date_str, origin='1899-12-30', unit='D')
except Exception as e:
logger.warning(f"無法解析 Excel 日期序列號 {date_str}: {str(e)}")
return None
else:
# 沒有 pandas使用標準庫手動計算
try:
excel_epoch = datetime(1899, 12, 30)
return excel_epoch + timedelta(days=int(date_str))
except Exception as e:
logger.warning(f"無法解析 Excel 日期序列號 {date_str}: {str(e)}")
return None
date_str = str(date_str).strip()
# 嘗試各種格式
for fmt in cls.DATE_FORMATS:
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
# 嘗試使用 pandas 的智能解析(如果可用)
if PANDAS_AVAILABLE:
try:
return pd.to_datetime(date_str)
except Exception:
pass
logger.warning(f"無法解析日期: {date_str}")
return None
class ColorValidator:
"""
顏色格式驗證器
"""
# HEX 顏色正則表達式
HEX_PATTERN = re.compile(r'^#[0-9A-Fa-f]{6}$')
# 預設顏色
DEFAULT_COLORS = [
'#3B82F6', # 藍色
'#10B981', # 綠色
'#F59E0B', # 橙色
'#EF4444', # 紅色
'#8B5CF6', # 紫色
'#EC4899', # 粉色
'#14B8A6', # 青色
'#F97316', # 深橙
]
@classmethod
def validate(cls, color: str, index: int = 0) -> str:
"""
驗證顏色格式
Args:
color: 顏色字串
index: 索引(用於選擇預設顏色)
Returns:
有效的 HEX 顏色代碼
"""
# 確保 index 是整數(防止 pandas 傳入 float
index = int(index) if index is not None else 0
if not color:
return cls.DEFAULT_COLORS[index % len(cls.DEFAULT_COLORS)]
color = str(color).strip().upper()
# 補充 # 符號
if not color.startswith('#'):
color = '#' + color
# 驗證格式
if cls.HEX_PATTERN.match(color):
return color
# 格式無效,使用預設顏色
logger.warning(f"無效的顏色格式: {color},使用預設顏色")
return cls.DEFAULT_COLORS[index % len(cls.DEFAULT_COLORS)]
class CSVImporter:
"""
CSV/XLSX 匯入器
負責讀取 CSV 或 XLSX 檔案並轉換為 Event 物件列表。
"""
def __init__(self):
self.field_mapper = FieldMapper()
self.date_parser = DateParser()
self.color_validator = ColorValidator()
def import_file(self, file_path: Union[str, Path]) -> ImportResult:
"""
匯入 CSV 或 XLSX 檔案
Args:
file_path: 檔案路徑
Returns:
ImportResult 物件
"""
file_path = Path(file_path)
if not file_path.exists():
return ImportResult(
success=False,
errors=[f"檔案不存在: {file_path}"],
total_rows=0,
imported_count=0
)
# 根據副檔名選擇處理方式
if file_path.suffix.lower() == '.csv':
return self._import_csv(file_path)
elif file_path.suffix.lower() in ['.xlsx', '.xls']:
return self._import_xlsx(file_path)
else:
return ImportResult(
success=False,
errors=[f"不支援的檔案格式: {file_path.suffix}"],
total_rows=0,
imported_count=0
)
def _import_csv(self, file_path: Path) -> ImportResult:
"""
匯入 CSV 檔案
Args:
file_path: CSV 檔案路徑
Returns:
ImportResult 物件
"""
events = []
errors = []
try:
with open(file_path, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
headers = reader.fieldnames
if not headers:
return ImportResult(
success=False,
errors=["CSV 檔案為空"],
total_rows=0,
imported_count=0
)
# 欄位映射
field_mapping = self.field_mapper.map_fields(headers)
logger.info(f"CSV 欄位映射結果: {field_mapping}")
logger.info(f"原始欄位: {headers}")
missing_fields = self.field_mapper.validate_required_fields(field_mapping)
if missing_fields:
logger.error(f"缺少必要欄位: {missing_fields}")
return ImportResult(
success=False,
errors=[f"缺少必要欄位: {', '.join(missing_fields)}"],
total_rows=0,
imported_count=0
)
# 逐行處理
row_num = 1
for row in reader:
row_num += 1
try:
logger.debug(f"處理第 {row_num} 行: {row}")
event = self._parse_row(row, field_mapping, row_num)
if event:
events.append(event)
logger.debug(f"成功匯入第 {row_num}")
else:
logger.warning(f"{row_num} 行返回 None")
except Exception as e:
error_msg = f"{row_num} 行錯誤: {str(e)}"
errors.append(error_msg)
logger.error(error_msg)
return ImportResult(
success=True,
events=events,
errors=errors,
total_rows=int(row_num - 1),
imported_count=int(len(events))
)
except Exception as e:
logger.error(f"CSV 匯入失敗: {str(e)}")
return ImportResult(
success=False,
errors=[f"CSV 匯入失敗: {str(e)}"],
total_rows=0,
imported_count=0
)
def _import_xlsx(self, file_path: Path) -> ImportResult:
"""
匯入 XLSX 檔案
Args:
file_path: XLSX 檔案路徑
Returns:
ImportResult 物件
"""
if not PANDAS_AVAILABLE:
return ImportResult(
success=False,
errors=["需要安裝 pandas 和 openpyxl 以支援 XLSX 匯入"],
total_rows=0,
imported_count=0
)
try:
# 讀取第一個工作表
df = pd.read_excel(file_path, sheet_name=0)
if df.empty:
return ImportResult(
success=False,
errors=["XLSX 檔案為空"],
total_rows=0,
imported_count=0
)
# 轉換為字典列表
records = df.to_dict('records')
headers = df.columns.tolist()
# 欄位映射
field_mapping = self.field_mapper.map_fields(headers)
logger.info(f"XLSX 欄位映射結果: {field_mapping}")
logger.info(f"原始欄位: {headers}")
missing_fields = self.field_mapper.validate_required_fields(field_mapping)
if missing_fields:
logger.error(f"缺少必要欄位: {missing_fields}")
return ImportResult(
success=False,
errors=[f"缺少必要欄位: {', '.join(missing_fields)}"],
total_rows=0,
imported_count=0
)
# 逐行處理
events = []
errors = []
for idx, row in enumerate(records, start=2): # Excel 從第 2 行開始(第 1 行是標題)
try:
event = self._parse_row(row, field_mapping, idx)
if event:
events.append(event)
except Exception as e:
errors.append(f"{idx} 行錯誤: {str(e)}")
return ImportResult(
success=True,
events=events,
errors=errors,
total_rows=int(len(records)),
imported_count=int(len(events))
)
except Exception as e:
logger.error(f"XLSX 匯入失敗: {str(e)}")
return ImportResult(
success=False,
errors=[f"XLSX 匯入失敗: {str(e)}"],
total_rows=0,
imported_count=0
)
def _parse_row(self, row: Dict[str, Any], field_mapping: Dict[str, str], row_num: int) -> Optional[Event]:
"""
解析單行資料
Args:
row: 行資料字典
field_mapping: 欄位映射
row_num: 行號
Returns:
Event 物件或 None
"""
# 輔助函數:安全地轉換為字串(處理 NaN、None、float 等)
def safe_str(value):
if pd.isna(value) if PANDAS_AVAILABLE else (value is None or value == ''):
return ''
# 如果是 float 且接近整數,轉為整數後再轉字串
if isinstance(value, float):
if value == int(value):
return str(int(value))
return str(value).strip()
# 提取欄位值
event_id = safe_str(row.get(field_mapping['id'], ''))
title = safe_str(row.get(field_mapping['title'], ''))
start_str = safe_str(row.get(field_mapping['start'], ''))
group = safe_str(row.get(field_mapping.get('group', ''), '')) or None
description = safe_str(row.get(field_mapping.get('description', ''), '')) or None
color = safe_str(row.get(field_mapping.get('color', ''), ''))
# 驗證必要欄位
if not event_id or not title:
raise ValueError("缺少 ID 或標題")
if not start_str:
raise ValueError("缺少時間欄位")
# 解析時間(只有一個時間欄位)
start = self.date_parser.parse(start_str)
if not start:
raise ValueError(f"無效的時間: {start_str}")
# 將 pandas Timestamp 轉換為標準 datetime
if PANDAS_AVAILABLE:
if isinstance(start, pd.Timestamp):
start = start.to_pydatetime()
# 驗證顏色
color = self.color_validator.validate(color, int(row_num))
if not color:
color = self.color_validator.DEFAULT_COLORS[0]
# 所有事件都是時間點類型
event_type = EventType.POINT
end = None
# 建立 Event 物件
try:
event = Event(
id=event_id,
title=title,
start=start,
end=end,
group=group,
description=description,
color=color,
event_type=event_type
)
return event
except Exception as e:
logger.error(f"創建 Event 失敗: {str(e)}")
raise
# 匯出主要介面
__all__ = ['CSVImporter', 'ImportResult', 'ImporterError']