File size: 2,881 Bytes
d3a26e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
"""Notebook export helpers.

Spec references:
- `specs/04_interfaces.md`: implements `export_notebook_zip()`.
- `specs/07_security.md`: export remains scoped to one user's notebook root.
- `specs/10_test_plan.md`: export logic is explicit and unit-testable.
"""

from __future__ import annotations

from datetime import datetime, timezone
from pathlib import Path
import zipfile

from notebooklm_clone.notebooks import get_notebook
from notebooklm_clone.storage import notebook_root, safe_join, user_root


class ExportError(Exception):
    """Base exception for notebook export failures."""


class ExportIOError(ExportError):
    """Raised when notebook export files cannot be created."""


def _utc_timestamp() -> str:
    """Return a timestamp suitable for export filenames."""

    return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")


def _zip_name(notebook_id: str) -> str:
    """Build a deterministic export filename for a notebook."""

    return f"{notebook_id}_{_utc_timestamp()}.zip"


def _should_exclude(path: Path) -> bool:
    """Return whether a file should be excluded as a transient artifact.

    Spec references:
    - User requirement: exclude large transient files if necessary.
    """

    return path.name.endswith(".lock") or path.name.endswith(".sqlite-wal") or path.name.endswith(
        ".sqlite-shm"
    )


def export_notebook_zip(username: str, notebook_id: str) -> Path:
    """Zip one notebook directory and return the archive path.

    Spec references:
    - `specs/04_interfaces.md`: implements `export_notebook_zip()`.
    - `specs/07_security.md`: keeps export paths within the user's storage root.

    Raises:
        ExportIOError: If the notebook archive cannot be created.
    """

    # Verifies notebook ownership and existence before export.
    get_notebook(username, notebook_id)

    source_root: Path = notebook_root(username, notebook_id)
    destination_root: Path = user_root(username)
    zip_path: Path = safe_join(destination_root, _zip_name(notebook_id))

    try:
        with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
            for file_path in sorted(source_root.rglob("*")):
                if not file_path.is_file():
                    continue
                if _should_exclude(file_path):
                    continue
                archive_name: Path = file_path.relative_to(source_root)
                archive.write(file_path, arcname=str(archive_name))
    except OSError as exc:
        raise ExportIOError(f"Failed to create notebook export archive: {zip_path}") from exc
    except ValueError as exc:
        raise ExportIOError(f"Failed to package notebook export archive: {zip_path}") from exc
    except zipfile.BadZipFile as exc:
        raise ExportIOError(f"Failed to finalize notebook export archive: {zip_path}") from exc

    return zip_path