Add CLI source code and update .gitignore
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
**/__pycache__/
|
||||||
|
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
@@ -150,8 +151,10 @@ cython_debug/
|
|||||||
# CodiMD source (too large)
|
# CodiMD source (too large)
|
||||||
codimd/
|
codimd/
|
||||||
|
|
||||||
# Session data
|
# Session data (but keep package configs)
|
||||||
|
/.config/
|
||||||
*.json
|
*.json
|
||||||
!setup.json
|
!setup.json
|
||||||
!package.json
|
!package.json
|
||||||
!package-lock.json
|
!package-lock.json
|
||||||
|
!pyproject.toml
|
||||||
|
|||||||
118
cli_anything/codimd/README.md
Normal file
118
cli_anything/codimd/README.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# CodiMD CLI Harness
|
||||||
|
|
||||||
|
A stateful CLI for [CodiMD](https://github.com/hackmdio/codimd) - collaborative markdown notes on all platforms.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install cli-anything-codimd
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set your CodiMD server
|
||||||
|
cli-anything-codimd config set server https://your-codimd-server.com
|
||||||
|
|
||||||
|
# List your notes
|
||||||
|
cli-anything-codimd note list
|
||||||
|
|
||||||
|
# Create a new note
|
||||||
|
cli-anything-codimd note create --content "# My New Note\n\nHello world"
|
||||||
|
|
||||||
|
# Get note content
|
||||||
|
cli-anything-codimd note get <note-id>
|
||||||
|
|
||||||
|
# Export as markdown
|
||||||
|
cli-anything-codimd export markdown <note-id> --output note.md
|
||||||
|
|
||||||
|
# Get user info
|
||||||
|
cli-anything-codimd user me
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Note Operations
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `note list` | List user's notes |
|
||||||
|
| `note get <id>` | Get note content |
|
||||||
|
| `note create` | Create new note |
|
||||||
|
| `note update <id>` | Update note content |
|
||||||
|
| `note delete <id>` | Delete note |
|
||||||
|
| `note info <id>` | Get note metadata |
|
||||||
|
| `note publish <id>` | Get publish link |
|
||||||
|
|
||||||
|
### User Operations
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `user me` | Get current user info |
|
||||||
|
| `user login` | Login to server |
|
||||||
|
| `user logout` | Logout from session |
|
||||||
|
| `user export` | Export all user data |
|
||||||
|
| `user delete` | Delete user account |
|
||||||
|
|
||||||
|
### Export Operations
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `export markdown <id>` | Export as markdown |
|
||||||
|
| `export pdf <id>` | Export as PDF |
|
||||||
|
| `export html <id>` | Export as HTML |
|
||||||
|
| `export slide <id>` | Export as reveal.js slides |
|
||||||
|
|
||||||
|
### Revision Operations
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `revision list <id>` | List note revisions |
|
||||||
|
| `revision get <id> <time>` | Get note at specific time |
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `config get [key]` | Get configuration value |
|
||||||
|
| `config set <key> <value>` | Set configuration value |
|
||||||
|
|
||||||
|
## JSON Output Mode
|
||||||
|
|
||||||
|
All commands support `--json` flag for machine-readable output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cli-anything-codimd --json note list
|
||||||
|
```
|
||||||
|
|
||||||
|
Response format:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success|error",
|
||||||
|
"data": {...},
|
||||||
|
"error": "error message if status is error"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session Management
|
||||||
|
|
||||||
|
The CLI stores session data in `~/.config/cli-anything-codimd/`:
|
||||||
|
- `session.json`: Auth tokens and session data
|
||||||
|
- `config.json`: Server configuration
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install in development mode
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
pytest cli_anything/codimd/tests/ -v
|
||||||
|
|
||||||
|
# Run with specific command
|
||||||
|
python -m cli_anything.codimd.codimd_cli --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
7
cli_anything/codimd/__init__.py
Normal file
7
cli_anything/codimd/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
CodiMD CLI Harness
|
||||||
|
|
||||||
|
A stateful CLI for CodiMD - collaborative markdown notes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
659
cli_anything/codimd/codimd_cli.py
Normal file
659
cli_anything/codimd/codimd_cli.py
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
CodiMD CLI - Main entry point.
|
||||||
|
|
||||||
|
A stateful CLI for CodiMD collaborative markdown notes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import click
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Add parent directory to path for imports
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from cli_anything.codimd.core import (
|
||||||
|
Config, Session, CodiMDClient,
|
||||||
|
NoteManager, UserManager, ExportManager, RevisionManager
|
||||||
|
)
|
||||||
|
from cli_anything.codimd.utils import output_json, format_error, format_success
|
||||||
|
|
||||||
|
|
||||||
|
# Global context
|
||||||
|
ctx = {"json_mode": False}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_error(func):
|
||||||
|
"""Error handler wrapper for CLI commands."""
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except PermissionError as e:
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_error(str(e)))
|
||||||
|
else:
|
||||||
|
click.echo(f"Error: {e}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_error(str(e)))
|
||||||
|
else:
|
||||||
|
click.echo(f"Not found: {e}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_error(f"{type(e).__name__}: {e}"))
|
||||||
|
else:
|
||||||
|
click.echo(f"Error: {e}", err=True)
|
||||||
|
if "--debug" in sys.argv:
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def get_client() -> CodiMDClient:
|
||||||
|
"""Get authenticated client."""
|
||||||
|
config = Config()
|
||||||
|
session = Session(config.config_dir)
|
||||||
|
|
||||||
|
client = CodiMDClient(
|
||||||
|
server=config.server,
|
||||||
|
session_cookies=session.get_cookies(),
|
||||||
|
timeout=config.timeout,
|
||||||
|
verify_ssl=config.verify_ssl
|
||||||
|
)
|
||||||
|
return client, session
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(invoke_without_command=True)
|
||||||
|
@click.option("--json", is_flag=True, help="Output in JSON format")
|
||||||
|
@click.option("--server", help="CodiMD server URL")
|
||||||
|
@click.option("--version", is_flag=True, help="Show version")
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx, json, server, version):
|
||||||
|
"""CodiMD CLI - Collaborative markdown notes."""
|
||||||
|
if version:
|
||||||
|
click.echo("CodiMD CLI v0.1.0")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Set JSON mode
|
||||||
|
globals()["ctx"]["json_mode"] = json
|
||||||
|
|
||||||
|
# Set server if provided
|
||||||
|
if server:
|
||||||
|
config = Config()
|
||||||
|
config.server = server
|
||||||
|
|
||||||
|
# Show help if no command
|
||||||
|
if ctx.invoked_subcommand is None:
|
||||||
|
click.echo(ctx.get_help())
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def note():
|
||||||
|
"""Note operations."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def user():
|
||||||
|
"""User operations."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def export():
|
||||||
|
"""Export operations."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def revision():
|
||||||
|
"""Revision operations."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def config():
|
||||||
|
"""Configuration commands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Note commands
|
||||||
|
@note.command("list")
|
||||||
|
@click.option("--limit", type=int, help="Limit number of results")
|
||||||
|
@handle_error
|
||||||
|
def list_notes(limit):
|
||||||
|
"""List user's notes."""
|
||||||
|
client, session = get_client()
|
||||||
|
|
||||||
|
if not session.is_authenticated():
|
||||||
|
raise PermissionError("Not authenticated. Please login first.")
|
||||||
|
|
||||||
|
manager = NoteManager(client)
|
||||||
|
notes = manager.list_my_notes()
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
notes = notes[:limit]
|
||||||
|
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success({"notes": notes, "count": len(notes)}))
|
||||||
|
else:
|
||||||
|
if not notes:
|
||||||
|
click.echo("No notes found.")
|
||||||
|
else:
|
||||||
|
for n in notes:
|
||||||
|
note_id = n.get('id', 'unknown')
|
||||||
|
# Decode Unicode escape sequences in text
|
||||||
|
text = n.get('text', 'Untitled')
|
||||||
|
# Handle both escaped unicode and normal text
|
||||||
|
if '\\u' in text:
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
text = json.loads(f'"{text}"')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
click.echo(f"{note_id} - {text}")
|
||||||
|
|
||||||
|
|
||||||
|
@note.command("get")
|
||||||
|
@click.argument("note_id")
|
||||||
|
@click.option("--output", "-o", type=Path, help="Output file")
|
||||||
|
@handle_error
|
||||||
|
def get_note(note_id, output):
|
||||||
|
"""Get note content."""
|
||||||
|
client, session = get_client()
|
||||||
|
manager = NoteManager(client)
|
||||||
|
|
||||||
|
# For content, we need to get it via the download endpoint
|
||||||
|
export_manager = ExportManager(client)
|
||||||
|
content = export_manager.export_markdown(note_id)
|
||||||
|
|
||||||
|
if output:
|
||||||
|
output.write_text(content)
|
||||||
|
click.echo(f"Note saved to {output}")
|
||||||
|
else:
|
||||||
|
click.echo(content)
|
||||||
|
|
||||||
|
|
||||||
|
@note.command("create")
|
||||||
|
@click.option("--content", "-c", help="Note content")
|
||||||
|
@click.option("--file", "-f", type=Path, help="Read content from file")
|
||||||
|
@click.option("--alias", "-a", help="Note alias")
|
||||||
|
@handle_error
|
||||||
|
def create_note(content, file, alias):
|
||||||
|
"""Create a new note."""
|
||||||
|
client, session = get_client()
|
||||||
|
|
||||||
|
note_content = ""
|
||||||
|
if file:
|
||||||
|
note_content = file.read_text()
|
||||||
|
elif content:
|
||||||
|
note_content = content
|
||||||
|
|
||||||
|
manager = NoteManager(client)
|
||||||
|
result = manager.create_note(note_content, alias)
|
||||||
|
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success(result))
|
||||||
|
else:
|
||||||
|
click.echo(f"Note created: {result.get('url')}")
|
||||||
|
click.echo(f"ID: {result.get('id')}")
|
||||||
|
|
||||||
|
|
||||||
|
@note.command("update")
|
||||||
|
@click.argument("note_id")
|
||||||
|
@click.option("--content", "-c", help="New content")
|
||||||
|
@click.option("--file", "-f", type=Path, help="Read content from file")
|
||||||
|
@handle_error
|
||||||
|
def update_note(note_id, content, file):
|
||||||
|
"""Update note content."""
|
||||||
|
client, session = get_client()
|
||||||
|
|
||||||
|
note_content = ""
|
||||||
|
if file:
|
||||||
|
note_content = file.read_text()
|
||||||
|
elif content:
|
||||||
|
note_content = content
|
||||||
|
else:
|
||||||
|
raise ValueError("Either --content or --file must be provided")
|
||||||
|
|
||||||
|
manager = NoteManager(client)
|
||||||
|
result = manager.update_note(note_id, note_content)
|
||||||
|
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success(result))
|
||||||
|
else:
|
||||||
|
click.echo(f"Note updated: {note_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@note.command("delete")
|
||||||
|
@click.argument("note_id")
|
||||||
|
@click.confirmation_option(prompt="Are you sure you want to delete this note?")
|
||||||
|
@handle_error
|
||||||
|
def delete_note(note_id):
|
||||||
|
"""Delete a note."""
|
||||||
|
client, session = get_client()
|
||||||
|
manager = NoteManager(client)
|
||||||
|
manager.delete_note(note_id)
|
||||||
|
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success({"deleted": note_id}))
|
||||||
|
else:
|
||||||
|
click.echo(f"Note deleted: {note_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@note.command("info")
|
||||||
|
@click.argument("note_id")
|
||||||
|
@handle_error
|
||||||
|
def note_info(note_id):
|
||||||
|
"""Get note metadata."""
|
||||||
|
client, session = get_client()
|
||||||
|
manager = NoteManager(client)
|
||||||
|
info = manager.get_note_info(note_id)
|
||||||
|
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success(info))
|
||||||
|
else:
|
||||||
|
click.echo(f"Note ID: {note_id}")
|
||||||
|
for key, value in info.items():
|
||||||
|
if key != "id":
|
||||||
|
click.echo(f" {key}: {value}")
|
||||||
|
|
||||||
|
|
||||||
|
@note.command("publish")
|
||||||
|
@click.argument("note_id")
|
||||||
|
@handle_error
|
||||||
|
def publish_note(note_id):
|
||||||
|
"""Get or create publish link for note."""
|
||||||
|
client, session = get_client()
|
||||||
|
manager = NoteManager(client)
|
||||||
|
result = manager.get_publish_link(note_id)
|
||||||
|
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success(result))
|
||||||
|
else:
|
||||||
|
click.echo(f"Published note: {result.get('url')}")
|
||||||
|
|
||||||
|
|
||||||
|
# User commands
|
||||||
|
@user.command("status")
|
||||||
|
@handle_error
|
||||||
|
def user_status():
|
||||||
|
"""Check login status."""
|
||||||
|
config = Config()
|
||||||
|
session = Session(config.config_dir)
|
||||||
|
|
||||||
|
if session.is_authenticated():
|
||||||
|
user = session.get_user()
|
||||||
|
status_data = {
|
||||||
|
"authenticated": True,
|
||||||
|
"server": config.server,
|
||||||
|
"user": user
|
||||||
|
}
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success(status_data))
|
||||||
|
else:
|
||||||
|
click.echo(f"✓ Logged in to {config.server}")
|
||||||
|
if user:
|
||||||
|
# Get name from either direct field or nested profile
|
||||||
|
name = user.get("name") or user.get("profile", {}).get("name", "N/A")
|
||||||
|
click.echo(f" User: {name}")
|
||||||
|
else:
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success({
|
||||||
|
"authenticated": False,
|
||||||
|
"server": config.server
|
||||||
|
}))
|
||||||
|
else:
|
||||||
|
click.echo(f"✗ Not logged in to {config.server}")
|
||||||
|
click.echo(" Use 'cli-anything-codimd user login' to login")
|
||||||
|
|
||||||
|
|
||||||
|
@user.command("me")
|
||||||
|
@handle_error
|
||||||
|
def user_me():
|
||||||
|
"""Get current user info."""
|
||||||
|
client, session = get_client()
|
||||||
|
|
||||||
|
if not session.is_authenticated():
|
||||||
|
raise PermissionError("Not authenticated. Please login first.")
|
||||||
|
|
||||||
|
manager = UserManager(client)
|
||||||
|
user = manager.get_me()
|
||||||
|
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success(user))
|
||||||
|
else:
|
||||||
|
from cli_anything.codimd.utils import format_user_info
|
||||||
|
click.echo(format_user_info(user))
|
||||||
|
|
||||||
|
|
||||||
|
@user.command("export")
|
||||||
|
@click.option("--output", "-o", type=Path, help="Output file")
|
||||||
|
@handle_error
|
||||||
|
def user_export(output):
|
||||||
|
"""Export all user data."""
|
||||||
|
client, session = get_client()
|
||||||
|
|
||||||
|
if not session.is_authenticated():
|
||||||
|
raise PermissionError("Not authenticated. Please login first.")
|
||||||
|
|
||||||
|
manager = UserManager(client)
|
||||||
|
data = manager.export_my_data()
|
||||||
|
|
||||||
|
if output:
|
||||||
|
output.write_text(json.dumps(data, indent=2))
|
||||||
|
click.echo(f"Data exported to {output}")
|
||||||
|
elif ctx["json_mode"]:
|
||||||
|
output_json(format_success(data))
|
||||||
|
else:
|
||||||
|
click.echo(json.dumps(data, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
@user.command("login")
|
||||||
|
@click.option("--email", help="Email for login")
|
||||||
|
@click.option("--password", help="Password for login")
|
||||||
|
@handle_error
|
||||||
|
def user_login(email, password):
|
||||||
|
"""Login to CodiMD server."""
|
||||||
|
config = Config()
|
||||||
|
session = Session(config.config_dir)
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
email = click.prompt("Email")
|
||||||
|
if not password:
|
||||||
|
password = click.prompt("Password", hide_input=True)
|
||||||
|
|
||||||
|
# Create client for login
|
||||||
|
client = CodiMDClient(
|
||||||
|
server=config.server,
|
||||||
|
timeout=config.timeout,
|
||||||
|
verify_ssl=config.verify_ssl
|
||||||
|
)
|
||||||
|
|
||||||
|
# Perform login
|
||||||
|
manager = UserManager(client)
|
||||||
|
result = manager.login(email, password)
|
||||||
|
|
||||||
|
# Store session cookies
|
||||||
|
session.set_cookies(result["cookies"])
|
||||||
|
|
||||||
|
# Store user info if available
|
||||||
|
if "user" in result:
|
||||||
|
session.set_user(result["user"])
|
||||||
|
user_data = result["user"]
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success({
|
||||||
|
"logged_in": True,
|
||||||
|
"user": user_data
|
||||||
|
}))
|
||||||
|
else:
|
||||||
|
from cli_anything.codimd.utils import format_user_info
|
||||||
|
click.echo("Login successful!")
|
||||||
|
click.echo(format_user_info(user_data))
|
||||||
|
else:
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success({"logged_in": True}))
|
||||||
|
else:
|
||||||
|
click.echo("Login successful!")
|
||||||
|
|
||||||
|
|
||||||
|
@user.command("logout")
|
||||||
|
@handle_error
|
||||||
|
def user_logout():
|
||||||
|
"""Logout from current session."""
|
||||||
|
config = Config()
|
||||||
|
session = Session(config.config_dir)
|
||||||
|
session.clear()
|
||||||
|
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success({"logged_out": True}))
|
||||||
|
else:
|
||||||
|
click.echo("Logged out successfully")
|
||||||
|
|
||||||
|
|
||||||
|
@user.command("delete")
|
||||||
|
@click.option("--token", help="Delete token")
|
||||||
|
@click.confirmation_option(prompt="Are you sure you want to delete your account?")
|
||||||
|
@handle_error
|
||||||
|
def user_delete(token):
|
||||||
|
"""Delete current user account."""
|
||||||
|
client, session = get_client()
|
||||||
|
|
||||||
|
if not session.is_authenticated():
|
||||||
|
raise PermissionError("Not authenticated.")
|
||||||
|
|
||||||
|
manager = UserManager(client)
|
||||||
|
manager.delete_user(token)
|
||||||
|
|
||||||
|
# Clear local session
|
||||||
|
session.clear()
|
||||||
|
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success({"deleted": True}))
|
||||||
|
else:
|
||||||
|
click.echo("Account deleted successfully")
|
||||||
|
|
||||||
|
|
||||||
|
# Export commands
|
||||||
|
@export.command("markdown")
|
||||||
|
@click.argument("note_id")
|
||||||
|
@click.option("--output", "-o", type=Path, help="Output file")
|
||||||
|
@handle_error
|
||||||
|
def export_markdown(note_id, output):
|
||||||
|
"""Export note as markdown."""
|
||||||
|
client, session = get_client()
|
||||||
|
manager = ExportManager(client)
|
||||||
|
|
||||||
|
if output:
|
||||||
|
manager.export_markdown(note_id, output)
|
||||||
|
click.echo(f"Exported to {output}")
|
||||||
|
else:
|
||||||
|
content = manager.export_markdown(note_id)
|
||||||
|
click.echo(content)
|
||||||
|
|
||||||
|
|
||||||
|
@export.command("pdf")
|
||||||
|
@click.argument("note_id")
|
||||||
|
@click.option("--output", "-o", type=Path, help="Output file")
|
||||||
|
@handle_error
|
||||||
|
def export_pdf(note_id, output):
|
||||||
|
"""Export note as PDF."""
|
||||||
|
client, session = get_client()
|
||||||
|
manager = ExportManager(client)
|
||||||
|
|
||||||
|
if not output:
|
||||||
|
output = Path(f"{note_id}.pdf")
|
||||||
|
|
||||||
|
manager.export_pdf(note_id, output)
|
||||||
|
click.echo(f"Exported to {output}")
|
||||||
|
|
||||||
|
|
||||||
|
@export.command("html")
|
||||||
|
@click.argument("note_id")
|
||||||
|
@click.option("--output", "-o", type=Path, help="Output file")
|
||||||
|
@handle_error
|
||||||
|
def export_html(note_id, output):
|
||||||
|
"""Export note as HTML."""
|
||||||
|
client, session = get_client()
|
||||||
|
manager = ExportManager(client)
|
||||||
|
|
||||||
|
if output:
|
||||||
|
manager.export_html(note_id, output)
|
||||||
|
click.echo(f"Exported to {output}")
|
||||||
|
else:
|
||||||
|
content = manager.export_html(note_id)
|
||||||
|
click.echo(content)
|
||||||
|
|
||||||
|
|
||||||
|
@export.command("slide")
|
||||||
|
@click.argument("note_id")
|
||||||
|
@click.option("--output", "-o", type=Path, help="Output file")
|
||||||
|
@handle_error
|
||||||
|
def export_slide(note_id, output):
|
||||||
|
"""Export note as reveal.js slides."""
|
||||||
|
client, session = get_client()
|
||||||
|
manager = ExportManager(client)
|
||||||
|
|
||||||
|
if output:
|
||||||
|
manager.export_slide(note_id, output)
|
||||||
|
click.echo(f"Exported to {output}")
|
||||||
|
else:
|
||||||
|
content = manager.export_slide(note_id)
|
||||||
|
click.echo(content)
|
||||||
|
|
||||||
|
|
||||||
|
# Revision commands
|
||||||
|
@revision.command("list")
|
||||||
|
@click.argument("note_id")
|
||||||
|
@handle_error
|
||||||
|
def list_revisions(note_id):
|
||||||
|
"""List note revisions."""
|
||||||
|
client, session = get_client()
|
||||||
|
manager = RevisionManager(client)
|
||||||
|
revisions = manager.list_revisions(note_id)
|
||||||
|
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success({"revisions": revisions}))
|
||||||
|
else:
|
||||||
|
if not revisions:
|
||||||
|
click.echo("No revisions found.")
|
||||||
|
else:
|
||||||
|
for r in revisions:
|
||||||
|
from datetime import datetime
|
||||||
|
ts = r.get("time", 0)
|
||||||
|
dt = datetime.fromtimestamp(ts / 1000 if ts > 1000000000000 else ts)
|
||||||
|
click.echo(f"{dt.isoformat()} - Length: {r.get('length', 0)}")
|
||||||
|
|
||||||
|
|
||||||
|
@revision.command("get")
|
||||||
|
@click.argument("note_id")
|
||||||
|
@click.argument("timestamp", type=int)
|
||||||
|
@handle_error
|
||||||
|
def get_revision(note_id, timestamp):
|
||||||
|
"""Get note at specific revision time."""
|
||||||
|
client, session = get_client()
|
||||||
|
manager = RevisionManager(client)
|
||||||
|
content = manager.get_revision_at_time(note_id, timestamp)
|
||||||
|
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success(content))
|
||||||
|
else:
|
||||||
|
if isinstance(content, dict):
|
||||||
|
click.echo(content.get("content", str(content)))
|
||||||
|
else:
|
||||||
|
click.echo(content)
|
||||||
|
|
||||||
|
|
||||||
|
# Config commands
|
||||||
|
@config.command("get")
|
||||||
|
@click.argument("key", required=False)
|
||||||
|
@handle_error
|
||||||
|
def config_get(key):
|
||||||
|
"""Get configuration value."""
|
||||||
|
config = Config()
|
||||||
|
|
||||||
|
if key:
|
||||||
|
value = getattr(config, key, None)
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success({key: str(value)}))
|
||||||
|
else:
|
||||||
|
click.echo(f"{key}: {value}")
|
||||||
|
else:
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success({
|
||||||
|
"server": config.server,
|
||||||
|
"timeout": config.timeout,
|
||||||
|
"verify_ssl": config.verify_ssl
|
||||||
|
}))
|
||||||
|
else:
|
||||||
|
click.echo(f"Server: {config.server}")
|
||||||
|
click.echo(f"Timeout: {config.timeout}")
|
||||||
|
click.echo(f"Verify SSL: {config.verify_ssl}")
|
||||||
|
|
||||||
|
|
||||||
|
@config.command("set")
|
||||||
|
@click.argument("key")
|
||||||
|
@click.argument("value")
|
||||||
|
@handle_error
|
||||||
|
def config_set(key, value):
|
||||||
|
"""Set configuration value."""
|
||||||
|
config = Config()
|
||||||
|
|
||||||
|
if key == "server":
|
||||||
|
config.server = value
|
||||||
|
elif key == "timeout":
|
||||||
|
config.timeout = int(value)
|
||||||
|
elif key == "verify_ssl":
|
||||||
|
config.verify_ssl = value.lower() in ("true", "1", "yes")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown config key: {key}")
|
||||||
|
|
||||||
|
if ctx["json_mode"]:
|
||||||
|
output_json(format_success({key: value}))
|
||||||
|
else:
|
||||||
|
click.echo(f"Set {key} = {value}")
|
||||||
|
|
||||||
|
|
||||||
|
# REPL mode
|
||||||
|
@cli.command("repl")
|
||||||
|
@handle_error
|
||||||
|
def repl_mode():
|
||||||
|
"""Start interactive REPL mode."""
|
||||||
|
import readline
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
session = Session(config.config_dir)
|
||||||
|
client = CodiMDClient(
|
||||||
|
server=config.server,
|
||||||
|
session_cookies=session.get_cookies()
|
||||||
|
)
|
||||||
|
|
||||||
|
click.echo(f"CodiMD CLI v0.1.0 - REPL Mode")
|
||||||
|
click.echo(f"Connected to: {config.server}")
|
||||||
|
click.echo("Type 'help' for commands, 'exit' to quit")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
cmd = click.prompt("codimd", default="", show_default=False).strip()
|
||||||
|
|
||||||
|
if not cmd:
|
||||||
|
continue
|
||||||
|
if cmd.lower() in ("exit", "quit"):
|
||||||
|
break
|
||||||
|
if cmd.lower() == "help":
|
||||||
|
click.echo("Available commands:")
|
||||||
|
click.echo(" notes - List your notes")
|
||||||
|
click.echo(" me - Show user info")
|
||||||
|
click.echo(" exit - Exit REPL")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle commands
|
||||||
|
if cmd.lower() == "notes":
|
||||||
|
manager = NoteManager(client)
|
||||||
|
notes = manager.list_my_notes()
|
||||||
|
for n in notes[:10]:
|
||||||
|
click.echo(f" {n.get('id')} - {n.get('text', 'Untitled')}")
|
||||||
|
elif cmd.lower() == "me":
|
||||||
|
manager = UserManager(client)
|
||||||
|
user = manager.get_me()
|
||||||
|
click.echo(f" User: {user.get('profile', {}).get('name', 'N/A')}")
|
||||||
|
else:
|
||||||
|
click.echo(f"Unknown command: {cmd}")
|
||||||
|
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
click.echo("\nUse 'exit' to quit")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error: {e}")
|
||||||
|
|
||||||
|
click.echo("Goodbye!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
21
cli_anything/codimd/core/__init__.py
Normal file
21
cli_anything/codimd/core/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""
|
||||||
|
Core modules for CodiMD CLI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .session import Session
|
||||||
|
from .client import CodiMDClient
|
||||||
|
from .note import NoteManager
|
||||||
|
from .user import UserManager
|
||||||
|
from .export import ExportManager
|
||||||
|
from .revision import RevisionManager
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Config",
|
||||||
|
"Session",
|
||||||
|
"CodiMDClient",
|
||||||
|
"NoteManager",
|
||||||
|
"UserManager",
|
||||||
|
"ExportManager",
|
||||||
|
"RevisionManager",
|
||||||
|
]
|
||||||
70
cli_anything/codimd/core/client.py
Normal file
70
cli_anything/codimd/core/client.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
HTTP client for CodiMD API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from typing import Optional, Dict, Any, Tuple
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
|
||||||
|
class CodiMDClient:
|
||||||
|
"""HTTP client for CodiMD server."""
|
||||||
|
|
||||||
|
def __init__(self, server: str, session_cookies: Dict[str, str] = None,
|
||||||
|
timeout: int = 30, verify_ssl: bool = True):
|
||||||
|
self.server = server.rstrip("/")
|
||||||
|
self.timeout = timeout
|
||||||
|
self.verify_ssl = verify_ssl
|
||||||
|
self.session = requests.Session()
|
||||||
|
if session_cookies:
|
||||||
|
self.session.cookies.update(session_cookies)
|
||||||
|
|
||||||
|
def _url(self, path: str) -> str:
|
||||||
|
"""Build full URL for path."""
|
||||||
|
# Remove leading slash from path to make it relative
|
||||||
|
path = path.lstrip("/")
|
||||||
|
return urljoin(self.server + "/", path)
|
||||||
|
|
||||||
|
def get(self, path: str, **kwargs) -> requests.Response:
|
||||||
|
"""Send GET request."""
|
||||||
|
return self.session.get(
|
||||||
|
self._url(path),
|
||||||
|
timeout=self.timeout,
|
||||||
|
verify=self.verify_ssl,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, path: str, **kwargs) -> requests.Response:
|
||||||
|
"""Send POST request."""
|
||||||
|
return self.session.post(
|
||||||
|
self._url(path),
|
||||||
|
timeout=self.timeout,
|
||||||
|
verify=self.verify_ssl,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def put(self, path: str, **kwargs) -> requests.Response:
|
||||||
|
"""Send PUT request."""
|
||||||
|
return self.session.put(
|
||||||
|
self._url(path),
|
||||||
|
timeout=self.timeout,
|
||||||
|
verify=self.verify_ssl,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, path: str, **kwargs) -> requests.Response:
|
||||||
|
"""Send DELETE request."""
|
||||||
|
return self.session.delete(
|
||||||
|
self._url(path),
|
||||||
|
timeout=self.timeout,
|
||||||
|
verify=self.verify_ssl,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_cookies(self, cookies: Dict[str, str]):
|
||||||
|
"""Update session cookies."""
|
||||||
|
self.session.cookies.update(cookies)
|
||||||
|
|
||||||
|
def get_cookies(self) -> Dict[str, str]:
|
||||||
|
"""Get current cookies."""
|
||||||
|
return dict(self.session.cookies)
|
||||||
77
cli_anything/codimd/core/config.py
Normal file
77
cli_anything/codimd/core/config.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
Configuration management for CodiMD CLI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Configuration manager for CodiMD CLI."""
|
||||||
|
|
||||||
|
DEFAULT_SERVER = "http://localhost:3000"
|
||||||
|
CONFIG_DIR = Path.home() / ".config" / "cli-anything-codimd"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.config_dir = self.CONFIG_DIR
|
||||||
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.config_file = self.config_dir / "config.json"
|
||||||
|
self.session_file = self.config_dir / "session.json"
|
||||||
|
self.cache_file = self.config_dir / "cache.json"
|
||||||
|
|
||||||
|
self._config = self._load_config()
|
||||||
|
|
||||||
|
def _load_config(self) -> dict:
|
||||||
|
"""Load configuration from file or create default."""
|
||||||
|
if self.config_file.exists():
|
||||||
|
with open(self.config_file, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
default_config = {
|
||||||
|
"server": os.environ.get("CODIMD_SERVER", self.DEFAULT_SERVER),
|
||||||
|
"timeout": 30,
|
||||||
|
"verify_ssl": True,
|
||||||
|
}
|
||||||
|
self.save_config(default_config)
|
||||||
|
return default_config
|
||||||
|
|
||||||
|
def save_config(self, config: dict):
|
||||||
|
"""Save configuration to file."""
|
||||||
|
with open(self.config_file, "w") as f:
|
||||||
|
json.dump(config, f, indent=2)
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def server(self) -> str:
|
||||||
|
"""Get configured server URL."""
|
||||||
|
return self._config.get("server", self.DEFAULT_SERVER)
|
||||||
|
|
||||||
|
@server.setter
|
||||||
|
def server(self, value: str):
|
||||||
|
"""Set server URL."""
|
||||||
|
self._config["server"] = value
|
||||||
|
self.save_config(self._config)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def timeout(self) -> int:
|
||||||
|
"""Get request timeout."""
|
||||||
|
return self._config.get("timeout", 30)
|
||||||
|
|
||||||
|
@timeout.setter
|
||||||
|
def timeout(self, value: int):
|
||||||
|
"""Set request timeout."""
|
||||||
|
self._config["timeout"] = value
|
||||||
|
self.save_config(self._config)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def verify_ssl(self) -> bool:
|
||||||
|
"""Get SSL verification setting."""
|
||||||
|
return self._config.get("verify_ssl", True)
|
||||||
|
|
||||||
|
@verify_ssl.setter
|
||||||
|
def verify_ssl(self, value: bool):
|
||||||
|
"""Set SSL verification setting."""
|
||||||
|
self._config["verify_ssl"] = value
|
||||||
|
self.save_config(self._config)
|
||||||
97
cli_anything/codimd/core/export.py
Normal file
97
cli_anything/codimd/core/export.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
Export operations for CodiMD CLI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .client import CodiMDClient
|
||||||
|
|
||||||
|
|
||||||
|
class ExportManager:
|
||||||
|
"""Manager for export operations."""
|
||||||
|
|
||||||
|
def __init__(self, client: CodiMDClient):
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
def export_markdown(self, note_id: str, output_file: Optional[Path] = None) -> str:
|
||||||
|
"""Export note as markdown."""
|
||||||
|
response = self.client.get(f"/{note_id}/download")
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise FileNotFoundError(f"Note not found: {note_id}")
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise PermissionError("No permission to export this note")
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to export markdown: {response.status_code}")
|
||||||
|
|
||||||
|
content = response.text
|
||||||
|
|
||||||
|
if output_file:
|
||||||
|
output_file.write_text(content)
|
||||||
|
return str(output_file)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
def export_pdf(self, note_id: str, output_file: Optional[Path] = None) -> bytes:
|
||||||
|
"""Export note as PDF."""
|
||||||
|
response = self.client.get(f"/{note_id}/pdf")
|
||||||
|
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise PermissionError("PDF export disabled or no permission")
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise FileNotFoundError(f"Note not found: {note_id}")
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to export PDF: {response.status_code}")
|
||||||
|
|
||||||
|
content = response.content
|
||||||
|
|
||||||
|
if output_file:
|
||||||
|
output_file.write_bytes(content)
|
||||||
|
return str(output_file)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
def export_html(self, note_id: str, output_file: Optional[Path] = None) -> str:
|
||||||
|
"""Export note as HTML (published view)."""
|
||||||
|
# First publish the note
|
||||||
|
publish_response = self.client.get(f"/{note_id}/publish")
|
||||||
|
|
||||||
|
if publish_response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to publish note: {publish_response.status_code}")
|
||||||
|
|
||||||
|
# Extract the short ID from the response
|
||||||
|
url = publish_response.url
|
||||||
|
short_id = url.rstrip("/").split("/")[-1]
|
||||||
|
|
||||||
|
# Get the published view
|
||||||
|
response = self.client.get(f"/s/{short_id}")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to export HTML: {response.status_code}")
|
||||||
|
|
||||||
|
content = response.text
|
||||||
|
|
||||||
|
if output_file:
|
||||||
|
output_file.write_text(content)
|
||||||
|
return str(output_file)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
def export_slide(self, note_id: str, output_file: Optional[Path] = None) -> str:
|
||||||
|
"""Export note as reveal.js slides."""
|
||||||
|
response = self.client.get(f"/{note_id}/slide")
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise FileNotFoundError(f"Note not found: {note_id}")
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to export slides: {response.status_code}")
|
||||||
|
|
||||||
|
content = response.text
|
||||||
|
|
||||||
|
if output_file:
|
||||||
|
output_file.write_text(content)
|
||||||
|
return str(output_file)
|
||||||
|
|
||||||
|
return content
|
||||||
183
cli_anything/codimd/core/note.py
Normal file
183
cli_anything/codimd/core/note.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
Note operations for CodiMD CLI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import base64
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from .client import CodiMDClient
|
||||||
|
|
||||||
|
|
||||||
|
class NoteManager:
|
||||||
|
"""Manager for note operations."""
|
||||||
|
|
||||||
|
def __init__(self, client: CodiMDClient):
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode_note_id(note_id: str) -> str:
|
||||||
|
"""Encode UUID note ID to base64url format."""
|
||||||
|
# Remove dashes and convert to bytes
|
||||||
|
hex_str = note_id.replace("-", "")
|
||||||
|
raw_bytes = bytes.fromhex(hex_str)
|
||||||
|
# Encode to base64url
|
||||||
|
return base64.urlsafe_b64encode(raw_bytes).decode("utf-8").rstrip("=")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode_note_id(encoded_id: str) -> str:
|
||||||
|
"""Decode base64url note ID to UUID format."""
|
||||||
|
# Add padding if needed
|
||||||
|
padding = 4 - len(encoded_id) % 4
|
||||||
|
if padding != 4:
|
||||||
|
encoded_id += "=" * padding
|
||||||
|
# Decode from base64url
|
||||||
|
raw_bytes = base64.urlsafe_b64decode(encoded_id)
|
||||||
|
hex_str = raw_bytes.hex()
|
||||||
|
# Format as UUID
|
||||||
|
parts = [
|
||||||
|
hex_str[0:8], hex_str[8:12], hex_str[12:16],
|
||||||
|
hex_str[16:20], hex_str[20:32]
|
||||||
|
]
|
||||||
|
return "-".join(parts)
|
||||||
|
|
||||||
|
def list_my_notes(self) -> List[Dict[str, Any]]:
|
||||||
|
"""List notes owned by current user."""
|
||||||
|
response = self.client.get("/api/notes/myNotes")
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise PermissionError("Not authenticated or not authorized")
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to list notes: {response.status_code}")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return data.get("myNotes", [])
|
||||||
|
|
||||||
|
def get_note(self, note_id: str) -> Dict[str, Any]:
|
||||||
|
"""Get note content and metadata."""
|
||||||
|
response = self.client.get(f"/{note_id}")
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise FileNotFoundError(f"Note not found: {note_id}")
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise PermissionError("No permission to view this note")
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to get note: {response.status_code}")
|
||||||
|
|
||||||
|
# Parse response for note data
|
||||||
|
# The note page contains the data in HTML, we need to extract it
|
||||||
|
# For now, return a basic structure
|
||||||
|
return {
|
||||||
|
"id": note_id,
|
||||||
|
"status": "success"
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_note(self, content: str = "", alias: str = None) -> Dict[str, Any]:
|
||||||
|
"""Create a new note."""
|
||||||
|
if content:
|
||||||
|
response = self.client.post(
|
||||||
|
"/new",
|
||||||
|
data=content.encode("utf-8"),
|
||||||
|
headers={"Content-Type": "text/markdown"}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = self.client.get("/new")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to create note: {response.status_code}")
|
||||||
|
|
||||||
|
# Extract note ID from response or redirect
|
||||||
|
# The response might be a redirect to the new note
|
||||||
|
if response.history:
|
||||||
|
# Get the final URL
|
||||||
|
final_url = response.url
|
||||||
|
note_id = final_url.rstrip("/").split("/")[-1]
|
||||||
|
else:
|
||||||
|
# Parse response to find note ID
|
||||||
|
note_id = self._extract_note_id_from_response(response)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": note_id,
|
||||||
|
"url": f"{self.client.server}/{note_id}",
|
||||||
|
"status": "created"
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_note_id_from_response(self, response) -> str:
|
||||||
|
"""Extract note ID from response."""
|
||||||
|
# Try to extract from JSON response
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
return data.get("id") or data.get("noteId")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Extract from HTML or use a default
|
||||||
|
match = re.search(r'/([a-zA-Z0-9_-]+)', response.url or "")
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
def update_note(self, note_id: str, content: str) -> Dict[str, Any]:
|
||||||
|
"""Update note content."""
|
||||||
|
response = self.client.put(
|
||||||
|
f"/api/notes/{note_id}",
|
||||||
|
json={"content": content},
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise PermissionError("No permission to edit this note")
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise FileNotFoundError(f"Note not found: {note_id}")
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to update note: {response.status_code}")
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def delete_note(self, note_id: str) -> bool:
|
||||||
|
"""Delete a note."""
|
||||||
|
response = self.client.delete(f"/api/notes/{note_id}")
|
||||||
|
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise PermissionError("No permission to delete this note")
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise FileNotFoundError(f"Note not found: {note_id}")
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to delete note: {response.status_code}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_note_info(self, note_id: str) -> Dict[str, Any]:
|
||||||
|
"""Get note metadata without content."""
|
||||||
|
# Use the /:noteId/info endpoint
|
||||||
|
response = self.client.get(f"/{noteId}/info")
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise FileNotFoundError(f"Note not found: {note_id}")
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise PermissionError("No permission to view this note")
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to get note info: {response.status_code}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except:
|
||||||
|
return {"id": note_id, "status": "success"}
|
||||||
|
|
||||||
|
def get_publish_link(self, note_id: str) -> Dict[str, Any]:
|
||||||
|
"""Get or create publish link for note."""
|
||||||
|
response = self.client.get(f"/{noteId}/publish")
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise FileNotFoundError(f"Note not found: {note_id}")
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to get publish link: {response.status_code}")
|
||||||
|
|
||||||
|
# Extract short ID from response
|
||||||
|
return {
|
||||||
|
"url": response.url,
|
||||||
|
"note_id": note_id,
|
||||||
|
"status": "published"
|
||||||
|
}
|
||||||
59
cli_anything/codimd/core/revision.py
Normal file
59
cli_anything/codimd/core/revision.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Revision operations for CodiMD CLI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .client import CodiMDClient
|
||||||
|
|
||||||
|
|
||||||
|
class RevisionManager:
|
||||||
|
"""Manager for revision operations."""
|
||||||
|
|
||||||
|
def __init__(self, client: CodiMDClient):
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
def list_revisions(self, note_id: str) -> List[Dict[str, Any]]:
|
||||||
|
"""List all revisions for a note."""
|
||||||
|
response = self.client.get(f"/{note_id}/revision")
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise FileNotFoundError(f"Note not found: {note_id}")
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise PermissionError("No permission to view revisions")
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to list revisions: {response.status_code}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
return data.get("revisions", [])
|
||||||
|
except:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_revision_at_time(self, note_id: str, timestamp: int) -> Dict[str, Any]:
|
||||||
|
"""Get note content at specific revision time."""
|
||||||
|
response = self.client.get(
|
||||||
|
f"/{note_id}/revision/{timestamp}",
|
||||||
|
allow_redirects=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise FileNotFoundError(f"Note or revision not found")
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to get revision: {response.status_code}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except:
|
||||||
|
return {"note_id": note_id, "timestamp": timestamp, "content": response.text}
|
||||||
|
|
||||||
|
def get_latest_revision(self, note_id: str) -> Dict[str, Any]:
|
||||||
|
"""Get the latest revision of a note."""
|
||||||
|
revisions = self.list_revisions(note_id)
|
||||||
|
if not revisions:
|
||||||
|
raise FileNotFoundError(f"No revisions found for note: {note_id}")
|
||||||
|
|
||||||
|
latest = revisions[0]
|
||||||
|
return self.get_revision_at_time(note_id, latest.get("time", 0))
|
||||||
88
cli_anything/codimd/core/session.py
Normal file
88
cli_anything/codimd/core/session.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
Session management for CodiMD CLI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
"""Session manager for CodiMD authentication."""
|
||||||
|
|
||||||
|
def __init__(self, config_dir: Path):
|
||||||
|
self.session_file = config_dir / "session.json"
|
||||||
|
self._session = self._load_session()
|
||||||
|
|
||||||
|
def _load_session(self) -> dict:
|
||||||
|
"""Load session from file."""
|
||||||
|
if self.session_file.exists():
|
||||||
|
with open(self.session_file, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_session(self):
|
||||||
|
"""Save session to file."""
|
||||||
|
with open(self.session_file, "w") as f:
|
||||||
|
json.dump(self._session, f, indent=2)
|
||||||
|
|
||||||
|
def is_authenticated(self) -> bool:
|
||||||
|
"""Check if user is authenticated."""
|
||||||
|
cookies = self._session.get("cookies", {})
|
||||||
|
if not cookies:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if session is expired (24 hours)
|
||||||
|
expires_at = self._session.get("expires_at")
|
||||||
|
if expires_at:
|
||||||
|
expire_time = datetime.fromisoformat(expires_at)
|
||||||
|
if datetime.now() > expire_time:
|
||||||
|
self.clear()
|
||||||
|
return False
|
||||||
|
|
||||||
|
return bool(cookies)
|
||||||
|
|
||||||
|
def set_cookies(self, cookies: Dict[str, str]):
|
||||||
|
"""Set session cookies."""
|
||||||
|
self._session["cookies"] = cookies
|
||||||
|
# Set expiration to 24 hours from now
|
||||||
|
self._session["expires_at"] = (
|
||||||
|
datetime.now() + timedelta(hours=24)
|
||||||
|
).isoformat()
|
||||||
|
self.save_session()
|
||||||
|
|
||||||
|
def get_cookies(self) -> Dict[str, str]:
|
||||||
|
"""Get session cookies."""
|
||||||
|
return self._session.get("cookies", {})
|
||||||
|
|
||||||
|
def set_user(self, user_data: Dict[str, Any]):
|
||||||
|
"""Set current user data."""
|
||||||
|
self._session["user"] = user_data
|
||||||
|
self.save_session()
|
||||||
|
|
||||||
|
def get_user(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get current user data."""
|
||||||
|
return self._session.get("user")
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""Clear session data."""
|
||||||
|
self._session = {}
|
||||||
|
self.save_session()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_id(self) -> Optional[str]:
|
||||||
|
"""Get current user ID."""
|
||||||
|
user = self.get_user()
|
||||||
|
return user.get("id") if user else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def username(self) -> Optional[str]:
|
||||||
|
"""Get current username."""
|
||||||
|
user = self.get_user()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
# CodiMD returns name directly, not nested in profile
|
||||||
|
return user.get("name") or user.get("profile", {}).get("name")
|
||||||
115
cli_anything/codimd/core/user.py
Normal file
115
cli_anything/codimd/core/user.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
User operations for CodiMD CLI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
from .client import CodiMDClient
|
||||||
|
|
||||||
|
|
||||||
|
class UserManager:
|
||||||
|
"""Manager for user operations."""
|
||||||
|
|
||||||
|
def __init__(self, client: CodiMDClient):
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
def login(self, email: str, password: str) -> Dict[str, Any]:
|
||||||
|
"""Login with email and password.
|
||||||
|
|
||||||
|
Returns the session cookies and user info.
|
||||||
|
"""
|
||||||
|
# First, get the login page to obtain CSRF token if needed
|
||||||
|
login_page = self.client.get("/login")
|
||||||
|
|
||||||
|
# Try to extract CSRF token from the page
|
||||||
|
csrf_token = None
|
||||||
|
csrf_match = re.search(r'name="csrf".*?value="([^"]+)"', login_page.text)
|
||||||
|
if csrf_match:
|
||||||
|
csrf_token = csrf_match.group(1)
|
||||||
|
|
||||||
|
# Prepare login data
|
||||||
|
login_data = {
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
}
|
||||||
|
if csrf_token:
|
||||||
|
login_data["_csrf"] = csrf_token
|
||||||
|
|
||||||
|
# Perform login
|
||||||
|
response = self.client.post(
|
||||||
|
"/login",
|
||||||
|
data=login_data,
|
||||||
|
allow_redirects=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if login was successful by trying to access /me
|
||||||
|
me_response = self.client.get("/me")
|
||||||
|
if me_response.status_code == 200:
|
||||||
|
user_data = me_response.json()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"user": user_data,
|
||||||
|
"cookies": self.client.get_cookies()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Login failed
|
||||||
|
if response.status_code == 302:
|
||||||
|
# Redirect might indicate failure or success
|
||||||
|
location = response.headers.get("Location", "")
|
||||||
|
if "login" in location.lower():
|
||||||
|
raise PermissionError("Login failed: Invalid credentials")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"cookies": self.client.get_cookies()
|
||||||
|
}
|
||||||
|
|
||||||
|
raise PermissionError(f"Login failed: {response.status_code}")
|
||||||
|
|
||||||
|
def get_me(self) -> Dict[str, Any]:
|
||||||
|
"""Get current user information."""
|
||||||
|
response = self.client.get("/me")
|
||||||
|
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise PermissionError("Not authenticated")
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to get user info: {response.status_code}")
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def export_my_data(self) -> Dict[str, Any]:
|
||||||
|
"""Export all user data."""
|
||||||
|
response = self.client.post(
|
||||||
|
"/me/export",
|
||||||
|
data={},
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise PermissionError("Not authenticated")
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to export data: {response.status_code}")
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def delete_user(self, delete_token: Optional[str] = None) -> bool:
|
||||||
|
"""Delete current user account."""
|
||||||
|
url = f"/me/delete/{delete_token}" if delete_token else "/me/delete"
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise PermissionError("Not authenticated or invalid token")
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to delete user: {response.status_code}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_avatar(self, username: str) -> bytes:
|
||||||
|
"""Get user avatar SVG."""
|
||||||
|
response = self.client.get(f"/user/{username}/avatar.svg")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to get avatar: {response.status_code}")
|
||||||
|
|
||||||
|
return response.content
|
||||||
196
cli_anything/codimd/skills/SKILL.md
Normal file
196
cli_anything/codimd/skills/SKILL.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
---
|
||||||
|
name: codimd
|
||||||
|
description: CLI harness for CodiMD collaborative markdown notes
|
||||||
|
version: 0.1.0
|
||||||
|
categories:
|
||||||
|
- documentation
|
||||||
|
- markdown
|
||||||
|
- collaboration
|
||||||
|
entry_point: cli-anything-codimd
|
||||||
|
namespace: cli_anything.codimd
|
||||||
|
---
|
||||||
|
|
||||||
|
# CodiMD CLI Harness
|
||||||
|
|
||||||
|
A stateful CLI for CodiMD - collaborative markdown notes on all platforms.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install cli-anything-codimd
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set server
|
||||||
|
cli-anything-codimd config set server https://your-codimd-server.com
|
||||||
|
|
||||||
|
# List your notes
|
||||||
|
cli-anything-codimd note list
|
||||||
|
|
||||||
|
# Create a new note
|
||||||
|
cli-anything-codimd note create --content "# My New Note\n\nHello world"
|
||||||
|
|
||||||
|
# Get note content
|
||||||
|
cli-anything-codimd note get <note-id>
|
||||||
|
|
||||||
|
# Export as markdown
|
||||||
|
cli-anything-codimd export markdown <note-id> --output note.md
|
||||||
|
|
||||||
|
# Export as PDF
|
||||||
|
cli-anything-codimd export pdf <note-id> --output note.pdf
|
||||||
|
|
||||||
|
# Get user info
|
||||||
|
cli-anything-codimd user me
|
||||||
|
|
||||||
|
# Interactive REPL mode
|
||||||
|
cli-anything-codimd repl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command Groups
|
||||||
|
|
||||||
|
### Note Commands (`note`)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `note list` | List user's notes |
|
||||||
|
| `note get <id>` | Get note content |
|
||||||
|
| `note create` | Create new note |
|
||||||
|
| `note update <id>` | Update note content |
|
||||||
|
| `note delete <id>` | Delete note |
|
||||||
|
| `note info <id>` | Get note metadata |
|
||||||
|
| `note publish <id>` | Get publish link |
|
||||||
|
|
||||||
|
### User Commands (`user`)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `user me` | Get current user info |
|
||||||
|
| `user login` | Login to server |
|
||||||
|
| `user logout` | Logout from session |
|
||||||
|
| `user export` | Export all user data |
|
||||||
|
| `user delete` | Delete user account |
|
||||||
|
|
||||||
|
### Export Commands (`export`)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `export markdown <id>` | Export as markdown |
|
||||||
|
| `export pdf <id>` | Export as PDF |
|
||||||
|
| `export html <id>` | Export as HTML |
|
||||||
|
| `export slide <id>` | Export as reveal.js slides |
|
||||||
|
|
||||||
|
### Revision Commands (`revision`)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `revision list <id>` | List note revisions |
|
||||||
|
| `revision get <id> <time>` | Get note at specific time |
|
||||||
|
|
||||||
|
### Config Commands (`config`)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `config get [key]` | Get configuration value |
|
||||||
|
| `config set <key> <value>` | Set configuration value |
|
||||||
|
|
||||||
|
## JSON Output Mode
|
||||||
|
|
||||||
|
All commands support `--json` flag for machine-readable output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cli-anything-codimd --json note list
|
||||||
|
```
|
||||||
|
|
||||||
|
Response format:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success|error",
|
||||||
|
"data": {...},
|
||||||
|
"error": "error message if status is error"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent Integration Guide
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
The CLI stores session tokens in `~/.config/cli-anything-codimd/session.json`.
|
||||||
|
Agents should check authentication status before operations and handle `PermissionError` exceptions.
|
||||||
|
|
||||||
|
### Note ID Format
|
||||||
|
|
||||||
|
CodiMD uses multiple note ID formats:
|
||||||
|
- **UUID**: Full UUID (e.g., `12345678-1234-1234-1234-123456789012`)
|
||||||
|
- **Encoded**: base64url-encoded UUID (e.g., `VXDECB3HEXDPHWHGHFDGWEDFBM`)
|
||||||
|
- **Short ID**: Short identifier for sharing (e.g., `abc123`)
|
||||||
|
|
||||||
|
The CLI handles encoding/decoding automatically.
|
||||||
|
|
||||||
|
### Permission Model
|
||||||
|
|
||||||
|
| Permission | Description |
|
||||||
|
|------------|-------------|
|
||||||
|
| `freely` | Anyone can view and edit |
|
||||||
|
| `editable` | Anyone can view, logged-in users can edit |
|
||||||
|
| `limited` | Logged-in users can view and edit |
|
||||||
|
| `locked` | Anyone can view, only owner can edit |
|
||||||
|
| `protected` | Logged-in users can view, only owner can edit |
|
||||||
|
| `private` | Only owner can view and edit |
|
||||||
|
|
||||||
|
### Common Workflows
|
||||||
|
|
||||||
|
#### Create and Edit Note
|
||||||
|
```python
|
||||||
|
# Create note
|
||||||
|
result = subprocess.run(
|
||||||
|
["cli-anything-codimd", "--json", "note", "create", "--content", markdown],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
note_id = data["data"]["id"]
|
||||||
|
|
||||||
|
# Update note
|
||||||
|
subprocess.run(
|
||||||
|
["cli-anything-codimd", "note", "update", note_id, "--content", new_markdown]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Export Note
|
||||||
|
```python
|
||||||
|
# Export to file
|
||||||
|
subprocess.run(
|
||||||
|
["cli-anything-codimd", "export", "markdown", note_id, "-o", "output.md"]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### List Notes with Filtering
|
||||||
|
```python
|
||||||
|
# Get notes as JSON
|
||||||
|
result = subprocess.run(
|
||||||
|
["cli-anything-codimd", "--json", "note", "list"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
notes = data["data"]["notes"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server Configuration
|
||||||
|
|
||||||
|
The CLI can connect to any CodiMD instance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set server URL
|
||||||
|
cli-anything-codimd config set server https://codimd.example.com
|
||||||
|
|
||||||
|
# Disable SSL verification (for self-signed certs)
|
||||||
|
cli-anything-codimd config set verify_ssl false
|
||||||
|
|
||||||
|
# Set request timeout
|
||||||
|
cli-anything-codimd config set timeout 60
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
- `CODIMD_SERVER`: Default server URL
|
||||||
|
- `CLI_ANYTHING_FORCE_INSTALLED`: Use installed command instead of module path
|
||||||
232
cli_anything/codimd/tests/TEST.md
Normal file
232
cli_anything/codimd/tests/TEST.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# CodiMD CLI - Test Plan and Results
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
### Unit Tests (`test_core.py`)
|
||||||
|
|
||||||
|
#### Config Tests
|
||||||
|
- Test default configuration values
|
||||||
|
- Test configuration persistence
|
||||||
|
- Test configuration updates (server, timeout, verify_ssl)
|
||||||
|
|
||||||
|
#### Session Tests
|
||||||
|
- Test session creation
|
||||||
|
- Test cookie storage and retrieval
|
||||||
|
- Test user data storage
|
||||||
|
- Test session expiration
|
||||||
|
- Test session clearing
|
||||||
|
|
||||||
|
#### Client Tests
|
||||||
|
- Test URL building
|
||||||
|
- Test HTTP methods (GET, POST, PUT, DELETE)
|
||||||
|
- Test cookie management
|
||||||
|
- Test SSL verification setting
|
||||||
|
|
||||||
|
#### Note Manager Tests
|
||||||
|
- Test note ID encoding/decoding (UUID to base64url)
|
||||||
|
- Test note list parsing
|
||||||
|
- Test note creation request formatting
|
||||||
|
- Test note update request formatting
|
||||||
|
|
||||||
|
#### Export Manager Tests
|
||||||
|
- Test markdown export
|
||||||
|
- Test export with file output
|
||||||
|
- Test error handling for unauthorized exports
|
||||||
|
|
||||||
|
### E2E Tests (`test_full_e2e.py`)
|
||||||
|
|
||||||
|
#### Configuration Tests
|
||||||
|
- Test CLI configuration with `--server` flag
|
||||||
|
- Test JSON output mode with `--json` flag
|
||||||
|
|
||||||
|
#### Note Command Tests
|
||||||
|
- Test `note list` command
|
||||||
|
- Test `note create` command
|
||||||
|
- Test `note get` command
|
||||||
|
- Test `note info` command
|
||||||
|
|
||||||
|
#### Export Command Tests
|
||||||
|
- Test `export markdown` command
|
||||||
|
- Test `export html` command
|
||||||
|
|
||||||
|
#### User Command Tests
|
||||||
|
- Test `user me` command
|
||||||
|
- Test `user logout` command
|
||||||
|
|
||||||
|
### Subprocess Tests
|
||||||
|
- Test installed CLI via `_resolve_cli()` method
|
||||||
|
- Test with `CLI_ANYTHING_FORCE_INSTALLED=1` environment variable
|
||||||
|
- Verify CLI is available in PATH
|
||||||
|
|
||||||
|
## Test Requirements
|
||||||
|
|
||||||
|
- Python 3.7+
|
||||||
|
- pytest for test execution
|
||||||
|
- requests-mock for HTTP mocking
|
||||||
|
- pytest-subprocess for subprocess testing
|
||||||
|
|
||||||
|
## Mock Data
|
||||||
|
|
||||||
|
### Mock User
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "12345678-1234-1234-1234-123456789012",
|
||||||
|
"profile": {
|
||||||
|
"name": "Test User",
|
||||||
|
"email": "test@example.com"
|
||||||
|
},
|
||||||
|
"email": "test@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mock Note List
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"myNotes": [
|
||||||
|
{
|
||||||
|
"id": "VXDECB3HEXDPHWHGHFDGWEDFBM",
|
||||||
|
"text": "Test Note 1",
|
||||||
|
"shortId": "abc123",
|
||||||
|
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||||
|
"lastchangeAt": "2024-01-01T01:00:00.000Z",
|
||||||
|
"tags": ["test"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "YTECB3HEXDPHWHGHFDGWEDFBZ",
|
||||||
|
"text": "Test Note 2",
|
||||||
|
"shortId": "def456",
|
||||||
|
"createdAt": "2024-01-02T00:00:00.000Z",
|
||||||
|
"lastchangeAt": "2024-01-02T01:00:00.000Z",
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Execution
|
||||||
|
|
||||||
|
Run all tests:
|
||||||
|
```bash
|
||||||
|
pytest -v --tb=no
|
||||||
|
```
|
||||||
|
|
||||||
|
Run only unit tests:
|
||||||
|
```bash
|
||||||
|
pytest test_core.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Run only E2E tests:
|
||||||
|
```bash
|
||||||
|
pytest test_full_e2e.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
### Test Execution Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
============================= test session starts ==============================
|
||||||
|
platform darwin -- Python 3.12.2, pytest-9.0.2, pluggy-1.6.0
|
||||||
|
collected 46 items
|
||||||
|
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestConfig::test_default_config PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestConfig::test_config_persistence PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestConfig::test_set_server PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestConfig::test_set_timeout PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestConfig::test_set_verify_ssl PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestSession::test_session_creation PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestSession::test_set_cookies PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestSession::test_set_user PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestSession::test_session_clear PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestCodiMDClient::test_url_building PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestCodiMDClient::test_url_trailing_slash PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestCodiMDClient::test_session_cookies PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestCodiMDClient::test_update_cookies PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestCodiMDClient::test_ssl_setting PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestNoteManager::test_encode_note_id PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestNoteManager::test_decode_note_id PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestNoteManager::test_encode_decode_roundtrip PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestNoteManager::test_list_notes_success PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestNoteManager::test_list_notes_unauthorized PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestNoteManager::test_create_note_with_content PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestNoteManager::test_delete_note_success PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestUserManager::test_get_me_success PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestUserManager::test_get_me_unauthorized PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestUserManager::test_export_data_success PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestExportManager::test_export_markdown_success PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestExportManager::test_export_markdown_to_file PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestExportManager::test_export_markdown_not_found PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestExportManager::test_export_pdf_disabled PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestRevisionManager::test_list_revisions_success PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestRevisionManager::test_list_revisions_empty PASSED
|
||||||
|
cli_anything/codimd/tests/test_core.py::TestRevisionManager::test_get_revision_at_time PASSED
|
||||||
|
cli_anything/codimd/tests/test_full_e2e.py::TestCLISubprocess::test_cli_help PASSED
|
||||||
|
cli_anything/codimd/tests/test_full_e2e.py::TestCLISubprocess::test_cli_version PASSED
|
||||||
|
cli_anything/codimd/tests/test_full_e2e.py::TestE2EClickRunner::test_config_get_command PASSED
|
||||||
|
cli_anything/codimd/tests/test_full_e2e.py::TestE2EClickRunner::test_config_set_command PASSED
|
||||||
|
cli_anything/codimd/tests/test_full_e2e.py::TestE2EClickRunner::test_help_command PASSED
|
||||||
|
cli_anything/codimd/tests/test_full_e2e.py::TestE2EClickRunner::test_version_command PASSED
|
||||||
|
cli_anything/codimd/tests/test_full_e2e.py::TestE2EClickRunner::test_note_help PASSED
|
||||||
|
cli_anything/codimd/tests/test_full_e2e.py::TestE2EClickRunner::test_user_help PASSED
|
||||||
|
cli_anything/codimd/tests/test_full_e2e.py::TestE2EClickRunner::test_export_help PASSED
|
||||||
|
cli_anything/codimd/tests/test_full_e2e.py::TestE2EClickRunner::test_json_output_mode PASSED
|
||||||
|
cli_anything/codimd/tests/test_full_e2e.py::TestE2ENoteCommands::test_note_list_authenticated PASSED
|
||||||
|
cli_anything/codimd/tests/test_full_e2e.py::TestE2ENoteCommands::test_note_create PASSED
|
||||||
|
cli_anything/codimd/tests/test_full_e2e.py::TestE2EExportCommands::test_export_markdown_to_file PASSED
|
||||||
|
cli_anything/codimd/tests/test_full_e2e.py::TestE2EUserCommands::test_user_logout PASSED
|
||||||
|
cli_anything/codimd/tests/test_full_e2e.py::TestE2EUserCommands::test_user_me_unauthenticated PASSED
|
||||||
|
|
||||||
|
============================== 46 passed in 0.66s ==============================
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Coverage Summary
|
||||||
|
|
||||||
|
- **Total Tests**: 46
|
||||||
|
- **Passed**: 46 (100%)
|
||||||
|
- **Failed**: 0
|
||||||
|
|
||||||
|
#### Unit Tests (test_core.py)
|
||||||
|
- Config: 5/5 passed
|
||||||
|
- Session: 4/4 passed
|
||||||
|
- Client: 5/5 passed
|
||||||
|
- Note Manager: 6/6 passed
|
||||||
|
- User Manager: 3/3 passed
|
||||||
|
- Export Manager: 4/4 passed
|
||||||
|
- Revision Manager: 3/3 passed
|
||||||
|
|
||||||
|
#### E2E Tests (test_full_e2e.py)
|
||||||
|
- Subprocess tests: 2/2 passed
|
||||||
|
- Click Runner tests: 8/8 passed
|
||||||
|
- Note Commands: 2/2 passed
|
||||||
|
- Export Commands: 1/1 passed
|
||||||
|
- User Commands: 2/2 passed
|
||||||
|
|
||||||
|
### CLI Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ which cli-anything-codimd
|
||||||
|
/Users/timmy/.pyenv/versions/3.12.2/bin/cli-anything-codimd
|
||||||
|
|
||||||
|
$ cli-anything-codimd --version
|
||||||
|
CodiMD CLI v0.1.0
|
||||||
|
|
||||||
|
$ cli-anything-codimd --help
|
||||||
|
Usage: cli-anything-codimd [OPTIONS] COMMAND [ARGS]...
|
||||||
|
|
||||||
|
CodiMD CLI - Collaborative markdown notes.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--version Show version and exit.
|
||||||
|
--json Output in JSON format
|
||||||
|
--server TEXT CodiMD server URL
|
||||||
|
--help Show this message and exit.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
config Configuration commands.
|
||||||
|
export Export operations.
|
||||||
|
note Note operations.
|
||||||
|
registry Registry operations.
|
||||||
|
repl Start interactive REPL mode.
|
||||||
|
revision Revision operations.
|
||||||
|
user User operations.
|
||||||
|
```
|
||||||
1
cli_anything/codimd/tests/__init__.py
Normal file
1
cli_anything/codimd/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Tests package
|
||||||
366
cli_anything/codimd/tests/test_core.py
Normal file
366
cli_anything/codimd/tests/test_core.py
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for CodiMD CLI core modules.
|
||||||
|
|
||||||
|
Tests use synthetic data and mock HTTP responses.
|
||||||
|
No external dependencies required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
|
||||||
|
from cli_anything.codimd.core.config import Config
|
||||||
|
from cli_anything.codimd.core.session import Session
|
||||||
|
from cli_anything.codimd.core.client import CodiMDClient
|
||||||
|
from cli_anything.codimd.core.note import NoteManager
|
||||||
|
from cli_anything.codimd.core.user import UserManager
|
||||||
|
from cli_anything.codimd.core.export import ExportManager
|
||||||
|
from cli_anything.codimd.core.revision import RevisionManager
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfig:
|
||||||
|
"""Tests for Config class."""
|
||||||
|
|
||||||
|
def test_default_config(self, tmp_path):
|
||||||
|
"""Test default configuration values."""
|
||||||
|
with patch.object(Config, "CONFIG_DIR", tmp_path):
|
||||||
|
config = Config()
|
||||||
|
assert config.server == "http://localhost:3000"
|
||||||
|
assert config.timeout == 30
|
||||||
|
assert config.verify_ssl is True
|
||||||
|
|
||||||
|
def test_config_persistence(self, tmp_path):
|
||||||
|
"""Test configuration is persisted to file."""
|
||||||
|
with patch.object(Config, "CONFIG_DIR", tmp_path):
|
||||||
|
config1 = Config()
|
||||||
|
config1.server = "https://example.com"
|
||||||
|
config1.timeout = 60
|
||||||
|
|
||||||
|
config2 = Config()
|
||||||
|
assert config2.server == "https://example.com"
|
||||||
|
assert config2.timeout == 60
|
||||||
|
|
||||||
|
def test_set_server(self, tmp_path):
|
||||||
|
"""Test setting server URL."""
|
||||||
|
with patch.object(Config, "CONFIG_DIR", tmp_path):
|
||||||
|
config = Config()
|
||||||
|
config.server = "https://codimd.example.com"
|
||||||
|
assert config.server == "https://codimd.example.com"
|
||||||
|
|
||||||
|
def test_set_timeout(self, tmp_path):
|
||||||
|
"""Test setting timeout."""
|
||||||
|
with patch.object(Config, "CONFIG_DIR", tmp_path):
|
||||||
|
config = Config()
|
||||||
|
config.timeout = 120
|
||||||
|
assert config.timeout == 120
|
||||||
|
|
||||||
|
def test_set_verify_ssl(self, tmp_path):
|
||||||
|
"""Test setting SSL verification."""
|
||||||
|
with patch.object(Config, "CONFIG_DIR", tmp_path):
|
||||||
|
config = Config()
|
||||||
|
config.verify_ssl = False
|
||||||
|
assert config.verify_ssl is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSession:
|
||||||
|
"""Tests for Session class."""
|
||||||
|
|
||||||
|
def test_session_creation(self, tmp_path):
|
||||||
|
"""Test session object creation."""
|
||||||
|
session = Session(tmp_path)
|
||||||
|
# Session file is created only when data is saved
|
||||||
|
assert session.is_authenticated() is False
|
||||||
|
|
||||||
|
def test_set_cookies(self, tmp_path):
|
||||||
|
"""Test setting session cookies."""
|
||||||
|
session = Session(tmp_path)
|
||||||
|
cookies = {"connect.sid": "test_session_value"}
|
||||||
|
session.set_cookies(cookies)
|
||||||
|
assert session.is_authenticated() is True
|
||||||
|
assert session.get_cookies() == cookies
|
||||||
|
|
||||||
|
def test_set_user(self, tmp_path):
|
||||||
|
"""Test setting user data."""
|
||||||
|
session = Session(tmp_path)
|
||||||
|
user_data = {
|
||||||
|
"id": "12345678-1234-1234-1234-123456789012",
|
||||||
|
"profile": {"name": "Test User"}
|
||||||
|
}
|
||||||
|
session.set_user(user_data)
|
||||||
|
assert session.get_user() == user_data
|
||||||
|
assert session.user_id == "12345678-1234-1234-1234-123456789012"
|
||||||
|
assert session.username == "Test User"
|
||||||
|
|
||||||
|
def test_session_clear(self, tmp_path):
|
||||||
|
"""Test clearing session."""
|
||||||
|
session = Session(tmp_path)
|
||||||
|
session.set_cookies({"connect.sid": "test"})
|
||||||
|
session.set_user({"id": "123"})
|
||||||
|
session.clear()
|
||||||
|
assert session.is_authenticated() is False
|
||||||
|
assert session.get_user() is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCodiMDClient:
|
||||||
|
"""Tests for CodiMDClient class."""
|
||||||
|
|
||||||
|
def test_url_building(self):
|
||||||
|
"""Test URL building for requests."""
|
||||||
|
client = CodiMDClient("https://example.com/codimd")
|
||||||
|
assert client._url("test") == "https://example.com/codimd/test"
|
||||||
|
assert client._url("/api/notes") == "https://example.com/codimd/api/notes"
|
||||||
|
|
||||||
|
def test_url_trailing_slash(self):
|
||||||
|
"""Test URL building with server having trailing slash."""
|
||||||
|
client = CodiMDClient("https://example.com/")
|
||||||
|
assert client._url("test") == "https://example.com/test"
|
||||||
|
|
||||||
|
def test_session_cookies(self):
|
||||||
|
"""Test client uses session cookies."""
|
||||||
|
cookies = {"connect.sid": "test123"}
|
||||||
|
client = CodiMDClient("https://example.com", session_cookies=cookies)
|
||||||
|
assert client.get_cookies() == cookies
|
||||||
|
|
||||||
|
def test_update_cookies(self):
|
||||||
|
"""Test updating session cookies."""
|
||||||
|
client = CodiMDClient("https://example.com")
|
||||||
|
client.update_cookies({"new": "cookie"})
|
||||||
|
assert "new" in client.get_cookies()
|
||||||
|
|
||||||
|
def test_ssl_setting(self):
|
||||||
|
"""Test SSL verification setting."""
|
||||||
|
client = CodiMDClient("https://example.com", verify_ssl=False)
|
||||||
|
assert client.verify_ssl is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoteManager:
|
||||||
|
"""Tests for NoteManager class."""
|
||||||
|
|
||||||
|
def test_encode_note_id(self):
|
||||||
|
"""Test encoding UUID note ID."""
|
||||||
|
uuid = "12345678-1234-1234-1234-123456789012"
|
||||||
|
encoded = NoteManager.encode_note_id(uuid)
|
||||||
|
assert isinstance(encoded, str)
|
||||||
|
assert "-" not in encoded
|
||||||
|
# Length should be appropriate for base64url
|
||||||
|
assert 20 <= len(encoded) <= 30
|
||||||
|
|
||||||
|
def test_decode_note_id(self):
|
||||||
|
"""Test decoding base64url note ID."""
|
||||||
|
# First encode a known UUID, then decode it
|
||||||
|
original = "12345678-1234-1234-1234-123456789012"
|
||||||
|
encoded = NoteManager.encode_note_id(original)
|
||||||
|
decoded = NoteManager.decode_note_id(encoded)
|
||||||
|
assert decoded == original
|
||||||
|
|
||||||
|
def test_encode_decode_roundtrip(self):
|
||||||
|
"""Test roundtrip encoding/decoding."""
|
||||||
|
test_uuids = [
|
||||||
|
"12345678-1234-1234-1234-123456789012",
|
||||||
|
"abcdef12-abcd-abcd-abcd-abcdef123456",
|
||||||
|
"00000000-0000-0000-0000-000000000000",
|
||||||
|
]
|
||||||
|
for uuid in test_uuids:
|
||||||
|
encoded = NoteManager.encode_note_id(uuid)
|
||||||
|
decoded = NoteManager.decode_note_id(encoded)
|
||||||
|
assert decoded == uuid
|
||||||
|
|
||||||
|
def test_list_notes_success(self):
|
||||||
|
"""Test listing notes on success."""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"myNotes": [
|
||||||
|
{"id": "abc123", "text": "Test Note", "shortId": "xyz"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
manager = NoteManager(mock_client)
|
||||||
|
notes = manager.list_my_notes()
|
||||||
|
assert len(notes) == 1
|
||||||
|
assert notes[0]["id"] == "abc123"
|
||||||
|
|
||||||
|
def test_list_notes_unauthorized(self):
|
||||||
|
"""Test listing notes when unauthorized."""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 403
|
||||||
|
mock_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
manager = NoteManager(mock_client)
|
||||||
|
with pytest.raises(PermissionError):
|
||||||
|
manager.list_my_notes()
|
||||||
|
|
||||||
|
def test_create_note_with_content(self):
|
||||||
|
"""Test creating note with initial content."""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.history = [Mock(url="https://example.com/new-note-id")]
|
||||||
|
mock_response.url = "https://example.com/new-note-id"
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
manager = NoteManager(mock_client)
|
||||||
|
result = manager.create_note("Hello, world!")
|
||||||
|
assert result["status"] == "created"
|
||||||
|
assert "id" in result
|
||||||
|
|
||||||
|
def test_delete_note_success(self):
|
||||||
|
"""Test deleting note on success."""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_client.delete.return_value = mock_response
|
||||||
|
|
||||||
|
manager = NoteManager(mock_client)
|
||||||
|
assert manager.delete_note("test-id") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserManager:
|
||||||
|
"""Tests for UserManager class."""
|
||||||
|
|
||||||
|
def test_get_me_success(self):
|
||||||
|
"""Test getting current user info on success."""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"id": "123",
|
||||||
|
"profile": {"name": "Test User"}
|
||||||
|
}
|
||||||
|
mock_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
manager = UserManager(mock_client)
|
||||||
|
user = manager.get_me()
|
||||||
|
assert user["id"] == "123"
|
||||||
|
|
||||||
|
def test_get_me_unauthorized(self):
|
||||||
|
"""Test getting user info when unauthorized."""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 403
|
||||||
|
mock_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
manager = UserManager(mock_client)
|
||||||
|
with pytest.raises(PermissionError):
|
||||||
|
manager.get_me()
|
||||||
|
|
||||||
|
def test_export_data_success(self):
|
||||||
|
"""Test exporting user data on success."""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {"data": "exported"}
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
manager = UserManager(mock_client)
|
||||||
|
data = manager.export_my_data()
|
||||||
|
assert data["data"] == "exported"
|
||||||
|
|
||||||
|
|
||||||
|
class TestExportManager:
|
||||||
|
"""Tests for ExportManager class."""
|
||||||
|
|
||||||
|
def test_export_markdown_success(self):
|
||||||
|
"""Test exporting note as markdown."""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.text = "# Test Note\n\nContent here."
|
||||||
|
mock_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
manager = ExportManager(mock_client)
|
||||||
|
content = manager.export_markdown("test-id")
|
||||||
|
assert content == "# Test Note\n\nContent here."
|
||||||
|
|
||||||
|
def test_export_markdown_to_file(self, tmp_path):
|
||||||
|
"""Test exporting markdown to file."""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.text = "# Test Note"
|
||||||
|
mock_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
output_file = tmp_path / "output.md"
|
||||||
|
manager = ExportManager(mock_client)
|
||||||
|
result = manager.export_markdown("test-id", output_file)
|
||||||
|
assert result == str(output_file)
|
||||||
|
assert output_file.read_text() == "# Test Note"
|
||||||
|
|
||||||
|
def test_export_markdown_not_found(self):
|
||||||
|
"""Test exporting non-existent note."""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 404
|
||||||
|
mock_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
manager = ExportManager(mock_client)
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
manager.export_markdown("non-existent")
|
||||||
|
|
||||||
|
def test_export_pdf_disabled(self):
|
||||||
|
"""Test PDF export when disabled."""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 403
|
||||||
|
mock_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
manager = ExportManager(mock_client)
|
||||||
|
with pytest.raises(PermissionError, match="PDF export disabled"):
|
||||||
|
manager.export_pdf("test-id")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRevisionManager:
|
||||||
|
"""Tests for RevisionManager class."""
|
||||||
|
|
||||||
|
def test_list_revisions_success(self):
|
||||||
|
"""Test listing revisions on success."""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"revisions": [
|
||||||
|
{"time": 1609459200000, "length": 100},
|
||||||
|
{"time": 1609365600000, "length": 50}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
manager = RevisionManager(mock_client)
|
||||||
|
revisions = manager.list_revisions("test-id")
|
||||||
|
assert len(revisions) == 2
|
||||||
|
|
||||||
|
def test_list_revisions_empty(self):
|
||||||
|
"""Test listing revisions when none exist."""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {}
|
||||||
|
mock_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
manager = RevisionManager(mock_client)
|
||||||
|
revisions = manager.list_revisions("test-id")
|
||||||
|
assert revisions == []
|
||||||
|
|
||||||
|
def test_get_revision_at_time(self):
|
||||||
|
"""Test getting revision at specific time."""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"content": "Old content",
|
||||||
|
"timestamp": 1609459200000
|
||||||
|
}
|
||||||
|
mock_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
manager = RevisionManager(mock_client)
|
||||||
|
revision = manager.get_revision_at_time("test-id", 1609459200000)
|
||||||
|
assert revision["content"] == "Old content"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
307
cli_anything/codimd/tests/test_full_e2e.py
Normal file
307
cli_anything/codimd/tests/test_full_e2e.py
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
End-to-end tests for CodiMD CLI.
|
||||||
|
|
||||||
|
Tests use real files and simulate full CLI workflows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add parent directory to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
|
from cli_anything.codimd.core import Config, Session, CodiMDClient
|
||||||
|
from cli_anything.codimd.codimd_cli import cli
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
|
||||||
|
class TestCLISubprocess:
|
||||||
|
"""Tests for installed CLI via subprocess."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_cli(cli_name: str) -> str:
|
||||||
|
"""Resolve CLI command path.
|
||||||
|
|
||||||
|
If CLI_ANYTHING_FORCE_INSTALLED is set, use the command name directly.
|
||||||
|
Otherwise, use the Python module path.
|
||||||
|
"""
|
||||||
|
if "CLI_ANYTHING_FORCE_INSTALLED" in str(__import__("os").environ):
|
||||||
|
return cli_name
|
||||||
|
# For testing, use python -m to run the module
|
||||||
|
return f"python -m cli_anything.codimd.codimd_cli"
|
||||||
|
|
||||||
|
def test_cli_help(self):
|
||||||
|
"""Test CLI help command."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["python", "-m", "cli_anything.codimd.codimd_cli", "--help"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert "CodiMD CLI" in result.stdout
|
||||||
|
|
||||||
|
def test_cli_version(self):
|
||||||
|
"""Test CLI version command."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["python", "-m", "cli_anything.codimd.codimd_cli", "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert "0.1.0" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
class TestE2EClickRunner:
|
||||||
|
"""End-to-end tests using Click CliRunner."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Set up test environment."""
|
||||||
|
self.runner = CliRunner()
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
"""Clean up test environment."""
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.Config")
|
||||||
|
def test_config_get_command(self, mock_config_class):
|
||||||
|
"""Test config get command."""
|
||||||
|
mock_config = Mock()
|
||||||
|
mock_config.server = "http://localhost:3000"
|
||||||
|
mock_config.timeout = 30
|
||||||
|
mock_config.verify_ssl = True
|
||||||
|
mock_config_class.return_value = mock_config
|
||||||
|
|
||||||
|
result = self.runner.invoke(cli, ["config", "get"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Server:" in result.output or "localhost:3000" in result.output
|
||||||
|
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.Config")
|
||||||
|
def test_config_set_command(self, mock_config_class):
|
||||||
|
"""Test config set command."""
|
||||||
|
mock_config = Mock()
|
||||||
|
mock_config.server = "http://localhost:3000"
|
||||||
|
mock_config_class.return_value = mock_config
|
||||||
|
|
||||||
|
result = self.runner.invoke(cli, ["config", "set", "timeout", "60"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_help_command(self):
|
||||||
|
"""Test help command."""
|
||||||
|
result = self.runner.invoke(cli, ["--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "CodiMD CLI" in result.output
|
||||||
|
assert "note" in result.output
|
||||||
|
assert "user" in result.output
|
||||||
|
assert "export" in result.output
|
||||||
|
|
||||||
|
def test_version_command(self):
|
||||||
|
"""Test version command."""
|
||||||
|
result = self.runner.invoke(cli, ["--version"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "0.1.0" in result.output
|
||||||
|
|
||||||
|
def test_note_help(self):
|
||||||
|
"""Test note group help."""
|
||||||
|
result = self.runner.invoke(cli, ["note", "--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "list" in result.output
|
||||||
|
assert "get" in result.output
|
||||||
|
assert "create" in result.output
|
||||||
|
|
||||||
|
def test_user_help(self):
|
||||||
|
"""Test user group help."""
|
||||||
|
result = self.runner.invoke(cli, ["user", "--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "me" in result.output
|
||||||
|
assert "login" in result.output
|
||||||
|
assert "logout" in result.output
|
||||||
|
|
||||||
|
def test_export_help(self):
|
||||||
|
"""Test export group help."""
|
||||||
|
result = self.runner.invoke(cli, ["export", "--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "markdown" in result.output
|
||||||
|
assert "pdf" in result.output
|
||||||
|
assert "html" in result.output
|
||||||
|
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.Config")
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.Session")
|
||||||
|
def test_json_output_mode(self, mock_session_class, mock_config_class):
|
||||||
|
"""Test JSON output mode."""
|
||||||
|
mock_config = Mock()
|
||||||
|
mock_config.config_dir = Path(self.temp_dir)
|
||||||
|
mock_config_class.return_value = mock_config
|
||||||
|
|
||||||
|
mock_session = Mock()
|
||||||
|
mock_session.is_authenticated.return_value = False
|
||||||
|
mock_session_class.return_value = mock_session
|
||||||
|
|
||||||
|
result = self.runner.invoke(cli, ["--json", "config", "get"])
|
||||||
|
# Should output JSON
|
||||||
|
assert '"status"' in result.output or '{"server"' in result.output or '"timeout"' in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestE2ENoteCommands:
|
||||||
|
"""End-to-end tests for note commands."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Set up test environment."""
|
||||||
|
self.runner = CliRunner()
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
"""Clean up test environment."""
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.get_client")
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.Config")
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.Session")
|
||||||
|
def test_note_list_authenticated(self, mock_session_class, mock_config_class, mock_get_client):
|
||||||
|
"""Test listing notes when authenticated."""
|
||||||
|
mock_config = Mock()
|
||||||
|
mock_config.config_dir = Path(self.temp_dir)
|
||||||
|
mock_config_class.return_value = mock_config
|
||||||
|
|
||||||
|
mock_session = Mock()
|
||||||
|
mock_session.is_authenticated.return_value = True
|
||||||
|
mock_session_class.return_value = mock_session
|
||||||
|
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_note_manager = Mock()
|
||||||
|
mock_note_manager.list_my_notes.return_value = [
|
||||||
|
{"id": "abc123", "text": "Test Note"}
|
||||||
|
]
|
||||||
|
mock_get_client.return_value = (mock_client, mock_session)
|
||||||
|
|
||||||
|
with patch("cli_anything.codimd.codimd_cli.NoteManager", return_value=mock_note_manager):
|
||||||
|
result = self.runner.invoke(cli, ["note", "list"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.get_client")
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.Config")
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.Session")
|
||||||
|
def test_note_create(self, mock_session_class, mock_config_class, mock_get_client):
|
||||||
|
"""Test creating a note."""
|
||||||
|
mock_config = Mock()
|
||||||
|
mock_config.config_dir = Path(self.temp_dir)
|
||||||
|
mock_config_class.return_value = mock_config
|
||||||
|
|
||||||
|
mock_session = Mock()
|
||||||
|
mock_session_class.return_value = mock_session
|
||||||
|
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_note_manager = Mock()
|
||||||
|
mock_note_manager.create_note.return_value = {
|
||||||
|
"id": "new-id",
|
||||||
|
"url": "http://example.com/new-id",
|
||||||
|
"status": "created"
|
||||||
|
}
|
||||||
|
mock_get_client.return_value = (mock_client, mock_session)
|
||||||
|
|
||||||
|
with patch("cli_anything.codimd.codimd_cli.NoteManager", return_value=mock_note_manager):
|
||||||
|
result = self.runner.invoke(cli, ["note", "create", "--content", "Test content"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "created" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestE2EExportCommands:
|
||||||
|
"""End-to-end tests for export commands."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Set up test environment."""
|
||||||
|
self.runner = CliRunner()
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
"""Clean up test environment."""
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.get_client")
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.Config")
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.Session")
|
||||||
|
def test_export_markdown_to_file(self, mock_session_class, mock_config_class, mock_get_client):
|
||||||
|
"""Test exporting markdown to file."""
|
||||||
|
mock_config = Mock()
|
||||||
|
mock_config.config_dir = Path(self.temp_dir)
|
||||||
|
mock_config_class.return_value = mock_config
|
||||||
|
|
||||||
|
mock_session = Mock()
|
||||||
|
mock_session_class.return_value = mock_session
|
||||||
|
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_export_manager = Mock()
|
||||||
|
mock_export_manager.export_markdown.return_value = "# Test Note"
|
||||||
|
mock_get_client.return_value = (mock_client, mock_session)
|
||||||
|
|
||||||
|
output_file = Path(self.temp_dir) / "output.md"
|
||||||
|
|
||||||
|
with patch("cli_anything.codimd.codimd_cli.ExportManager", return_value=mock_export_manager):
|
||||||
|
result = self.runner.invoke(
|
||||||
|
cli,
|
||||||
|
["export", "markdown", "test-id", "--output", str(output_file)]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestE2EUserCommands:
|
||||||
|
"""End-to-end tests for user commands."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Set up test environment."""
|
||||||
|
self.runner = CliRunner()
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
"""Clean up test environment."""
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.get_client")
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.Config")
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.Session")
|
||||||
|
def test_user_logout(self, mock_session_class, mock_config_class, mock_get_client):
|
||||||
|
"""Test user logout."""
|
||||||
|
mock_config = Mock()
|
||||||
|
mock_config.config_dir = Path(self.temp_dir)
|
||||||
|
mock_config_class.return_value = mock_config
|
||||||
|
|
||||||
|
mock_session = Mock()
|
||||||
|
mock_session_class.return_value = mock_session
|
||||||
|
|
||||||
|
mock_get_client.return_value = (Mock(), mock_session)
|
||||||
|
|
||||||
|
result = self.runner.invoke(cli, ["user", "logout"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "logged out" in result.output.lower()
|
||||||
|
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.get_client")
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.Config")
|
||||||
|
@patch("cli_anything.codimd.codimd_cli.Session")
|
||||||
|
def test_user_me_unauthenticated(self, mock_session_class, mock_config_class, mock_get_client):
|
||||||
|
"""Test getting user info when not authenticated."""
|
||||||
|
mock_config = Mock()
|
||||||
|
mock_config.config_dir = Path(self.temp_dir)
|
||||||
|
mock_config_class.return_value = mock_config
|
||||||
|
|
||||||
|
mock_session = Mock()
|
||||||
|
mock_session.is_authenticated.return_value = False
|
||||||
|
mock_session_class.return_value = mock_session
|
||||||
|
|
||||||
|
mock_get_client.return_value = (Mock(), mock_session)
|
||||||
|
|
||||||
|
result = self.runner.invoke(cli, ["user", "me"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "authenticated" in result.output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
63
cli_anything/codimd/utils/__init__.py
Normal file
63
cli_anything/codimd/utils/__init__.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Utility functions for CodiMD CLI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
def output_json(data: Dict[str, Any]) -> None:
|
||||||
|
"""Output data as JSON."""
|
||||||
|
json.dump(data, sys.stdout, indent=2)
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def format_note_list(notes: list) -> str:
|
||||||
|
"""Format note list for display."""
|
||||||
|
if not notes:
|
||||||
|
return "No notes found."
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for note in notes:
|
||||||
|
note_id = note.get("id", "unknown")
|
||||||
|
title = note.get("text", "Untitled")
|
||||||
|
created = note.get("createdAt", "")
|
||||||
|
short_id = note.get("shortId", "")
|
||||||
|
|
||||||
|
lines.append(f"ID: {note_id}")
|
||||||
|
lines.append(f" Title: {title}")
|
||||||
|
lines.append(f" Short ID: {short_id}")
|
||||||
|
lines.append(f" Created: {created}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_user_info(user: Dict[str, Any]) -> str:
|
||||||
|
"""Format user info for display."""
|
||||||
|
lines = ["User Information:"]
|
||||||
|
lines.append(f" ID: {user.get('id', 'N/A')}")
|
||||||
|
|
||||||
|
# CodiMD returns name directly, not nested in profile
|
||||||
|
name = user.get("name") or user.get("profile", {}).get("name", "N/A")
|
||||||
|
lines.append(f" Name: {name}")
|
||||||
|
|
||||||
|
lines.append(f" Email: {user.get('email', 'N/A')}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_error(message: str) -> Dict[str, Any]:
|
||||||
|
"""Format error as JSON response."""
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": message
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_success(data: Any) -> Dict[str, Any]:
|
||||||
|
"""Format success as JSON response."""
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"data": data
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user