Add library push to website from the content editor
This commit is contained in:
1 parent
ec57bd73b3
commit
0f90babb7a
13 files changed
+281
-20
No files matched your search
+34
-1
@@ -1,12 +1,14 @@
|
||||
import sys
|
||||
|
||||
from textual import on
|
||||
from textual import on, work
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.widgets import DataTable, Footer, Header, TabbedContent, TabPane
|
||||
|
||||
import library_push
|
||||
import portfolio
|
||||
from tui.books_pane import BooksPane
|
||||
from tui.dialogs import ConfirmDialog
|
||||
from tui.games_pane import GamesPane
|
||||
from tui.media_pane import MediaPane
|
||||
from tui.music_pane import MusicPane
|
||||
@@ -41,6 +43,7 @@ class ContentApp(App):
|
||||
BINDINGS = [
|
||||
Binding("q", "quit", "Quit"),
|
||||
Binding("t", "next_theme", "Theme"),
|
||||
Binding("P", "push_library", "Push to website"),
|
||||
*[Binding(tab_key(number), f"show_tab('{tab_id}')", label, show=False) for number, (tab_id, label) in enumerate(TABS, start=1)],
|
||||
]
|
||||
|
||||
@@ -97,6 +100,36 @@ class ContentApp(App):
|
||||
def on_mount(self):
|
||||
self.focus_active_table()
|
||||
|
||||
def action_push_library(self):
|
||||
self.notify("Comparing the library with the website...")
|
||||
self.prepare_push()
|
||||
|
||||
@work(thread=True, exclusive=True, group="push")
|
||||
def prepare_push(self):
|
||||
try:
|
||||
plan = library_push.plan_push()
|
||||
except (OSError, ValueError) as error:
|
||||
self.call_from_thread(self.notify, str(error), title="Push", severity="error", timeout=10)
|
||||
return
|
||||
self.call_from_thread(self.confirm_push, plan)
|
||||
|
||||
def confirm_push(self, plan: library_push.PushPlan):
|
||||
def confirmed(should_push: bool | None):
|
||||
if should_push:
|
||||
self.push_library(plan)
|
||||
|
||||
self.push_screen(ConfirmDialog(plan.describe(), "Push", "primary"), confirmed)
|
||||
|
||||
@work(thread=True, exclusive=True, group="push")
|
||||
def push_library(self, plan: library_push.PushPlan):
|
||||
progress = lambda message: self.call_from_thread(self.notify, message)
|
||||
result = library_push.run_push(plan, progress)
|
||||
for message in result.errors[:5]:
|
||||
self.call_from_thread(self.notify, message, title="Push", severity="warning", timeout=10)
|
||||
summary = f"Pushed {result.pushed} and removed {result.removed} on {plan.target.url}"
|
||||
severity = "warning" if result.errors else "information"
|
||||
self.call_from_thread(self.notify, f"{summary}, {len(result.errors)} failed", title="Push", severity=severity, timeout=10)
|
||||
|
||||
def action_show_tab(self, tab_id: str):
|
||||
self.query_one(TabbedContent).active = tab_id
|
||||
|
||||
|
||||
@@ -14,15 +14,30 @@ class LibraryError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class Site:
|
||||
def __init__(self, url: str, key: str | None):
|
||||
self.url = url.rstrip("/")
|
||||
self.key = key
|
||||
|
||||
def require_key(self) -> str:
|
||||
if not self.key:
|
||||
raise LibraryError(f"Set an API key in .env to change the library at {self.url}")
|
||||
return self.key
|
||||
|
||||
|
||||
def api_url() -> str:
|
||||
return (env_value("LIBRARY_API_URL") or DEFAULT_API_URL).rstrip("/")
|
||||
|
||||
|
||||
def api_key() -> str:
|
||||
key = env_value("LIBRARY_API_KEY")
|
||||
if not key:
|
||||
raise LibraryError("Set LIBRARY_API_KEY in .env to change the library")
|
||||
return key
|
||||
def local_site() -> Site:
|
||||
return Site(api_url(), env_value("LIBRARY_API_KEY"))
|
||||
|
||||
|
||||
def push_site() -> Site:
|
||||
url = env_value("LIBRARY_PUSH_URL")
|
||||
if not url:
|
||||
raise LibraryError("Set LIBRARY_PUSH_URL in .env to the website to push to, for example https://dev.sirblob.co")
|
||||
return Site(url, env_value("LIBRARY_PUSH_KEY") or env_value("LIBRARY_API_KEY"))
|
||||
|
||||
|
||||
def error_message(error: urllib.error.HTTPError) -> str:
|
||||
@@ -32,22 +47,23 @@ def error_message(error: urllib.error.HTTPError) -> str:
|
||||
return str(error.reason)
|
||||
|
||||
|
||||
def send(method: str, path: str, body: dict | None = None, signed: bool = True):
|
||||
def send(method: str, path: str, body: dict | None = None, signed: bool = True, site: Site | None = None):
|
||||
site = site or local_site()
|
||||
headers = {"Accept": "application/json"}
|
||||
data = None
|
||||
if signed:
|
||||
headers["Authorization"] = f"Bearer {api_key()}"
|
||||
headers["Authorization"] = f"Bearer {site.require_key()}"
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
request = urllib.request.Request(f"{api_url()}{path}", data=data, headers=headers, method=method)
|
||||
request = urllib.request.Request(f"{site.url}{path}", data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
|
||||
text = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as error:
|
||||
raise LibraryError(f"{api_url()} said {error.code}: {error_message(error)}") from error
|
||||
raise LibraryError(f"{site.url} said {error.code}: {error_message(error)}") from error
|
||||
except urllib.error.URLError as error:
|
||||
raise LibraryError(f"Could not reach {api_url()}: {error.reason}") from error
|
||||
raise LibraryError(f"Could not reach {site.url}: {error.reason}") from error
|
||||
return json.loads(text) if text else None
|
||||
|
||||
|
||||
@@ -59,6 +75,10 @@ def fetch_library() -> dict:
|
||||
return send("GET", "/api/library", signed=False)
|
||||
|
||||
|
||||
def fetch_export(site: Site) -> dict:
|
||||
return send("GET", "/api/library/export", site=site)
|
||||
|
||||
|
||||
def save_media(imdb_id: str, fields: dict) -> dict:
|
||||
return send("PUT", f"/api/library/media/{quoted(imdb_id)}", fields)
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import sys
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
import library_api
|
||||
|
||||
SECTIONS = {
|
||||
"media": {"label": "shows and movies", "path": "/api/library/media/"},
|
||||
"books": {"label": "books", "path": "/api/library/books/"},
|
||||
"games": {"label": "games", "path": "/api/library/games/"},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PushPlan:
|
||||
source: library_api.Site
|
||||
target: library_api.Site
|
||||
entries: dict[str, list[dict]]
|
||||
removals: dict[str, list[str]]
|
||||
|
||||
def describe(self) -> str:
|
||||
pushed = ", ".join(f"{len(self.entries[name])} {section['label']}" for name, section in SECTIONS.items())
|
||||
removed = sum(len(identifiers) for identifiers in self.removals.values())
|
||||
removal_text = f" and remove {removed} entries that only exist there" if removed else ""
|
||||
return f"Push {pushed} from {self.source.url} to {self.target.url}{removal_text}?"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PushResult:
|
||||
pushed: int = 0
|
||||
removed: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def entry_path(section: str, identifier: str) -> str:
|
||||
return f"{SECTIONS[section]['path']}{urllib.parse.quote(identifier, safe='')}"
|
||||
|
||||
|
||||
def plan_push() -> PushPlan:
|
||||
source = library_api.local_site()
|
||||
target = library_api.push_site()
|
||||
if source.url == target.url:
|
||||
raise library_api.LibraryError("LIBRARY_PUSH_URL is the same site as LIBRARY_API_URL")
|
||||
|
||||
local = library_api.fetch_export(source)
|
||||
if not any(local[section] for section in SECTIONS):
|
||||
raise library_api.LibraryError(f"The library at {source.url} is empty, so nothing was pushed")
|
||||
remote = library_api.fetch_export(target)
|
||||
|
||||
removals = {}
|
||||
for section in SECTIONS:
|
||||
local_ids = {entry["id"] for entry in local[section]}
|
||||
removals[section] = [entry["id"] for entry in remote[section] if entry["id"] not in local_ids]
|
||||
return PushPlan(source, target, {section: local[section] for section in SECTIONS}, removals)
|
||||
|
||||
|
||||
def run_push(plan: PushPlan, on_progress: Callable[[str], None] = lambda message: None) -> PushResult:
|
||||
result = PushResult()
|
||||
for section, identifiers in plan.removals.items():
|
||||
for identifier in identifiers:
|
||||
try:
|
||||
library_api.send("DELETE", entry_path(section, identifier), site=plan.target)
|
||||
result.removed += 1
|
||||
except library_api.LibraryError as error:
|
||||
result.errors.append(f"remove {identifier}: {error}")
|
||||
|
||||
for section, entries in plan.entries.items():
|
||||
on_progress(f"Pushing {len(entries)} {SECTIONS[section]['label']}...")
|
||||
for entry in entries:
|
||||
try:
|
||||
library_api.send("PUT", entry_path(section, entry["id"]), entry["fields"], site=plan.target)
|
||||
result.pushed += 1
|
||||
except library_api.LibraryError as error:
|
||||
result.errors.append(f"{entry['id']}: {error}")
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
plan = plan_push()
|
||||
except (OSError, ValueError) as error:
|
||||
print(f"Push stopped: {error}", file=sys.stderr)
|
||||
return 1
|
||||
print(plan.describe())
|
||||
if input("Type yes to continue: ").strip().lower() != "yes":
|
||||
print("Nothing was pushed")
|
||||
return 0
|
||||
result = run_push(plan, print)
|
||||
for message in result.errors:
|
||||
print(f"Error: {message}", file=sys.stderr)
|
||||
print(f"Pushed {result.pushed}, removed {result.removed}, {len(result.errors)} failed")
|
||||
return 1 if result.errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -86,15 +86,17 @@ class FormDialog(ModalScreen[dict | None]):
|
||||
class ConfirmDialog(ModalScreen[bool]):
|
||||
BINDINGS = [("y", "confirm", "Yes"), ("n", "cancel", "No"), ("escape", "cancel", "No")]
|
||||
|
||||
def __init__(self, message: str):
|
||||
def __init__(self, message: str, confirm_label: str = "Delete", confirm_variant: str = "error"):
|
||||
super().__init__()
|
||||
self.message = message
|
||||
self.confirm_label = confirm_label
|
||||
self.confirm_variant = confirm_variant
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(classes="dialog"):
|
||||
yield Label(self.message, classes="dialog-title")
|
||||
with Horizontal(classes="dialog-buttons"):
|
||||
yield Button("Delete", variant="error", id="confirm")
|
||||
yield Button(self.confirm_label, variant=self.confirm_variant, id="confirm")
|
||||
yield Button("Cancel", id="cancel")
|
||||
|
||||
@on(Button.Pressed, "#confirm")
|
||||
|
||||
Reference in new issue
Block a user