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
300 changes: 185 additions & 115 deletions .agents/skills/x/SKILL.md

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A Python CLI tool to sync your X (Twitter) bookmarks and liked posts to Raindrop
## Features

- Sync X bookmarks and/or liked posts to Raindrop.io collections
- Optional: map X bookmark folders to Raindrop subcollections under the parent collection
- Per-source configuration: separate collections, tags, link modes, and remove-from-X settings
- Configurable link handling:
- Use X post permalink
Expand Down Expand Up @@ -284,6 +285,7 @@ remove_from_x = false
skip_existing_links = true
link_mode = "permalink" # permalink, first_external_url, or both
both_behavior = "one_external_plus_note" # one_external_plus_note or two_raindrops
map_folders_to_subcollections = false # Map X bookmark folders to Raindrop child collections

[sync.likes]
enabled = false
Expand All @@ -304,6 +306,13 @@ supported and are treated as bookmark settings for backward compatibility.
Each source can be configured independently: different Raindrop collections,
tags, link modes, and remove-from-X behavior.

When `map_folders_to_subcollections = true` on `[sync.bookmarks]`, X bookmark
folders are mirrored as Raindrop subcollections under the configured parent
collection. For example, bookmarks in an X folder named `Test` are stored in a
child collection also named `Test`. The child collection is created if it does
not already exist. Unfiled bookmarks stay in the parent collection. This option
requires a regular Raindrop collection (not All / Unsorted / Trash).

CLI flags override config when passed. Boolean flags are tri-state, so
`--no-remove-from-x` and `--no-dry-run` clear values that are enabled in config.
`log_level` controls stdlib/structlog verbosity for CLI commands.
Expand Down Expand Up @@ -351,6 +360,8 @@ The tool stores data in the current working directory:
**API Request Breakdown:**
- Fetching bookmarks: 1 request per 100 bookmarks (paginated)
- Fetching liked posts: 1 request per 100 likes (paginated)
- Listing bookmark folders: 1 request per 100 folders (when mapping folders)
- Listing bookmarks in a folder: 1 request per folder (when mapping folders)
- Deleting a bookmark / unliking a post: 1 request per item

**Rate Limit Behavior:**
Expand Down
1 change: 1 addition & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ remove_from_x = false
skip_existing_links = true
link_mode = "permalink"
both_behavior = "one_external_plus_note"
map_folders_to_subcollections = false

[sync.likes]
enabled = false
Expand Down
17 changes: 17 additions & 0 deletions src/x2raindrop_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,13 @@ def sync(
help="Skip links already present in the target Raindrop collection",
),
] = None,
map_folders_to_subcollections: Annotated[
bool | None,
typer.Option(
"--map-folders-to-subcollections/--no-map-folders-to-subcollections",
help="Map X bookmark folders to Raindrop subcollections",
),
] = None,
link_mode: Annotated[
LinkMode | None,
typer.Option(
Expand Down Expand Up @@ -295,6 +302,8 @@ def sync(
settings.sync.bookmarks.skip_existing_links = skip_existing_links
if settings.sync.likes.enabled:
settings.sync.likes.skip_existing_links = skip_existing_links
if map_folders_to_subcollections is not None:
settings.sync.bookmarks.map_folders_to_subcollections = map_folders_to_subcollections
if link_mode is not None:
if settings.sync.bookmarks.enabled:
settings.sync.bookmarks.link_mode = link_mode
Expand Down Expand Up @@ -366,6 +375,10 @@ def sync(
"Bookmarks Skip Existing Links",
"Yes" if settings.sync.bookmarks.skip_existing_links else "No",
)
table.add_row(
"Map Folders to Subcollections",
"Yes" if settings.sync.bookmarks.map_folders_to_subcollections else "No",
)
table.add_row("Likes Enabled", "Yes" if settings.sync.likes.enabled else "No")
if settings.sync.likes.enabled:
table.add_row("Likes Collection ID", str(settings.sync.likes.collection_id))
Expand Down Expand Up @@ -816,6 +829,10 @@ def config_show(
)
table.add_row("Bookmarks Link Mode", settings.sync.bookmarks.link_mode.value)
table.add_row("Bookmarks Both Behavior", settings.sync.bookmarks.both_behavior.value)
table.add_row(
"Map Folders to Subcollections",
str(settings.sync.bookmarks.map_folders_to_subcollections),
)
table.add_row("Likes Enabled", str(settings.sync.likes.enabled))
table.add_row(
"Likes Collection ID",
Expand Down
19 changes: 19 additions & 0 deletions src/x2raindrop_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ class SourceSyncSettings(BaseModel):
skip_existing_links: Whether to skip links that already exist in Raindrop.
link_mode: How to determine the Raindrop link.
both_behavior: Behavior when link_mode is 'both'.
map_folders_to_subcollections: For bookmarks, map X folders to Raindrop
child collections under the configured parent collection.
"""

enabled: bool = Field(True, description="Whether this source is synced")
Expand All @@ -213,6 +215,12 @@ class SourceSyncSettings(BaseModel):
BothBehavior.ONE_EXTERNAL_PLUS_NOTE,
description="Behavior when link_mode is 'both'",
)
map_folders_to_subcollections: bool = Field(
False,
description=(
"Map X bookmark folders to Raindrop subcollections under the parent collection"
),
)

@field_validator("tags", mode="before")
@classmethod
Expand Down Expand Up @@ -309,6 +317,16 @@ def validate_enabled_sources(self) -> None:
)
if not self.any_enabled():
raise ValueError("At least one sync source (bookmarks or likes) must be enabled")
if (
self.bookmarks.enabled
and self.bookmarks.map_folders_to_subcollections
and self.bookmarks.collection_id is not None
and self.bookmarks.collection_id <= 0
):
raise ValueError(
"map_folders_to_subcollections requires a regular Raindrop collection "
"(not 0/All, -1/Unsorted, or -99/Trash)"
)


class Settings(BaseSettings):
Expand Down Expand Up @@ -448,6 +466,7 @@ def create_default_config(path: Path | None = None) -> Path:
"skip_existing_links": True,
"link_mode": "permalink",
"both_behavior": "one_external_plus_note",
"map_folders_to_subcollections": False,
},
"likes": {
"enabled": False,
Expand Down
19 changes: 19 additions & 0 deletions src/x2raindrop_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ class BothBehavior(StrEnum):
TWO_RAINDROPS = "two_raindrops"


class BookmarkFolder(BaseModel):
"""Represents an X bookmark folder.

Attributes:
id: Unique folder identifier from the X API.
name: Display name of the folder.
"""

model_config = ConfigDict(frozen=True)

id: str = Field(..., description="Unique bookmark folder identifier")
name: str = Field(..., description="Bookmark folder display name")


class BookmarkItem(BaseModel):
"""Represents a bookmark from X (Twitter).

Expand All @@ -62,6 +76,7 @@ class BookmarkItem(BaseModel):
created_at: When the tweet was created.
permalink: The direct URL to the tweet.
external_urls: List of external URLs found in the tweet.
folder_name: X bookmark folder name, if the post is filed in a folder.
"""

model_config = ConfigDict(frozen=True)
Expand All @@ -73,6 +88,10 @@ class BookmarkItem(BaseModel):
created_at: datetime | None = Field(None, description="Tweet creation timestamp")
permalink: str = Field(..., description="Direct URL to the tweet")
external_urls: list[str] = Field(default_factory=list, description="External URLs in tweet")
folder_name: str | None = Field(
None,
description="X bookmark folder name when the post is filed in a folder",
)

def get_title(self) -> str:
"""Generate a title for the bookmark.
Expand Down
58 changes: 58 additions & 0 deletions src/x2raindrop_cli/raindrop/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@ def list_collection_links(self, collection_id: int) -> set[str]:
"""
...

def create_collection(self, title: str, parent_id: int) -> RaindropCollection:
"""Create a child collection under a parent collection.

Args:
title: Collection title.
parent_id: Parent collection ID.

Returns:
Created collection details.
"""
...

def check_link_exists(self, link: str, collection_id: int | None = None) -> bool:
"""Check if a link already exists in Raindrop.

Expand Down Expand Up @@ -225,6 +237,52 @@ def list_collections(self) -> list[RaindropCollection]:
logger.debug("Listed collections", count=len(collections))
return collections

def create_collection(self, title: str, parent_id: int) -> RaindropCollection:
"""Create a child collection under a parent collection.

Uses the REST create endpoint so ``parent.$id`` is sent in the
documented shape (the python-raindropio helper sends a bare integer).

Args:
title: Collection title.
parent_id: Parent collection ID.

Returns:
Created collection details.

Raises:
ValueError: If the API response is missing a collection ID.
"""
create_url = "https://api.raindrop.io/rest/v1/collection"
payload = {"title": title, "parent": {"$id": parent_id}}
logger.debug("Creating collection", title=title, parent_id=parent_id)
response = self.api.post(create_url, json=payload)
response.raise_for_status()
response_data = response.json()
item = response_data.get("item", {})
if not isinstance(item, dict):
raise ValueError("Unexpected collection create response: item is not an object")

item_id = item.get("_id")
if item_id is None:
item_id = item.get("id")
if item_id is None:
raise ValueError("Unexpected collection create response: missing collection id")

created = RaindropCollection(
id=int(item_id),
title=str(item.get("title") or title),
count=int(item.get("count") or 0),
parent_id=parent_id,
)
logger.info(
"Created collection",
collection_id=created.id,
title=created.title,
parent_id=parent_id,
)
return created

def get_collection_by_title(self, title: str) -> RaindropCollection | None:
"""Find a collection by its title.

Expand Down
Loading