Skip to main content

Command Palette

Search for a command to run...

Building a Quick Capture Tool for Obsidian on Linux

Published
6 min read
Building a Quick Capture Tool for Obsidian on Linux
M

I am a developer with 36+ years of rigorous business operations experience. I don't just write Python; I build tools that save businesses 20+ hours a week. Specialising in custom dashboards (Streamlit), high-performance APIs (FastAPI), and automated data gathering (Scraping).

If you use Obsidian for note-taking, you know how tedious capturing content from applications on your desktop: copy, switch windows, paste, format. I wanted something faster: highlight text, hit a hotkey, done. Here's how I built it.

The Goal

A simple capture system that:

  • Grabs any highlighted text instantly

  • Optionally includes screenshots

  • Works with Obsidian's Local REST API

  • Integrates with Linux desktop shortcuts

The Solution

A Python script that leverages the Local REST API plugin for Obsidian. The plugin exposes a REST API that lets you read, create, and modify notes programmatically.

How It Works

  1. Text Selection: Uses xclip (X11) or wl-paste (Wayland) to read the primary selection—text you've highlighted but haven't copied

  2. Screenshots: Integrates with Flameshot (X11) or grim/slurp (Wayland) for area selection

  3. REST API: Uploads images and appends content to your vault via HTTP

  4. Notifications: Desktop feedback via notify-send

The Script

#!/usr/bin/env python3
"""
Obsidian Quick Capture Script

Captures highlighted text (and optionally screenshots) to an Obsidian note
via the Local REST API plugin.

Usage:
    obs_capture.py           # Capture selected text only
    obs_capture.py -s        # Capture text + screenshot
    obs_capture.py -n Note   # Use custom target note
"""

import argparse
import subprocess
import requests
import datetime
import os
import sys
import urllib3

# Suppress insecure HTTPS request warnings (expected for Local REST API self-signed certs)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# --- Configuration ---
API_KEY = "YOUR_API_KEY_HERE"  # Get from Obsidian Local REST API plugin settings
BASE_URL = "https://127.0.0.1:27124"

# Default paths (relative to vault root)
DEFAULT_NOTE = "00-Inbox/Quick Captures.md"
ATTACHMENT_DIR = "Attachments/"

HEADERS = {
    "Authorization": f"Bearer {API_KEY}"
}


def notify(title: str, message: str, urgency: str = "normal"):
    """Send a desktop notification using notify-send."""
    try:
        subprocess.run(
            ["notify-send", "-u", urgency, title, message],
            check=False
        )
    except FileNotFoundError:
        print(f"[{urgency.upper()}] {title}: {message}")


def get_selected_text() -> str:
    """Grab currently highlighted text from primary selection (X11 or Wayland)."""
    # Try xclip first (X11 primary selection)
    try:
        result = subprocess.run(
            ["xclip", "-o", "-selection", "primary"],
            capture_output=True,
            text=True,
            check=True
        )
        return result.stdout.strip()
    except (subprocess.CalledProcessError, FileNotFoundError):
        pass

    # Fallback to wl-paste (Wayland)
    try:
        result = subprocess.run(
            ["wl-paste", "-p"],
            capture_output=True,
            text=True,
            check=True
        )
        return result.stdout.strip()
    except (subprocess.CalledProcessError, FileNotFoundError):
        pass

    return ""


def take_screenshot(filepath: str) -> bool:
    """Allow user to draw a rectangle and capture it (X11 with Flameshot or Wayland with grim/slurp)."""
    # Try Flameshot first (X11)
    try:
        subprocess.run(
            ["flameshot", "gui", "-p", filepath],
            check=True,
            capture_output=True
        )
        if os.path.exists(filepath):
            return True
    except (subprocess.CalledProcessError, FileNotFoundError):
        pass

    # Fallback to grim/slurp (Wayland)
    try:
        subprocess.run(
            f'grim -g "$(slurp)" "{filepath}"',
            shell=True,
            check=True
        )
        return os.path.exists(filepath)
    except subprocess.CalledProcessError:
        return False

    return False


def check_api_connection() -> bool:
    """Verify the Obsidian Local REST API is reachable."""
    try:
        response = requests.get(
            f"{BASE_URL}/",
            headers=HEADERS,
            verify=False,
            timeout=5
        )
        return response.status_code in (200, 404)
    except requests.exceptions.RequestException:
        return False


def ensure_note_exists(note_path: str) -> bool:
    """Create the target note if it doesn't exist."""
    url = f"{BASE_URL}/vault/{note_path}"
    try:
        response = requests.get(url, headers=HEADERS, verify=False)
        if response.status_code == 200:
            return True

        if response.status_code == 404:
            create_response = requests.put(
                url,
                headers={**HEADERS, "Content-Type": "text/markdown"},
                data="# Quick Captures\n".encode("utf-8"),
                verify=False
            )
            return create_response.status_code in (200, 201, 204)
        return False
    except requests.exceptions.RequestException:
        return False


def upload_image(img_path: str, dest_filename: str) -> bool:
    """Upload an image to the Obsidian vault."""
    url = f"{BASE_URL}/vault/{ATTACHMENT_DIR}{dest_filename}"

    try:
        with open(img_path, "rb") as f:
            img_data = f.read()

        response = requests.put(
            url,
            headers={**HEADERS, "Content-Type": "image/png"},
            data=img_data,
            verify=False
        )
        return response.status_code in (200, 201, 204)
    except requests.exceptions.RequestException:
        return False


def append_to_note(note_path: str, content: str) -> bool:
    """Append content to a note in the vault."""
    url = f"{BASE_URL}/vault/{note_path}"

    try:
        response = requests.post(
            url,
            headers={**HEADERS, "Content-Type": "text/markdown"},
            data=content.encode("utf-8"),
            verify=False
        )
        return response.status_code in (200, 201, 204)
    except requests.exceptions.RequestException:
        return False


def parse_args() -> argparse.Namespace:
    """Parse command-line arguments."""
    parser = argparse.ArgumentParser(
        description="Capture highlighted text (and optionally screenshots) to Obsidian.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  %(prog)s              Capture selected text only
  %(prog)s -s           Capture text + screenshot
  %(prog)s -n Inbox.md  Use custom target note
"""
    )
    parser.add_argument(
        "-s", "--screenshot",
        action="store_true",
        help="Also capture a screenshot (text is always captured)"
    )
    parser.add_argument(
        "-n", "--note",
        default=DEFAULT_NOTE,
        help=f"Target note path (default: {DEFAULT_NOTE})"
    )
    return parser.parse_args()


def main():
    args = parse_args()
    timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    # 1. Check API connection
    if not check_api_connection():
        notify(
            "Obsidian Capture Failed",
            "Cannot connect to Obsidian Local REST API.\nMake sure Obsidian is running with the plugin enabled.",
            "critical"
        )
        sys.exit(1)

    # 2. Get selected text
    text = get_selected_text()
    if not text:
        notify(
            "Obsidian Capture Failed",
            "No text selected. Highlight some text first.",
            "critical"
        )
        sys.exit(1)

    # 3. Ensure target note exists
    if not ensure_note_exists(args.note):
        notify(
            "Obsidian Capture Failed",
            f"Could not create or access note: {args.note}",
            "critical"
        )
        sys.exit(1)

    # 4. Build content to append
    content_to_append = f"\n\n---\n\n**{timestamp}**\n\n"
    content_to_append += f"> {text}\n"

    # 5. Handle screenshot if requested
    img_filename = None
    if args.screenshot:
        img_filename = f"capture_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
        img_filepath = f"/tmp/{img_filename}"

        if take_screenshot(img_filepath):
            if upload_image(img_filepath, img_filename):
                content_to_append += f"\n![[{img_filename}]]\n"
            else:
                notify("Warning", "Screenshot taken but failed to upload to Obsidian.")
            if os.path.exists(img_filepath):
                os.remove(img_filepath)
        else:
            notify("Warning", "Screenshot cancelled or failed. Text will still be captured.")

    # 6. Append to note
    if append_to_note(args.note, content_to_append):
        preview = text[:50] + "..." if len(text) > 50 else text
        msg = f'Captured: "{preview}"'
        if img_filename:
            msg += " + screenshot"
        notify("Obsidian Capture", msg)
    else:
        notify("Obsidian Capture Failed", "Failed to append content to note.", "critical")
        sys.exit(1)


if __name__ == "__main__":
    main()

Setting Up Keyboard Shortcuts

On GNOME, register global hotkeys via gsettings:

# Text capture: Super+Shift+C
gsettings set org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/ name 'Obsidian Capture Text'
gsettings set org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/ command '/home/you/.local/bin/obs_capture.py'
gsettings set org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/ binding '<Super><Shift>c'

# Text + screenshot: Super+Shift+S
gsettings set org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom1/ name 'Obsidian Capture Screenshot'
gsettings set org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom1/ command '/home/you/.local/bin/obs_capture.py -s'
gsettings set org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom1/ binding '<Super><Shift>s'

# Register them
gsettings set org.gnome.settings-daemon.plugins.media-keys custom-keybindings "['/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/', '/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom1/']"

The Result

Highlight any text, press Super+Shift+C, and it appears in your vault:

---

**2026-02-22 21:34:19**

> Your captured text here

With -s flag or Super+Shift+S, you get text plus an embedded screenshot.

Requirements

  • Obsidian with Local REST API plugin enabled

  • Python 3 + requests library

  • X11: xclip, flameshot, libnotify-bin

  • Wayland: wl-clipboard, grim, slurp

# Ubuntu/Debian (X11)
sudo apt install xclip flameshot libnotify-bin
pip install requests

Why This Matters

The best capture system is the one you actually use. By reducing friction to a single hotkey, I find myself capturing more—interesting quotes, code snippets, error messages—without breaking my workflow. Everything flows into a single inbox note that I process later.

The full documentation is available at ~/.local/share/doc/obs_capture.md.

Happy capturing!