Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.5] - 2025-07-25

### Added
- GET Highlights

## [0.1.4] - 2025-05-31

### Added
Expand Down
8 changes: 7 additions & 1 deletion src/readeck/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
Readeck's bookmark and reading features.
"""

__version__ = "0.1.4"
__version__ = "0.1.5"
__author__ = "Yash Vanzara"
__email__ = "yashvanzara@gmail.com"

Expand All @@ -26,6 +26,9 @@
BookmarkResource,
BookmarkResources,
EmailSettings,
Highlight,
HighlightListParams,
HighlightListResponse,
MarkdownExportMetadata,
MarkdownExportResult,
Provider,
Expand All @@ -49,6 +52,9 @@
"ReaderSettings",
"EmailSettings",
"Bookmark",
"Highlight",
"HighlightListParams",
"HighlightListResponse",
"BookmarkCreateRequest",
"BookmarkCreateResponse",
"BookmarkCreateResult",
Expand Down
109 changes: 109 additions & 0 deletions src/readeck/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
BookmarkCreateResponse,
BookmarkCreateResult,
BookmarkListParams,
Highlight,
HighlightListParams,
HighlightListResponse,
MarkdownExportMetadata,
MarkdownExportResult,
UserProfile,
Expand Down Expand Up @@ -418,6 +421,112 @@ async def export_bookmark_parsed(self, bookmark_id: str) -> MarkdownExportResult
metadata=metadata, content=content, raw_content=raw_content
)

async def get_highlights(
self, limit: Optional[int] = None, offset: Optional[int] = None
) -> HighlightListResponse:
"""Fetch a list of highlights/annotations.

Args:
limit: Maximum number of highlights to return (1-100)
offset: Pagination offset for the results

Returns:
HighlightListResponse containing the list of highlights and pagination info

Raises:
ReadeckAuthError: If authentication fails (401)
ReadeckError: For other request errors
"""
params = HighlightListParams(limit=limit, offset=offset)

try:
# Make the request directly to access headers
url = self._build_url("bookmarks/annotations")
response = await self._client.get(url, params=params.to_query_params())

# Handle different status codes
if response.status_code == 401:
raise ReadeckAuthError(
"Authentication failed. Please check your token.",
status_code=response.status_code,
)
elif response.status_code == 403:
raise ReadeckAuthError(
"Access forbidden. Insufficient permissions.",
status_code=response.status_code,
)
elif response.status_code == 404:
raise ReadeckNotFoundError(
"Resource not found.",
status_code=response.status_code,
)
elif response.status_code in (400, 422):
error_data = None
try:
error_data = response.json()
except Exception:
pass
raise ReadeckValidationError(
f"Validation error: {response.text}",
status_code=response.status_code,
response_data=error_data,
)
elif 500 <= response.status_code < 600:
raise ReadeckServerError(
f"Server error: {response.text}",
status_code=response.status_code,
)
elif not response.is_success:
raise ReadeckError(
f"HTTP {response.status_code}: {response.text}",
status_code=response.status_code,
)

# Parse JSON response
try:
json_response = response.json()
except Exception as e:
raise ReadeckError(f"Failed to parse JSON response: {e}")

# Parse the response
if not isinstance(json_response, list):
json_response = []

items = [Highlight(**item) for item in json_response]

# Get pagination info from headers
response_headers = response.headers
total_count = int(response_headers.get("Total-Count", len(items)))
current_page = int(response_headers.get("Current-Page", 1))
total_pages = int(response_headers.get("Total-Pages", 1))

# Parse Link header if present
links: Dict[str, Optional[str]] = {}
if "Link" in response_headers:
# Example: <https://readeck.example.com/api/bookmarks/annotations?limit=1&offset=199>; rel="previous"
for link in response_headers["Link"].split(","):
link = link.strip()
if ";" in link:
url_part, rel_part = link.split(";", 1)
url = url_part.strip(" <>")
rel = rel_part.split("=")[1].strip(' "')
links[rel] = url

return HighlightListResponse(
items=items,
total_count=total_count,
page=current_page,
total_pages=total_pages,
links=links,
)

except httpx.TimeoutException as e:
raise ReadeckError(f"Request timeout: {e}")
except httpx.RequestError as e:
raise ReadeckError(f"Request error: {e}")
except ValidationError as e:
raise ReadeckError(f"Failed to parse highlights response: {e}")

@staticmethod
def _parse_markdown_frontmatter(
content: str,
Expand Down
61 changes: 60 additions & 1 deletion src/readeck/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Pydantic models for Readeck API responses."""

from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union

from pydantic import BaseModel, Field, field_serializer

Expand Down Expand Up @@ -304,6 +304,65 @@ class MarkdownExportMetadata(BaseModel):
labels: Optional[List[str]] = Field(default=None, description="Bookmark labels")


class Highlight(BaseModel):
"""A single highlight/annotation from a bookmark."""

id: str = Field(..., description="Unique identifier for the highlight")
href: str = Field(..., description="API URL for the highlight")
bookmark_id: str = Field(..., description="ID of the bookmarked content")
bookmark_href: str = Field(..., description="API URL of the bookmarked content")
bookmark_title: str = Field(..., description="Title of the bookmarked content")
bookmark_url: str = Field(..., description="URL of the bookmarked content")
bookmark_site_name: Optional[str] = Field(
default=None, description="Name of the website where the content is from"
)
text: str = Field(..., description="The highlighted text content")
created: datetime = Field(..., description="When the highlight was created")
updated: Optional[datetime] = Field(
default=None, description="When the highlight was last updated"
)

@field_serializer("created", "updated", when_used="json")
def serialize_datetime(self, value: Optional[datetime]) -> Optional[str]:
"""Serialize datetime fields to ISO format."""
return value.isoformat() if value is not None else None


class HighlightListResponse(BaseModel):
"""Response model for the list highlights endpoint."""

items: List[Highlight] = Field(
default_factory=list, description="List of highlights"
)
total_count: int = Field(..., description="Total number of highlights available")
page: int = Field(..., description="Current page number")
total_pages: int = Field(..., description="Total number of pages available")
links: Dict[str, Optional[str]] = Field(
default_factory=dict, description="Pagination links"
)


class HighlightListParams(BaseModel):
"""Parameters for fetching highlights list."""

limit: Optional[int] = Field(
default=None,
ge=1,
le=100,
description="Number of items per page (1-100)",
)
offset: Optional[int] = Field(default=None, ge=0, description="Pagination offset")

def to_query_params(self) -> Dict[str, Union[str, int]]:
"""Convert parameters to query string dictionary."""
params: Dict[str, Union[str, int]] = {}
if self.limit is not None:
params["limit"] = self.limit
if self.offset is not None:
params["offset"] = self.offset
return params


class MarkdownExportResult(BaseModel):
"""Result of markdown export with parsed metadata and content."""

Expand Down
Loading