Metadata-Version: 2.5
Name: x2raindrop-cli
Version: 1.0.5
Summary: CLI tool to sync X (Twitter) Bookmarks to Raindrop.io
Project-URL: Homepage, https://github.com/dotWee/x2raindrop-cli
Project-URL: Issues, https://github.com/dotWee/x2raindrop-cli/issues
Project-URL: Repository, https://github.com/dotWee/x2raindrop-cli
Author-email: Lukas Wolfsteiner <lukas@wolfsteiner.media>
License-File: LICENSE
Keywords: bookmarks,cli,raindrop,sync,twitter,x
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: License :: Freely Distributable
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet
Classifier: Topic :: Utilities
Requires-Python: >=3.12
Requires-Dist: httpx>=0.28.1
Requires-Dist: pydantic-settings>=2.14.0
Requires-Dist: pydantic>=2.13.3
Requires-Dist: python-raindropio>=0.0.4
Requires-Dist: rich>=15.0.0
Requires-Dist: structlog>=25.5.0
Requires-Dist: tomli-w>=1.2.0
Requires-Dist: typer>=0.24.1
Requires-Dist: xdk>=0.9.0
Description-Content-Type: text/markdown

# x2raindrop-cli

A Python CLI tool to sync your X (Twitter) bookmarks and liked posts to Raindrop.io collections.

[![PyPI version](https://badge.fury.io/py/x2raindrop-cli.svg)](https://badge.fury.io/py/x2raindrop-cli)
[![Docker Image](https://ghcr-badge.egpl.dev/dotwee/x2raindrop-cli/latest_tag?trim=major&label=docker)](https://ghcr.io/dotwee/x2raindrop-cli)
[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
[![License](https://img.shields.io/badge/license-WTFPL-green.svg)](LICENSE)

## Features

- Sync X bookmarks and/or liked posts to Raindrop.io collections
- Per-source configuration: separate collections, tags, link modes, and remove-from-X settings
- Configurable link handling:
  - Use X post permalink
  - Use first external URL from the post (with fallback to permalink)
  - Both: create entries for external URLs with X permalink stored in notes
- Apply custom tags to synced Raindrops
- Optional: unbookmark and/or unlike on X after syncing
- Idempotent syncing with local state tracking (bookmarks and likes tracked separately)
- Dry-run mode for safe testing
- Interactive OAuth 2.0 PKCE authentication flow for X

## Requirements

- Python 3.12 or higher
- uv for dependency and environment management
- X Developer account with OAuth 2.0 app
- Raindrop.io account with API token

## Installation

### From PyPI (Recommended)

```bash
pip install x2raindrop-cli
```

### From Source

```bash
git clone https://github.com/dotWee/x2raindrop-cli.git
cd x2raindrop-cli

# Install dependencies
uv sync
```

### Using Docker

Pull the image from GitHub Container Registry:

```bash
docker pull ghcr.io/dotwee/x2raindrop-cli:latest
```

Run commands by mounting your local directory (for config and state persistence):

```bash
# Show help
docker run --rm ghcr.io/dotwee/x2raindrop-cli --help

# Initialize config in current directory
docker run --rm -v "$PWD":/data ghcr.io/dotwee/x2raindrop-cli config init

# Sync bookmarks
docker run --rm -v "$PWD":/data ghcr.io/dotwee/x2raindrop-cli sync --collection 12345

# Sync bookmarks and likes
docker run --rm -v "$PWD":/data ghcr.io/dotwee/x2raindrop-cli \
  sync --collection 12345 --likes --likes-collection 54321
```

See the [Docker Usage](#docker-usage) section for more details.

### 2. Set Up X API Credentials

You have two options for X authentication:

#### Option A: Direct Access Token (Simplest)

If you already have an access token (e.g., from another OAuth flow or the X Developer Portal):

1. Set `X_ACCESS_TOKEN` in your config or environment
2. No browser login required - just run sync directly

```toml
[x]
access_token = "your_access_token_here"
# Optional: provide refresh_token to enable automatic token refresh
refresh_token = "your_refresh_token_here"
```

#### Option B: OAuth 2.0 PKCE Flow (Interactive)

For browser-based login:

1. Go to the [X Developer Portal](https://developer.x.com/en/portal/dashboard)
2. Create a new project and app (or use an existing one)
3. Under "User authentication settings", configure:
   - **App permissions**: Read and write
   - **Type of App**: Native App (for PKCE without client secret) or Confidential Client
   - **Callback URL**: `http://127.0.0.1:8765/callback`
4. Note your **Client ID** (and **Client Secret** if using Confidential Client)
5. Run `x2raindrop x login` to authenticate

**Required OAuth 2.0 Scopes:**
- `bookmark.read` - Read your bookmarks
- `bookmark.write` - Remove bookmarks (optional, only if using `--remove-from-x` for bookmarks)
- `like.read` - Read your liked posts (required for likes sync)
- `like.write` - Unlike posts (optional, only if using `--remove-from-x` for likes)
- `tweet.read` - Read tweet data
- `users.read` - Read user profile data
- `offline.access` - Refresh tokens for persistent access

If you previously authenticated without `like.read` / `like.write`, run
`x2raindrop x logout` then `x2raindrop x login` again so the new scopes are granted.

### 3. Set Up Raindrop.io API Token

1. Go to [Raindrop.io Integrations](https://app.raindrop.io/settings/integrations)
2. Under "For Developers", create a new app or use "Test token"
3. Copy the **Test token** for personal use

### 4. Configure the Application

Create a configuration file:

```bash
# Create default config file in current directory
uv run x2raindrop config init

# Edit the config file
nano config.toml
```

Or use environment variables:

```bash
# X API credentials (choose one method)
# Option A: Direct access token
export X_ACCESS_TOKEN="your_access_token"

# Option B: OAuth PKCE flow (then run `x2raindrop x login`)
export X_CLIENT_ID="your_client_id"
export X_CLIENT_SECRET="your_client_secret"  # Optional for public clients

# Raindrop.io credentials
export RAINDROP_TOKEN="your_raindrop_token"

# Sync settings (nested per source; note single underscore after SYNC_)
export SYNC_BOOKMARKS__COLLECTION_ID="12345"
export SYNC_BOOKMARKS__TAGS='["x-bookmark", "auto-synced"]'
export SYNC_BOOKMARKS__REMOVE_FROM_X="false"
export SYNC_BOOKMARKS__SKIP_EXISTING_LINKS="true"
export SYNC_BOOKMARKS__LINK_MODE="permalink"

# Optional: enable liked-post sync
export SYNC_LIKES__ENABLED="true"
export SYNC_LIKES__COLLECTION_ID="54321"
export SYNC_LIKES__TAGS='["x-like", "auto-synced"]'
```

## Usage

### Authenticate with X

First, authenticate with X using the interactive OAuth 2.0 PKCE flow:

```bash
uv run x2raindrop x login
```

This will open your browser for authorization. After approving, the tokens are saved locally.

### List Raindrop.io Collections

Find the collection ID you want to sync to:

```bash
uv run x2raindrop raindrop collections
```

### Sync Bookmarks and Likes

Basic bookmark sync:

```bash
uv run x2raindrop sync --collection 12345
```

Sync liked posts only:

```bash
uv run x2raindrop sync --no-bookmarks --likes --likes-collection 54321
```

Sync both bookmarks and likes (each to its own Raindrop collection):

```bash
uv run x2raindrop sync --collection 12345 --likes --likes-collection 54321
```

> Passing `--likes-collection` also enables likes sync unless `--no-likes` is set.

With options:

```bash
# Sync bookmarks with custom tags (--tags applies to bookmarks only;
# configure likes tags under [sync.likes] in config.toml)
uv run x2raindrop sync --collection 12345 --tags "x,bookmarks,auto"

# Use first external URL from tweets (applies to all enabled sources)
uv run x2raindrop sync --collection 12345 --link-mode first_external_url

# Remove synced items from X after syncing (unbookmark and/or unlike)
uv run x2raindrop sync --collection 12345 --likes --likes-collection 54321 --remove-from-x

# Override config that has remove_from_x / dry_run enabled
uv run x2raindrop sync --no-remove-from-x --no-dry-run

# Dry run - see what would happen without making changes
uv run x2raindrop sync --collection 12345 --likes --likes-collection 54321 --dry-run
```

Enable likes permanently in `config.toml` with `[sync.likes] enabled = true` and a
`collection_id`, then plain `x2raindrop sync` will sync both sources.

### Check X Authentication Status

```bash
uv run x2raindrop x status
```

### Logout from X

```bash
uv run x2raindrop x logout
```

## Configuration Reference

### Config File Location

Default: `config.toml` in the current working directory (project root).

Override with `--config` flag on any command.

### Config File Format

```toml
log_level = "INFO"

[x]
# Option A: Direct access token (simplest - no browser login needed)
access_token = ""

# Option B: OAuth PKCE flow (use `x2raindrop x login`)
client_id = ""
client_secret = ""  # Leave empty for public clients
redirect_uri = "http://127.0.0.1:8765/callback"
scopes = [
    "bookmark.read",
    "bookmark.write",
    "like.read",
    "like.write",
    "tweet.read",
    "users.read",
    "offline.access",
]

[raindrop]
token = "YOUR_RAINDROP_TOKEN"

[sync]
dry_run = false

[sync.bookmarks]
enabled = true
collection_id = 12345
collection_title = ""  # Optional: look up by title when collection_id is unset
tags = ["x-bookmark", "auto-synced"]
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

[sync.likes]
enabled = false
collection_id = 54321
collection_title = ""  # Optional: look up by title when collection_id is unset
tags = ["x-like", "auto-synced"]
remove_from_x = false  # Unlike posts on X after syncing
skip_existing_links = true
link_mode = "permalink"
both_behavior = "one_external_plus_note"
```

Copy [`config.example.toml`](config.example.toml) as a starting point, or run `x2raindrop config init`.

Legacy flat `[sync]` settings (without `bookmarks` / `likes` sections) are still
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.

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.

### Link Modes

| Mode | Description |
|------|-------------|
| `permalink` | Create a Raindrop with the X post URL |
| `first_external_url` | Use the first external URL in the tweet (falls back to permalink if none) |
| `both` | Create entries for both external URL and permalink (behavior configurable) |

### Both Behavior Options

When `link_mode = "both"` and the tweet contains an external URL:

| Option | Description |
|--------|-------------|
| `one_external_plus_note` | Create one Raindrop for the external URL, store X permalink in the note |
| `two_raindrops` | Create two separate Raindrops (one for external URL, one for X permalink) |

## Data Storage

The tool stores data in the current working directory:

- `config.toml` - Configuration file
- `.x2raindrop/x_token.json` - X OAuth tokens (keep secure!)
- `.x2raindrop/state.json` - Sync state for idempotency (bookmarks and likes tracked separately)

## Safety Notes

1. **Dry Run First**: Always use `--dry-run` before syncing to preview changes
2. **Remove from X**: The `remove_from_x` setting permanently removes bookmarks or unlikes posts on X. Use with caution and consider backing up first
3. **Token Security**: The `x_token.json` file contains sensitive tokens. Ensure proper file permissions

## X API Rate Limits

**IMPORTANT**: X API has strict rate limits, especially on the Free Tier.

| Tier | Rate Limit | Notes |
|------|------------|-------|
| Free | 1 request / 15 min | Very limited - sync may take a long time |
| Basic | Higher limits | Check X Developer Portal for current limits |

**API Request Breakdown:**
- Fetching bookmarks: 1 request per 100 bookmarks (paginated)
- Fetching liked posts: 1 request per 100 likes (paginated)
- Deleting a bookmark / unliking a post: 1 request per item

**Rate Limit Behavior:**
The CLI now uses the official Python XDK for X API calls. If X returns a 429
rate-limit response, the command exits with the API error from the SDK.

**Recommendations for Free Tier:**
1. **Don't use `--remove-from-x`** - each unbookmark/unlike is a separate request
2. Wait for the current rate-limit window to reset, then rerun the command
3. The tool tracks synced bookmarks and likes locally, so interrupted syncs can resume
4. Sync one source at a time on Free Tier if rate limits are tight
5. Consider upgrading to Basic tier if you have many bookmarks or likes

## Docker Usage

The Docker image provides a convenient way to run x2raindrop-cli without installing Python dependencies locally.

### Pulling the Image

```bash
# Latest version
docker pull ghcr.io/dotwee/x2raindrop-cli:latest
```

### Running Commands

The container's working directory is `/data`. Mount your local directory there to persist configuration and state:

```bash
# Create an alias for convenience
alias x2raindrop='docker run --rm -v "$PWD":/data ghcr.io/dotwee/x2raindrop-cli'

# Now use it like the native CLI
x2raindrop --version
x2raindrop config init
x2raindrop raindrop collections
x2raindrop sync --collection 12345 --dry-run
x2raindrop sync --collection 12345 --likes --likes-collection 54321 --dry-run
```

### Using Environment Variables

Pass credentials via environment variables instead of a config file:

```bash
docker run --rm \
  -e X_ACCESS_TOKEN="your_token" \
  -e RAINDROP_TOKEN="your_raindrop_token" \
  -e SYNC_BOOKMARKS__COLLECTION_ID="12345" \
  -e SYNC_LIKES__ENABLED="true" \
  -e SYNC_LIKES__COLLECTION_ID="54321" \
  -v "$PWD":/data \
  ghcr.io/dotwee/x2raindrop-cli sync
```

### OAuth Authentication in Docker

The interactive OAuth 2.0 PKCE flow (`x2raindrop x login`) requires a browser, which doesn't work well inside a container. You have two options:

**Option 1: Use a Direct Access Token (Recommended for Docker)**

Set `X_ACCESS_TOKEN` in your config or as an environment variable. No browser login required.

**Option 2: Authenticate on Host, Then Use in Docker**

1. Install the CLI locally and run `x2raindrop x login` on your host machine
2. This creates `.x2raindrop/x_token.json` in your current directory
3. Mount that directory when running Docker:

```bash
docker run --rm -v "$PWD":/data ghcr.io/dotwee/x2raindrop-cli sync --collection 12345
```

The container will use the token file from your mounted directory.

### Data Persistence

The container stores data in `/data` (the working directory):

| File | Purpose |
|------|---------|
| `config.toml` | Configuration file |
| `.x2raindrop/x_token.json` | X OAuth tokens |
| `.x2raindrop/state.json` | Sync state for bookmarks and likes |

Always mount a volume to `/data` to persist this data between runs.

## Development

### Setup Development Environment

```bash
uv sync --group dev
```

### Run Tests

```bash
uv run pytest
```

### Run Tests with Coverage

```bash
uv run pytest --cov=x2raindrop_cli --cov-report=html
```

### Linting

```bash
uv run ruff check src tests
uv run ruff format src tests
```

### Type Checking

```bash
uv run ty check src
```

## Troubleshooting

### "Not authenticated with X"

Run `x2raindrop x login` to authenticate.

### "Token expired"

The tool automatically refreshes tokens. If issues persist, run `x2raindrop x logout` then `x2raindrop x login`.

### "Collection ID not found"

Run `x2raindrop raindrop collections` to list available collections and their IDs.

### Rate Limit Errors

Wait for the current rate-limit window to reset (often 15 minutes on Free Tier),
then rerun. Fetching bookmarks/likes and each unbookmark/unlike consume separate
request quota.

### Likes sync fails with authorization / scope errors

Ensure your X app token includes `like.read` (and `like.write` if removing likes).
Re-authenticate with `x2raindrop x logout` then `x2raindrop x login` after updating
scopes in `config.toml`.

## License

Copyright (c) 2026 Lukas 'dotWee' Wolfsteiner <lukas@wolfsteiner.media>

Licensed under the Do What The Fuck You Want To Public License. See the [LICENSE](LICENSE) file for details.

## Credits

- [python-raindropio](https://github.com/atsuoishimoto/python-raindropio) - Raindrop.io API wrapper
- [X Python XDK](https://docs.x.com/xdks/python/quickstart) - X API documentation
