agent-folder-workspace 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. agent_folder_workspace/__init__.py +42 -0
  2. agent_folder_workspace/__main__.py +6 -0
  3. agent_folder_workspace/adapters/__init__.py +5 -0
  4. agent_folder_workspace/adapters/base.py +93 -0
  5. agent_folder_workspace/adapters/legacy_office.py +552 -0
  6. agent_folder_workspace/adapters/manager.py +178 -0
  7. agent_folder_workspace/adapters/odf.py +636 -0
  8. agent_folder_workspace/adapters/ooxml.py +645 -0
  9. agent_folder_workspace/adapters/pdf.py +680 -0
  10. agent_folder_workspace/adapters/sqlite.py +326 -0
  11. agent_folder_workspace/adapters/structured.py +280 -0
  12. agent_folder_workspace/adapters/text.py +145 -0
  13. agent_folder_workspace/alternative_backends.py +329 -0
  14. agent_folder_workspace/backend_conversion_worker.py +232 -0
  15. agent_folder_workspace/backend_probe_worker.py +347 -0
  16. agent_folder_workspace/backends.py +321 -0
  17. agent_folder_workspace/cache.py +395 -0
  18. agent_folder_workspace/cfb.py +425 -0
  19. agent_folder_workspace/cli.py +139 -0
  20. agent_folder_workspace/config.py +37 -0
  21. agent_folder_workspace/cursors.py +46 -0
  22. agent_folder_workspace/errors.py +27 -0
  23. agent_folder_workspace/ids.py +42 -0
  24. agent_folder_workspace/installer.py +180 -0
  25. agent_folder_workspace/legacy.py +697 -0
  26. agent_folder_workspace/mcp_server.py +179 -0
  27. agent_folder_workspace/models.py +158 -0
  28. agent_folder_workspace/opencode/plugin/agent-folder-workspace.ts.tmpl +41 -0
  29. agent_folder_workspace/opencode/skill/SKILL.md +24 -0
  30. agent_folder_workspace/parser_workers.py +690 -0
  31. agent_folder_workspace/path_safety.py +28 -0
  32. agent_folder_workspace/probe_fixtures/probe.doc +0 -0
  33. agent_folder_workspace/probe_fixtures/probe.ppt +0 -0
  34. agent_folder_workspace/probe_fixtures/probe.xls +0 -0
  35. agent_folder_workspace/py.typed +1 -0
  36. agent_folder_workspace/registry.py +394 -0
  37. agent_folder_workspace/schemas/CapabilityReportV1.schema.json +121 -0
  38. agent_folder_workspace/schemas/ContentPageV1.schema.json +138 -0
  39. agent_folder_workspace/schemas/DiagnosticV1.schema.json +49 -0
  40. agent_folder_workspace/schemas/NodePageV1.schema.json +340 -0
  41. agent_folder_workspace/schemas/NodeV1.schema.json +289 -0
  42. agent_folder_workspace/schemas/SearchPageV1.schema.json +95 -0
  43. agent_folder_workspace/workspace.py +1141 -0
  44. agent_folder_workspace-0.1.0.dist-info/METADATA +407 -0
  45. agent_folder_workspace-0.1.0.dist-info/RECORD +48 -0
  46. agent_folder_workspace-0.1.0.dist-info/WHEEL +4 -0
  47. agent_folder_workspace-0.1.0.dist-info/entry_points.txt +2 -0
  48. agent_folder_workspace-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,636 @@
1
+ """Safe, direct ODF ZIP/XML hierarchy without a LibreOffice runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path, PurePosixPath
6
+ from typing import Any, cast
7
+ from zipfile import BadZipFile, ZipFile, ZipInfo
8
+
9
+ from defusedxml import ElementTree as SafeElementTree # type: ignore[import-untyped]
10
+ from defusedxml.common import DefusedXmlException # type: ignore[import-untyped]
11
+
12
+ from agent_folder_workspace.adapters.base import BaseAdapter, ReadResult, VirtualNodeSpec
13
+ from agent_folder_workspace.config import WorkspaceConfig
14
+ from agent_folder_workspace.errors import InputValidationError, ResourceLimitError
15
+ from agent_folder_workspace.models import NodeKind, NodeV1
16
+
17
+ _OFFICE = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"
18
+ _TEXT = "urn:oasis:names:tc:opendocument:xmlns:text:1.0"
19
+ _TABLE = "urn:oasis:names:tc:opendocument:xmlns:table:1.0"
20
+ _DRAW = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
21
+ _MANIFEST = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"
22
+ _BLOCK_SIZE = 1_000
23
+
24
+
25
+ class OdfAdapter(BaseAdapter):
26
+ """Expose ODT, ODS, and ODP semantics alongside every package part."""
27
+
28
+ def __init__(self, family: str) -> None:
29
+ if family not in {"text", "spreadsheet", "presentation"}:
30
+ raise ValueError(f"unsupported ODF family: {family}")
31
+ self.family = family
32
+
33
+ def _infos(self, source: Path, config: WorkspaceConfig) -> list[ZipInfo]:
34
+ try:
35
+ with ZipFile(source) as package:
36
+ infos = package.infolist()
37
+ except (BadZipFile, OSError) as error:
38
+ raise InputValidationError(f"invalid ODF package: {source.name}") from error
39
+ if len(infos) > config.max_zip_entries:
40
+ raise ResourceLimitError(f"ODF package exceeds {config.max_zip_entries} entries")
41
+ if sum(info.file_size for info in infos) > config.max_zip_uncompressed_bytes:
42
+ raise ResourceLimitError(
43
+ f"ODF package exceeds {config.max_zip_uncompressed_bytes} uncompressed bytes"
44
+ )
45
+ seen: set[str] = set()
46
+ for info in infos:
47
+ name = info.filename
48
+ path = PurePosixPath(name)
49
+ if name.startswith("/") or "\\" in name or ".." in path.parts:
50
+ raise InputValidationError(f"unsafe ODF package part name: {name}")
51
+ if name in seen:
52
+ raise InputValidationError(f"duplicate ODF package part: {name}")
53
+ seen.add(name)
54
+ if info.flag_bits & 0x1:
55
+ raise InputValidationError("encrypted ODF package parts are not supported")
56
+ if info.compress_size and info.file_size / info.compress_size > 1_000:
57
+ raise ResourceLimitError("ODF package contains a compression bomb candidate")
58
+ manifest = next((info for info in infos if info.filename == "META-INF/manifest.xml"), None)
59
+ if manifest is not None and not manifest.is_dir():
60
+ if manifest.file_size > config.max_xml_bytes:
61
+ raise ResourceLimitError("ODF manifest exceeds the XML size limit")
62
+ try:
63
+ with ZipFile(source) as package:
64
+ manifest_root = SafeElementTree.fromstring(package.read(manifest))
65
+ except (
66
+ BadZipFile,
67
+ OSError,
68
+ RuntimeError,
69
+ SafeElementTree.ParseError,
70
+ DefusedXmlException,
71
+ ValueError,
72
+ ) as error:
73
+ raise InputValidationError("invalid ODF package manifest") from error
74
+ if manifest_root.find(f".//{{{_MANIFEST}}}encryption-data") is not None:
75
+ raise InputValidationError("encrypted ODF content requires a credential")
76
+ return infos
77
+
78
+ def _info(self, source: Path, name: str, config: WorkspaceConfig) -> ZipInfo:
79
+ info = next(
80
+ (candidate for candidate in self._infos(source, config) if candidate.filename == name),
81
+ None,
82
+ )
83
+ if info is None or info.is_dir():
84
+ raise InputValidationError(f"ODF part not found: {name}")
85
+ return info
86
+
87
+ def _part(
88
+ self,
89
+ source: Path,
90
+ name: str,
91
+ config: WorkspaceConfig,
92
+ *,
93
+ maximum: int | None = None,
94
+ ) -> bytes:
95
+ info = self._info(source, name, config)
96
+ if maximum is not None and info.file_size > maximum:
97
+ raise ResourceLimitError(f"ODF part {name} exceeds {maximum} bytes")
98
+ try:
99
+ with ZipFile(source) as package:
100
+ return package.read(info)
101
+ except (BadZipFile, OSError, RuntimeError) as error:
102
+ raise InputValidationError(f"unable to read ODF part: {name}") from error
103
+
104
+ def _xml(self, source: Path, name: str, config: WorkspaceConfig) -> Any:
105
+ payload = self._part(source, name, config, maximum=config.max_xml_bytes)
106
+ try:
107
+ return SafeElementTree.fromstring(payload)
108
+ except (SafeElementTree.ParseError, DefusedXmlException, ValueError) as error:
109
+ raise InputValidationError(f"invalid ODF XML part: {name}") from error
110
+
111
+ def _names(self, source: Path, config: WorkspaceConfig) -> list[str]:
112
+ return sorted(info.filename for info in self._infos(source, config) if not info.is_dir())
113
+
114
+ @staticmethod
115
+ def _local_name(tag: str) -> str:
116
+ return tag.rsplit("}", 1)[-1]
117
+
118
+ def _element_text(self, element: Any, config: WorkspaceConfig) -> str:
119
+ pieces: list[str] = []
120
+ byte_count = 0
121
+ visited = 0
122
+ stack: list[tuple[str, Any]] = [("element", element)]
123
+ while stack:
124
+ operation, current = stack.pop()
125
+ if operation == "text":
126
+ piece = str(current)
127
+ pieces.append(piece)
128
+ byte_count += len(piece.encode("utf-8"))
129
+ if byte_count > config.max_content_cache_bytes:
130
+ raise ResourceLimitError("ODF text exceeds the configured content limit")
131
+ continue
132
+ visited += 1
133
+ if visited > config.max_index_nodes_per_file:
134
+ raise ResourceLimitError("ODF XML exceeds the structural element limit")
135
+ child_operations: list[tuple[str, Any]] = []
136
+ if current.text:
137
+ child_operations.append(("text", str(current.text)))
138
+ for child in current:
139
+ if child.tag == f"{{{_TEXT}}}s":
140
+ repeated = self._repeat(
141
+ child.attrib.get(f"{{{_TEXT}}}c", "1"), config, "spaces"
142
+ )
143
+ child_operations.append(("text", " " * repeated))
144
+ elif child.tag == f"{{{_TEXT}}}tab":
145
+ child_operations.append(("text", "\t"))
146
+ elif child.tag == f"{{{_TEXT}}}line-break":
147
+ child_operations.append(("text", "\n"))
148
+ else:
149
+ child_operations.append(("element", child))
150
+ if child.tail:
151
+ child_operations.append(("text", str(child.tail)))
152
+ stack.extend(reversed(child_operations))
153
+ return "".join(pieces)
154
+
155
+ @staticmethod
156
+ def _repeat(raw: str, config: WorkspaceConfig, label: str) -> int:
157
+ try:
158
+ value = int(raw)
159
+ except ValueError as error:
160
+ raise InputValidationError(f"invalid ODF {label} repetition") from error
161
+ if value < 1:
162
+ raise InputValidationError(f"invalid ODF {label} repetition")
163
+ if value > config.max_zip_entries:
164
+ raise ResourceLimitError(f"ODF {label} repetition exceeds structural limit")
165
+ return value
166
+
167
+ def _content_root(self, source: Path, config: WorkspaceConfig) -> Any:
168
+ return self._xml(source, "content.xml", config)
169
+
170
+ def _paragraph_elements(self, source: Path, config: WorkspaceConfig) -> list[Any]:
171
+ root = self._content_root(source, config)
172
+ body = root.find(f".//{{{_OFFICE}}}text")
173
+ if body is None:
174
+ raise InputValidationError("ODT document has no text body")
175
+ paragraphs: list[Any] = []
176
+ visited = 0
177
+ pending = list(reversed(list(body)))
178
+ while pending:
179
+ element = pending.pop()
180
+ visited += 1
181
+ if visited > config.max_index_nodes_per_file:
182
+ raise ResourceLimitError("ODF XML exceeds the structural element limit")
183
+ if element.tag == f"{{{_TABLE}}}table":
184
+ continue
185
+ if element.tag in {f"{{{_TEXT}}}h", f"{{{_TEXT}}}p"}:
186
+ paragraphs.append(element)
187
+ continue
188
+ pending.extend(reversed(list(element)))
189
+ return paragraphs
190
+
191
+ def _tables(self, source: Path, config: WorkspaceConfig) -> list[Any]:
192
+ return cast(list[Any], self._content_root(source, config).findall(f".//{{{_TABLE}}}table"))
193
+
194
+ def _sheets(self, source: Path, config: WorkspaceConfig) -> list[Any]:
195
+ return cast(list[Any], self._content_root(source, config).findall(f".//{{{_TABLE}}}table"))
196
+
197
+ def _slides(self, source: Path, config: WorkspaceConfig) -> list[Any]:
198
+ return cast(list[Any], self._content_root(source, config).findall(f".//{{{_DRAW}}}page"))
199
+
200
+ def _metadata(self, source: Path, config: WorkspaceConfig) -> dict[str, str]:
201
+ if "meta.xml" not in self._names(source, config):
202
+ return {}
203
+ root = self._xml(source, "meta.xml", config)
204
+ result: dict[str, str] = {}
205
+ for element in root.findall(f".//{{{_OFFICE}}}meta//*"):
206
+ text = self._element_text(element, config).strip()
207
+ if text and len(element) == 0:
208
+ result[self._local_name(str(element.tag))] = text
209
+ return result
210
+
211
+ def _root_categories(self, source: Path, config: WorkspaceConfig) -> list[VirtualNodeSpec]:
212
+ infos = {info.filename: info for info in self._infos(source, config) if not info.is_dir()}
213
+ labels = {
214
+ "text": ("metadata", "paragraphs", "tables", "package_parts"),
215
+ "spreadsheet": ("metadata", "sheets", "package_parts"),
216
+ "presentation": ("metadata", "slides", "package_parts"),
217
+ }[self.family]
218
+ counts = {
219
+ "metadata": 1,
220
+ "paragraphs": len(self._paragraph_elements(source, config))
221
+ if self.family == "text"
222
+ else 0,
223
+ "tables": len(self._tables(source, config)) if self.family == "text" else 0,
224
+ "sheets": len(self._sheets(source, config)) if self.family == "spreadsheet" else 0,
225
+ "slides": len(self._slides(source, config)) if self.family == "presentation" else 0,
226
+ "package_parts": len(infos),
227
+ }
228
+ semantic_labels = {"paragraphs", "tables", "sheets", "slides"}
229
+ result = []
230
+ for label in labels:
231
+ source_part = (
232
+ "meta.xml"
233
+ if label == "metadata" and "meta.xml" in infos
234
+ else "content.xml"
235
+ if label in semantic_labels
236
+ else None
237
+ )
238
+ result.append(
239
+ VirtualNodeSpec(
240
+ name=label,
241
+ kind=NodeKind.METADATA if label == "metadata" else NodeKind.CONTAINER,
242
+ logical_path=f"/odf/{self.family}/{label}",
243
+ child_count=counts[label] if label != "metadata" else 0,
244
+ metadata={"odf_category": label},
245
+ source_part=source_part,
246
+ source_offset=0 if source_part is not None else None,
247
+ source_length=(
248
+ infos[source_part].file_size if source_part is not None else None
249
+ ),
250
+ )
251
+ )
252
+ return result
253
+
254
+ def _package_part_specs(
255
+ self, node: NodeV1, source: Path, config: WorkspaceConfig
256
+ ) -> list[VirtualNodeSpec]:
257
+ infos = {info.filename: info for info in self._infos(source, config)}
258
+ return [
259
+ VirtualNodeSpec(
260
+ name=name,
261
+ kind=NodeKind.PACKAGE_PART,
262
+ logical_path=f"{node.logical_path}/{name}",
263
+ size=infos[name].file_size,
264
+ metadata={"binary_part": name, "odf_category": "package_parts"},
265
+ source_part=name,
266
+ source_offset=0,
267
+ source_length=infos[name].file_size,
268
+ )
269
+ for name in self._names(source, config)
270
+ ]
271
+
272
+ @staticmethod
273
+ def _block_specs(
274
+ logical_path: str,
275
+ label: str,
276
+ count: int,
277
+ block_type: str,
278
+ source_part: str | None = None,
279
+ source_length: int | None = None,
280
+ **metadata: Any,
281
+ ) -> list[VirtualNodeSpec]:
282
+ return [
283
+ VirtualNodeSpec(
284
+ name=f"{label} {start + 1}-{min(start + _BLOCK_SIZE, count)}",
285
+ kind=NodeKind.ROW_BLOCK,
286
+ logical_path=f"{logical_path}/{start}:{min(start + _BLOCK_SIZE, count)}",
287
+ metadata={
288
+ "block_type": block_type,
289
+ "start": start,
290
+ "end": min(start + _BLOCK_SIZE, count),
291
+ **metadata,
292
+ },
293
+ source_part=source_part,
294
+ source_offset=0 if source_part is not None else None,
295
+ source_length=source_length,
296
+ )
297
+ for start in range(0, count, _BLOCK_SIZE)
298
+ ]
299
+
300
+ def children(
301
+ self, node: NodeV1, source: Path, config: WorkspaceConfig
302
+ ) -> list[VirtualNodeSpec]:
303
+ if node.logical_path == "/":
304
+ return self._root_categories(source, config)
305
+ category = node.metadata.get("odf_category")
306
+ if category == "package_parts" and node.kind == NodeKind.CONTAINER:
307
+ return self._package_part_specs(node, source, config)
308
+ if category == "paragraphs" and node.kind == NodeKind.CONTAINER:
309
+ count = len(self._paragraph_elements(source, config))
310
+ return self._block_specs(
311
+ node.logical_path,
312
+ "Paragraphs",
313
+ count,
314
+ "odf_paragraph",
315
+ source_part="content.xml",
316
+ source_length=self._info(source, "content.xml", config).file_size,
317
+ )
318
+ if category == "tables" and node.kind == NodeKind.CONTAINER:
319
+ return [
320
+ VirtualNodeSpec(
321
+ name=str(table.attrib.get(f"{{{_TABLE}}}name", f"Table {index + 1}")),
322
+ kind=NodeKind.TABLE,
323
+ logical_path=f"{node.logical_path}/{index}",
324
+ metadata={"odf_table_index": index},
325
+ source_part="content.xml",
326
+ source_offset=0,
327
+ source_length=self._info(source, "content.xml", config).file_size,
328
+ )
329
+ for index, table in enumerate(self._tables(source, config))
330
+ ]
331
+ if category == "sheets" and node.kind == NodeKind.CONTAINER:
332
+ return [
333
+ VirtualNodeSpec(
334
+ name=str(sheet.attrib.get(f"{{{_TABLE}}}name", f"Sheet {index + 1}")),
335
+ kind=NodeKind.SHEET,
336
+ logical_path=f"{node.logical_path}/{index}",
337
+ metadata={"odf_sheet_index": index},
338
+ source_part="content.xml",
339
+ source_offset=0,
340
+ source_length=self._info(source, "content.xml", config).file_size,
341
+ )
342
+ for index, sheet in enumerate(self._sheets(source, config))
343
+ ]
344
+ if category == "slides" and node.kind == NodeKind.CONTAINER:
345
+ return [
346
+ VirtualNodeSpec(
347
+ name=str(slide.attrib.get(f"{{{_DRAW}}}name", f"Slide {index + 1}")),
348
+ kind=NodeKind.SLIDE,
349
+ logical_path=f"{node.logical_path}/{index}",
350
+ metadata={"odf_slide_index": index},
351
+ source_part="content.xml",
352
+ source_offset=0,
353
+ source_length=self._info(source, "content.xml", config).file_size,
354
+ )
355
+ for index, slide in enumerate(self._slides(source, config))
356
+ ]
357
+ if node.kind == NodeKind.SHEET and "odf_sheet_index" in node.metadata:
358
+ row_count = self._sheet_row_count(source, int(node.metadata["odf_sheet_index"]), config)
359
+ return self._block_specs(
360
+ node.logical_path,
361
+ "Rows",
362
+ row_count,
363
+ "odf_sheet_rows",
364
+ source_part=node.source_ref.part if node.source_ref is not None else None,
365
+ source_length=node.source_ref.length if node.source_ref is not None else None,
366
+ sheet_index=int(node.metadata["odf_sheet_index"]),
367
+ )
368
+ return []
369
+
370
+ def _cell_value(self, cell: Any, config: WorkspaceConfig) -> tuple[Any, str]:
371
+ text = self._element_text(cell, config).strip()
372
+ value_type = str(cell.attrib.get(f"{{{_OFFICE}}}value-type", "string"))
373
+ raw_value = cell.attrib.get(f"{{{_OFFICE}}}value")
374
+ if value_type in {"float", "currency", "percentage"} and raw_value is not None:
375
+ try:
376
+ return float(raw_value), text
377
+ except ValueError:
378
+ return raw_value, text
379
+ if value_type == "boolean":
380
+ return cell.attrib.get(f"{{{_OFFICE}}}boolean-value") == "true", text
381
+ if value_type in {"date", "time"}:
382
+ return (
383
+ cell.attrib.get(f"{{{_OFFICE}}}{value_type}-value", raw_value or text),
384
+ text,
385
+ )
386
+ return cell.attrib.get(f"{{{_OFFICE}}}string-value", text), text
387
+
388
+ def _row_cells(
389
+ self, row: Any, row_number: int, config: WorkspaceConfig
390
+ ) -> list[dict[str, Any]]:
391
+ cells: list[dict[str, Any]] = []
392
+ for cell in row:
393
+ if cell.tag not in {
394
+ f"{{{_TABLE}}}table-cell",
395
+ f"{{{_TABLE}}}covered-table-cell",
396
+ }:
397
+ continue
398
+ repeated = self._repeat(
399
+ cell.attrib.get(f"{{{_TABLE}}}number-columns-repeated", "1"),
400
+ config,
401
+ "column",
402
+ )
403
+ if len(cells) + repeated > config.max_zip_entries:
404
+ raise ResourceLimitError("ODF row exceeds the structural item limit")
405
+ value, text = self._cell_value(cell, config)
406
+ for _ in range(repeated):
407
+ cells.append(
408
+ {
409
+ "column": len(cells) + 1,
410
+ "row": row_number,
411
+ "value": value,
412
+ "text": text,
413
+ "value_type": cell.attrib.get(f"{{{_OFFICE}}}value-type"),
414
+ "formula": cell.attrib.get(f"{{{_TABLE}}}formula"),
415
+ }
416
+ )
417
+ return cells
418
+
419
+ def _row_cell_count(self, row: Any, config: WorkspaceConfig) -> int:
420
+ count = 0
421
+ for cell in row:
422
+ if cell.tag not in {
423
+ f"{{{_TABLE}}}table-cell",
424
+ f"{{{_TABLE}}}covered-table-cell",
425
+ }:
426
+ continue
427
+ repeated = self._repeat(
428
+ cell.attrib.get(f"{{{_TABLE}}}number-columns-repeated", "1"),
429
+ config,
430
+ "column",
431
+ )
432
+ if repeated > config.max_index_nodes_per_file - count:
433
+ raise ResourceLimitError("ODF exceeds the expanded cell limit")
434
+ count += repeated
435
+ return count
436
+
437
+ def _expanded_row_count(self, container: Any, config: WorkspaceConfig) -> int:
438
+ row_count = 0
439
+ cell_count = 0
440
+ budget = config.max_index_nodes_per_file
441
+ for row in container.findall(f".//{{{_TABLE}}}table-row"):
442
+ repeated = self._repeat(
443
+ row.attrib.get(f"{{{_TABLE}}}number-rows-repeated", "1"), config, "row"
444
+ )
445
+ cells_per_row = self._row_cell_count(row, config)
446
+ if repeated > budget - row_count:
447
+ raise ResourceLimitError("ODF exceeds the expanded row limit")
448
+ if cells_per_row and repeated > (budget - cell_count) // cells_per_row:
449
+ raise ResourceLimitError("ODF exceeds the expanded cell limit")
450
+ row_count += repeated
451
+ cell_count += repeated * cells_per_row
452
+ return row_count
453
+
454
+ def _sheet(self, source: Path, sheet_index: int, config: WorkspaceConfig) -> Any:
455
+ sheets = self._sheets(source, config)
456
+ if sheet_index < 0 or sheet_index >= len(sheets):
457
+ raise InputValidationError("ODF sheet index is out of range")
458
+ return sheets[sheet_index]
459
+
460
+ def _sheet_row_count(self, source: Path, sheet_index: int, config: WorkspaceConfig) -> int:
461
+ return self._expanded_row_count(self._sheet(source, sheet_index, config), config)
462
+
463
+ def _sheet_rows(
464
+ self,
465
+ source: Path,
466
+ sheet_index: int,
467
+ start: int,
468
+ end: int,
469
+ config: WorkspaceConfig,
470
+ ) -> list[dict[str, Any]]:
471
+ sheet = self._sheet(source, sheet_index, config)
472
+ self._expanded_row_count(sheet, config)
473
+ result: list[dict[str, Any]] = []
474
+ current = 0
475
+ for row in sheet.findall(f".//{{{_TABLE}}}table-row"):
476
+ repeated = self._repeat(
477
+ row.attrib.get(f"{{{_TABLE}}}number-rows-repeated", "1"), config, "row"
478
+ )
479
+ overlap_start = max(start, current)
480
+ overlap_end = min(end, current + repeated)
481
+ for row_offset in range(overlap_start, overlap_end):
482
+ row_number = row_offset + 1
483
+ result.append(
484
+ {"row": row_number, "cells": self._row_cells(row, row_number, config)}
485
+ )
486
+ current += repeated
487
+ if current >= end:
488
+ break
489
+ return result
490
+
491
+ def _table(self, source: Path, table_index: int, config: WorkspaceConfig) -> Any:
492
+ tables = self._tables(source, config)
493
+ if table_index < 0 or table_index >= len(tables):
494
+ raise InputValidationError("ODF table index is out of range")
495
+ return tables[table_index]
496
+
497
+ def _table_rows(
498
+ self,
499
+ source: Path,
500
+ table_index: int,
501
+ start: int,
502
+ end: int,
503
+ config: WorkspaceConfig,
504
+ ) -> tuple[list[list[str]], int]:
505
+ table = self._table(source, table_index, config)
506
+ total = self._expanded_row_count(table, config)
507
+ result: list[list[str]] = []
508
+ current = 0
509
+ for row in table.findall(f".//{{{_TABLE}}}table-row"):
510
+ repeated = self._repeat(
511
+ row.attrib.get(f"{{{_TABLE}}}number-rows-repeated", "1"), config, "row"
512
+ )
513
+ values = [
514
+ str(cell["value"] if cell["value"] is not None else "")
515
+ for cell in self._row_cells(row, current + 1, config)
516
+ ]
517
+ overlap = max(0, min(end, current + repeated) - max(start, current))
518
+ result.extend([list(values) for _ in range(overlap)])
519
+ current += repeated
520
+ if current >= end:
521
+ break
522
+ return result, total
523
+
524
+ def _slide_content(
525
+ self, source: Path, slide_index: int, config: WorkspaceConfig
526
+ ) -> list[dict[str, Any]]:
527
+ slides = self._slides(source, config)
528
+ if slide_index < 0 or slide_index >= len(slides):
529
+ raise InputValidationError("ODF slide index is out of range")
530
+ return [
531
+ {
532
+ "paragraph": index + 1,
533
+ "text": self._element_text(paragraph, config),
534
+ }
535
+ for index, paragraph in enumerate(slides[slide_index].findall(f".//{{{_TEXT}}}p"))
536
+ ]
537
+
538
+ def read(
539
+ self,
540
+ node: NodeV1,
541
+ source: Path,
542
+ offset: int,
543
+ limit: int,
544
+ config: WorkspaceConfig,
545
+ ) -> ReadResult:
546
+ category = node.metadata.get("odf_category")
547
+ if category == "metadata":
548
+ items = [self._metadata(source, config)]
549
+ return ReadResult(items=items[offset : offset + limit], total=1)
550
+ if node.metadata.get("block_type") == "odf_paragraph":
551
+ start = int(node.metadata["start"])
552
+ end = int(node.metadata["end"])
553
+ paragraphs = [
554
+ {
555
+ "paragraph": index + 1,
556
+ "kind": "heading" if element.tag == f"{{{_TEXT}}}h" else "paragraph",
557
+ "text": self._element_text(element, config),
558
+ "style": element.attrib.get(f"{{{_TEXT}}}style-name"),
559
+ }
560
+ for index, element in enumerate(
561
+ self._paragraph_elements(source, config)[start:end], start
562
+ )
563
+ ]
564
+ return ReadResult(items=paragraphs[offset : offset + limit], total=len(paragraphs))
565
+ if node.metadata.get("block_type") == "odf_sheet_rows":
566
+ start = int(node.metadata["start"])
567
+ end = int(node.metadata["end"])
568
+ selected_start = min(start + offset, end)
569
+ selected_end = min(selected_start + limit, end)
570
+ rows = self._sheet_rows(
571
+ source,
572
+ int(node.metadata["sheet_index"]),
573
+ selected_start,
574
+ selected_end,
575
+ config,
576
+ )
577
+ return ReadResult(items=rows, total=end - start)
578
+ if node.kind == NodeKind.TABLE and "odf_table_index" in node.metadata:
579
+ table_rows, total = self._table_rows(
580
+ source,
581
+ int(node.metadata["odf_table_index"]),
582
+ offset,
583
+ offset + limit,
584
+ config,
585
+ )
586
+ return ReadResult(items=table_rows, total=total)
587
+ if node.kind == NodeKind.SLIDE and "odf_slide_index" in node.metadata:
588
+ content = self._slide_content(source, int(node.metadata["odf_slide_index"]), config)
589
+ selected = content[offset : offset + limit]
590
+ return ReadResult(
591
+ items=selected,
592
+ total=len(content),
593
+ text="\n".join(str(item["text"]) for item in selected),
594
+ )
595
+ if node.kind == NodeKind.PACKAGE_PART:
596
+ name = str(node.metadata["binary_part"])
597
+ info = self._info(source, name, config)
598
+ part_items: list[dict[str, Any]] = [
599
+ {
600
+ "part": name,
601
+ "size": info.file_size,
602
+ "content_access": "read_binary",
603
+ }
604
+ ]
605
+ return ReadResult(items=part_items[offset : offset + limit], total=1)
606
+ return ReadResult(items=[], total=0)
607
+
608
+ def read_binary_part(
609
+ self,
610
+ node: NodeV1,
611
+ source: Path,
612
+ offset: int,
613
+ length: int,
614
+ config: WorkspaceConfig,
615
+ ) -> tuple[bytes, int]:
616
+ part = node.metadata.get("binary_part")
617
+ materialized_root = (
618
+ node.metadata.get("materialized_source") is True
619
+ and node.metadata.get("adapter_logical_path") == "/"
620
+ )
621
+ if (
622
+ not isinstance(part, str)
623
+ and node.source_ref is not None
624
+ and node.source_ref.part not in {None, "original"}
625
+ and not materialized_root
626
+ ):
627
+ part = node.source_ref.part
628
+ if not isinstance(part, str):
629
+ return super().read_binary_part(node, source, offset, length, config)
630
+ info = self._info(source, part, config)
631
+ try:
632
+ with ZipFile(source) as package, package.open(info) as member:
633
+ member.seek(offset)
634
+ return member.read(length), info.file_size
635
+ except (BadZipFile, OSError, RuntimeError) as error:
636
+ raise InputValidationError(f"unable to read ODF part: {part}") from error