diff --git a/README.md b/README.md
index 4b24dbd..a648218 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ CLI tool to query LDAP/AD servers
* JSON stream (with detailed or simplified attribute values)
* CSV
* Markdown table with stretched columns (for viewing in CLI/for monospaces fonts)
+ * HTML
* Decodes certain well-known attributes (UUIDs, Timestamps, SID, userAccountControl)
* Requires server to support [RFC 2696: Simple Paged Results](https://www.rfc-editor.org/rfc/rfc2696) for proper pagination
* By default the first 1000 entries are shown, and it errors if there are more results
diff --git a/src/ldaptool/_main.py b/src/ldaptool/_main.py
index da67c45..60f081e 100644
--- a/src/ldaptool/_main.py
+++ b/src/ldaptool/_main.py
@@ -4,6 +4,7 @@ import argparse
import csv
import dataclasses
import enum
+import html
import subprocess
import sys
import typing
@@ -16,6 +17,13 @@ from ldaptool._utils.ldap import Result, SizeLimitExceeded
class TableOutput(enum.StrEnum):
MARKDOWN = "markdown"
CSV = "csv"
+ HTML = "html"
+
+
+def _html_escape_line(columns: typing.Sequence[str], *, cell: str = "td") -> str:
+ cell_s = f"<{cell}>"
+ cell_e = f"{cell}>"
+ return "
" + ("".join(cell_s + html.escape(col) + cell_e for col in columns)) + "
\n"
@dataclasses.dataclass(slots=True, kw_only=True)
@@ -35,6 +43,12 @@ class Arguments(search.Arguments):
),
)
table_output: typing.Optional[TableOutput] = None
+ html: bool = dataclasses.field(
+ default=False,
+ metadata=argclasses.arg(
+ help="HTML table output - requires list of attributes",
+ ),
+ )
sort: bool = dataclasses.field(
default=False,
metadata=argclasses.arg(
@@ -45,14 +59,16 @@ class Arguments(search.Arguments):
def __post_init__(self) -> None:
super(Arguments, self).__post_init__() # super() not working here, unclear why.
- # pick at most one in csv, (markdown) table
- if [self.csv, self.table].count(True) > 1:
+ # pick at most one in csv, (markdown) table, html
+ if [self.csv, self.table, self.html].count(True) > 1:
raise SystemExit("Can't use more than one table output type")
if self.csv:
self.table_output = TableOutput.CSV
elif self.table:
self.table_output = TableOutput.MARKDOWN
+ elif self.html:
+ self.table_output = TableOutput.HTML
if self.sort and self.table_output is None:
# default to markdown table
@@ -135,11 +151,20 @@ class _Context:
if self.arguments.sort:
line_iterator = sorted(line_iterator)
- csv_out = csv.writer(stream, lineterminator="\n")
- csv_out.writerow(self.arguments.columns)
+ if self.arguments.table_output in [TableOutput.CSV, TableOutput.MARKDOWN]:
+ csv_out = csv.writer(stream, lineterminator="\n")
+ csv_out.writerow(self.arguments.columns)
- for line in line_iterator:
- csv_out.writerow(line)
+ for line in line_iterator:
+ csv_out.writerow(line)
+ else:
+ assert self.arguments.table_output == TableOutput.HTML
+
+ stream.write("\n")
+ stream.write(_html_escape_line(self.arguments.columns, cell="th"))
+ for line in line_iterator:
+ stream.write(_html_escape_line(line))
+ stream.write("
\n")
def _ldif_or_json_output(self, search_iterator: typing.Iterable[Result], *, stream: typing.IO[str]) -> None:
decoder = decode.Decoder(arguments=self.arguments)