diff --git a/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/inspect_workbook.py b/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/inspect_workbook.py index 11fc0e3457..a9975ff303 100644 --- a/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/inspect_workbook.py +++ b/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/inspect_workbook.py @@ -7,6 +7,7 @@ import csv import json import zipfile +from datetime import datetime, time from pathlib import Path import chardet @@ -56,6 +57,35 @@ def _inspect_delimited(path: Path, sample_rows: int, sample_cols: int) -> dict: } +def _xls_cell_value(cell: xlrd.sheet.Cell, datemode: int) -> object: + """Read one xlrd cell as its value, the way the XLSX path already reads one. + + xlrd hands back the raw storage: a date is the serial number Excel keeps it + as, a boolean is 1 or 0, and every number is a double, so a whole number + arrives as 12.0. Left alone, the same workbook is described one way as .xls + and another as .xlsx, and the date cannot be recovered from the sample. + """ + if cell.ctype == xlrd.XL_CELL_DATE: + try: + year, month, day, hour, minute, second = xlrd.xldate_as_tuple( + cell.value, datemode + ) + except (ValueError, xlrd.XLDateError): + return cell.value + if (year, month, day) == (0, 0, 0): + # A time-only cell has no date part; openpyxl reads one as a time. + return time(hour, minute, second) + return datetime(year, month, day, hour, minute, second) + if cell.ctype == xlrd.XL_CELL_BOOLEAN: + return bool(cell.value) + if cell.ctype == xlrd.XL_CELL_NUMBER and float(cell.value).is_integer(): + return int(cell.value) + if cell.ctype == xlrd.XL_CELL_ERROR: + # openpyxl reports the text Excel shows, e.g. #DIV/0! + return xlrd.error_text_from_code.get(cell.value, "") + return cell.value + + def _inspect_xls(path: Path, sample_rows: int, sample_cols: int) -> dict: """Inspect a legacy XLS workbook. @@ -71,13 +101,17 @@ def _inspect_xls(path: Path, sample_rows: int, sample_cols: int) -> dict: sheets = [] for name in workbook.sheet_names(): sheet = workbook.sheet_by_name(name) + columns = min(sheet.ncols, sample_cols) sheets.append( { "name": name, "rows": sheet.nrows, "columns": sheet.ncols, "sample": [ - sheet.row_values(row, end_colx=min(sheet.ncols, sample_cols)) + [ + _xls_cell_value(cell, workbook.datemode) + for cell in sheet.row_slice(row, 0, columns) + ] for row in range(min(sheet.nrows, sample_rows)) ], } diff --git a/pyproject.toml b/pyproject.toml index 14406011ac..13a0322b80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ dev = [ "pytest>=8.4.1", "pytest-asyncio>=1.1.0", "pytest-cov>=6.2.1", + "xlwt>=1.3.0", # writes the legacy .xls fixtures the spreadsheet skill tests read "ruff==0.15.22", ] diff --git a/tests/test_builtin_office_skills.py b/tests/test_builtin_office_skills.py index aae86d47c5..27448a22e9 100644 --- a/tests/test_builtin_office_skills.py +++ b/tests/test_builtin_office_skills.py @@ -74,6 +74,98 @@ def test_spreadsheet_skill_converts_inspects_and_validates_csv(tmp_path: Path) - assert json.loads(validated.stdout)["valid"] is True +def test_spreadsheet_skill_inspects_legacy_xls_values_not_their_storage( + tmp_path: Path, +) -> None: + """xlrd hands back the raw storage, so the two formats described one sheet + two different ways: a date as its serial number, a boolean as 1, and a whole + number as a float. + """ + import pytest + + xlwt = pytest.importorskip("xlwt") + + import datetime + + date_style = xlwt.XFStyle() + date_style.num_format_str = "YYYY-MM-DD" + + book = xlwt.Workbook() + sheet = book.add_sheet("Data") + for column, heading in enumerate(["When", "Active", "Units", "Rate"]): + sheet.write(0, column, heading) + sheet.write(1, 0, datetime.date(2024, 1, 1), date_style) + sheet.write(1, 1, True) + sheet.write(1, 2, 12) + sheet.write(1, 3, 1.5) + legacy = tmp_path / "legacy.xls" + book.save(legacy) + + modern = tmp_path / "modern.xlsx" + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "Data" + worksheet.append(["When", "Active", "Units", "Rate"]) + worksheet.append([datetime.date(2024, 1, 1), True, 12, 1.5]) + workbook.save(modern) + workbook.close() + + inspected_xls = _run_script(SPREADSHEET_SCRIPTS / "inspect_workbook.py", legacy) + inspected_xlsx = _run_script(SPREADSHEET_SCRIPTS / "inspect_workbook.py", modern) + + assert inspected_xls.returncode == 0, inspected_xls.stderr + assert inspected_xlsx.returncode == 0, inspected_xlsx.stderr + + xls_sample = json.loads(inspected_xls.stdout)["sheets"][0]["sample"] + xlsx_sample = json.loads(inspected_xlsx.stdout)["sheets"][0]["sample"] + + # [45292.0, 1, 12.0, 1.5] before this. + assert xls_sample[1] == ["2024-01-01 00:00:00", True, 12, 1.5] + assert xls_sample == xlsx_sample + + +def test_spreadsheet_skill_inspects_a_legacy_xls_time_only_cell( + tmp_path: Path, +) -> None: + """A time carries no date, so xlrd reports year, month and day as zero.""" + import pytest + + xlwt = pytest.importorskip("xlwt") + + import datetime + + time_style = xlwt.XFStyle() + time_style.num_format_str = "HH:MM:SS" + + book = xlwt.Workbook() + sheet = book.add_sheet("Data") + sheet.write(0, 0, "Starts") + sheet.write(1, 0, datetime.time(12, 0, 0), time_style) + legacy = tmp_path / "legacy.xls" + book.save(legacy) + + modern = tmp_path / "modern.xlsx" + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "Data" + worksheet.append(["Starts"]) + worksheet.append([datetime.time(12, 0, 0)]) + workbook.save(modern) + workbook.close() + + inspected_xls = _run_script(SPREADSHEET_SCRIPTS / "inspect_workbook.py", legacy) + inspected_xlsx = _run_script(SPREADSHEET_SCRIPTS / "inspect_workbook.py", modern) + + assert inspected_xls.returncode == 0, inspected_xls.stderr + assert inspected_xlsx.returncode == 0, inspected_xlsx.stderr + + xls_sample = json.loads(inspected_xls.stdout)["sheets"][0]["sample"] + xlsx_sample = json.loads(inspected_xlsx.stdout)["sheets"][0]["sample"] + + assert xls_sample[1] == ["12:00:00"] + assert xls_sample == xlsx_sample + + def test_spreadsheet_skill_rejects_broken_formula_reference(tmp_path: Path) -> None: path = tmp_path / "broken.xlsx" workbook = Workbook()