diff --git a/README.md b/README.md index 38fc388..9e9e53c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit ## Kompatibilität -Die Version `0.5.3` zielt auf NetBox 4.x (mindestens 4.0). Vor einem produktiven Rollout sollte das Plugin gegen die konkret eingesetzte NetBox-Minor-Version in einer Testinstanz geprüft werden. +Die Version `0.5.4` zielt auf NetBox 4.x (mindestens 4.0). Vor einem produktiven Rollout sollte das Plugin gegen die konkret eingesetzte NetBox-Minor-Version in einer Testinstanz geprüft werden. ## Installation @@ -125,7 +125,7 @@ Alte binäre `.doc`- und `.xls`-Dateien müssen vorher in `.docx` bzw. `.xlsx` k Für große Excel-Dateien kann auf der Importseite **„Excel mit mehreren Arbeitsblättern – je Blatt eine Dokumentation“** aktiviert werden. Dazu ist ein Zielordner verpflichtend. Jedes nicht leere Arbeitsblatt wird als eigene Dokumentation mit dem Blattnamen als Titel angelegt und erhält eine eigene Kopie der Original-Exceldatei als Anhang. Normale eingebettete PNG-, JPEG-, GIF- und WebP-Bilder werden dem jeweiligen Dokument als Medienanhang hinzugefügt. Diagramme, SmartArt, Steuerelemente und extern verknüpfte Bilder können nicht zuverlässig übernommen werden. -Mit **„Excel-Tabellen glätten“** werden die Zellen ohne künstliche Datensatz- oder Feldbezeichnungen in ihrer Lesereihenfolge übernommen. Zellen einer Excel-Zeile erscheinen als einzelne Textzeilen eines Absatzes; zwischen Excel-Zeilen wird ein Absatz eingefügt. Das Ergebnis ähnelt damit einer Übertragung nach Word ohne Tabelle. Nicht geglättete Tabellen werden in der Dokumentansicht automatisch kompakt dargestellt und bei Bedarf innerhalb des Inhalts horizontal scrollbar, ohne das NetBox-Layout zu verbreitern. +Mit **„Excel-Tabellen glätten“** werden normale Zellen ohne künstliche Datensatz- oder Feldbezeichnungen in ihrer Lesereihenfolge übernommen. Zellen einer Excel-Zeile erscheinen als einzelne Textzeilen eines Absatzes; zwischen Excel-Zeilen wird ein Absatz eingefügt. Bereiche, die in Excel ausdrücklich über **Einfügen → Tabelle** als strukturierte Tabelle gekennzeichnet wurden, bleiben dagegen als Tabelle erhalten. Rein optisch mit Rahmen oder Farben formatierte Bereiche gelten nicht als Tabelle. Nicht geglättete Tabellen werden in der Dokumentansicht automatisch kompakt dargestellt und bei Bedarf innerhalb des Inhalts horizontal scrollbar, ohne das NetBox-Layout zu verbreitern. ## REST-API diff --git a/netbox_documentation/__init__.py b/netbox_documentation/__init__.py index 9311ba2..560a079 100644 --- a/netbox_documentation/__init__.py +++ b/netbox_documentation/__init__.py @@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig): name = "netbox_documentation" verbose_name = "NetBox Dokumentation" description = "Wiki und Office-Dokumentation direkt in NetBox" - version = "0.5.3" + version = "0.5.4" author = "LKE" base_url = "documentation" min_version = "4.0.0" diff --git a/netbox_documentation/forms.py b/netbox_documentation/forms.py index 4c68a14..b599bde 100644 --- a/netbox_documentation/forms.py +++ b/netbox_documentation/forms.py @@ -134,7 +134,7 @@ class ImportForm(forms.Form): required=False, label=help_label( "Excel-Tabellen glätten", - "Übernimmt alle Zellen ohne Tabelle in Lesereihenfolge als dokumentartigen Text. Es werden keine Datensatz- oder Feldbezeichnungen erzeugt.", + "Übernimmt normale Zellen ohne Tabelle als dokumentartigen Text. Nur in Excel ausdrücklich über Einfügen → Tabelle definierte Bereiche bleiben Tabellen.", ), ) category = forms.ModelChoiceField( diff --git a/netbox_documentation/importers.py b/netbox_documentation/importers.py index 2afb2ee..fed0d6b 100644 --- a/netbox_documentation/importers.py +++ b/netbox_documentation/importers.py @@ -80,7 +80,7 @@ def _escape_markdown_text(value): def _xlsx(content, flatten=False): from openpyxl import load_workbook - workbook = load_workbook(BytesIO(content), read_only=True, data_only=True) + workbook = load_workbook(BytesIO(content), read_only=not flatten, data_only=True) sections = [] for sheet in workbook.worksheets: rows = [["" if cell is None else str(cell) for cell in row] for row in sheet.iter_rows(values_only=True)] @@ -88,7 +88,8 @@ def _xlsx(content, flatten=False): rows.pop() if not rows: continue - sections.append(f"## {sheet.title}\n\n{_render_excel_rows(rows, flatten)}") + rendered = _render_flattened_worksheet(sheet) if flatten else _render_excel_rows(rows) + sections.append(f"## {sheet.title}\n\n{rendered}") if not sections: raise ImportFailure("Die Arbeitsmappe enthält keine Daten.") return ImportResult("\n\n".join(sections)) @@ -131,7 +132,7 @@ def import_excel_sheets(upload, flatten=False) -> list[ExcelSheetResult]: if not rows and not images: continue if rows: - markdown = _render_excel_rows(rows, flatten) + markdown = _render_flattened_worksheet(sheet) if flatten else _render_excel_rows(rows) else: markdown = "" results.append(ExcelSheetResult(title=sheet.title, markdown=markdown, images=images)) @@ -145,6 +146,51 @@ def slugify_filename(value): return value[:80] or "arbeitsblatt" +def _render_flattened_worksheet(sheet): + """Flatten ordinary cells while preserving explicitly defined Excel tables.""" + from openpyxl.utils.cell import range_boundaries + + table_ranges = [] + for table in sheet.tables.values(): + min_col, min_row, max_col, max_row = range_boundaries(table.ref) + table_ranges.append((min_col, min_row, max_col, max_row)) + table_ranges.sort(key=lambda item: (item[1], item[0])) + + def containing_table(row, column): + for bounds in table_ranges: + min_col, min_row, max_col, max_row = bounds + if min_row <= row <= max_row and min_col <= column <= max_col: + return bounds + return None + + blocks = [] + rendered_tables = set() + for row_number in range(1, sheet.max_row + 1): + ordinary_values = [] + tables_starting_here = [] + for column_number in range(1, sheet.max_column + 1): + bounds = containing_table(row_number, column_number) + if bounds: + if bounds[1] == row_number and bounds not in rendered_tables: + tables_starting_here.append(bounds) + rendered_tables.add(bounds) + continue + value = sheet.cell(row=row_number, column=column_number).value + if value not in (None, ""): + ordinary_values.append(_escape_markdown_text(value)) + if ordinary_values: + blocks.append(" \n".join(ordinary_values)) + for min_col, min_row, max_col, max_row in tables_starting_here: + rows = [] + for table_row in sheet.iter_rows( + min_row=min_row, max_row=max_row, min_col=min_col, max_col=max_col, values_only=True + ): + rows.append(["" if value is None else str(value) for value in table_row]) + if rows: + blocks.append(_render_excel_rows(rows, flatten=False)) + return "\n\n".join(blocks) or "_Keine Inhalte_" + + def _pdf(content): from pypdf import PdfReader reader = PdfReader(BytesIO(content)) diff --git a/pyproject.toml b/pyproject.toml index d2238e9..e0cea91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-documentation" -version = "0.5.3" +version = "0.5.4" description = "Integrated Markdown wiki and office document importer for NetBox" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_importers.py b/tests/test_importers.py index 5ee76f7..fa97469 100644 --- a/tests/test_importers.py +++ b/tests/test_importers.py @@ -5,6 +5,7 @@ import sys import pytest from openpyxl import Workbook +from openpyxl.worksheet.table import Table spec = importlib.util.spec_from_file_location( @@ -81,3 +82,24 @@ def test_excel_flatten_turns_cells_into_document_flow(): assert "Datensatz" not in results[0].markdown assert "Feld" not in results[0].markdown assert "|" not in results[0].markdown + + +def test_excel_flatten_preserves_explicit_excel_tables_only(): + workbook = Workbook() + sheet = workbook.active + sheet["A1"] = "Name" + sheet["B1"] = "IP" + sheet["A2"] = "web01" + sheet["B2"] = "10.0.0.1" + sheet["D1"] = "Normaler Hinweis" + sheet.add_table(Table(displayName="ServerTable", ref="A1:B2")) + stream = BytesIO() + workbook.save(stream) + + results = import_excel_sheets(Upload(stream.getvalue(), "server.xlsx"), flatten=True) + markdown = results[0].markdown + + assert "Normaler Hinweis" in markdown + assert "| Name" in markdown + assert "| web01" in markdown + assert "Datensatz" not in markdown