Joppy: Joplin Client and Server API Wrapper in Python

Since I hijacked a few other threads, like the predecessor, it's probably more appropriate to create a dedicated thread.

joppy supports basically two use cases. For more details, see the Github readme. In both cases, it can be installed by pip install joppy.

Joplin Client API

Requires a running Joplin client including web clipper. Functions from Joplin's data API are supported.

Example:

from joppy.client_api import ClientApi

# Create a new Api instance.
api = ClientApi(token=YOUR_TOKEN)

# Get all notes. Note that this method calls get_notes() multiple times to assemble the unpaginated result.
notes = api.get_all_notes()

Joplin Server API

There were some questions about a server API:

This is an attempt to build a wrapper that's working and sufficient for my needs. It is still experimental: Make a backup of your data before using. I can't provide any help at sync issues or lost data.

Supported: Some reverse engineered functions with similar interface like the client API.

Not supported:

  • Encryption
  • Some functions that were either to complex or I simply didn't need.

Example (requires a running Joplin server):

from joppy.server_api import ServerApi

# Create a new Api instance.
api = ServerApi(user="admin@localhost", password="admin", url="http://localhost:22300")

# Acquire a sync lock.
with api.sync_lock():

    # Add a notebook.
    notebook_id = api.add_notebook(title="My first notebook")

    # Add a note in the previously created notebook.
    note_id = api.add_note(title="My first note", body="With some content", parent_id=notebook_id)

NB: The server API wrapper might be superseded by the stand-alone sync API GSoC project.

4 Likes

This is really cool!
And maybe this helps somebody who also tries to make great things with joplin and joppy against the Joplin Server. I created this to see which users are registered.

#!/usr/bin/env python3
import argparse
import logging
from joppy.server_api import ServerApi
from pathlib import Path
import sys
from typing import Dict, Optional, Tuple
import re
from datetime import datetime
import json
from json import JSONEncoder

class DateTimeEncoder(JSONEncoder):
    """Custom JSON encoder for datetime objects"""
    def default(self, obj):
        if isinstance(obj, datetime):
            return obj.isoformat()
        return super().default(obj)

def setup_logging(debug: bool = False) -> None:
    """Configure logging with appropriate level and format"""
    level = logging.DEBUG if debug else logging.INFO
    logging.basicConfig(
        level=level,
        format='%(asctime)s - %(levelname)s - %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )

def read_config() -> Dict[str, str]:
    """Reads configuration from ~/.joplin.creds"""
    config_path = Path.home() / '.joplin.creds'
    if not config_path.exists():
        raise FileNotFoundError(f"Configuration file not found: {config_path}")

    config = {}
    try:
        with open(config_path, 'r') as f:
            for line in f:
                line = line.strip()
                if line and '=' in line:
                    key, value = line.split('=', 1)
                    value = value.strip().strip('"\'')
                    config[key.strip()] = value
    except Exception as e:
        logging.error(f"Error reading config file: {e}")
        raise

    return {
        'url': config.get('JOPLIN_SERVER_URL', '').rstrip('/'),
        'admin_user': config.get('JOPLIN_ADMIN', ''),
        'admin_password': config.get('JOPLIN_ADMIN_PASS', '')
    }

def create_api_client(config: Dict[str, str]) -> Optional[ServerApi]:
    """Creates an admin API client with error handling"""
    if not (config['admin_user'] and config['admin_password']):
        logging.warning("Admin credentials not provided in config")
        return None

    try:
        admin_api = ServerApi(
            user=config['admin_user'],
            password=config['admin_password'],
            url=config['url']
        )
        logging.info(f"šŸ”‘ Admin user logged in as: {config['admin_user']}")
        return admin_api
    except Exception as e:
        logging.error(f"Failed to create admin API client: {e}")
        return None

def perform_admin_operations(admin_api: ServerApi) -> None:
    """Performs administrative operations with enhanced error handling"""
    if not admin_api:
        logging.error("No admin API client available")
        return

    try:
        with admin_api.sync_lock():
            users = admin_api.get_all_users()
            logging.info(f"\n[{admin_api.user}] Registered users:")
            for user in users:
                logging.info(f"   - {user.email}")
                if logging.getLogger().isEnabledFor(logging.DEBUG):
                    logging.debug(f"     User details: {json.dumps(user.__dict__, indent=2, cls=DateTimeEncoder)}")
    except Exception as e:
        logging.error(f"Error during admin operations: {e}")
        if logging.getLogger().isEnabledFor(logging.DEBUG):
            logging.debug("Stack trace:", exc_info=True)

def parse_arguments():
    """Parse command line arguments"""
    parser = argparse.ArgumentParser(description='Joplin Admin Tool')
    parser.add_argument('--debug', action='store_true', help='Enable debug logging')
    parser.add_argument('--config', type=str, help='Custom config file path')
    return parser.parse_args()

def main() -> None:
    """Main function with enhanced API examples and error handling"""
    args = parse_arguments()
    setup_logging(args.debug)

    try:
        config = read_config()
        admin_api = create_api_client(config)

        if admin_api:
            logging.info("\nšŸ‘¤ Admin Functions:")
            logging.info("=" * 50)
            perform_admin_operations(admin_api)
        else:
            logging.error("Failed to initialize admin functionality")

    except Exception as e:
        logging.error(f"Critical error: {str(e)}")
        if logging.getLogger().isEnabledFor(logging.DEBUG):
            logging.debug("Stack trace:", exc_info=True)
        sys.exit(1)

if __name__ == "__main__":
    main()
3 Likes

I agree, this looks super cool; with automated tests and everything. I'm surprised I haven't seen this thread pick up more attention. One of those "I don't have a use case for but super appreciate existing incase I suddenly do" things for me.

2 Likes