From ce10a55c71f08b032ee2ae32f11e408f035cc8e0 Mon Sep 17 00:00:00 2001 From: zhouyr9 Date: Wed, 31 Dec 2025 11:10:13 +0800 Subject: [PATCH] update --- data/KSP_data/log_book.xlsx | Bin 20810 -> 20901 bytes src/KSP_tools/KSP_wiki/utils/__init__.py | 0 .../KSP_wiki/utils/cal_rss_timestamp.py | 25 + .../KSP_wiki/utils/sync_to_overview.py | 700 ++++++++++++++++++ .../KSP_wiki/utils/upload_log_book_to_db.py | 151 ++++ .../KSP_wiki/vessel_log/Stellaria_history.py | 101 +++ .../KSP_wiki/vessel_log/xihe_agent.py | 323 ++++++++ src/OneNote_tool/__init__.py | 28 + src/OneNote_tool/onenote.py | 323 ++++++++ 9 files changed, 1651 insertions(+) create mode 100644 src/KSP_tools/KSP_wiki/utils/__init__.py create mode 100644 src/KSP_tools/KSP_wiki/utils/cal_rss_timestamp.py create mode 100644 src/KSP_tools/KSP_wiki/utils/sync_to_overview.py create mode 100644 src/KSP_tools/KSP_wiki/utils/upload_log_book_to_db.py create mode 100644 src/KSP_tools/KSP_wiki/vessel_log/Stellaria_history.py create mode 100644 src/KSP_tools/KSP_wiki/vessel_log/xihe_agent.py create mode 100644 src/OneNote_tool/__init__.py create mode 100644 src/OneNote_tool/onenote.py diff --git a/data/KSP_data/log_book.xlsx b/data/KSP_data/log_book.xlsx index 4b7595050c40a63a4f0773b9383a5e6f9817d073..91659d3561426ec9a398bc6e6c02c2e4aead268a 100644 GIT binary patch delta 13286 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 str: + """Calculate RSS timestamp given offset in seconds from 1951-01-01 00:00:00. + + Args: + offset_seconds (int): Number of seconds offset from the base time. + + Returns: + str: Formatted timestamp string in 'YYYY-MM-DD HH:MM:SS' format. + """ + target_time = t0 + datetime.timedelta(seconds=offset_seconds) + return target_time.strftime("%Y-%m-%d %H:%M:%S") + +def cal_launch_date_via_met(met): + met_time = now_time - met + return cal_rss_timestamp(met_time) + +if __name__ == "__main__": + print(cal_launch_date_via_met(23083334.171755522)) + print('done') + diff --git a/src/KSP_tools/KSP_wiki/utils/sync_to_overview.py b/src/KSP_tools/KSP_wiki/utils/sync_to_overview.py new file mode 100644 index 0000000..ef13a36 --- /dev/null +++ b/src/KSP_tools/KSP_wiki/utils/sync_to_overview.py @@ -0,0 +1,700 @@ +"""Sync a given sheet's log entries into the Overview sheet in local_content_dir/log_book.xlsx. + +Usage (from project root): + python -m src.utils.sync_to_overview --sheet XH-01 + +This script: +- Loads local_content_dir/log_book.xlsx +- Reads all rows from the specified sheet +- Applies half-month attribution rules to start/end dates +- Maps each entry onto the Overview sheet's timeline row for that sheet +- Merges cells from the start half-month to the end half-month +- Copies the fill color from the source log_name cell + +Assumptions about the workbook layout are documented in the docstrings below. +""" + +from dataclasses import dataclass +from datetime import date, datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +import xml.etree.ElementTree as ET +from openpyxl.workbook.workbook import Workbook as _Workbook + +from openpyxl import load_workbook +from openpyxl.styles import PatternFill, Alignment +from openpyxl.worksheet.worksheet import Worksheet +from openpyxl.cell.cell import Cell, MergedCell + +# Paths and constants +PROJECT_ROOT = Path(__file__).resolve().parents[2] +LOG_BOOK_PATH = PROJECT_ROOT / "local_content_dir" / "log_book.xlsx" + +OVERVIEW_SHEET_NAME = "Overview" +HEADER_ROW_INDEX = 1 +OVERVIEW_TIMELINE_START_COL = 3 # Column C by default (used for horizontal layout) + + +@dataclass +class LogEntry: + start: date + end: date + name: str + fill: Optional[PatternFill] + + +def _normalize_header_name(value: Optional[str]) -> Optional[str]: + if not isinstance(value, str): + return None + v = value.strip().lower().replace("-", "_").replace(" ", "_") + return v or None + + +def get_log_sheet_headers(ws: Worksheet, header_row: int = HEADER_ROW_INDEX) -> Dict[str, int]: + """Return a mapping from logical header names to 1-based column indices. + + Required logical names: start_date, end_date, log_name + Optional: color + """ + headers: Dict[str, int] = {} + for col in range(1, ws.max_column + 1): + raw = ws.cell(row=header_row, column=col).value + norm = _normalize_header_name(raw) + if not norm: + continue + if norm in ("start", "start_date", "start_time"): + headers["start_date"] = col + elif norm in ("end", "end_date", "end_time"): + headers["end_date"] = col + elif norm in ("log_name", "name", "title"): + headers["log_name"] = col + elif norm in ("color", "colour", "fill"): + headers["color"] = col + required = {"start_date", "end_date", "log_name"} + missing = required - headers.keys() + if missing: + raise ValueError(f"Missing required columns in sheet {ws.title!r}: {sorted(missing)}") + return headers + + +def _parse_excel_date(value) -> Optional[date]: + """Best-effort parse of an Excel cell value into a date. + + Supports datetime/date values and common string formats. + """ + if value is None or value == "": + return None + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + if isinstance(value, (int, float)): + # We avoid depending on workbook epoch; numeric dates are less expected here. + # Fallback: treat as ordinal days from 1900-01-01 if needed in future. + return None + if isinstance(value, str): + text = value.strip() + if not text: + return None + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"): + try: + return datetime.strptime(text, fmt).date() + except ValueError: + continue + return None + + +def _apply_tint(argb_hex: str, tint: float) -> str: + """Apply Excel tint to an ARGB hex string and return ARGB hex. + + Tint formula (approximate): + - tint in (-1, 1) + - for each RGB channel (0-255): + if tint < 0: result = channel * (1.0 + tint) + else: result = channel + (255 - channel) * tint + + argb_hex may be 8-char (ARGB) or 6-char (RGB) -- we assume ARGB and preserve alpha. + Returns uppercase 8-char ARGB string. + """ + s = argb_hex.strip().lstrip("#") + if len(s) == 6: + alpha = 255 + rgb = s + elif len(s) == 8: + alpha = int(s[0:2], 16) + rgb = s[2:] + else: + return argb_hex.upper() + + try: + r = int(rgb[0:2], 16) + g = int(rgb[2:4], 16) + b = int(rgb[4:6], 16) + except Exception: + return argb_hex.upper() + + def mix(channel: int, t: float) -> int: + if t < 0: + v = int(round(channel * (1.0 + t))) + else: + v = int(round(channel + (255 - channel) * t)) + return max(0, min(255, v)) + + r2 = mix(r, tint) + g2 = mix(g, tint) + b2 = mix(b, tint) + + return f"{alpha:02X}{r2:02X}{g2:02X}{b2:02X}" + + +def _coerce_fill(value) -> Optional[PatternFill]: + """Convert various fill-like objects into a concrete solid PatternFill. + + Rules: + - Only convert when the source is a solid fill (or has an explicit fgColor rgb/index/theme) + - Do not convert transparent/empty colors (alpha == '00' or missing) to avoid black blocks + - Support Color.rgb (ARGB or RGB), Color.indexed via openpyxl.styles.colors.COLOR_INDEX + - If we can't find a clear solid color, return None so caller won't apply a fill + """ + if value is None: + return None + + # If already a concrete PatternFill with solid type and a usable fgColor, try to reuse + try: + if isinstance(value, PatternFill): + ptype = getattr(value, "patternType", None) or getattr(value, "fill_type", None) + fg = getattr(value, "fgColor", None) + if ptype and str(ptype).lower() in ("solid", "darkgray", "gray125", "gray0625") and fg is not None: + rgb = getattr(fg, "rgb", None) or getattr(fg, "value", None) + if isinstance(rgb, str) and rgb.strip(): + hex_str = rgb.strip().lstrip("#") + if len(hex_str) == 6: + hex_str = "FF" + hex_str + if len(hex_str) == 8 and not hex_str.startswith("00"): + return PatternFill(fill_type="solid", fgColor=hex_str) + # otherwise fallthrough + except Exception: + pass + + # If the object indicates no pattern (no fill), bail out to avoid assigning black + try: + ptype = getattr(value, "patternType", None) or getattr(value, "fill_type", None) + if ptype is None or str(ptype).strip().lower() in ("none", ""): + return None + except Exception: + pass + + # Generic proxy/object with color attributes + fg = getattr(value, "fgColor", None) or getattr(value, "start_color", None) + rgb = None + if fg is not None: + rgb = getattr(fg, "rgb", None) or getattr(fg, "value", None) + # try indexed + if rgb is None: + idx = getattr(fg, "indexed", None) + if isinstance(idx, int): + try: + from openpyxl.styles.colors import COLOR_INDEX + + hex_str = COLOR_INDEX[idx].lstrip("#") + if len(hex_str) == 6: + hex_str = "FF" + hex_str + # avoid default/black colors + if len(hex_str) == 8 and not hex_str.startswith("00") and hex_str.upper() != "FF000000": + return PatternFill(fill_type="solid", fgColor=hex_str) + except Exception: + pass + if isinstance(rgb, str) and rgb.strip(): + hex_str = rgb.strip().lstrip("#") + # normalize to ARGB + if len(hex_str) == 6: + hex_str = "FF" + hex_str + if len(hex_str) == 8 and not hex_str.startswith("00") and hex_str.upper() != "FF000000": + # apply tint if present + tint = getattr(fg, "tint", None) + if tint is not None: + try: + hex_str = _apply_tint(hex_str, float(tint)) + except Exception: + pass + return PatternFill(fill_type="solid", fgColor=hex_str) + # theme-based color (fg.theme) + theme_idx = getattr(fg, "theme", None) + if theme_idx is not None and _CURRENT_WB is not None: + try: + global _THEME_COLORS_CACHE + if _THEME_COLORS_CACHE is None: + _THEME_COLORS_CACHE = _load_theme_colors(_CURRENT_WB) + if isinstance(theme_idx, int) and _THEME_COLORS_CACHE and theme_idx < len(_THEME_COLORS_CACHE): + theme_hex = _THEME_COLORS_CACHE[theme_idx] + if theme_hex and theme_hex.upper() != "FF000000": + tint = getattr(fg, "tint", None) + if tint is not None: + try: + theme_hex = _apply_tint(theme_hex, float(tint)) + except Exception: + pass + return PatternFill(fill_type="solid", fgColor=theme_hex) + except Exception: + pass + + # As a last-ditch, the object itself might expose a string color + valstr = None + try: + if isinstance(value, str): + valstr = value + else: + valstr = str(value) + except Exception: + valstr = None + if isinstance(valstr, str): + s = valstr.strip().lstrip("#") + if len(s) == 6: + hex_str = "FF" + s + if hex_str.upper() != "FF000000": + return PatternFill(fill_type="solid", fgColor=hex_str) + if len(s) == 8 and not s.startswith("00") and s.upper() != "FF000000": + return PatternFill(fill_type="solid", fgColor=s) + + return None + + +def _load_theme_colors(wb: _Workbook) -> List[str]: + """Parse workbook.loaded_theme XML and return list of SRGB ARGB hex strings in scheme order. + + This is best-effort. We look for children and for each child prefer 'srgbClr' @val. + Returned hex strings are normalized to ARGB (8 chars, FF prefixed when needed). + """ + colors: List[str] = [] + try: + data = getattr(wb, "loaded_theme", None) + if data is None: + return colors + if isinstance(data, bytes): + data = data.decode("utf-8") + root = ET.fromstring(data) + # find any clrScheme element + clr = None + for node in root.findall('.//'): + tag = node.tag + if tag.endswith('clrScheme'): + clr = node + break + if clr is None: + return colors + for child in list(clr): + # find srgbClr descendant + srgb = child.find('.//') + val = None + # search for any element with 'srgbClr' in tag + for desc in child.iter(): + if desc.tag.endswith('srgbClr'): + val = desc.get('val') + break + if val: + s = val.strip().lstrip('#') + if len(s) == 6: + colors.append('FF' + s.upper()) + elif len(s) == 8: + colors.append(s.upper()) + else: + colors.append('') + else: + colors.append('') + except Exception: + return [] + return colors + + +def parse_log_row(ws: Worksheet, row_index: int, headers: Dict[str, int]) -> Optional[LogEntry]: + start_cell: Cell = ws.cell(row=row_index, column=headers["start_date"]) + end_cell: Cell = ws.cell(row=row_index, column=headers["end_date"]) + name_cell: Cell = ws.cell(row=row_index, column=headers["log_name"]) + + start = _parse_excel_date(start_cell.value) + end = _parse_excel_date(end_cell.value) + name_raw = name_cell.value + name = str(name_raw).strip() if name_raw is not None else "" + + if start is None and end is None and not name: + return None + + if start is None and end is not None: + start = end + if end is None and start is not None: + end = start + if start is None or end is None: + # Still not enough info + return None + + fill: Optional[PatternFill] = None + if "color" in headers: + color_cell = ws.cell(row=row_index, column=headers["color"]) + # Here we trust the user to put a proper hex like RRGGBB; minimal handling only. + if isinstance(color_cell.value, str) and color_cell.value.strip(): + hex_str = color_cell.value.strip().lstrip("#") + if len(hex_str) in (6, 8): + # openpyxl expects ARGB; if RGB given, prepend FF + if len(hex_str) == 6: + hex_str = "FF" + hex_str + fill = PatternFill(fill_type="solid", fgColor=hex_str) + if fill is None: + # annotate name_cell.fill access for static checkers + nf = name_cell.fill + fill = _coerce_fill(nf) + if nf is not None and fill is None: + # Debug info: collect fgColor attributes to help diagnose why coercion failed + try: + fg = getattr(nf, "fgColor", None) or getattr(nf, "start_color", None) + rgb = getattr(fg, "rgb", None) if fg is not None else None + theme = getattr(fg, "theme", None) if fg is not None else None + indexed = getattr(fg, "indexed", None) if fg is not None else None + tint = getattr(fg, "tint", None) if fg is not None else None + print(f"[sync_to_overview] sheet={ws.title!r} row={row_index} log_name={name!r} had fill proxy but coercion failed: fg.rgb={rgb!r} fg.theme={theme!r} fg.indexed={indexed!r} fg.tint={tint!r}") + except Exception: + print(f"[sync_to_overview] sheet={ws.title!r} row={row_index} log_name={name!r} had fill proxy but coercion failed (unable to inspect attributes)") + + if not name: + # If there is no name but valid dates, we still can render a colored block; use empty string. + name = "" + + return LogEntry(start=start, end=end, name=name, fill=fill) + + +def _shift_month(d: date, delta: int) -> date: + """Shift date by delta months, keeping day when possible. + + If the target month has fewer days, clamp to last day of month. + """ + month = d.month - 1 + delta + year = d.year + month // 12 + month = month % 12 + 1 + # Clamp day to last day of target month + from calendar import monthrange + + last_day = monthrange(year, month)[1] + day = min(d.day, last_day) + return date(year, month, day) + + +def attribute_start_date(d: date) -> date: + """Map start date to its month start. + + New rule: the start month is always the month of the original start date. + Returns a date representing the first day of that month (for timeline bucket comparisons). + """ + return date(d.year, d.month, 1) + + +def attribute_end_date(d: date) -> date: + """Map end date to its month start. + + New rule: the end month is the month containing the actual end date (no half-month cutoff). + Returns the first day of that month for timeline comparisons. + """ + return date(d.year, d.month, 1) + + +# --- New: support both horizontal (timeline across a header row) and vertical (timeline down column A) --- + +def _detect_overview_orientation(ws: Worksheet, header_row: int = HEADER_ROW_INDEX, start_col: int = OVERVIEW_TIMELINE_START_COL) -> str: + """Detect whether Overview worksheet uses a horizontal timeline (dates across header_row) + or a vertical timeline (dates down column A). Returns 'horizontal' or 'vertical'. + Simple heuristic: if column A (starting at row 2) contains many dates -> vertical. + Otherwise if header_row contains many dates across columns starting at start_col -> horizontal. + Defaults to 'horizontal'. + """ + # check column A for dates + col_a_date_count = 0 + for r in range(header_row + 1, min(ws.max_row, header_row + 200) + 1): + v = ws.cell(row=r, column=1).value + if _parse_excel_date(v) is not None: + col_a_date_count += 1 + if col_a_date_count >= 3: + return "vertical" + # check header row for dates across columns + row_date_count = 0 + for c in range(start_col, min(ws.max_column, start_col + 200) + 1): + v = ws.cell(row=header_row, column=c).value + if _parse_excel_date(v) is not None: + row_date_count += 1 + if row_date_count >= 3: + return "horizontal" + return "horizontal" + + +def get_overview_timeline( + ws: Worksheet, + header_row: int = HEADER_ROW_INDEX, + start_col: int = OVERVIEW_TIMELINE_START_COL, +) -> Tuple[str, List[Tuple[int, date]]]: + """Extract overview timeline. + + Returns (orientation, timeline) where orientation is 'horizontal' or 'vertical'. + - horizontal: timeline is list of (col_index, date) scanning header_row from start_col rightwards + - vertical: timeline is list of (row_index, date) scanning column A from header_row+1 downward + """ + orientation = _detect_overview_orientation(ws, header_row, start_col) + timeline: List[Tuple[int, date]] = [] + + if orientation == "horizontal": + empty_streak = 0 + max_empty_streak = 10 + for col in range(start_col, ws.max_column + 1): + cell = ws.cell(row=header_row, column=col) + d = _parse_excel_date(cell.value) + if d is None: + empty_streak += 1 + if empty_streak >= max_empty_streak: + break + continue + empty_streak = 0 + timeline.append((col, d)) + timeline.sort(key=lambda x: x[1]) + return orientation, timeline + + # vertical + empty_streak = 0 + max_empty_streak = 10 + for row in range(header_row + 1, ws.max_row + 1): + cell = ws.cell(row=row, column=1) + d = _parse_excel_date(cell.value) + if d is None: + empty_streak += 1 + if empty_streak >= max_empty_streak: + break + continue + empty_streak = 0 + timeline.append((row, d)) + timeline.sort(key=lambda x: x[1]) + return orientation, timeline + + +def find_bucket_span( + timeline: List[Tuple[int, date]], start_attr: date, end_attr: date +) -> Optional[Tuple[int, int]]: + if not timeline: + return None + # Match by year-month to be robust when timeline cells are mid-month + start_key = (start_attr.year, start_attr.month) + end_key = (end_attr.year, end_attr.month) + + ym_list: List[Tuple[int, Tuple[int, int]]] = [(idx, (d.year, d.month)) for idx, d in timeline] + + first_pos = None + for i, (_, ym) in enumerate(ym_list): + if ym >= start_key: + first_pos = i + break + if first_pos is None: + return None + + last_pos = None + for i in range(len(ym_list) - 1, -1, -1): + if ym_list[i][1] <= end_key: + last_pos = i + break + if last_pos is None or last_pos < first_pos: + return None + + return timeline[first_pos][0], timeline[last_pos][0] + + +def _has_merged_intersection(ws: Worksheet, min_row: int, max_row: int, min_col: int, max_col: int) -> bool: + """Return True if any existing merged cell range intersects the supplied rectangle.""" + for merged in ws.merged_cells.ranges: + if merged.min_row <= max_row and merged.max_row >= min_row and merged.min_col <= max_col and merged.max_col >= min_col: + return True + return False + + +def _unmerge_intersecting_ranges(ws: Worksheet, min_row: int, max_row: int, min_col: int, max_col: int) -> None: + to_unmerge = [] + for merged in list(ws.merged_cells.ranges): + if merged.min_row <= max_row and merged.max_row >= min_row and merged.min_col <= max_col and merged.max_col >= min_col: + to_unmerge.append(merged) + for merged in to_unmerge: + ws.unmerge_cells(str(merged.coord)) + + +def clear_overview_range(ws: Worksheet, sheet_pos: int, indices: List[int], orientation: str) -> None: + """Clear the destination cells for a given sheet in the Overview. + + - For horizontal orientation: sheet_pos is a row index, indices are column indices. + - For vertical orientation: sheet_pos is a column index, indices are row indices. + """ + if not indices: + return + min_idx = min(indices) + max_idx = max(indices) + + if orientation == "horizontal": + min_col = min_idx + max_col = max_idx + row_index = sheet_pos + # Unmerge any merged cells intersecting this row & column range + _unmerge_intersecting_ranges(ws, row_index, row_index, min_col, max_col) + for col in range(min_col, max_col + 1): + cell: Any = ws.cell(row=row_index, column=col) + cell.value = None + cell.fill = PatternFill() # clear fill + else: + # vertical orientation: we clear a column at sheet_pos between min_idx..max_idx rows + col_index = sheet_pos + min_row = min_idx + max_row = max_idx + _unmerge_intersecting_ranges(ws, min_row, max_row, col_index, col_index) + for row in range(min_row, max_row + 1): + cell: Any = ws.cell(row=row, column=col_index) + cell.value = None + cell.fill = PatternFill() # clear fill + + +def apply_log_to_overview( + ws: Worksheet, + sheet_pos: int, + start_idx: int, + end_idx: int, + log_name: str, + fill: Optional[PatternFill], + orientation: str, +) -> None: + """Apply a log entry to the Overview. + + - For horizontal: sheet_pos is row, start_idx/end_idx are columns -> merge horizontally (single row). + - For vertical: sheet_pos is column, start_idx/end_idx are rows -> merge vertically (single column). + """ + if start_idx > end_idx: + start_idx, end_idx = end_idx, start_idx + + # Conflict resolution: if any existing merged range intersects our intended target, shrink the start index forward + if orientation == "horizontal": + # target rectangle: (sheet_pos, start_idx..end_idx) + while start_idx <= end_idx and _has_merged_intersection(ws, sheet_pos, sheet_pos, start_idx, end_idx): + start_idx += 1 + if start_idx > end_idx: + return + if start_idx == end_idx: + cell: Any = ws.cell(row=sheet_pos, column=start_idx) + else: + ws.merge_cells(start_row=sheet_pos, start_column=start_idx, end_row=sheet_pos, end_column=end_idx) + cell: Any = ws.cell(row=sheet_pos, column=start_idx) + else: + # vertical: target rectangle: (start_idx..end_idx, sheet_pos) + while start_idx <= end_idx and _has_merged_intersection(ws, start_idx, end_idx, sheet_pos, sheet_pos): + start_idx += 1 + if start_idx > end_idx: + return + if start_idx == end_idx: + cell: Any = ws.cell(row=start_idx, column=sheet_pos) + else: + ws.merge_cells(start_row=start_idx, start_column=sheet_pos, end_row=end_idx, end_column=sheet_pos) + cell: Any = ws.cell(row=start_idx, column=sheet_pos) + + # Defensive: if we somehow got a MergedCell (read-only), find the actual top-left cell + try: + if isinstance(cell, MergedCell): + for merged in ws.merged_cells.ranges: + if merged.min_row <= cell.row <= merged.max_row and merged.min_col <= cell.column <= merged.max_col: + cell = ws.cell(row=merged.min_row, column=merged.min_col) + break + except Exception: + # ignore and continue + pass + + cell.value = log_name + cell.alignment = Alignment(horizontal="center", vertical="center") # type: ignore[attr-defined] + if fill is not None: + cell.fill = fill # type: ignore[attr-defined] + + +def _find_overview_position_for_sheet(ws: Worksheet, sheet_name: str, orientation: str) -> Optional[int]: + """Find where the sheet's overview cells live. + + - horizontal orientation: sheet names are expected in column A (like before) -> return the row index + - vertical orientation: sheet names are expected in header row (ROW 1) across columns -> return the column index + """ + if orientation == "horizontal": + for row in range(2, ws.max_row + 1): # assume header in row 1 + val = ws.cell(row=row, column=1).value + if isinstance(val, str) and val.strip() == sheet_name: + return row + return None + # vertical + for col in range(2, ws.max_column + 1): # assume column A is timeline, so sheet headers start at B + val = ws.cell(row=HEADER_ROW_INDEX, column=col).value + if isinstance(val, str) and val.strip() == sheet_name: + return col + return None + + +def sync_to_overview(source_sheet_name: str, workbook_path: Optional[Path] = None) -> None: + global _CURRENT_WB, _THEME_COLORS_CACHE + wb_path = Path(workbook_path) if workbook_path is not None else LOG_BOOK_PATH + if not wb_path.exists(): + raise FileNotFoundError(f"Workbook not found: {wb_path}") + + wb = load_workbook(wb_path, data_only=False) + _CURRENT_WB = wb + _THEME_COLORS_CACHE = None + + if source_sheet_name not in wb.sheetnames: + raise KeyError(f"Sheet not found: {source_sheet_name}") + if OVERVIEW_SHEET_NAME not in wb.sheetnames: + raise KeyError(f"Overview sheet not found: {OVERVIEW_SHEET_NAME}") + + ws_src = wb[source_sheet_name] + ws_ov = wb[OVERVIEW_SHEET_NAME] + + orientation, timeline = get_overview_timeline(ws_ov) + timeline_indices = [idx for idx, _ in timeline] + + overview_pos = _find_overview_position_for_sheet(ws_ov, source_sheet_name, orientation) + if overview_pos is None: + if orientation == "horizontal": + raise ValueError(f"Could not find row in Overview with name {source_sheet_name!r} in column A") + else: + raise ValueError(f"Could not find column in Overview with header {source_sheet_name!r} in row 1") + + headers = get_log_sheet_headers(ws_src) + + # Clear the destination range first + clear_overview_range(ws_ov, overview_pos, timeline_indices, orientation) + + for row in range(HEADER_ROW_INDEX + 1, ws_src.max_row + 1): + entry = parse_log_row(ws_src, row, headers) + if entry is None: + continue + start_attr = attribute_start_date(entry.start) + end_attr = attribute_end_date(entry.end) + if end_attr < start_attr: + end_attr = start_attr + span = find_bucket_span(timeline, start_attr, end_attr) + if span is None: + continue + idx_start, idx_end = span + apply_log_to_overview(ws_ov, overview_pos, idx_start, idx_end, entry.name, entry.fill, orientation) + + wb.save(wb_path) + # clear global + _CURRENT_WB = None + _THEME_COLORS_CACHE = None + + +def main(sheet: str, workbook: Optional[str] = None) -> None: + """Run sync_to_overview with explicit parameters. + + Args: + sheet: source sheet name (e.g. 'XH-01') + workbook: path to workbook file; if None, use default LOG_BOOK_PATH + """ + wb_path = Path(workbook) if workbook is not None else LOG_BOOK_PATH + sync_to_overview(sheet, wb_path) + + +if __name__ == "__main__": + # Configure these values here and call main directly. + # Edit these lines to change which sheet / workbook are processed. + sheet_name = "XH-01" + workbook_path = str(LOG_BOOK_PATH) + main(sheet_name, workbook_path) diff --git a/src/KSP_tools/KSP_wiki/utils/upload_log_book_to_db.py b/src/KSP_tools/KSP_wiki/utils/upload_log_book_to_db.py new file mode 100644 index 0000000..c53f5ab --- /dev/null +++ b/src/KSP_tools/KSP_wiki/utils/upload_log_book_to_db.py @@ -0,0 +1,151 @@ +"""Upload specified sheets from local_content_dir/log_book.xlsx into the database table public.log_book. + +Behavior: +- Reads each sheet in the provided list (currently set to ["XH-01"]). +- Uses the first row of each sheet as column names; expected to match the existing DB table columns. +- Appends a fixed column "vehicle" with the value equal to the sheet name. +- Skips fully-empty rows. + +Notes: +- The script uses src.pg_handler.db_handler.PgHandler and src.config.load_config.project_config for DB connection info. +- This script performs INSERTs; run with a backup of the workbook if needed. +""" +import os + +from pathlib import Path +from typing import List, Optional +from datetime import datetime, date +from openpyxl import load_workbook + +from src.PostgresSQL.db_handler import PgHandler +from src.config.load_config import project_config + +PROJECT_ROOT = Path(__file__).resolve().parents[4] +KSP_DATA_DIR = os.path.join(PROJECT_ROOT, "data", "KSP_data") + +def _parse_excel_date(value): + if value is None or value == "": + return None + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + if isinstance(value, (int, float)): + return None + if isinstance(value, str): + text = value.strip() + if not text: + return None + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%Y-%m-%d %H:%M:%S"): + try: + return datetime.strptime(text, fmt).date() + except Exception: + continue + return None + + +def _read_sheet(ws): + """Read header row and data rows from worksheet. Returns (headers, rows). + + headers: list of header names (strings) + rows: list of lists of cell values (preserving original types where possible) + """ + headers: List[str] = [] + for col in range(1, ws.max_column + 1): + raw = ws.cell(row=1, column=col).value + if raw is None: + headers.append("") + else: + headers.append(str(raw).strip()) + + data_rows: List[List[Optional[object]]] = [] + for r in range(2, ws.max_row + 1): + row_vals: List[Optional[object]] = [] + non_empty = False + for c in range(1, ws.max_column + 1): + v = ws.cell(row=r, column=c).value + if isinstance(v, (datetime, date)): + v = _parse_excel_date(v) + elif isinstance(v, str): + v = v.strip() + if v not in (None, ""): + non_empty = True + row_vals.append(v) + if non_empty: + data_rows.append(row_vals) + return headers, data_rows + + +def upload_sheets(sheet_names: List[str], workbook_path) -> None: + wb_path = Path(workbook_path) + if not wb_path.exists(): + raise FileNotFoundError(f"Workbook not found: {wb_path}") + + wb = load_workbook(wb_path, data_only=True) + + # DB connection info from project_config + host = project_config.get('db_host') + port = project_config.get('db_port') + user = project_config.get('db_user') + password = project_config.get('db_password') + database = project_config.get('db_name') + + with PgHandler(host=host, port=port, user=user, password=password or None, database=database) as pg: + conn = pg._get_conn() + cur = conn.cursor() + + for sheet_name in sheet_names: + if sheet_name not in wb.sheetnames: + print(f"Sheet not found in workbook: {sheet_name}, skipping") + continue + ws = wb[sheet_name] + headers, rows = _read_sheet(ws) + if not headers or not any(h for h in headers): + print(f"No headers found in sheet {sheet_name}, skipping") + continue + + # Prepare columns: take non-empty header names only + cols = [h for h in headers if h] + if not cols: + print(f"Sheet {sheet_name} has no named columns, skipping") + continue + + # final columns include vehicle + db_columns = cols + ["vehicle"] + col_sql = ", ".join([f'"{c}"' for c in db_columns]) + placeholders = ", ".join(["%s"] * len(db_columns)) + insert_sql = f'INSERT INTO "public"."log_book" ({col_sql}) VALUES ({placeholders})' + + # Build values: map each row (which may contain more columns than headers length) to cols + values_to_insert = [] + for row_vals in rows: + # Truncate/extend row_vals to match number of defined headers + mapped = [] + for i, col_name in enumerate(cols): + if i < len(row_vals): + mapped.append(row_vals[i]) + else: + mapped.append(None) + mapped.append(sheet_name) # vehicle + values_to_insert.append(tuple(mapped)) + + if not values_to_insert: + print(f"No data rows for sheet {sheet_name}, skipping insert") + continue + + try: + cur.executemany(insert_sql, values_to_insert) + conn.commit() + print(f"Inserted {len(values_to_insert)} rows from sheet {sheet_name} into log_book") + except Exception as e: + conn.rollback() + print(f"Failed to insert rows from sheet {sheet_name}: {e}") + + cur.close() + + +if __name__ == "__main__": + WORKBOOK_PATH = os.path.join(KSP_DATA_DIR, 'log_book.xlsx') + SHEETS_TO_UPLOAD: List[str] = ["XH-01"] + upload_sheets(SHEETS_TO_UPLOAD, WORKBOOK_PATH) + diff --git a/src/KSP_tools/KSP_wiki/vessel_log/Stellaria_history.py b/src/KSP_tools/KSP_wiki/vessel_log/Stellaria_history.py new file mode 100644 index 0000000..4a92a30 --- /dev/null +++ b/src/KSP_tools/KSP_wiki/vessel_log/Stellaria_history.py @@ -0,0 +1,101 @@ +operating_history_raw = """ +2050-09-15 First module delivered to LEO via Vulture Shuttle +2053-02-02 Assembly Halted + +2053-05-11 Reconfiguring to ST-01 Stellaria + Only fitted with the Light Speed Engine. Other systems are still Xihe standard. Awaiting further upgrades. +2054-4-28 Reconfiguration Complete + +2054-05-12 Begin Trial Run in Earth Orbit, testing all systems +2054-08-22 Trial Run complete, all systems perform as expected. Including multiple run to GEO and Lunar Orbit before returning to LEO. + +2054-09-07 Begin Deep Space Test Mission + Uncrewed mission to Earth-Sun Lagrange Point 2 to test long-duration operations +2054-11-28 Test Mission Complete, all systems nominal. Returning to LEO. + +2055-12-01 Start maintenance +2055-02-28 Maintenance complete, all systems operational, ready for commissioning. + +2055-03-20 Picking up crew for Inner Solar System Exploration Mission 1 +2055-03-22 Earth Departure for Mecury + Use standard trajectory with 127km/s of departure burn +2055-04-17 Mercury Arrival + First crewed mission to Mercury + 2055-04-18 Orbital Survey of Mercury begins + 2055-06-14 First Crewed Surface Landing on Mercury + 2055-06-15 EVA operations commence + 2055-06-30 Surface operations complete, returning to orbit +2055-08-15 Departure from Mercury for Venus + Use standard trajectory with 85km/s of departure burn +2055-10-01 Venus Arrival + First crewed mission to Venus, no crewed landing attempted due to extreme surface conditions. Probe deployment only. + 2055-10-02 Orbital Survey of Venus begins + 2055-10-10 Landing Probe Deployment + 2055-11-10 Begin Upper Atmosphere Survey via Callisto MPV +2056-03-24 Departure from Venus for Earth + Use standard trajectory with 75km/s of departure burn +2056-04-12 Earth Arrival + +2056-04-15 Start maintenance +2056-06-01 Maintenance complete. + +2056-06-02 Picking up crew for Mars One Construction Mission 2 +2056-06-04 Picking up Mars One construction cargo +2056-06-06 Earth Departure for Mars + Use standard trajectory with 126km/s of departure burn +2056-07-01 Mars Arrival, First crewed mission to Mars using Stellaria + 2056-07-03 Crew descend to Martian Surface, Continuing the construction of Mars One Base + 2056-07-20 Heavy Cargo Delivery to Surface via Callisto MPV + 2056-09-17 Mars One Construction Mission 3 Crew Arrival via XH-01, joining Stellaria crew. First Crew Handover on Mars. + 2056-10-12 Mars One Construction Mission 2 Crew ascend to Stellaria +2056-10-15 Departure from Mars for Earth + Use Fast Return trajectory with 212km/s of departure burn +2056-11-03 Earth Arrival + +2056-11-04 Start maintenance +2056-12-10 Maintenance complete. + +2056-12-15 Start preparations for Europa Outpost Construction Mission 1 +2056-12-20 Europa Outpost Construction Package arrival via Qingtian Cargo Shuttle +2056-12-31 Picking up crew for Europa Outpost Construction Mission 1 +2057-01-02 Earth Departure for Europa + Use standard trajectory with 165km/s of departure burn + + + + +""" + + + + +operating_history = [ + { + "log_name": "Construction", + "start_time": "2050-09-15", + "end_time": "2053-02-02", + "mission_detail": "Construction", + "sub_log": + [ + "2050-09-15 First module delivered to LEO via Vulture Shuttle", + "2053-02-02 Assembly Halted", + ] + }, + { + "log_name": "Reconfiguring to ST-01 Stellaria", + "start_time": "2053-05-11", + "end_time": "2054-4-28", + "mission_detail": "Only fitted with the Light Speed Engine. Other systems are still Xihe standard. Awaiting further upgrades.", + "sub_log":[] + }, + { + 'log_name': "Trial Run", + 'start_time': "2054-05-12", + 'end_time': "2054-8-22", + 'mission_detail': "Trial Run before commissioning", + 'sub_log': [ + "2054-05-12 Begin Trial Run in Earth Orbit, testing all systems", + "2054-08-22 Trial Run complete, all systems perform as expected. Including multiple run to GEO and Lunar Orbit before returning to LEO.", + ] + } +] \ No newline at end of file diff --git a/src/KSP_tools/KSP_wiki/vessel_log/xihe_agent.py b/src/KSP_tools/KSP_wiki/vessel_log/xihe_agent.py new file mode 100644 index 0000000..50402cc --- /dev/null +++ b/src/KSP_tools/KSP_wiki/vessel_log/xihe_agent.py @@ -0,0 +1,323 @@ +import json +import os +import openai + +from pathlib import Path +from openai import OpenAI +from langchain_core.messages import HumanMessage, AIMessage + +from src.config.load_config import LLM, project_config + +PROJECT_ROOT = Path(__file__).parent.parent.parent.parent +DATA_PATH = os.path.join(PROJECT_ROOT, "data") + +xihe_main_wiki_path = os.path.join(PROJECT_ROOT,"local_content_dir","Exploration_Motherships","Xihe", "Xihe.md") +with open(xihe_main_wiki_path, "r", encoding="utf-8") as f: + XIHE_MAIN_WIKI_CONTEXT = f.read() + +with open(os.path.join(PROJECT_ROOT, "local_content_dir", "meta_info.md"), "r", encoding="utf-8") as f: + meta_info_content = f.read() + +user_input = """ +请根据给到你的材料,撰写羲和号(XH-01)的维基百科条目内容。 +你需要撰写多个章节,涵盖羲和号的各个方面。每个章节应当非常详细,内容应当丰富且专业。 +你应当根据提供的信息进行适度的扩展,但不可以脱离提供的信息范围。 +你应当保持中立的语气,避免使用主观评价性的语言。 +运行历史应当作为单独章节列出,时间线应当尽可能完整。 + +以下是羲和号的运行历史: +[ +{ + "log_name": "Construction", + "start_time": "2050-03-15", + "end_time": "2052-05-31", + "mission_detail": "Xihe XH-01 Construction", + "sub_log": + [ + "2050-03-15 First module delivered to LEO via Vulture Shuttle", + "2052-02-06 Assembly completed in LEO", + ] +}, +{ + "log_name": "Trial Run in Earth Orbit", + "start_time": "2052-06-01", + "end_time": "2052-11-30", + "mission_detail": "Trial Run before commissioning", + "sub_log": [] +}, +{ + "log_name": "Deep Space Test Mission", + "start_time": "2053-01-12", + "end_time": "2053-12-18", + "mission_detail": "Long term uncrewed test", + "sub_log": + [ + "2053-01-12 Deep Space Test Mission, uncrewed mission to Earth-Sun L2 point to test deep space operations", + "2053-12-18 Back to LEO" + ] +}, +{ + "log_name": "Maintenance and upgrades", + "start_time": "2054-01-07", + "end_time": "2054-06-28", + "mission_detail": "", + "sub_log": [] +}, +{ + "log_name": "Mars Mission Outbound", + "start_time": "2054-07-10", + "end_time": "2054-08-21", + "mission_detail": "Earth-Mars transfer using standard trajectory with 102km/s of departure burn", + "sub_log": [] +}, +{ + "log_name": "Mars Mission Demo at Mars", + "start_time": "2054-08-21", + "end_time": "2055-01-10", + "mission_detail": "Uncrewed, Deliver the first Mars-One base construction equipment package to Mars orbit.", + "sub_log": + [ + "2054-08-21 Mars Orbit Arrival, deploy equipment package to Mars surface via lander", + "2055-01-10 Mars Departure, return to Earth" + ] +}, +{ + "log_name": "Mars Mission Demo Return to Earth", + "start_time": "2055-01-10", + "end_time": "2055-02-28", + "mission_detail": "Mars-Earth transfer using standard trajectory with 97km/s of departure burn", + "sub_log": [] +}, +{ + "log_name": "Maintenance and upgrades", + "start_time": "2055-03-01", + "end_time": "2055-05-14", + "mission_detail": "", + "sub_log": [] +}, +{ + "log_name": "Mars One base construction mission 1 outbound", + "start_time": "2055-06-15", + "end_time": "2055-07-30", + "mission_detail": "Earth-Mars transfer using standard trajectory with 115km/s of departure burn", + "sub_log": [ + "2055-06-15 Picking up crew for Mars One base construction mission 1", + "2055-06-18 Earth Departure for Mars" + ] +}, +{ + "log_name": "Mars One base construction mission 1 at Mars", + "start_time": "2055-07-30", + "end_time": "2056-02-07", + "mission_detail": "First Mars One base construction mission, begin base construction", + "sub_log": [ + "2055-07-30 Mars Arrival, begin base construction", + "2056-02-07 Mars Departure, return to Earth" + ] +}, +{ + "log_name": "Mars One base construction mission 1 Return to Earth", + "start_time": "2056-02-07", + "end_time": "2056-03-18", + "mission_detail": "Mars-Earth transfer using standard trajectory with 122km/s of departure burn", + "sub_log": [ + "2056-02-07 Mars Departure, return to Earth. First to use quick return trajectory.", + "2056-03-18 Earth Arrival, crew return" + ] +}, +{ + "log_name": "Maintenance and upgrades", + "start_time": "2056-03-19", + "end_time": "2056-06-20", + "mission_detail": "", + "sub_log": [] +}, +{ + "log_name": "Mars One base construction mission 3 outbound", + "start_time": "2056-07-30", + "end_time": "2056-09-15", + "mission_detail": "Earth-Mars transfer using standard trajectory with 95km/s of departure burn", + "sub_log": [] +}, +{ + "log_name": "Mars One base construction mission 3 at Mars", + "start_time": "2056-09-15", + "end_time": "2057-02-12", + "mission_detail": "Third Mars One base construction. First crew hand-off on Mars(with Mars One base construction 2).", + "sub_log": [ + "2056-09-15 Mars Arrival, continue base construction", + "2057-02-12 Mars Departure, return to Earth." + ] +}, +{ + "log_name": "Mars One base construction mission 3 Return to Earth", + "start_time": "2057-02-12", + "end_time": "2057-03-25", + "mission_detail": "Mars-Earth transfer using standard trajectory with 122km/s of departure burn", + "sub_log": [ + "2057-02-12 Mars Departure, return to Earth.", + "2057-03-25 Earth Arrival, crew return." + ] +}, +{ + "log_name": "Maintenance", + "start_time": "2057-03-26", + "end_time": "2057-04-25", + "mission_detail": "", + "sub_log": [] +}, +{ + "log_name": "Mars Expedition 2 Outbound", + "start_time": "2057-05-05", + "end_time": "2057-06-22", + "mission_detail": "Earth-Mars transfer using standard trajectory with 110km/s of departure burn", + "sub_log": [ + "2057-05-05 Picking up crew for Mars Expedition 2", + "2057-05-09 Earth Departure for Mars" + ] +}, +{ + "log_name": "Mars Expedition 2 at Mars", + "start_time": "2057-06-22", + "end_time": "2058-04-18", + "mission_detail": "Second long term Mars Expedition, first mission to Mars One base after the construction is completed. First mission involving space tourism to Mars.", + "sub_log": [ + "2057-06-22 Mars Arrival", + "2057-06-25 Mars EDL and surface operations begin", + "2058-04-10 Begin Mars Departure preparations", + "2058-04-18 Mars Departure for Earth" + ] +}, +{ + "log_name": "Mars Expedition 2 Return to Earth", + "start_time": "2058-04-18", + "end_time": "2058-05-20", + "mission_detail": "Mars-Earth transfer using standard trajectory with 115km/s of departure burn", + "sub_log": [ + "2058-04-18 Mars Departure for Earth", + "2058-05-20 Earth Arrival, crew return" + ] +}, +{ + "log_name": "Maintenance", + "start_time": "2058-05-21", + "end_time": "2058-06-18", + "mission_detail": "", + "sub_log": [] +}, +{ + "log_name": "Europa Expedition 1 Outbound", + "start_time": "2058-07-10", + "end_time": "2058-09-25", + "mission_detail": "Earth-Jupiter transfer using standard trajectory with 160km/s of departure burn", + "sub_log": [ + "2058-07-10 Picking up crew for Europa Expedition 1", + "2058-07-12 Earth Departure for Jupiter system", + "2058-09-25 Europa Arrival" + ] +}, +{ + "log_name": "Europa Expedition 1 at Jupiter System", + "start_time": "2058-09-25", + "end_time": "2059-07-10", + "mission_detail": "First standard crew expedition to Europa Outpost to begin exploration of Jupiter system Long term surface operation at Europa Outpost, followed by exploration of IO and Ganymede", + "sub_log": [ + "2058-09-25 Europa Arrival", + "2058-09-27 Europa descend, begin surface operation at Europa Outpost", + "2058-12-15 Europa ascend, begin Jupiter system exploration", + "2058-12-20 Begin IO exploration", + "2059-01-20 Return to Europa Outpost", + "2059-06-20 Begin Europa Outpost departure preparations", + "2059-06-25 Europa Expedition 1 crew ascend from Europa Outpost", + "2059-07-05 begin Ganymede exploration", + "2059-07-10 Departure for Earth, upgrade planned." + ] +}, +{ + "log_name": "Europa Expedition 1 Return to Earth", + "start_time": "2059-07-10", + "end_time": "2059-11-15", + "mission_detail": "Uses standard trajectory with 112km/s of departure burn Parked to Star Port", + "sub_log": [ + "2059-07-10 Departure for Earth.", + "2059-11-15 Earth Arrival, Park to Star Port." + ] +}, +{ + "log_name": "Maintenance and upgrade", + "start_time": "2059-11-16", + "end_time": "2060-02-19", + "mission_detail": "Greenhouse upgrade to support long term deep space missions", + "sub_log": [] +}, +{ + "log_name": "Europa Expedition 3 Outbound", + "start_time": "2060-02-20", + "end_time": "2060-05-10", + "mission_detail": "Uses standard trajectory with 163km/s of departure burn", + "sub_log": [ + "2060-02-20 Picking up Europa Expedition 3 crew, On its way to explore Jupiter systems before sending crew to Europa Outpost", + ] +}, +] +""" + +prompt = f""" +# 角色 +你是一个航天领域的维基百科撰写专家,精通航天技术和科学。你熟悉维基百科的格式和风格指南,能够撰写符合其标准的内容。 + +# 任务 +你的任务是根据用户提供的信息,帮助用户以维基百科的风格撰写相关的航天器维基百科条目内容。 + +# 注意点 +- 交给你的信息是一个架空世界观下的航天器信息,请基于这些信息撰写内容,避免引入现实世界的航天器知识。 +- 你需要根据提供的信息撰写多个章节,涵盖航天器的各个方面。 +- 你应当根据提供的信息进行适度的扩展,但不可以脱离提供的信息范围。 +- 你应当保持中立的语气,避免使用主观评价性的语言。 +- 你应当以markdown格式输出内容,内容应当仿照维基百科的模式,分成多个章节。 +- 不要提及航天器的研发机构或国家背景,这是一个架空世界观下的航天器,因此不涉及现实世界的国家或组织,也不要编造一个组织或者机构。 +- 对于载具的名称,如果能够在提供的信息中找到对应的中文名称,应当尽量使用中文名称,否则应当遵循原材料中的名称。 +- 避免出现外部链接,所有的超链接都应当是指向维基百科内部的其他条目。 + +# 输出格式要求 +你应当以markdown格式输出内容,内容应当仿照维基百科的模式,分成多个章节。为了保证维基百科的风格,最开始一定要有一个简介段落。 +你的输出一定要保持中立的语气,避免使用主观评价性的语言。 +你应当根据提供的信息进行适度的扩展,但不可以脱离提供的信息范围。 + +你应当以中文来完成撰写 +如果涉及到其他的航天器,你应当以超链接的形式链接到对应的航天器维基百科条目。维基百科的根目录url为: /home。给到你的材料当中已经包含了其他航天器的一些url信息,你可以参考这些信息来创建超链接。如果没有相关信息,你可以自己指定一个。比如说,如果提到“奥丁一号”,你可以创建一个链接指向“/home/Odin-1”: [奥丁一号](/home/Odin-1) + +以下是提供给你的材料: +{XIHE_MAIN_WIKI_CONTEXT} + +以下是一些通用材料: +{meta_info_content} + +以下是你的任务需求: +{user_input} +""" + +message_list = [ + { + "role": "user", + "content": prompt + } +] + +# llm_resp = LLM.stream(prompt) +# full_content = '' +# for chunk in llm_resp: +# print(chunk.content, end="", flush=True) +# full_content += chunk.content + +llm_resp = LLM.invoke(prompt) +full_content = llm_resp.content + +message_list.append( + { + "role": "assistant", + "content": full_content + } +) + +print('done') diff --git a/src/OneNote_tool/__init__.py b/src/OneNote_tool/__init__.py new file mode 100644 index 0000000..e7b1c3b --- /dev/null +++ b/src/OneNote_tool/__init__.py @@ -0,0 +1,28 @@ +"""OneNote_tool package exports utilities to load OneNote documents. + +Currently exposes load_OneNote_document. +""" +import importlib + +try: + # dynamic import to get reference if available + _mod = importlib.import_module(".onenote", __package__) + _load_OneNote_document = getattr(_mod, "load_OneNote_document", None) +except Exception: + _load_OneNote_document = None + + +def load_OneNote_document(*args, **kwargs): + """Load OneNote document (wrapper). + + Uses an eagerly-imported function when available (helps some IDEs/statics); otherwise + imports the implementation lazily to avoid import-time side effects. + """ + if _load_OneNote_document is not None: + return _load_OneNote_document(*args, **kwargs) + mod = importlib.import_module(".onenote", __package__) + func = getattr(mod, "load_OneNote_document") + return func(*args, **kwargs) + + +__all__ = ["load_OneNote_document"] diff --git a/src/OneNote_tool/onenote.py b/src/OneNote_tool/onenote.py new file mode 100644 index 0000000..c30b729 --- /dev/null +++ b/src/OneNote_tool/onenote.py @@ -0,0 +1,323 @@ +"""OneNote loader utilities. + +Provides load_OneNote_document which supports: +- Loading from local exported OneNote HTML directory or single .html/.mht file. +- Loading via Microsoft Graph API when provided with "graph" source and an access token. + +This implementation keeps dependencies optional: only `requests` is required for Graph fetches. +""" +from typing import Optional, Dict, Any, List +import pathlib +import mimetypes +import logging +import re +import email +from email import policy + +logger = logging.getLogger(__name__) + +# Minimal contract: +# - input: path_or_source (str), options (dict) +# - output: Dict with keys: "type" ("local"|"graph"), "pages": list of {"title","content","id","lastModified"} + + +def _read_local_html_file(path: str) -> str: + with open(path, "rb") as f: + data = f.read() + # try to decode utf-8, fallback to latin1 + for enc in ("utf-8", "utf-8-sig", "latin-1"): + try: + return data.decode(enc) + except Exception: + continue + return data.decode("latin-1", errors="ignore") + + +def _decode_part_payload(part) -> str: + # part is an email.message.Message + raw = part.get_payload(decode=True) + if raw is None: + return "" + ch = part.get_content_charset() + if ch: + try: + return raw.decode(ch, errors="replace") + except Exception: + pass + for enc in ("utf-8", "utf-8-sig", "latin-1"): + try: + return raw.decode(enc) + except Exception: + continue + return raw.decode("latin-1", errors="replace") + + +def _extract_title_from_html(html: str) -> str: + m = re.search(r"(.*?)", html, flags=re.I | re.S) + if m: + return m.group(1).strip() + # try to find first h1/h2 as fallback + m2 = re.search(r"]*>(.*?)", html, flags=re.I | re.S) + if m2: + return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", m2.group(1))).strip() + return "(untitled)" + + +def _split_html_into_pages(html: str) -> List[str]: + """Heuristic split of a single HTML that may contain multiple OneNote pages. + + Tries multiple strategies in order and returns the first that yields more than one fragment: + 1. class-based:
+ 2. id-based:
+ 3. header-based: split by repeated

(or

) elements which often mark page titles + + Returns list of HTML fragments (each a standalone-ish HTML string) or single-element list if heuristics fail. + """ + # 1) class-based (existing) + pattern_class = re.compile(r"]+class=[\"'][^\"']*(?:page|onenote-page|onenotepage|oneNotePage)[^\"']*[\"'][^>]*>", flags=re.I) + matches = list(pattern_class.finditer(html)) + if len(matches) > 1: + parts = [] + head_match = re.search(r"(.*?)", html, flags=re.I | re.S) + head_html = head_match.group(0) if head_match else "" + for i, m in enumerate(matches): + start = m.start() + end = matches[i + 1].start() if i + 1 < len(matches) else len(html) + body_fragment = html[start:end] + frag = "" + head_html + "" + body_fragment + "" + parts.append(frag) + return parts + + # 2) id-based: look for divs whose id begins with 'Page' or 'page' or 'id="P' common OneNote exports + pattern_id = re.compile(r"]+id=[\"']?(?:Page|page|P)_[^\s\"'>]*[\"']?[^>]*>", flags=re.I) + matches_id = list(pattern_id.finditer(html)) + if len(matches_id) > 1: + parts = [] + head_match = re.search(r"(.*?)", html, flags=re.I | re.S) + head_html = head_match.group(0) if head_match else "" + for i, m in enumerate(matches_id): + start = m.start() + end = matches_id[i + 1].start() if i + 1 < len(matches_id) else len(html) + body_fragment = html[start:end] + frag = "" + head_html + "" + body_fragment + "" + parts.append(frag) + return parts + + # 3) header-based: split by repeated

(or

) which often denote page titles in OneNote exports + h1_matches = list(re.finditer(r"]*>", html, flags=re.I)) + if len(h1_matches) > 1: + parts = [] + head_match = re.search(r"(.*?)", html, flags=re.I | re.S) + head_html = head_match.group(0) if head_match else "" + for i, m in enumerate(h1_matches): + start = m.start() + end = h1_matches[i + 1].start() if i + 1 < len(h1_matches) else len(html) + body_fragment = html[start:end] + frag = "" + head_html + "" + body_fragment + "" + parts.append(frag) + return parts + + # fallback: no multi-page split detected + return [html] + + +def _parse_mht_bytes(data: bytes) -> List[Dict[str, Any]]: + """Parse MHT/MHTML bytes and return a list of pages (dicts with id/title/content). + + Strategy: + - Use email.parser.BytesParser to parse the multipart MHT. + - For each part with content-type text/html, treat as one page. + - If there's only a single html part but it seems to contain multiple pages, try heuristic splitting. + """ + msg = email.parser.BytesParser(policy=policy.default).parsebytes(data) + html_parts: List[str] = [] + ids: List[str] = [] + # walk parts + if msg.is_multipart(): + for part in msg.walk(): + ctype = part.get_content_type() + if ctype == "text/html": + try: + html = _decode_part_payload(part) + except Exception: + html = _decode_part_payload(part) + html_parts.append(html) + # try to get a useful id from Content-Location or Content-ID + loc = part.get("Content-Location") or part.get("Content-ID") or "" + ids.append(loc) + else: + # not multipart: try parse as text/html + ctype = msg.get_content_type() + if ctype == "text/html": + html_parts.append(_decode_part_payload(msg)) + ids.append("") + pages: List[Dict[str, Any]] = [] + if len(html_parts) == 0: + return pages + if len(html_parts) == 1: + # maybe this single html contains multiple OneNote pages; try splitting heuristically + subpages = _split_html_into_pages(html_parts[0]) + if len(subpages) > 1: + for idx, sp in enumerate(subpages): + title = _extract_title_from_html(sp) or f"page_{idx}" + pages.append({"id": ids[0] or f"mht_part_{idx}", "title": title, "content": sp}) + return pages + # otherwise single page + title = _extract_title_from_html(html_parts[0]) + pages.append({"id": ids[0] or "mht_root", "title": title, "content": html_parts[0]}) + return pages + # multiple html parts -> each is a page + for idx, html in enumerate(html_parts): + title = _extract_title_from_html(html) + pages.append({"id": ids[idx] if idx < len(ids) else f"mht_part_{idx}", "title": title, "content": html}) + return pages + + +def load_OneNote_document(path_or_source: str, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Load OneNote content. + + If `path_or_source` is a local filesystem path to a directory or file, load local HTML/MHT exports. + If `path_or_source` == "graph", options must contain `access_token` and either `notebook_id` or `site_id` + `notebook_id`. + + Returns a dict with parsed pages. + """ + options = options or {} + + p = pathlib.Path(path_or_source) + if path_or_source == "graph": + # fetch via Microsoft Graph + token = options.get("access_token") + if not token: + raise ValueError("Graph source requires 'access_token' in options") + notebook_id = options.get("notebook_id") + # simple graph pagination for pages + import requests + + headers = {"Authorization": f"Bearer {token}"} + pages = [] + if not notebook_id: + # try current user's notebooks + url = "https://graph.microsoft.com/v1.0/me/onenote/notebooks" + else: + url = f"https://graph.microsoft.com/v1.0/me/onenote/notebooks/{notebook_id}/sections" + resp = requests.get(url, headers=headers) + resp.raise_for_status() + data = resp.json() + # This is a thin implementation: collect pages by iterating sections then pages + sections = data.get("value", []) + for sec in sections: + sec_id = sec.get("id") + sec_title = sec.get("displayName") + # list pages in section + pages_url = f"https://graph.microsoft.com/v1.0/me/onenote/sections/{sec_id}/pages" + r2 = requests.get(pages_url, headers=headers) + if r2.status_code != 200: + continue + for pjson in r2.json().get("value", []): + content_url = pjson.get("contentUrl") + page_id = pjson.get("id") + title = pjson.get("title") + last_modified = pjson.get("lastModifiedDateTime") + # fetch content (HTML) + try: + c_resp = requests.get(content_url, headers=headers) + c_resp.raise_for_status() + content = c_resp.text + except Exception as e: + logger.exception("Failed to fetch OneNote page content: %s", e) + content = "" + pages.append({"id": page_id, "title": title, "content": content, "lastModified": last_modified, "section": sec_title}) + return {"type": "graph", "pages": pages} + + # Local path handling + if not p.exists(): + raise FileNotFoundError(f"Path not found: {path_or_source}") + + pages = [] + if p.is_file(): + mtype, _ = mimetypes.guess_type(p.as_posix()) + if (p.suffix.lower() == ".mht") or (mtype == "message/rfc822") or (p.suffix.lower() == ".mhtml"): + # .mht parsing: try to parse parts and split into pages + with open(p.as_posix(), "rb") as f: + data = f.read() + try: + mht_pages = _parse_mht_bytes(data) + if mht_pages: + pages.extend(mht_pages) + else: + # fallback to raw read + content = _read_local_html_file(p.as_posix()) + pages.append({"id": p.name, "title": p.stem, "content": content}) + except Exception as e: + logger.exception("Failed to parse MHT file %s: %s", p, e) + content = _read_local_html_file(p.as_posix()) + pages.append({"id": p.name, "title": p.stem, "content": content}) + else: + content = _read_local_html_file(p.as_posix()) + pages.append({"id": p.name, "title": p.stem, "content": content}) + return {"type": "local", "pages": pages} + + # directory: search for .html/.htm/.mht/.mhtml + exts = (".html", ".htm", ".mht", ".mhtml") + for fp in sorted(p.rglob("*")): + if fp.suffix.lower() in exts and fp.is_file(): + try: + if fp.suffix.lower() in (".mht", ".mhtml"): + with open(fp.as_posix(), "rb") as f: + data = f.read() + mht_pages = _parse_mht_bytes(data) + if mht_pages: + for mp in mht_pages: + pages.append({"id": fp.as_posix() + "::" + mp.get("id", ""), "title": mp.get("title"), "content": mp.get("content")}) + continue + # else fall through to raw read + content = _read_local_html_file(fp.as_posix()) + except Exception as e: + logger.exception("Failed to read file %s: %s", fp, e) + continue + # try to extract title from HTML + m = re.search(r"(.*?)", content, flags=re.I | re.S) + title = m.group(1).strip() if m else fp.stem + pages.append({"id": fp.as_posix(), "title": title, "content": content}) + return {"type": "local", "pages": pages} + + +def _safe_filename(s: str) -> str: + # create a filesystem-safe filename + s = s or "page" + s = re.sub(r"[\\\/\:\*\?\"<>\|]", "_", s) + s = re.sub(r"\s+", "_", s) + return s[:180] + + +def _maybe_debug_save(pages: List[Dict[str, Any]], options: Optional[Dict[str, Any]] = None) -> None: + """If options contains 'debug_save_dir', save each page's HTML into that directory for inspection.""" + options = options or {} + outdir = options.get("debug_save_dir") + if not outdir: + return + outp = pathlib.Path(outdir) + try: + outp.mkdir(parents=True, exist_ok=True) + except Exception: + return + for idx, page in enumerate(pages): + try: + title = page.get("title") or f"page_{idx}" + fname = f"{idx:03d}_{_safe_filename(title)}.html" + fp = outp.joinpath(fname) + with open(fp, "w", encoding="utf-8") as f: + f.write(page.get("content") or "") + except Exception: + logger.exception("Failed to write debug page %s", page.get("id")) + + +# Small smoke-run when executed directly +if __name__ == "__main__": + import sys + if len(sys.argv) > 1: + path = sys.argv[1] + else: + path = r'C:\Users\zhouyr9\Documents\2025.mht' + out = load_OneNote_document(path) + print(f"Found {len(out['pages'])} pages")