Add CLI source code and update .gitignore

This commit is contained in:
2026-04-07 09:38:12 +08:00
parent 6ef2dddf3f
commit 9e17f1bf29
18 changed files with 2663 additions and 1 deletions

View 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",
]

View 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)

View 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)

View 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

View 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"
}

View 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))

View 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")

View 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