Skill: nickian/supernote-cloud (version 1.0.0)

--- name: supernote description: Access a self-hosted Supernote Private Cloud instance to browse files and folders, upload documents (PDF, EPUB) and notes, convert web articles to EPUB/PDF and send them to the device, check storage capacity, and navigate the directory tree. Use when the user mentions Supernote, e-ink device files, wants to upload/browse documents on their Supernote cloud, or wants to send an article/URL to their e-reader. --- # Supernote Private Cloud Browse, upload, and manage files on a self-hosted Supernote Private Cloud via its reverse-engineered REST API. Includes article-to-ebook conversion for sending web content to the device. ## Setup ```bash export SUPERNOTE_URL="http://192.168.50.168:8080" export SUPERNOTE_USER="[REDACTED_SECRET]" export SUPERNOTE_PASSWORD="your_password" ``` Python dependencies (for article conversion): `readability-lxml`, `ebooklib`, `requests`, `beautifulsoup4`, `lxml`. ## Commands ### Send a web article to the device ```bash {baseDir}/scripts/supernote.sh send-article --url "https://example.com/article" --format epub --dir-path Document {baseDir}/scripts/supernote.sh send-article --url "https://example.com/article" --format pdf --dir-path "Document/Articles" {baseDir}/scripts/supernote.sh send-article --url "https://example.com/article" --title "Custom Title" --dir-path Document ``` Fetches article content, extracts readable text with images, converts to clean EPUB or PDF, then uploads to the specified folder. Default format: epub. Default folder: Document. ### List directory contents ```bash {baseDir}/scripts/supernote.sh ls {baseDir}/scripts/supernote.sh ls --path Document {baseDir}/scripts/supernote.sh ls --path "Note/Journal" {baseDir}/scripts/supernote.sh ls --dir 778507258886619136 ``` ### Directory tree ```bash {baseDir}/scripts/supernote.sh tree --depth 2 ``` ### Find directory ID by path ```bash {baseDir}/scripts/supernote.sh find-dir --path "Document/Books" ``` ### Upload a file ```bash {baseDir}/scripts/supernote.sh upload --file /path/to/file.pdf --dir-path Document {baseDir}/scripts/supernote.sh upload --file /path/to/book.epub --dir-path "Document/Books" {baseDir}/scripts/supernote.sh upload --file /path/to/file.pdf --dir 778507258773372928 --name "Renamed.pdf" ``` ### Check storage capacity ```bash {baseDir}/scripts/supernote.sh capacity ``` ### Login (manual) ```bash {baseDir}/scripts/supernote.sh login ``` ## Default Folders | Folder | Purpose | |--------|---------| | Note | Handwritten notes (.note files) | | Document | PDFs, EPUBs, documents | | Inbox | Incoming files | | Export | Exported content | | Screenshot | Screenshots | | Mystyle | Custom styles/templates | ## Notes - EPUB is recommended for articles — renders cleanly on e-ink with reflowable text - The API is reverse-engineered and unofficial — endpoints may change with firmware updates - Directory args accept paths (e.g., "Document/Books") or numeric IDs - Some sites block scraping — if fetch fails, try a different URL or use a cached/saved page

path: scripts/article2ebook.py
content: #!/usr/bin/env python3
"""
Convert a web article URL into a clean EPUB or PDF for e-readers.

Usage:
    python3 article2ebook.py URL [--format epub|pdf] [--output PATH] [--title TITLE]

Requires: requests, readability-lxml, beautifulsoup4, lxml, ebooklib
"""
import argparse
import hashlib
import html
import os
import re
import sys
import tempfile
from datetime import datetime, timezone
from urllib.parse import urlparse

import requests
from bs4 import BeautifulSoup
from readability import Document


def fetch_article(url):
    """Fetch and extract readable article content."""
    headers = {
        'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
                       'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
    }
    resp = requests.get(url, headers=headers, timeout=30)
    resp.raise_for_status()

    doc = Document(resp.text)
    title = doc.title()
    content_html = doc.summary()

    # Clean up with BeautifulSoup
    soup = BeautifulSoup(content_html, 'lxml')

    # Remove scripts, styles, iframes
    for tag in soup.find_all(['script', 'style', 'iframe', 'noscript']):
        tag.decompose()

    # Extract images (download for EPUB embedding)
    images = []
    for img in soup.find_all('img'):
        src = img.get('src') or img.get('data-src', '')
        if src:
            if src.startswith('//'):
                src = 'https:' + src
            elif src.startswith('/'):
                parsed = urlparse(url)
                src = f"{parsed.scheme}://{parsed.netloc}{src}"
            images.append({'tag': img, 'src': src})

    return title, soup, images, url


def download_image(src, timeout=10):
    """Download an image, return (content_bytes, content_type, extension)."""
    try:
        resp = requests.get(src, timeout=timeout, headers={
            'User-Agent': 'Mozilla/5.0'
        })
        resp.raise_for_status()
        ct = resp.headers.get('Content-Type', 'image/jpeg')
        ext = 'jpg'
        if 'png' in ct:
            ext = 'png'
        elif 'gif' in ct:
            ext = 'gif'
        elif 'webp' in ct:
            ext = 'webp'
        elif 'svg' in ct:
            ext = 'svg'
        return resp.content, ct, ext
    except Exception:
        return None, None, None


def make_clean_html(title, soup, source_url):
    """Generate a clean, readable HTML document."""
    # Get body content
    body = soup.get_text() if not soup.body else str(soup)

    date_str = datetime.now(timezone.utc).strftime('%Y-%m-%d')
    domain = urlparse(source_url).netloc

    return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{html.escape(title)}</title>
<style>
    body {{
        font-family: Georgia, 'Times New Roman', serif;
        max-width: 42em;
        margin: 2em auto;
        padding: 0 1em;
        line-height: 1.7;
        color: #1a1a1a;
        font-size: 16px;
    }}
    h1 {{
        font-size: 1.8em;
        line-height: 1.2;
        margin-bottom: 0.3em;
    }}
    .meta {{
        color: #666;
        font-size: 0.9em;
        margin-bottom: 2em;
        border-bottom: 1px solid #ddd;
        padding-bottom: 1em;
    }}
    img {{
        max-width: 100%;
        height: auto;
    }}
    blockquote {{
        border-left: 3px solid #ccc;
        margin-left: 0;
        padding-left: 1em;
        color: #555;
    }}
    a {{ color: #1a5276; }}
    p {{ margin: 1em 0; }}
    pre, code {{
        background: #f5f5f5;
        padding: 0.2em 0.4em;
        font-size: 0.9em;
    }}
    pre {{
        padding: 1em;
        overflow-x: auto;
    }}
</style>
</head>
<body>
<h1>{html.escape(title)}</h1>
<div class="meta">Source: {html.escape(domain)} &middot; Saved: {date_str}</div>
{body}
</body>
</html>"""


def to_epub(title, soup, images, source_url, output_path):
    """Convert article to EPUB."""
    from ebooklib import epub

    book = epub.EpubBook()

    # Metadata
    safe_id = hashlib.md5(source_url.encode()).hexdigest()
    book.set_identifier(f'article-{safe_id}')
    book.set_title(title)
    book.set_language('en')
    book.add_author('Web Article')

    # Download and embed images
    img_map = {}
    for i, img_info in enumerate(images):
        data, ct, ext = download_image(img_info['src'])
        if data:
            img_name = f'images/img_{i}.{ext}'
            epub_img = epub.EpubImage()
            epub_img.file_name = img_name
            epub_img.media_type = ct
            epub_img.content = data
            book.add_item(epub_img)
            img_info['tag']['src'] = img_name
            img_map[img_info['src']] = img_name

    # Build chapter content
    clean_html = make_clean_html(title, soup, source_url)

    chapter = epub.EpubHtml(title=title, file_name='article.xhtml', lang='en')
    chapter.content = clean_html

    book.add_item(chapter)
    book.toc = [chapter]
    book.add_item(epub.EpubNcx())
    book.add_item(epub.EpubNav())
    book.spine = ['nav', chapter]

    # Default CSS
    style = epub.EpubItem(
        uid='style',
        file_name='style/default.css',
        media_type='text/css',
        content=b'''
body { font-family: Georgia, serif; line-height: 1.7; margin: 1em; }
h1 { font-size: 1.6em; line-height: 1.2; }
.meta { color: #666; font-size: 0.85em; margin-bottom: 1.5em; }
img { max-width: 100%; }
blockquote { border-left: 3px solid #ccc; padding-left: 1em; color: #555; margin-left: 0; }
'''
    )
    book.add_item(style)
    chapter.add_item(style)

    epub.write_epub(output_path, book)
    return output_path


def to_pdf(title, soup, images, source_url, output_path):
    """Convert article to PDF using basic HTML rendering.
    
    Falls back to a simple text-based approach if no PDF renderer is available.
    """
    clean_html = make_clean_html(title, soup, source_url)

    # Try weasyprint first
    try:
        from weasyprint import HTML
        HTML(string=clean_html).write_pdf(output_path)
        return output_path
    except ImportError:
        pass
    except Exception:
        pass

    # Fallback: use macOS textutil if available
    tmp_html = output_path.replace('.pdf', '.html')
    with open(tmp_html, 'w', encoding='utf-8') as f:
        f.write(clean_html)

    # Try cupsfilter or textutil → PDF
    import subprocess

    # Try using /usr/sbin/cupsfilter (macOS built-in)
    try:
        result = subprocess.run(
            ['/usr/bin/python3', '-c', f'''
import subprocess, sys
# Use macOS Automator/AppKit for HTML to PDF
from Foundation import NSURL
from AppKit import NSPrintOperation, NSPrintInfo
from WebKit import WKWebView, WKWebViewConfiguration
# This is complex, try simpler approach
sys.exit(1)
'''],
            capture_output=True, timeout=10
        )
    except Exception:
        pass

    # Simplest fallback: use textutil to make RTF, then python to wrap
    # Actually, let's just save as HTML and note it in output
    try:
        # cupsfilter can convert HTML to PDF on macOS
        result = subprocess.run(
            ['cupsfilter', tmp_html],
            capture_output=True, timeout=30
        )
        if result.returncode == 0 and result.stdout:
            with open(output_path, 'wb') as f:
                f.write(result.stdout)
            os.unlink(tmp_html)
            return output_path
    except Exception:
        pass

    # Final fallback: save as HTML (still very readable on Supernote)
    os.rename(tmp_html, output_path.replace('.pdf', '.html'))
    print(f"Note: PDF generation unavailable. Saved as HTML instead.", file=sys.stderr)
    return output_path.replace('.pdf', '.html')


def sanitize_filename(title, ext):
    """Create a safe filename from the article title."""
    # Remove special chars, truncate
    safe = re.sub(r'[^\w\s\-]', '', title)
    safe = re.sub(r'\s+', '_', safe.strip())
    if len(safe) > 80:
        safe = safe[:80]
    return f"supernote_{safe}.{ext}"


def main():
    parser = argparse.ArgumentParser(description='Convert web article to EPUB/PDF')
    parser.add_argument('url', help='Article URL')
    parser.add_argument('--format', '-f', choices=['epub', 'pdf'], default='epub',
                        help='Output format (default: epub)')
    parser.add_argument('--output', '-o', help='Output file path')
    parser.add_argument('--title', '-t', help='Override article title')
    args = parser.parse_args()

    print(f"Fetching: {args.url}", file=sys.stderr)
    title, soup, images, url = fetch_article(args.url)

    if args.title:
        title = args.title

    print(f"Title: {title}", file=sys.stderr)

    # Determine output path
    if args.output:
        output_path = args.output
    else:
        filename = sanitize_filename(title, args.format)
        output_path = os.path.join(tempfile.gettempdir(), filename)

    if args.format == 'epub':
        result = to_epub(title, soup, images, url, output_path)
    else:
        result = to_pdf(title, soup, images, url, output_path)

    print(f"Saved: {result}", file=sys.stderr)
    # Print path to stdout for piping
    print(result)


if __name__ == '__main__':
    main()


path: scripts/supernote.sh
content: #!/bin/bash
# Supernote Private Cloud CLI
# Reverse-engineered API client for file listing, upload, and storage info
#
# Requires:
#   SUPERNOTE_URL      - Cloud server URL (e.g., http://192.168.50.168:8080)
#   SUPERNOTE_USER     - Account email
#   SUPERNOTE_PASSWORD - Account password
#
# Usage:
#   supernote.sh login
#   supernote.sh ls [--dir ID] [--path PATH]
#   supernote.sh tree [--depth N]
#   supernote.sh find-dir --path PATH
#   supernote.sh upload --file PATH [--dir ID] [--dir-path PATH] [--name NAME]
#   supernote.sh capacity

set -e

if [ -z "$SUPERNOTE_URL" ] || [ -z "$SUPERNOTE_USER" ] || [ -z "$SUPERNOTE_PASSWORD" ]; then
  echo "Error: SUPERNOTE_URL, SUPERNOTE_USER, and SUPERNOTE_PASSWORD must be set" >&2
  exit 1
fi

BASE_URL="${SUPERNOTE_URL%/}"
TOKEN_FILE="/tmp/.supernote_token"

# --- Auth helpers ---

get_token() {
  # Check cached token
  if [ -f "$TOKEN_FILE" ]; then
    local TOKEN
    TOKEN=$(cat "$TOKEN_FILE")
    if is_token_valid "$TOKEN"; then
      echo "$TOKEN"
      return
    fi
  fi
  # Login fresh
  do_login
  cat "$TOKEN_FILE"
}

is_token_valid() {
  local TOKEN="$1"
  if [ -z "$TOKEN" ]; then return 1; fi
  python3 -c "
import base64, json, time, sys
parts = '$TOKEN'.split('.')
if len(parts) != 3:
    sys.exit(1)
b64 = parts[1] + '=' * (4 - len(parts[1]) % 4)
try:
    data = json.loads(base64.urlsafe_b64decode(b64))
    if data.get('exp', 0) > time.time() + 3600:
        sys.exit(0)
    else:
        sys.exit(1)
except:
    sys.exit(1)
" 2>/dev/null
}

do_login() {
  # Step 1: Get random code
  local RANDOM_RESP
  RANDOM_RESP=$(curl -s -X POST \
    -H "Content-Type: application/json" \
    -H "withCredentials: true" \
    "$BASE_URL/api/official/user/query/random/code" \
    -d "{\"countryCode\":null,\"account\":\"$SUPERNOTE_USER\"}")

  local RANDOM_CODE TIMESTAMP
  RANDOM_CODE=$(echo "$RANDOM_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['randomCode'])")
  TIMESTAMP=$(echo "$RANDOM_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['timestamp'])")

  # Step 2: Hash [REDACTED_SECRET]) + randomCode)
  local HASHED_PASS
  HASHED_PASS=$(python3 -c "
import hashlib
md5 = hashlib.md5('$SUPERNOTE_PASSWORD'.encode()).hexdigest()
print(hashlib.sha256((md5 + '$RANDOM_CODE').encode()).hexdigest())
")

  # Step 3: Login
  local LOGIN_RESP
  LOGIN_RESP=$(curl -s -X POST \
    -H "Content-Type: application/json" \
    -H "withCredentials: true" \
    "$BASE_URL/api/official/user/account/login/new" \
    -d "{\"countryCode\":null,\"account\":\"$SUPERNOTE_USER\",\"password\":\"$HASHED_PASS\",\"browser\":\"CLI-Client\",\"equipment\":\"1\",\"loginMethod\":\"1\",\"timestamp\":$TIMESTAMP,\"language\":\"en\"}")

  local TOKEN
  [REDACTED_SECRET] "$LOGIN_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('token',''))" 2>/dev/null)

  if [ -z "$TOKEN" ]; then
    echo "Login failed: $LOGIN_RESP" >&2
    exit 1
  fi

  echo "$TOKEN" > "$TOKEN_FILE"
  chmod 600 "$TOKEN_FILE"
}

api_post() {
  local ENDPOINT="$1"
  local DATA="$2"
  local TOKEN
  [REDACTED_SECRET])
  curl -s -X POST \
    -H "Content-Type: application/json" \
    -H "x-access-[REDACTED_SECRET]" \
    -H "withCredentials: true" \
    "$BASE_URL$ENDPOINT" \
    -d "$DATA"
}

# --- Commands ---

cmd_login() {
  do_login
  local TOKEN
  TOKEN=$(cat "$TOKEN_FILE")
  local USER_ID
  USER_ID=$(echo "$TOKEN" | cut -d. -f2 | python3 -c "
import sys, base64, json
b64 = sys.stdin.read().strip()
b64 += '=' * (4 - len(b64) % 4)
data = json.loads(base64.urlsafe_b64decode(b64))
print(data.get('userId', 'unknown'))
")
  echo "Logged in successfully. User ID: $USER_ID"
}

cmd_ls() {
  local DIR_ID="0"
  local DIR_PATH=""

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dir) DIR_ID="$2"; shift 2 ;;
      --path) DIR_PATH="$2"; shift 2 ;;
      *) shift ;;
    esac
  done

  # Resolve path to ID if given
  if [ -n "$DIR_PATH" ]; then
    DIR_ID=$(resolve_path "$DIR_PATH")
    if [ -z "$DIR_ID" ] || [ "$DIR_ID" = "null" ]; then
      echo "Error: Directory '$DIR_PATH' not found" >&2
      exit 1
    fi
  fi

  local RESP
  RESP=$(api_post "/api/file/list/query" "{\"directoryId\":\"$DIR_ID\",\"pageNo\":1,\"pageSize\":100,\"order\":\"time\",\"sequence\":\"desc\"}")

  echo "$RESP" | python3 -c "
import sys, json
data = json.load(sys.stdin)
items = data.get('userFileVOList', data.get('data', {}).get('userFileVOList', []))
if not items:
    print('(empty directory)')
else:
    for item in items:
        icon = '📁' if item.get('isFolder') == 'Y' else '📄'
        name = item.get('fileName', '?')
        fid = item.get('id', '?')
        size = item.get('size', 0)
        if item.get('isFolder') == 'Y':
            print(f'{icon} {name}  (id: {fid})')
        else:
            sz = f'{size:,}' if size else '?'
            print(f'{icon} {name}  ({sz} bytes, id: {fid})')
"
}

resolve_path() {
  local PATH_STR="$1"
  local CURRENT_ID="0"

  # Split on /
  IFS='/' read -ra PARTS <<< "$PATH_STR"
  for PART in "${PARTS[@]}"; do
    [ -z "$PART" ] && continue
    local RESP
    RESP=$(api_post "/api/file/list/query" "{\"directoryId\":\"$CURRENT_ID\",\"pageNo\":1,\"pageSize\":100,\"order\":\"time\",\"sequence\":\"desc\"}")
    local FOUND_ID
    FOUND_ID=$(echo "$RESP" | python3 -c "
import sys, json
data = json.load(sys.stdin)
target = '$PART'
for item in data.get('userFileVOList', data.get('data', {}).get('userFileVOList', [])):
    if item.get('fileName') == target and item.get('isFolder') == 'Y':
        print(item['id'])
        break
" 2>/dev/null)
    if [ -z "$FOUND_ID" ]; then
      echo ""
      return
    fi
    CURRENT_ID="$FOUND_ID"
  done
  echo "$CURRENT_ID"
}

cmd_find_dir() {
  local DIR_PATH=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --path) DIR_PATH="$2"; shift 2 ;;
      *) shift ;;
    esac
  done
  if [ -z "$DIR_PATH" ]; then
    echo "Error: --path is required" >&2; exit 1
  fi
  local DIR_ID
  DIR_ID=$(resolve_path "$DIR_PATH")
  if [ -z "$DIR_ID" ] || [ "$DIR_ID" = "null" ]; then
    echo "Directory '$DIR_PATH' not found" >&2
    exit 1
  fi
  echo "$DIR_ID"
}

cmd_tree() {
  local MAX_DEPTH=2
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --depth) MAX_DEPTH="$2"; shift 2 ;;
      *) shift ;;
    esac
  done
  print_tree "0" "" 0 "$MAX_DEPTH"
}

print_tree() {
  local DIR_ID="$1"
  local PREFIX="$2"
  local DEPTH="$3"
  local MAX_DEPTH="$4"

  if [ "$DEPTH" -ge "$MAX_DEPTH" ]; then return; fi

  local RESP
  RESP=$(api_post "/api/file/list/query" "{\"directoryId\":\"$DIR_ID\",\"pageNo\":1,\"pageSize\":100,\"order\":\"time\",\"sequence\":\"desc\"}")

  echo "$RESP" | python3 -c "
import sys, json
data = json.load(sys.stdin)
items = data.get('userFileVOList', data.get('data', {}).get('userFileVOList', []))
for item in items:
    icon = '📁' if item.get('isFolder') == 'Y' else '📄'
    name = item.get('fileName', '?')
    fid = item.get('id', '?')
    is_folder = item.get('isFolder') == 'Y'
    print(f'ITEM|{is_folder}|{fid}|${PREFIX}{icon} {name}')
" 2>/dev/null | while IFS='|' read -r _ IS_FOLDER FID LINE; do
    echo "$LINE"
    if [ "$IS_FOLDER" = "True" ] && [ "$((DEPTH + 1))" -lt "$MAX_DEPTH" ]; then
      print_tree "$FID" "${PREFIX}  " "$((DEPTH + 1))" "$MAX_DEPTH"
    fi
  done
}

cmd_upload() {
  local FILE_PATH="" DIR_ID="" DIR_PATH="" REMOTE_NAME=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --file) FILE_PATH="$2"; shift 2 ;;
      --dir) DIR_ID="$2"; shift 2 ;;
      --dir-path) DIR_PATH="$2"; shift 2 ;;
      --name) REMOTE_NAME="$2"; shift 2 ;;
      *) shift ;;
    esac
  done

  if [ -z "$FILE_PATH" ]; then
    echo "Error: --file is required" >&2; exit 1
  fi
  if [ ! -f "$FILE_PATH" ]; then
    echo "Error: File not found: $FILE_PATH" >&2; exit 1
  fi

  # Resolve dir
  if [ -n "$DIR_PATH" ]; then
    DIR_ID=$(resolve_path "$DIR_PATH")
    if [ -z "$DIR_ID" ] || [ "$DIR_ID" = "null" ]; then
      echo "Error: Directory '$DIR_PATH' not found" >&2; exit 1
    fi
  fi
  if [ -z "$DIR_ID" ]; then
    echo "Error: --dir or --dir-path is required" >&2; exit 1
  fi

  local FILE_NAME="${REMOTE_NAME:-$(basename "$FILE_PATH")}"
  local FILE_SIZE
  FILE_SIZE=$(stat -f%z "$FILE_PATH" 2>/dev/null || stat -c%s "$FILE_PATH" 2>/dev/null)
  local FILE_MD5
  FILE_MD5=$(python3 -c "import hashlib; print(hashlib.md5(open('$FILE_PATH','rb').read()).hexdigest())")

  local TOKEN
  [REDACTED_SECRET])
  local TIMESTAMP
  TIMESTAMP=$(python3 -c "import time; print(int(time.time()*1000))")
  local NONCE
  NONCE=$(python3 -c "import random,time; print(str(random.randint(0,9999999999)).zfill(10) + str(int(time.time()*1000)))")

  # Step 1: Apply for upload
  local APPLY_RESP
  APPLY_RESP=$(curl -s -X POST \
    -H "Content-Type: application/json" \
    -H "x-access-[REDACTED_SECRET]" \
    -H "withCredentials: true" \
    -H "timestamp: $TIMESTAMP" \
    -H "nonce: $NONCE" \
    "$BASE_URL/api/file/upload/apply" \
    -d "{\"size\":$FILE_SIZE,\"fileName\":\"$FILE_NAME\",\"directoryId\":\"$DIR_ID\",\"md5\":\"$FILE_MD5\"}")

  local UPLOAD_URL INNER_NAME
  UPLOAD_URL=$(echo "$APPLY_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('fullUploadUrl',''))" 2>/dev/null)
  INNER_NAME=$(echo "$APPLY_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('innerName',''))" 2>/dev/null)

  if [ -z "$UPLOAD_URL" ]; then
    echo "Upload apply failed: $APPLY_RESP" >&2
    exit 1
  fi

  # Step 2: Upload to OSS
  local OSS_RESP
  OSS_RESP=$(curl -s -X POST \
    -H "x-access-[REDACTED_SECRET]" \
    -F "file=@$FILE_PATH" \
    "$UPLOAD_URL")

  # Step 3: Finish upload
  local FINISH_RESP
  FINISH_RESP=$(curl -s -X POST \
    -H "Content-Type: application/json" \
    -H "x-access-[REDACTED_SECRET]" \
    -H "withCredentials: true" \
    "$BASE_URL/api/file/upload/finish" \
    -d "{\"directoryId\":\"$DIR_ID\",\"fileName\":\"$FILE_NAME\",\"fileSize\":$FILE_SIZE,\"innerName\":\"$INNER_NAME\",\"md5\":\"$FILE_MD5\"}")

  local SUCCESS
  SUCCESS=$(echo "$FINISH_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('success', False))" 2>/dev/null)

  if [ "$SUCCESS" = "True" ]; then
    echo "Uploaded: $FILE_NAME ($FILE_SIZE bytes) → directory $DIR_ID"
  else
    echo "Upload may have failed. Finish response: $FINISH_RESP" >&2
    exit 1
  fi
}

cmd_capacity() {
  local RESP
  RESP=$(api_post "/api/file/capacity/query" "{}")
  echo "$RESP" | python3 -c "
import sys, json
data = json.load(sys.stdin)
d = data.get('data', data)
if isinstance(d, dict):
    for k, v in d.items():
        if isinstance(v, (int, float)) and v > 1000000:
            mb = v / (1024*1024)
            print(f'{k}: {mb:.1f} MB')
        else:
            print(f'{k}: {v}')
else:
    print(json.dumps(data, indent=2))
"
}

cmd_send_article() {
  local URL="" FORMAT="epub" DIR_PATH="Document" DIR_ID="" TITLE=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --url) URL="$2"; shift 2 ;;
      --format) FORMAT="$2"; shift 2 ;;
      --dir-path) DIR_PATH="$2"; shift 2 ;;
      --dir) DIR_ID="$2"; shift 2 ;;
      --title) TITLE="$2"; shift 2 ;;
      *) URL="${URL:-$1}"; shift ;;
    esac
  done

  if [ -z "$URL" ]; then
    echo "Error: URL is required" >&2; exit 1
  fi

  local SCRIPT_DIR
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

  # Convert article
  local EXTRA_ARGS=""
  if [ -n "$TITLE" ]; then
    EXTRA_ARGS="--title \"$TITLE\""
  fi

  local OUTPUT_FILE
  OUTPUT_FILE=$(python3 "$SCRIPT_DIR/article2ebook.py" "$URL" --format "$FORMAT" $EXTRA_ARGS 2>&1 | tee /dev/stderr | tail -1)

  if [ ! -f "$OUTPUT_FILE" ]; then
    echo "Error: Failed to create ebook file" >&2
    exit 1
  fi

  echo "Uploading to Supernote..." >&2

  # Resolve directory
  if [ -z "$DIR_ID" ]; then
    DIR_ID=$(resolve_path "$DIR_PATH")
    if [ -z "$DIR_ID" ] || [ "$DIR_ID" = "null" ]; then
      echo "Error: Directory '$DIR_PATH' not found" >&2; exit 1
    fi
  fi

  # Upload
  cmd_upload --file "$OUTPUT_FILE" --dir "$DIR_ID"

  # Cleanup temp file
  rm -f "$OUTPUT_FILE"
}

# --- Main ---
COMMAND="${1:-ls}"
shift 2>/dev/null || true

case "$COMMAND" in
  login) cmd_login ;;
  ls|list) cmd_ls "$@" ;;
  tree) cmd_tree "$@" ;;
  find-dir) cmd_find_dir "$@" ;;
  upload) cmd_upload "$@" ;;
  capacity|cap) cmd_capacity ;;
  send-article|article) cmd_send_article "$@" ;;
  *)
    echo "Usage: $0 {login|ls|tree|find-dir|upload|capacity|send-article}" >&2
    exit 1
    ;;
esac


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (MEDIUM)