Files
nextcloud-deck-tools/common.py
Chen Asraf 8aa1213f61 feat: create board fix, create tags, main cmd flow
feat: create tags on board
feat: main flow asks for task instead of import by default
fix: create board requires color
2026-05-01 00:06:29 +03:00

298 lines
8.5 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import getpass
import os
from typing import Callable, Iterable, Optional, TypeVar
from urllib.parse import urlparse
import inquirer
import requests
T = TypeVar("T")
# ---------- color presets / picker ----------
DECK_COLOR_PRESETS: list[tuple[str, str]] = [
("Nextcloud Blue", "0082c9"),
("Red", "e74c3c"),
("Orange", "f39c12"),
("Yellow", "f1c40f"),
("Green", "27ae60"),
("Teal", "1abc9c"),
("Purple", "9b59b6"),
("Pink", "e91e63"),
("Gray", "95a5a6"),
]
def select_existing_board(
session: requests.Session,
base_url: str,
message: str = "Which board?",
) -> tuple[int, str]:
"""
Fetch boards via the Deck API and let the user pick one interactively.
Returns (board_id, board_title).
"""
# Lazy import to avoid circular module load.
from list_boards import list_boards
boards = list_boards(session, base_url)
if not boards:
raise RuntimeError("No boards found on this instance.")
choices: list[str] = []
board_map: dict[str, tuple[int, str]] = {}
for board in boards:
bid = board.get("id")
title = board.get("title", "Untitled")
label = f"{title} (ID: {bid})"
choices.append(label)
board_map[label] = (bid, title)
answer = inquirer.prompt(
[inquirer.List("board", message=message, choices=choices)]
)
if not answer:
raise RuntimeError("Board selection cancelled")
return board_map[answer["board"]]
def interactive_color_selection(message: str = "Pick a color") -> str:
"""
Show interactive menu to pick a hex color from presets, or enter a custom hex.
Returns 6-char lowercase hex string (no '#').
"""
choices = [f"{name} (#{hex_})" for name, hex_ in DECK_COLOR_PRESETS]
custom = "Custom hex..."
choices.append(custom)
answer = inquirer.prompt(
[inquirer.List("color", message=message, choices=choices)]
)
if not answer:
raise RuntimeError("Color selection cancelled")
selected = answer["color"]
if selected == custom:
custom_answer = inquirer.prompt(
[
inquirer.Text(
"hex",
message="Enter hex color (6 chars, no '#')",
default="0082c9",
)
]
)
if not custom_answer:
raise RuntimeError("Color entry cancelled")
hex_value = custom_answer["hex"].strip().lstrip("#")
if len(hex_value) != 6 or not all(
c in "0123456789abcdefABCDEF" for c in hex_value
):
raise RuntimeError(f"Invalid hex color: {hex_value}")
return hex_value.lower()
for name, hex_ in DECK_COLOR_PRESETS:
if selected == f"{name} (#{hex_})":
return hex_
raise RuntimeError(f"Unexpected color selection: {selected}")
# ---------- URL & session helpers ----------
def normalize_base_url(domain_or_url: str) -> str:
"""
Accepts either a bare domain (example.com) or a full URL (https://example.com[/...]).
Returns a normalized base URL without trailing slash (e.g., https://example.com).
"""
val = (domain_or_url or "").strip().rstrip("/")
if not val:
return val
parsed = urlparse(val)
if parsed.scheme in ("http", "https"):
return val
return f"https://{val}"
def build_session(username: str, password: str) -> requests.Session:
s = requests.Session()
s.auth = (username, password)
return s
# ---------- argparse helpers ----------
def add_common_args(parser: argparse.ArgumentParser) -> None:
"""
Adds commonly shared CLI flags. Not marked as required because we support env vars + prompts.
"""
parser.add_argument(
"--domain",
help="Nextcloud instance domain or base URL (e.g., example.com or https://example.com)",
)
parser.add_argument(
"--board-id",
type=int,
help="Deck Board ID",
)
parser.add_argument(
"--username",
help="Nextcloud username (if not provided, will be prompted)",
)
parser.add_argument(
"--password",
help="Nextcloud password or app password (if not provided, will be prompted)",
)
# ---------- resolution helpers ----------
def _first_env_match(keys: Iterable[str]) -> Optional[str]:
for k in keys:
v = os.getenv(k)
if v is not None and str(v).strip() != "":
return v
return None
def resolve_arg(
args_value: Optional[str],
env_keys: Iterable[str],
*,
prompt_text: Optional[str] = None,
secret: bool = False,
cast: Optional[Callable[[str], T]] = None,
allow_empty: bool = False,
) -> T | str:
"""
Resolution order: ENV -> CLI arg -> prompt (if provided) -> error.
- env_keys: list/iterable of env var names to try in order.
- secret: use getpass for prompt if True.
- cast: optional converter (e.g., int). Cast is applied to whichever source was used.
- allow_empty: if True, an empty prompt is accepted (returns empty string).
"""
raw = _first_env_match(env_keys)
if raw is None and args_value is not None:
raw = args_value
if raw is None:
if prompt_text is not None:
raw = (
getpass.getpass(prompt_text) if secret else input(prompt_text)
).strip()
if not allow_empty and raw == "":
raise ValueError(f"Missing value for: {', '.join(env_keys)}")
else:
# No prompt provided -> treat as missing.
raise ValueError(
f"Missing required value. Provide one of env({', '.join(env_keys)}) or CLI option."
)
return cast(raw) if cast else raw
def resolve_common(
*,
cli_domain: Optional[str],
cli_board_id: Optional[int],
cli_username: Optional[str],
cli_password: Optional[str],
) -> tuple[str, int, str, str]:
"""
Resolves common inputs from ENV/CLI/prompt:
Returns: (base_url, board_id, username, password)
"""
domain = resolve_arg(
None if cli_domain is None else str(cli_domain),
["NEXTCLOUD_DOMAIN", "NEXTCLOUD_BASE_URL"],
prompt_text="Nextcloud domain (e.g., example.com or https://example.com): ",
secret=False,
cast=str,
)
base_url = normalize_base_url(domain)
board_id = resolve_arg(
None if cli_board_id is None else str(cli_board_id),
["NEXTCLOUD_BOARD_ID", "BOARD_ID"],
prompt_text="Deck Board ID: ",
secret=False,
cast=lambda s: int(s.strip()),
)
username = resolve_arg(
cli_username,
["NEXTCLOUD_USERNAME", "NC_USERNAME"],
prompt_text="Nextcloud username: ",
secret=False,
cast=str,
)
password = resolve_arg(
cli_password,
["NEXTCLOUD_PASSWORD", "NC_PASSWORD"],
prompt_text="Nextcloud password (or app password): ",
secret=True,
cast=str,
)
return base_url, board_id, username, password
def add_domain_and_auth_args(parser: argparse.ArgumentParser) -> None:
"""
Like add_common_args, but WITHOUT --board-id (useful for scripts that don't need a board yet).
"""
parser.add_argument(
"--domain",
help="Nextcloud instance domain or base URL (e.g., example.com or https://example.com)",
)
parser.add_argument(
"--username",
help="Nextcloud username (if not provided, will be prompted)",
)
parser.add_argument(
"--password",
help="Nextcloud password or app password (if not provided, will be prompted)",
)
def resolve_domain_and_auth(
*,
cli_domain: str | None,
cli_username: str | None,
cli_password: str | None,
) -> tuple[str, str, str]:
"""
Resolve base_url, username, password via ENV -> CLI -> prompt.
Returns: (base_url, username, password)
"""
domain = resolve_arg(
None if cli_domain is None else str(cli_domain),
["NEXTCLOUD_DOMAIN", "NEXTCLOUD_BASE_URL"],
prompt_text="Nextcloud domain (e.g., example.com or https://example.com): ",
secret=False,
cast=str,
)
base_url = normalize_base_url(domain)
username = resolve_arg(
cli_username,
["NEXTCLOUD_USERNAME", "NC_USERNAME"],
prompt_text="Nextcloud username: ",
secret=False,
cast=str,
)
password = resolve_arg(
cli_password,
["NEXTCLOUD_PASSWORD", "NC_PASSWORD"],
prompt_text="Nextcloud password (or app password): ",
secret=True,
cast=str,
)
return base_url, username, password