Add CLI source code and update .gitignore
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
**/__pycache__/
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
@@ -150,8 +151,10 @@ cython_debug/
|
||||
# CodiMD source (too large)
|
||||
codimd/
|
||||
|
||||
# Session data
|
||||
# Session data (but keep package configs)
|
||||
/.config/
|
||||
*.json
|
||||
!setup.json
|
||||
!package.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