main
py 1,084 lines 40.5 KB
Raw
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">&#160;</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)