| 1 | from __future__ import annotations |
| 2 | |
| 3 | import csv |
| 4 | import hashlib |
| 5 | import io |
| 6 | import json |
| 7 | import os |
| 8 | import re |
| 9 | import sqlite3 |
| 10 | import time |
| 11 | import uuid |
| 12 | import zipfile |
| 13 | from contextlib import contextmanager |
| 14 | from pathlib import Path |
| 15 | from typing import Any |
| 16 | from xml.sax.saxutils import escape |
| 17 | |
| 18 | from helpers import files |
| 19 | from helpers.localization import Localization |
| 20 | from plugins._office.helpers import pptx_writer |
| 21 | |
| 22 | |
| 23 | PLUGIN_NAME = "_office" |
| 24 | OPEN_DOCUMENT_EXTENSIONS = {"odt", "ods", "odp"} |
| 25 | OOXML_EXTENSIONS = {"docx", "xlsx", "pptx"} |
| 26 | EDITOR_TEXT_EXTENSIONS = {"md", "txt"} |
| 27 | SUPPORTED_EXTENSIONS = {*EDITOR_TEXT_EXTENSIONS, *OPEN_DOCUMENT_EXTENSIONS, *OOXML_EXTENSIONS} |
| 28 | DEFAULT_TTL_SECONDS = 8 * 60 * 60 |
| 29 | MAX_SAVE_BYTES = 512 * 1024 * 1024 |
| 30 | ODF_OFFICE_NS = "urn:oasis:names:tc:opendocument:xmlns:office:1.0" |
| 31 | ODF_TEXT_NS = "urn:oasis:names:tc:opendocument:xmlns:text:1.0" |
| 32 | ODF_TABLE_NS = "urn:oasis:names:tc:opendocument:xmlns:table:1.0" |
| 33 | ODF_DRAW_NS = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" |
| 34 | ODF_PRESENTATION_NS = "urn:oasis:names:tc:opendocument:xmlns:presentation:1.0" |
| 35 | ODF_STYLE_NS = "urn:oasis:names:tc:opendocument:xmlns:style:1.0" |
| 36 | ODF_FO_NS = "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" |
| 37 | ODF_MANIFEST_NS = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" |
| 38 | ODF_VERSION = "1.2" |
| 39 | ODF_MIMETYPES = { |
| 40 | "odt": "application/vnd.oasis.opendocument.text", |
| 41 | "ods": "application/vnd.oasis.opendocument.spreadsheet", |
| 42 | "odp": "application/vnd.oasis.opendocument.presentation", |
| 43 | } |
| 44 | |
| 45 | STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "documents")) |
| 46 | DB_PATH = STATE_DIR / "documents.sqlite3" |
| 47 | BACKUP_DIR = STATE_DIR / "backups" |
| 48 | WORKDIR = Path(files.get_abs_path("usr", "workdir")) |
| 49 | DOCUMENTS_DIR = WORKDIR / "documents" |
| 50 | |
| 51 | |
| 52 | def now() -> float: |
| 53 | return time.time() |
| 54 | |
| 55 | |
| 56 | def now_iso() -> str: |
| 57 | return Localization.get().now_iso(timespec="seconds") |
| 58 | |
| 59 | |
| 60 | def ensure_dirs() -> None: |
| 61 | STATE_DIR.mkdir(parents=True, exist_ok=True) |
| 62 | BACKUP_DIR.mkdir(parents=True, exist_ok=True) |
| 63 | |
| 64 | |
| 65 | def sha256_bytes(data: bytes) -> str: |
| 66 | return hashlib.sha256(data).hexdigest() |
| 67 | |
| 68 | |
| 69 | def safe_title(title: str, fallback: str = "Document") -> str: |
| 70 | cleaned = "".join(ch if ch.isalnum() or ch in " ._-" else "_" for ch in title).strip(" ._") |
| 71 | return cleaned or fallback |
| 72 | |
| 73 | |
| 74 | def normalize_extension(value: str) -> str: |
| 75 | ext = value.lower().strip().lstrip(".") |
| 76 | if not ext: |
| 77 | ext = "md" |
| 78 | if ext not in SUPPORTED_EXTENSIONS: |
| 79 | raise ValueError(f"Unsupported document format: {ext}") |
| 80 | return ext |
| 81 | |
| 82 | |
| 83 | def document_home(context_id: str = "") -> Path: |
| 84 | context_id = str(context_id or "").strip() |
| 85 | if context_id: |
| 86 | try: |
| 87 | from agent import AgentContext |
| 88 | |
| 89 | context = AgentContext.get(context_id) |
| 90 | project_helpers = _projects() |
| 91 | project_name = project_helpers.get_context_project_name(context) if context else None |
| 92 | if project_name: |
| 93 | return Path(project_helpers.get_project_folder(project_name)).resolve(strict=False) |
| 94 | except Exception: |
| 95 | pass |
| 96 | |
| 97 | configured = str(_settings().get_settings().get("workdir_path") or "").strip() |
| 98 | if configured: |
| 99 | return _path_from_a0(configured).resolve(strict=False) |
| 100 | return WORKDIR.resolve(strict=False) |
| 101 | |
| 102 | |
| 103 | def document_binary_home(context_id: str = "") -> Path: |
| 104 | if str(context_id or "").strip(): |
| 105 | return document_home(context_id) / "documents" |
| 106 | return DOCUMENTS_DIR.resolve(strict=False) |
| 107 | |
| 108 | |
| 109 | def default_open_path(context_id: str = "") -> str: |
| 110 | return display_path(document_home(context_id)) |
| 111 | |
| 112 | |
| 113 | def display_path(path: str | Path) -> str: |
| 114 | resolved = Path(path).resolve(strict=False) |
| 115 | base = Path(files.get_base_dir()).resolve(strict=False) |
| 116 | if str(base).startswith("/a0"): |
| 117 | return str(resolved) |
| 118 | try: |
| 119 | return "/a0/" + str(resolved.relative_to(base)).lstrip("/") |
| 120 | except ValueError: |
| 121 | return str(path) |
| 122 | |
| 123 | |
| 124 | def _path_from_a0(path: str | Path) -> Path: |
| 125 | raw = str(path) |
| 126 | if raw.startswith("/a0/") and not files.get_base_dir().startswith("/a0"): |
| 127 | raw = files.get_abs_path(raw.removeprefix("/a0/")) |
| 128 | return Path(raw if os.path.isabs(raw) else files.get_abs_path(raw)).expanduser() |
| 129 | |
| 130 | |
| 131 | def allowed_roots(context_id: str = "", allow_base_dir: bool = False) -> list[Path]: |
| 132 | project_helpers = _projects() |
| 133 | roots = { |
| 134 | WORKDIR.resolve(strict=False), |
| 135 | DOCUMENTS_DIR.resolve(strict=False), |
| 136 | Path(project_helpers.get_projects_parent_folder()).resolve(strict=False), |
| 137 | document_home(context_id).resolve(strict=False), |
| 138 | document_binary_home(context_id).resolve(strict=False), |
| 139 | } |
| 140 | configured = str(_settings().get_settings().get("workdir_path") or "").strip() |
| 141 | if configured: |
| 142 | roots.add(_path_from_a0(configured).resolve(strict=False)) |
| 143 | if allow_base_dir: |
| 144 | roots.add(Path(files.get_base_dir()).resolve(strict=False)) |
| 145 | return sorted(roots, key=lambda item: str(item)) |
| 146 | |
| 147 | |
| 148 | def _projects() -> Any: |
| 149 | from helpers import projects |
| 150 | |
| 151 | return projects |
| 152 | |
| 153 | |
| 154 | def _settings() -> Any: |
| 155 | from helpers import settings |
| 156 | |
| 157 | return settings |
| 158 | |
| 159 | |
| 160 | def normalize_path(path: str | Path, context_id: str = "", allow_base_dir: bool = False) -> Path: |
| 161 | candidate = _path_from_a0(path) |
| 162 | resolved = candidate.resolve(strict=False) |
| 163 | roots = allowed_roots(context_id, allow_base_dir=allow_base_dir) |
| 164 | if not any(_is_relative_to(resolved, root) for root in roots): |
| 165 | raise PermissionError("Document artifacts must stay inside the active project or workdir.") |
| 166 | if candidate.exists(): |
| 167 | real = candidate.resolve(strict=True) |
| 168 | if not any(_is_relative_to(real, root) for root in roots): |
| 169 | raise PermissionError("Document artifact symlink escapes the active project or workdir.") |
| 170 | return resolved |
| 171 | |
| 172 | |
| 173 | def _is_relative_to(path: Path, root: Path) -> bool: |
| 174 | try: |
| 175 | os.path.commonpath([str(path), str(root)]) |
| 176 | except ValueError: |
| 177 | return False |
| 178 | return os.path.commonpath([str(path), str(root)]) == str(root) |
| 179 | |
| 180 | |
| 181 | @contextmanager |
| 182 | def connect() -> Any: |
| 183 | ensure_dirs() |
| 184 | conn = sqlite3.connect(DB_PATH, timeout=30) |
| 185 | conn.row_factory = sqlite3.Row |
| 186 | conn.execute("PRAGMA journal_mode=WAL") |
| 187 | conn.execute("PRAGMA foreign_keys=ON") |
| 188 | init_db(conn) |
| 189 | try: |
| 190 | yield conn |
| 191 | conn.commit() |
| 192 | finally: |
| 193 | conn.close() |
| 194 | |
| 195 | |
| 196 | def init_db(conn: sqlite3.Connection) -> None: |
| 197 | conn.executescript( |
| 198 | """ |
| 199 | CREATE TABLE IF NOT EXISTS documents ( |
| 200 | file_id TEXT PRIMARY KEY, |
| 201 | path TEXT NOT NULL UNIQUE, |
| 202 | basename TEXT NOT NULL, |
| 203 | extension TEXT NOT NULL, |
| 204 | owner_id TEXT NOT NULL, |
| 205 | size INTEGER NOT NULL, |
| 206 | version INTEGER NOT NULL, |
| 207 | sha256 TEXT NOT NULL, |
| 208 | last_modified TEXT NOT NULL, |
| 209 | created_at REAL NOT NULL, |
| 210 | updated_at REAL NOT NULL |
| 211 | ); |
| 212 | CREATE TABLE IF NOT EXISTS sessions ( |
| 213 | session_id TEXT PRIMARY KEY, |
| 214 | file_id TEXT NOT NULL, |
| 215 | user_id TEXT NOT NULL, |
| 216 | permission TEXT NOT NULL, |
| 217 | origin TEXT NOT NULL, |
| 218 | created_at REAL NOT NULL, |
| 219 | expires_at REAL NOT NULL |
| 220 | ); |
| 221 | CREATE TABLE IF NOT EXISTS versions ( |
| 222 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 223 | file_id TEXT NOT NULL, |
| 224 | version TEXT NOT NULL, |
| 225 | path TEXT NOT NULL, |
| 226 | size INTEGER NOT NULL, |
| 227 | sha256 TEXT NOT NULL, |
| 228 | created_at REAL NOT NULL |
| 229 | ); |
| 230 | CREATE TABLE IF NOT EXISTS events ( |
| 231 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 232 | file_id TEXT, |
| 233 | event_type TEXT NOT NULL, |
| 234 | payload TEXT NOT NULL, |
| 235 | created_at REAL NOT NULL |
| 236 | ); |
| 237 | """ |
| 238 | ) |
| 239 | |
| 240 | |
| 241 | def register_document( |
| 242 | path: str | Path, |
| 243 | owner_id: str = "a0", |
| 244 | context_id: str = "", |
| 245 | allow_base_dir: bool = False, |
| 246 | ) -> dict[str, Any]: |
| 247 | resolved = normalize_path(path, context_id=context_id, allow_base_dir=allow_base_dir) |
| 248 | if not resolved.exists(): |
| 249 | raise FileNotFoundError(str(resolved)) |
| 250 | ext = normalize_extension(resolved.suffix.lstrip(".")) |
| 251 | data = resolved.read_bytes() |
| 252 | digest = sha256_bytes(data) |
| 253 | stat = resolved.stat() |
| 254 | current_time = now() |
| 255 | with connect() as conn: |
| 256 | row = conn.execute("SELECT * FROM documents WHERE path = ?", (str(resolved),)).fetchone() |
| 257 | if row: |
| 258 | conn.execute( |
| 259 | """ |
| 260 | UPDATE documents |
| 261 | SET basename=?, extension=?, size=?, sha256=?, last_modified=?, updated_at=? |
| 262 | WHERE file_id=? |
| 263 | """, |
| 264 | (resolved.name, ext, stat.st_size, digest, now_iso(), current_time, row["file_id"]), |
| 265 | ) |
| 266 | return get_document(row["file_id"], conn=conn) |
| 267 | |
| 268 | file_id = uuid.uuid4().hex |
| 269 | conn.execute( |
| 270 | """ |
| 271 | INSERT INTO documents |
| 272 | (file_id, path, basename, extension, owner_id, size, version, sha256, last_modified, created_at, updated_at) |
| 273 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| 274 | """, |
| 275 | (file_id, str(resolved), resolved.name, ext, owner_id, stat.st_size, 1, digest, now_iso(), current_time, current_time), |
| 276 | ) |
| 277 | _record_version(conn, file_id, resolved, "1", data) |
| 278 | return get_document(file_id, conn=conn) |
| 279 | |
| 280 | |
| 281 | def get_document(file_id: str, conn: sqlite3.Connection | None = None) -> dict[str, Any]: |
| 282 | def _fetch(active: sqlite3.Connection) -> dict[str, Any]: |
| 283 | row = active.execute("SELECT * FROM documents WHERE file_id = ?", (file_id,)).fetchone() |
| 284 | if not row: |
| 285 | raise FileNotFoundError(file_id) |
| 286 | return dict(row) |
| 287 | |
| 288 | if conn is not None: |
| 289 | return _fetch(conn) |
| 290 | with connect() as active: |
| 291 | return _fetch(active) |
| 292 | |
| 293 | |
| 294 | def update_document_path(file_id: str, path: str | Path, context_id: str = "") -> dict[str, Any]: |
| 295 | resolved = normalize_path(path, context_id=context_id) |
| 296 | if not resolved.exists(): |
| 297 | raise FileNotFoundError(str(resolved)) |
| 298 | ext = normalize_extension(resolved.suffix.lstrip(".")) |
| 299 | data = resolved.read_bytes() |
| 300 | digest = sha256_bytes(data) |
| 301 | stat = resolved.stat() |
| 302 | changed_at = now() |
| 303 | |
| 304 | with connect() as conn: |
| 305 | doc = get_document(file_id, conn=conn) |
| 306 | row = conn.execute("SELECT file_id FROM documents WHERE path = ?", (str(resolved),)).fetchone() |
| 307 | if row and row["file_id"] != file_id: |
| 308 | raise ValueError(f"Document path is already registered: {display_path(resolved)}") |
| 309 | conn.execute( |
| 310 | """ |
| 311 | UPDATE documents |
| 312 | SET path=?, basename=?, extension=?, size=?, sha256=?, last_modified=?, updated_at=? |
| 313 | WHERE file_id=? |
| 314 | """, |
| 315 | (str(resolved), resolved.name, ext, stat.st_size, digest, now_iso(), changed_at, file_id), |
| 316 | ) |
| 317 | conn.execute( |
| 318 | "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)", |
| 319 | ( |
| 320 | file_id, |
| 321 | "renamed", |
| 322 | json.dumps({"from": display_path(doc["path"]), "to": display_path(resolved)}), |
| 323 | changed_at, |
| 324 | ), |
| 325 | ) |
| 326 | return get_document(file_id, conn=conn) |
| 327 | |
| 328 | |
| 329 | def rename_document( |
| 330 | file_id: str, |
| 331 | path: str | Path, |
| 332 | content: str | None = None, |
| 333 | context_id: str = "", |
| 334 | ) -> dict[str, Any]: |
| 335 | resolved = normalize_path(path, context_id=context_id) |
| 336 | ext = normalize_extension(resolved.suffix.lstrip(".")) |
| 337 | data = None |
| 338 | if content is not None: |
| 339 | if ext not in EDITOR_TEXT_EXTENSIONS: |
| 340 | raise ValueError("Inline content can only be provided for Editor text documents.") |
| 341 | data = str(content or "").encode("utf-8") |
| 342 | if len(data) > MAX_SAVE_BYTES: |
| 343 | raise OverflowError("Document save exceeds maximum size") |
| 344 | |
| 345 | changed_at = now() |
| 346 | with connect() as conn: |
| 347 | doc = get_document(file_id, conn=conn) |
| 348 | source = Path(doc["path"]) |
| 349 | source_resolved = source.resolve(strict=False) |
| 350 | changed_path = str(source_resolved) != str(resolved) |
| 351 | source_exists = source.exists() |
| 352 | |
| 353 | if ext != str(doc["extension"]).lower(): |
| 354 | raise ValueError("Document extension cannot change during rename.") |
| 355 | |
| 356 | row = conn.execute("SELECT file_id FROM documents WHERE path = ?", (str(resolved),)).fetchone() |
| 357 | if row and row["file_id"] != file_id: |
| 358 | raise ValueError(f"Document path is already registered: {display_path(resolved)}") |
| 359 | if changed_path and resolved.exists(): |
| 360 | raise FileExistsError(f"Target already exists: {display_path(resolved)}") |
| 361 | if not source_exists and data is None: |
| 362 | raise FileNotFoundError(str(source_resolved)) |
| 363 | |
| 364 | previous = source.read_bytes() if source_exists else b"" |
| 365 | content_changed = data is not None and data != previous |
| 366 | |
| 367 | if changed_path and data is None: |
| 368 | resolved.parent.mkdir(parents=True, exist_ok=True) |
| 369 | source.rename(resolved) |
| 370 | final_data = resolved.read_bytes() |
| 371 | elif data is not None: |
| 372 | if content_changed: |
| 373 | _record_version(conn, file_id, source_resolved, item_version(doc), previous) |
| 374 | _write_atomic(resolved, data) |
| 375 | if changed_path and source_exists: |
| 376 | source.unlink(missing_ok=True) |
| 377 | final_data = data |
| 378 | else: |
| 379 | final_data = previous |
| 380 | |
| 381 | stat = resolved.stat() |
| 382 | next_version = int(doc["version"]) + 1 if content_changed else int(doc["version"]) |
| 383 | conn.execute( |
| 384 | """ |
| 385 | UPDATE documents |
| 386 | SET path=?, basename=?, extension=?, size=?, version=?, sha256=?, last_modified=?, updated_at=? |
| 387 | WHERE file_id=? |
| 388 | """, |
| 389 | ( |
| 390 | str(resolved), |
| 391 | resolved.name, |
| 392 | ext, |
| 393 | stat.st_size, |
| 394 | next_version, |
| 395 | sha256_bytes(final_data), |
| 396 | now_iso(), |
| 397 | changed_at, |
| 398 | file_id, |
| 399 | ), |
| 400 | ) |
| 401 | conn.execute( |
| 402 | "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)", |
| 403 | ( |
| 404 | file_id, |
| 405 | "renamed", |
| 406 | json.dumps( |
| 407 | { |
| 408 | "from": display_path(source_resolved), |
| 409 | "to": display_path(resolved), |
| 410 | "saved": content_changed, |
| 411 | "materialized": not source_exists, |
| 412 | } |
| 413 | ), |
| 414 | changed_at, |
| 415 | ), |
| 416 | ) |
| 417 | return get_document(file_id, conn=conn) |
| 418 | |
| 419 | |
| 420 | def get_open_documents(limit: int = 6) -> list[dict[str, Any]]: |
| 421 | with connect() as conn: |
| 422 | _clear_expired_sessions(conn) |
| 423 | rows = conn.execute( |
| 424 | """ |
| 425 | SELECT |
| 426 | d.*, |
| 427 | COUNT(s.session_id) AS open_sessions, |
| 428 | MAX(s.created_at) AS last_opened_at, |
| 429 | MAX(s.expires_at) AS session_expires_at |
| 430 | FROM documents d |
| 431 | JOIN sessions s ON s.file_id = d.file_id |
| 432 | WHERE s.expires_at > ? |
| 433 | GROUP BY d.file_id |
| 434 | ORDER BY last_opened_at DESC |
| 435 | LIMIT ? |
| 436 | """, |
| 437 | (now(), limit), |
| 438 | ).fetchall() |
| 439 | return [dict(row) for row in rows] |
| 440 | |
| 441 | |
| 442 | def create_session( |
| 443 | file_id: str, |
| 444 | user_id: str = "agent-zero-user", |
| 445 | permission: str = "write", |
| 446 | origin: str = "", |
| 447 | ttl_seconds: int = DEFAULT_TTL_SECONDS, |
| 448 | ) -> dict[str, Any]: |
| 449 | permission = "write" if permission == "write" else "read" |
| 450 | created = now() |
| 451 | expires = created + ttl_seconds |
| 452 | session_id = uuid.uuid4().hex |
| 453 | with connect() as conn: |
| 454 | conn.execute( |
| 455 | "INSERT INTO sessions (session_id, file_id, user_id, permission, origin, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", |
| 456 | (session_id, file_id, user_id, permission, origin, created, expires), |
| 457 | ) |
| 458 | return { |
| 459 | "session_id": session_id, |
| 460 | "file_id": file_id, |
| 461 | "expires_at": expires, |
| 462 | "permission": permission, |
| 463 | "origin": origin, |
| 464 | } |
| 465 | |
| 466 | |
| 467 | def close_session(session_id: str = "", file_id: str = "") -> int: |
| 468 | session_id = str(session_id or "").strip() |
| 469 | file_id = str(file_id or "").strip() |
| 470 | if not session_id and not file_id: |
| 471 | return 0 |
| 472 | |
| 473 | with connect() as conn: |
| 474 | _clear_expired_sessions(conn) |
| 475 | if session_id: |
| 476 | row = conn.execute("SELECT * FROM sessions WHERE session_id = ?", (session_id,)).fetchone() |
| 477 | if not row: |
| 478 | return 0 |
| 479 | conn.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,)) |
| 480 | conn.execute( |
| 481 | "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)", |
| 482 | (row["file_id"], "close_session", json.dumps({"session_id": session_id}), now()), |
| 483 | ) |
| 484 | return 1 |
| 485 | |
| 486 | rows = conn.execute("SELECT session_id FROM sessions WHERE file_id = ?", (file_id,)).fetchall() |
| 487 | conn.execute("DELETE FROM sessions WHERE file_id = ?", (file_id,)) |
| 488 | conn.execute( |
| 489 | "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)", |
| 490 | (file_id, "close_document_sessions", json.dumps({"closed": len(rows)}), now()), |
| 491 | ) |
| 492 | return len(rows) |
| 493 | |
| 494 | |
| 495 | def read_text_for_editor(doc: dict[str, Any]) -> str: |
| 496 | path = Path(doc["path"]) |
| 497 | ext = str(doc["extension"]).lower() |
| 498 | if ext in EDITOR_TEXT_EXTENSIONS: |
| 499 | return path.read_text(encoding="utf-8", errors="replace") |
| 500 | raise ValueError(f"Text editing is not available for .{ext}.") |
| 501 | |
| 502 | |
| 503 | def write_text_document(file_id: str, content: str) -> dict[str, Any]: |
| 504 | doc = get_document(file_id) |
| 505 | ext = str(doc.get("extension") or "").lower() |
| 506 | if ext not in EDITOR_TEXT_EXTENSIONS: |
| 507 | raise ValueError(f"Editor text saves are not available for .{ext}.") |
| 508 | return replace_document_bytes(file_id, str(content or "").encode("utf-8"), actor=f"editor:{ext}") |
| 509 | |
| 510 | |
| 511 | def write_markdown(file_id: str, content: str) -> dict[str, Any]: |
| 512 | return write_text_document(file_id, content) |
| 513 | |
| 514 | |
| 515 | def save_text_document_as( |
| 516 | file_id: str, |
| 517 | path: str | Path, |
| 518 | content: str, |
| 519 | context_id: str = "", |
| 520 | ) -> dict[str, Any]: |
| 521 | target = normalize_path(path, context_id=context_id) |
| 522 | ext = normalize_extension(target.suffix.lstrip(".")) |
| 523 | if ext not in EDITOR_TEXT_EXTENSIONS: |
| 524 | raise ValueError("Editor Save As only supports Markdown (.md) and text (.txt) files.") |
| 525 | if target.exists(): |
| 526 | raise FileExistsError(f"Target already exists: {display_path(target)}") |
| 527 | |
| 528 | data = str(content or "").encode("utf-8") |
| 529 | if len(data) > MAX_SAVE_BYTES: |
| 530 | raise OverflowError("Document save exceeds maximum size") |
| 531 | |
| 532 | with connect() as conn: |
| 533 | source = get_document(file_id, conn=conn) |
| 534 | source_ext = str(source.get("extension") or "").lower() |
| 535 | if source_ext not in EDITOR_TEXT_EXTENSIONS: |
| 536 | raise ValueError(f"Editor Save As is not available for .{source_ext}.") |
| 537 | changed_at = now() |
| 538 | target.parent.mkdir(parents=True, exist_ok=True) |
| 539 | _write_atomic(target, data) |
| 540 | digest = sha256_bytes(data) |
| 541 | stat = target.stat() |
| 542 | new_file_id = uuid.uuid4().hex |
| 543 | conn.execute( |
| 544 | """ |
| 545 | INSERT INTO documents |
| 546 | (file_id, path, basename, extension, owner_id, size, version, sha256, last_modified, created_at, updated_at) |
| 547 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| 548 | """, |
| 549 | ( |
| 550 | new_file_id, |
| 551 | str(target), |
| 552 | target.name, |
| 553 | ext, |
| 554 | str(source.get("owner_id") or "a0"), |
| 555 | stat.st_size, |
| 556 | 1, |
| 557 | digest, |
| 558 | now_iso(), |
| 559 | changed_at, |
| 560 | changed_at, |
| 561 | ), |
| 562 | ) |
| 563 | _record_version(conn, new_file_id, target, "1", data) |
| 564 | conn.execute( |
| 565 | "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)", |
| 566 | ( |
| 567 | file_id, |
| 568 | "saved_as", |
| 569 | json.dumps({"from": display_path(source["path"]), "to": display_path(target)}), |
| 570 | changed_at, |
| 571 | ), |
| 572 | ) |
| 573 | conn.execute( |
| 574 | "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)", |
| 575 | ( |
| 576 | new_file_id, |
| 577 | "created_from_save_as", |
| 578 | json.dumps({"from": display_path(source["path"])}), |
| 579 | changed_at, |
| 580 | ), |
| 581 | ) |
| 582 | return get_document(new_file_id, conn=conn) |
| 583 | |
| 584 | |
| 585 | def replace_document_bytes( |
| 586 | file_id: str, |
| 587 | data: bytes, |
| 588 | actor: str = "agent", |
| 589 | invalidate_sessions: bool = False, |
| 590 | ) -> dict[str, Any]: |
| 591 | if len(data) > MAX_SAVE_BYTES: |
| 592 | raise OverflowError("Document save exceeds maximum size") |
| 593 | with connect() as conn: |
| 594 | doc = get_document(file_id, conn=conn) |
| 595 | path = Path(doc["path"]) |
| 596 | previous = path.read_bytes() if path.exists() else b"" |
| 597 | if previous == data: |
| 598 | return doc |
| 599 | |
| 600 | _record_version(conn, file_id, path, item_version(doc), previous) |
| 601 | _write_atomic(path, data) |
| 602 | digest = sha256_bytes(data) |
| 603 | next_version = int(doc["version"]) + 1 |
| 604 | changed_at = now() |
| 605 | conn.execute( |
| 606 | """ |
| 607 | UPDATE documents |
| 608 | SET size=?, version=?, sha256=?, last_modified=?, updated_at=? |
| 609 | WHERE file_id=? |
| 610 | """, |
| 611 | (len(data), next_version, digest, now_iso(), changed_at, file_id), |
| 612 | ) |
| 613 | if invalidate_sessions: |
| 614 | conn.execute("DELETE FROM sessions WHERE file_id = ?", (file_id,)) |
| 615 | conn.execute( |
| 616 | "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)", |
| 617 | (file_id, "saved", json.dumps({"actor": actor, "version": f"{next_version}-{digest[:12]}"}), changed_at), |
| 618 | ) |
| 619 | return get_document(file_id, conn=conn) |
| 620 | |
| 621 | |
| 622 | def item_version(doc: dict[str, Any]) -> str: |
| 623 | return f"{int(doc['version'])}-{str(doc['sha256'])[:12]}" |
| 624 | |
| 625 | |
| 626 | def _write_atomic(path: Path, data: bytes) -> None: |
| 627 | path.parent.mkdir(parents=True, exist_ok=True) |
| 628 | tmp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") |
| 629 | try: |
| 630 | with tmp_path.open("wb") as handle: |
| 631 | handle.write(data) |
| 632 | handle.flush() |
| 633 | os.fsync(handle.fileno()) |
| 634 | os.replace(tmp_path, path) |
| 635 | finally: |
| 636 | if tmp_path.exists(): |
| 637 | tmp_path.unlink(missing_ok=True) |
| 638 | |
| 639 | |
| 640 | def _clear_expired_sessions(conn: sqlite3.Connection) -> None: |
| 641 | conn.execute("DELETE FROM sessions WHERE expires_at < ?", (now(),)) |
| 642 | |
| 643 | |
| 644 | def _record_version(conn: sqlite3.Connection, file_id: str, path: Path, version: str, data: bytes) -> None: |
| 645 | if not data: |
| 646 | return |
| 647 | BACKUP_DIR.mkdir(parents=True, exist_ok=True) |
| 648 | backup_path = BACKUP_DIR / f"{file_id}-{int(time.time() * 1000)}-{version.replace('/', '_')}" |
| 649 | backup_path.write_bytes(data) |
| 650 | conn.execute( |
| 651 | "INSERT INTO versions (file_id, version, path, size, sha256, created_at) VALUES (?, ?, ?, ?, ?, ?)", |
| 652 | (file_id, version, str(backup_path), len(data), sha256_bytes(data), now()), |
| 653 | ) |
| 654 | |
| 655 | |
| 656 | def version_history(file_id: str) -> list[dict[str, Any]]: |
| 657 | with connect() as conn: |
| 658 | rows = conn.execute( |
| 659 | "SELECT id, file_id, version, path, size, sha256, created_at FROM versions WHERE file_id = ? ORDER BY id DESC", |
| 660 | (file_id,), |
| 661 | ).fetchall() |
| 662 | return [dict(row) for row in rows] |
| 663 | |
| 664 | |
| 665 | def restore_version(file_id: str, version_id: int) -> dict[str, Any]: |
| 666 | with connect() as conn: |
| 667 | doc = get_document(file_id, conn=conn) |
| 668 | row = conn.execute("SELECT * FROM versions WHERE id = ? AND file_id = ?", (version_id, file_id)).fetchone() |
| 669 | if not row: |
| 670 | raise FileNotFoundError(f"Version {version_id} not found") |
| 671 | data = Path(row["path"]).read_bytes() |
| 672 | path = Path(doc["path"]) |
| 673 | _record_version(conn, file_id, path, item_version(doc), path.read_bytes() if path.exists() else b"") |
| 674 | _write_atomic(path, data) |
| 675 | digest = sha256_bytes(data) |
| 676 | next_version = int(doc["version"]) + 1 |
| 677 | conn.execute( |
| 678 | "UPDATE documents SET size=?, version=?, sha256=?, last_modified=?, updated_at=? WHERE file_id=?", |
| 679 | (len(data), next_version, digest, now_iso(), now(), file_id), |
| 680 | ) |
| 681 | return get_document(file_id, conn=conn) |
| 682 | |
| 683 | |
| 684 | def create_document( |
| 685 | kind: str, |
| 686 | title: str, |
| 687 | fmt: str = "md", |
| 688 | content: str = "", |
| 689 | path: str = "", |
| 690 | context_id: str = "", |
| 691 | ) -> dict[str, Any]: |
| 692 | ext = normalize_extension(fmt or "md") |
| 693 | target = normalize_path(path, context_id=context_id) if path else _unique_document_path(title, ext, context_id=context_id) |
| 694 | target.parent.mkdir(parents=True, exist_ok=True) |
| 695 | if target.exists(): |
| 696 | raise FileExistsError(str(target)) |
| 697 | data = template_bytes(kind, ext, title, content) |
| 698 | _write_atomic(target, data) |
| 699 | return register_document(target, context_id=context_id) |
| 700 | |
| 701 | |
| 702 | def _unique_document_path(title: str, ext: str, context_id: str = "") -> Path: |
| 703 | base = safe_document_stem(title, ext, "Document") |
| 704 | root = document_home(context_id) if ext in EDITOR_TEXT_EXTENSIONS else document_binary_home(context_id) |
| 705 | candidate = root / f"{base}.{ext}" |
| 706 | index = 2 |
| 707 | while candidate.exists(): |
| 708 | candidate = root / f"{base} {index}.{ext}" |
| 709 | index += 1 |
| 710 | return candidate.resolve(strict=False) |
| 711 | |
| 712 | |
| 713 | def safe_document_stem(title: str, ext: str, fallback: str = "Document") -> str: |
| 714 | base = safe_title(title, fallback) |
| 715 | suffix = f".{normalize_extension(ext)}" |
| 716 | if base.casefold().endswith(suffix.casefold()): |
| 717 | base = base[: -len(suffix)].rstrip(" ._") or fallback |
| 718 | return base |
| 719 | |
| 720 | |
| 721 | def template_bytes(kind: str, ext: str, title: str, content: str) -> bytes: |
| 722 | ext = normalize_extension(ext or "md") |
| 723 | if ext == "md": |
| 724 | return _markdown(title, content).encode("utf-8") |
| 725 | if ext == "txt": |
| 726 | return (str(content or "") or str(title or "")).encode("utf-8") |
| 727 | if ext == "odt": |
| 728 | return odt_bytes(title, content) |
| 729 | if ext == "ods": |
| 730 | return ods_bytes(title, content) |
| 731 | if ext == "odp": |
| 732 | return odp_bytes(title, content) |
| 733 | if ext == "docx": |
| 734 | return _docx(title, content) |
| 735 | if ext == "xlsx": |
| 736 | return _xlsx(title, content) |
| 737 | if ext == "pptx": |
| 738 | return _pptx(title, content) |
| 739 | raise ValueError(ext) |
| 740 | |
| 741 | |
| 742 | def _markdown(title: str, content: str) -> str: |
| 743 | text = str(content or "").strip() |
| 744 | if text: |
| 745 | return text if text.startswith("#") else f"# {title}\n\n{text}\n" |
| 746 | return f"# {title}\n" |
| 747 | |
| 748 | |
| 749 | def _zip_bytes(files_map: dict[str, str | bytes]) -> bytes: |
| 750 | buffer = io.BytesIO() |
| 751 | with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: |
| 752 | for name, value in files_map.items(): |
| 753 | data = value.encode("utf-8") if isinstance(value, str) else value |
| 754 | archive.writestr(name, data) |
| 755 | return buffer.getvalue() |
| 756 | |
| 757 | |
| 758 | def odf_zip_bytes(ext: str, files_map: dict[str, str | bytes]) -> bytes: |
| 759 | ext = normalize_extension(ext) |
| 760 | if ext not in ODF_MIMETYPES: |
| 761 | raise ValueError(f"Unsupported ODF format: {ext}") |
| 762 | media_type = ODF_MIMETYPES[ext] |
| 763 | buffer = io.BytesIO() |
| 764 | with zipfile.ZipFile(buffer, "w") as archive: |
| 765 | archive.writestr("mimetype", media_type, compress_type=zipfile.ZIP_STORED) |
| 766 | for name, value in files_map.items(): |
| 767 | if name == "mimetype": |
| 768 | continue |
| 769 | data = value.encode("utf-8") if isinstance(value, str) else value |
| 770 | archive.writestr(name, data, compress_type=zipfile.ZIP_DEFLATED) |
| 771 | return buffer.getvalue() |
| 772 | |
| 773 | |
| 774 | def odt_bytes(title: str, content: str) -> bytes: |
| 775 | return odt_bytes_from_paragraphs(_document_lines(title, content)) |
| 776 | |
| 777 | |
| 778 | def odt_bytes_from_paragraphs(paragraphs: list[str]) -> bytes: |
| 779 | lines = [str(line) for line in paragraphs] or [""] |
| 780 | body = "\n".join(_odt_paragraph(line, index == 0) for index, line in enumerate(lines)) |
| 781 | return _odf_package( |
| 782 | "odt", |
| 783 | f"""<?xml version="1.0" encoding="UTF-8"?> |
| 784 | <office:document-content {_odf_content_namespaces()} office:version="{ODF_VERSION}"> |
| 785 | <office:body> |
| 786 | <office:text> |
| 787 | {body} |
| 788 | </office:text> |
| 789 | </office:body> |
| 790 | </office:document-content> |
| 791 | """, |
| 792 | ) |
| 793 | |
| 794 | |
| 795 | def ods_bytes(title: str, content: str) -> bytes: |
| 796 | return ods_bytes_from_sheets([{"name": "Sheet1", "rows": _xlsx_rows(title, content)}]) |
| 797 | |
| 798 | |
| 799 | def ods_bytes_from_sheets(sheets: list[dict[str, Any]]) -> bytes: |
| 800 | normalized = [] |
| 801 | for index, sheet in enumerate(sheets or []): |
| 802 | name = safe_title(str(sheet.get("name") or f"Sheet{index + 1}"), f"Sheet{index + 1}")[:31] or f"Sheet{index + 1}" |
| 803 | rows = sheet.get("rows") or [] |
| 804 | normalized.append({"name": name, "rows": rows}) |
| 805 | if not normalized: |
| 806 | normalized = [{"name": "Sheet1", "rows": [["Spreadsheet"]]}] |
| 807 | |
| 808 | tables = "\n".join( |
| 809 | f"""<table:table table:name="{escape(sheet['name'])}"> |
| 810 | {''.join(_ods_row(row) for row in sheet['rows'])} |
| 811 | </table:table>""" |
| 812 | for sheet in normalized |
| 813 | ) |
| 814 | return _odf_package( |
| 815 | "ods", |
| 816 | f"""<?xml version="1.0" encoding="UTF-8"?> |
| 817 | <office:document-content {_odf_content_namespaces()} office:version="{ODF_VERSION}"> |
| 818 | <office:body> |
| 819 | <office:spreadsheet> |
| 820 | {tables} |
| 821 | </office:spreadsheet> |
| 822 | </office:body> |
| 823 | </office:document-content> |
| 824 | """, |
| 825 | ) |
| 826 | |
| 827 | |
| 828 | def odp_bytes(title: str, content: str) -> bytes: |
| 829 | return odp_bytes_from_slides(pptx_writer.slides_from_text(title, content)) |
| 830 | |
| 831 | |
| 832 | def odp_bytes_from_slides(slides: list[dict[str, Any]]) -> bytes: |
| 833 | normalized = pptx_writer.normalize_slides(slides) |
| 834 | if not normalized: |
| 835 | normalized = [{"title": "Presentation", "bullets": []}] |
| 836 | pages = "\n".join(_odp_page(slide, index) for index, slide in enumerate(normalized, start=1)) |
| 837 | return _odf_package( |
| 838 | "odp", |
| 839 | f"""<?xml version="1.0" encoding="UTF-8"?> |
| 840 | <office:document-content {_odf_content_namespaces()} office:version="{ODF_VERSION}"> |
| 841 | <office:body> |
| 842 | <office:presentation> |
| 843 | {pages} |
| 844 | </office:presentation> |
| 845 | </office:body> |
| 846 | </office:document-content> |
| 847 | """, |
| 848 | ) |
| 849 | |
| 850 | |
| 851 | def _document_lines(title: str, content: str) -> list[str]: |
| 852 | lines = [str(title or "Document").strip() or "Document"] |
| 853 | lines.extend(line.rstrip() for line in str(content or "").splitlines() if line.strip()) |
| 854 | if len(lines) == 1: |
| 855 | lines.append("") |
| 856 | return lines |
| 857 | |
| 858 | |
| 859 | def _odf_package(ext: str, content_xml: str) -> bytes: |
| 860 | return odf_zip_bytes( |
| 861 | ext, |
| 862 | { |
| 863 | "content.xml": content_xml, |
| 864 | "styles.xml": _odf_styles_xml(), |
| 865 | "meta.xml": _odf_meta_xml(), |
| 866 | "settings.xml": _odf_settings_xml(), |
| 867 | "META-INF/manifest.xml": _odf_manifest_xml(ODF_MIMETYPES[ext]), |
| 868 | }, |
| 869 | ) |
| 870 | |
| 871 | |
| 872 | def _odf_content_namespaces() -> str: |
| 873 | return ( |
| 874 | f'xmlns:office="{ODF_OFFICE_NS}" ' |
| 875 | f'xmlns:text="{ODF_TEXT_NS}" ' |
| 876 | f'xmlns:table="{ODF_TABLE_NS}" ' |
| 877 | f'xmlns:draw="{ODF_DRAW_NS}" ' |
| 878 | f'xmlns:presentation="{ODF_PRESENTATION_NS}" ' |
| 879 | f'xmlns:style="{ODF_STYLE_NS}" ' |
| 880 | f'xmlns:fo="{ODF_FO_NS}"' |
| 881 | ) |
| 882 | |
| 883 | |
| 884 | def _odf_styles_xml() -> str: |
| 885 | return f"""<?xml version="1.0" encoding="UTF-8"?> |
| 886 | <office:document-styles {_odf_content_namespaces()} office:version="{ODF_VERSION}"> |
| 887 | <office:styles> |
| 888 | <style:style style:name="Standard" style:family="paragraph"/> |
| 889 | <style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph"> |
| 890 | <style:text-properties fo:font-weight="bold" fo:font-size="18pt"/> |
| 891 | </style:style> |
| 892 | </office:styles> |
| 893 | </office:document-styles> |
| 894 | """ |
| 895 | |
| 896 | |
| 897 | def _odf_meta_xml() -> str: |
| 898 | return f"""<?xml version="1.0" encoding="UTF-8"?> |
| 899 | <office:document-meta xmlns:office="{ODF_OFFICE_NS}" office:version="{ODF_VERSION}"> |
| 900 | <office:meta/> |
| 901 | </office:document-meta> |
| 902 | """ |
| 903 | |
| 904 | |
| 905 | def _odf_settings_xml() -> str: |
| 906 | return f"""<?xml version="1.0" encoding="UTF-8"?> |
| 907 | <office:document-settings xmlns:office="{ODF_OFFICE_NS}" office:version="{ODF_VERSION}"> |
| 908 | <office:settings/> |
| 909 | </office:document-settings> |
| 910 | """ |
| 911 | |
| 912 | |
| 913 | def _odf_manifest_xml(media_type: str) -> str: |
| 914 | return f"""<?xml version="1.0" encoding="UTF-8"?> |
| 915 | <manifest:manifest xmlns:manifest="{ODF_MANIFEST_NS}" manifest:version="{ODF_VERSION}"> |
| 916 | <manifest:file-entry manifest:full-path="/" manifest:media-type="{media_type}"/> |
| 917 | <manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/> |
| 918 | <manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/> |
| 919 | <manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/> |
| 920 | <manifest:file-entry manifest:full-path="settings.xml" manifest:media-type="text/xml"/> |
| 921 | </manifest:manifest> |
| 922 | """ |
| 923 | |
| 924 | |
| 925 | def _odt_paragraph(line: str, heading: bool = False) -> str: |
| 926 | text = escape(str(line)) |
| 927 | if heading: |
| 928 | return f'<text:h text:outline-level="1">{text}</text:h>' |
| 929 | return f"<text:p>{text}</text:p>" |
| 930 | |
| 931 | |
| 932 | def _ods_row(row: list[Any]) -> str: |
| 933 | cells = "".join(_ods_cell(value) for value in row) |
| 934 | return f"<table:table-row>{cells}</table:table-row>" |
| 935 | |
| 936 | |
| 937 | def _ods_cell(value: Any) -> str: |
| 938 | value = _xlsx_value(value) |
| 939 | if value in (None, ""): |
| 940 | return "<table:table-cell/>" |
| 941 | if isinstance(value, bool): |
| 942 | text = "TRUE" if value else "FALSE" |
| 943 | return ( |
| 944 | f'<table:table-cell office:value-type="boolean" office:boolean-value="{str(value).lower()}">' |
| 945 | f"<text:p>{text}</text:p></table:table-cell>" |
| 946 | ) |
| 947 | if isinstance(value, (int, float)): |
| 948 | return ( |
| 949 | f'<table:table-cell office:value-type="float" office:value="{value}">' |
| 950 | f"<text:p>{value}</text:p></table:table-cell>" |
| 951 | ) |
| 952 | text = escape(str(value)) |
| 953 | return f'<table:table-cell office:value-type="string"><text:p>{text}</text:p></table:table-cell>' |
| 954 | |
| 955 | |
| 956 | def _odp_page(slide: dict[str, Any], index: int) -> str: |
| 957 | title = escape(str(slide.get("title") or f"Slide {index}")) |
| 958 | bullets = [escape(str(item)) for item in slide.get("bullets") or []] |
| 959 | bullet_items = "".join(f"<text:list-item><text:p>{bullet}</text:p></text:list-item>" for bullet in bullets) |
| 960 | body = f"<text:list>{bullet_items}</text:list>" if bullet_items else "<text:p/>" |
| 961 | return f"""<draw:page draw:name="Slide {index}" draw:master-page-name="Default"> |
| 962 | <draw:frame presentation:class="title" draw:name="Title {index}" svg:width="24cm" svg:height="2cm" svg:x="1.5cm" svg:y="1cm" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"> |
| 963 | <draw:text-box><text:p>{title}</text:p></draw:text-box> |
| 964 | </draw:frame> |
| 965 | <draw:frame presentation:class="outline" draw:name="Content {index}" svg:width="24cm" svg:height="12cm" svg:x="1.5cm" svg:y="3.5cm" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"> |
| 966 | <draw:text-box>{body}</draw:text-box> |
| 967 | </draw:frame> |
| 968 | </draw:page>""" |
| 969 | |
| 970 | |
| 971 | def _docx(title: str, content: str) -> bytes: |
| 972 | lines = [title] + [line for line in content.splitlines() if line.strip()] |
| 973 | if len(lines) == 1: |
| 974 | lines.append("") |
| 975 | body = "".join(_docx_paragraph(line) for line in lines) |
| 976 | return _zip_bytes({ |
| 977 | "[Content_Types].xml": """<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>""", |
| 978 | "_rels/.rels": """<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>""", |
| 979 | "word/document.xml": f"""<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}<w:sectPr/></w:body></w:document>""", |
| 980 | }) |
| 981 | |
| 982 | |
| 983 | def _docx_paragraph(line: str) -> str: |
| 984 | if not str(line).strip(): |
| 985 | return '<w:p><w:r><w:t xml:space="preserve"> </w:t></w:r></w:p>' |
| 986 | return f"<w:p><w:r><w:t>{escape(line)}</w:t></w:r></w:p>" |
| 987 | |
| 988 | |
| 989 | def _xlsx(title: str, content: str) -> bytes: |
| 990 | rows = _xlsx_rows(title, content) |
| 991 | sheet_rows = "".join( |
| 992 | f'<row r="{row_idx}">{"".join(_xlsx_cell(row_idx, col_idx, value) for col_idx, value in enumerate(row, start=1))}</row>' |
| 993 | for row_idx, row in enumerate(rows, start=1) |
| 994 | ) |
| 995 | return _zip_bytes({ |
| 996 | "[Content_Types].xml": """<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>""", |
| 997 | "_rels/.rels": """<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>""", |
| 998 | "xl/_rels/workbook.xml.rels": """<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>""", |
| 999 | "xl/workbook.xml": """<?xml version="1.0" encoding="UTF-8"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>""", |
| 1000 | "xl/worksheets/sheet1.xml": f"""<?xml version="1.0" encoding="UTF-8"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>{sheet_rows}</sheetData></worksheet>""", |
| 1001 | }) |
| 1002 | |
| 1003 | |
| 1004 | def _xlsx_rows(title: str, content: str) -> list[list[Any]]: |
| 1005 | parsed = _tabular_rows(content) |
| 1006 | if parsed: |
| 1007 | return parsed |
| 1008 | lines = [line for line in str(content or "").splitlines() if line.strip()] |
| 1009 | if lines: |
| 1010 | return [[title], *[[line] for line in lines]] |
| 1011 | return [[title]] |
| 1012 | |
| 1013 | |
| 1014 | def _tabular_rows(content: str) -> list[list[Any]]: |
| 1015 | text = str(content or "").strip("\n") |
| 1016 | if not text.strip(): |
| 1017 | return [] |
| 1018 | lines = [line for line in text.splitlines() if line.strip()] |
| 1019 | markdown_rows = _markdown_table_rows(lines) |
| 1020 | if markdown_rows: |
| 1021 | return markdown_rows |
| 1022 | |
| 1023 | delimiter = "\t" if any("\t" in line for line in lines) else ("," if any("," in line for line in lines) else None) |
| 1024 | if not delimiter: |
| 1025 | return [] |
| 1026 | return [[_xlsx_value(cell) for cell in row] for row in csv.reader(io.StringIO("\n".join(lines)), delimiter=delimiter)] |
| 1027 | |
| 1028 | |
| 1029 | def _markdown_table_rows(lines: list[str]) -> list[list[Any]]: |
| 1030 | table_lines = [line.strip() for line in lines if line.strip().startswith("|") and line.strip().endswith("|")] |
| 1031 | if len(table_lines) < 2: |
| 1032 | return [] |
| 1033 | rows = [] |
| 1034 | for line in table_lines: |
| 1035 | cells = [cell.strip() for cell in line.strip("|").split("|")] |
| 1036 | if all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells): |
| 1037 | continue |
| 1038 | rows.append([_xlsx_value(cell) for cell in cells]) |
| 1039 | return rows |
| 1040 | |
| 1041 | |
| 1042 | def _xlsx_cell(row_idx: int, col_idx: int, value: Any) -> str: |
| 1043 | ref = f"{_column_name(col_idx)}{row_idx}" |
| 1044 | value = _xlsx_value(value) |
| 1045 | if value in (None, ""): |
| 1046 | return f'<c r="{ref}"/>' |
| 1047 | if isinstance(value, bool): |
| 1048 | return f'<c r="{ref}" t="b"><v>{1 if value else 0}</v></c>' |
| 1049 | if isinstance(value, (int, float)): |
| 1050 | return f'<c r="{ref}"><v>{value}</v></c>' |
| 1051 | return f'<c r="{ref}" t="inlineStr"><is><t>{escape(str(value))}</t></is></c>' |
| 1052 | |
| 1053 | |
| 1054 | def _xlsx_value(value: Any) -> Any: |
| 1055 | if not isinstance(value, str): |
| 1056 | return value |
| 1057 | stripped = value.strip() |
| 1058 | if not stripped: |
| 1059 | return "" |
| 1060 | if stripped.lower() in {"true", "false"}: |
| 1061 | return stripped.lower() == "true" |
| 1062 | if re.fullmatch(r"[+-]?\d+", stripped) and not (len(stripped.lstrip("+-")) > 1 and stripped.lstrip("+-").startswith("0")): |
| 1063 | try: |
| 1064 | return int(stripped) |
| 1065 | except ValueError: |
| 1066 | return stripped |
| 1067 | if re.fullmatch(r"[+-]?(?:\d+\.\d*|\.\d+)(?:[eE][+-]?\d+)?", stripped) or re.fullmatch(r"[+-]?\d+[eE][+-]?\d+", stripped): |
| 1068 | try: |
| 1069 | return float(stripped) |
| 1070 | except ValueError: |
| 1071 | return stripped |
| 1072 | return stripped |
| 1073 | |
| 1074 | |
| 1075 | def _column_name(index: int) -> str: |
| 1076 | name = "" |
| 1077 | while index: |
| 1078 | index, remainder = divmod(index - 1, 26) |
| 1079 | name = chr(65 + remainder) + name |
| 1080 | return name |
| 1081 | |
| 1082 | |
| 1083 | def _pptx(title: str, content: str) -> bytes: |
| 1084 | return pptx_writer.pptx_from_text(title, content) |